diff --git a/.env.example b/.env.example index fe7184c..fd1dd01 100644 --- a/.env.example +++ b/.env.example @@ -34,8 +34,19 @@ COMPANY_TAX_ID= COMPANY_WEBSITE= COMPANY_LOGO_PATH= +# Outbound mail (Amazon SES — the channel the office already uses for bulk +# notification, see docs/MASS_EMAIL_NOTIFICATIONS.md). Without all four +# vars the API still boots; in dev the MailService logs sends to stdout, +# in production every send throws ServiceUnavailableException. SES_REGION= -SES_FROM= SES_ACCESS_KEY= SES_SECRET_KEY= +SES_FROM=mail@jorgecuadros.com +SES_FROM_NAME=Information Server +# Optional — bounce/complaint event publishing configuration set. SES_CONFIGURATION_SET= + +# Comma-separated addresses that receive the per-job admin summary email +# (one summary per address, JSON body, sent after every sweep). Defaults to +# the legacy pair if unset. +NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5afa8b8..e96490d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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], }) diff --git a/apps/api/src/auth/abilities.ts b/apps/api/src/auth/abilities.ts index 9e22172..94c58db 100644 --- a/apps/api/src/auth/abilities.ts +++ b/apps/api/src/auth/abilities.ts @@ -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 = { @@ -73,6 +74,11 @@ export const ABILITY_MIN: Record = { "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[]; diff --git a/apps/api/src/mail/mail.module.ts b/apps/api/src/mail/mail.module.ts new file mode 100644 index 0000000..6dcc162 --- /dev/null +++ b/apps/api/src/mail/mail.module.ts @@ -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 {} diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts new file mode 100644 index 0000000..aa76cc6 --- /dev/null +++ b/apps/api/src/mail/mail.service.ts @@ -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("SES_REGION"); + const accessKeyId = config.get("SES_ACCESS_KEY"); + const secretAccessKey = config.get("SES_SECRET_KEY"); + this.fromAddress = + config.get("SES_FROM") ?? + config.get("MAIL_FROM") ?? + null; + this.fromName = + config.get("SES_FROM_NAME") ?? + config.get("MAIL_FROM_NAME") ?? + "Information Server"; + this.configurationSet = config.get("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-` 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 { + 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), + }; + } +} diff --git a/apps/api/src/notifications/notification.types.ts b/apps/api/src/notifications/notification.types.ts new file mode 100644 index 0000000..7b25368 --- /dev/null +++ b/apps/api/src/notifications/notification.types.ts @@ -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"; diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..62bd222 --- /dev/null +++ b/apps/api/src/notifications/notifications.controller.ts @@ -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; + } +} diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts new file mode 100644 index 0000000..c6b9252 --- /dev/null +++ b/apps/api/src/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts new file mode 100644 index 0000000..357b5e1 --- /dev/null +++ b/apps/api/src/notifications/notifications.service.ts @@ -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("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 { + const debug = !!flags.debug; + const candidates = await this.prisma.customer.findMany({ + where: { + email: { not: null }, + archivedAt: null, + transactions: { + some: { + outstanding: true, + amount: { lt: 0 }, + voidedAt: null, + }, + }, + }, + select: { + id: true, + name: true, + email: true, + transactions: { + where: { outstanding: true, amount: { lt: 0 }, voidedAt: null }, + orderBy: { transactionDate: "asc" }, + select: { + id: true, + transactionDate: true, + reference: true, + period: true, + amount: true, + type: { select: { nameEn: true } }, + }, + }, + }, + orderBy: { id: "asc" }, + }); + + let sent = 0; + let skipped = 0; + let failed = 0; + + for (const c of candidates) { + const email = debug ? this.debugEmail() : (c.email ?? "").toLowerCase(); + if (!email || !email.includes("@")) { + await this.recordAttempt({ + notificationType: "OUTSTANDING_PAYMENT", + servicio: "CUSTOMERS", + customerId: c.id, + customerName: c.name, + customerEmail: email || "(missing)", + subject: "Jorge Cuadros - Outstanding Payments", + bodySnapshot: "(skipped: no email)", + status: "SKIPPED_NO_EMAIL", + debug, + }); + skipped++; + continue; + } + + const total = c.transactions.reduce( + (acc, t) => acc.plus(t.amount), + new Prisma.Decimal(0), + ); + let running = new Prisma.Decimal(0); + const rows = c.transactions.map((t) => { + running = running.plus(t.amount); + return { + date: t.transactionDate.toISOString().slice(0, 10), + reference: t.reference, + period: t.period, + type: t.type?.nameEn ?? null, + amount: t.amount.toFixed(2), + balance: running.toFixed(2), + }; + }); + + const body = renderOutstanding({ + customerId: c.id, + customerName: c.name, + total: total.abs().toFixed(2), + rows, + year: new Date().getFullYear(), + }); + + const attempt = await this.deliver({ + notificationType: "OUTSTANDING_PAYMENT", + servicio: "CUSTOMERS", + customerId: c.id, + customerName: c.name, + customerEmail: email, + subject: "Jorge Cuadros - Outstanding Payments", + bodySnapshot: body, + debug, + }); + if (attempt === "SENT") sent++; + else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++; + else failed++; + } + + const response: NotificationJobResponse = { + result: "success", + notificationType: "sendPaymentConfirmation", + reason: `sent confirmation ${sent} emails`, + statusCode: 200, + sent, + skipped, + failed, + debug, + type: "OUTSTANDING_PAYMENT", + }; + await this.adminSummary("Jorge Cuadros - Outstanding Payments", response); + return response; + } + + /** Job 2 — Payment confirmation. One email per customer whose latest + * credit (positive amount Transaction) landed in the last 24h. */ + async runPaymentConfirmation(flags: { + debug?: boolean; + ignoreDayRestriction?: boolean; + useEmailLimit?: boolean; + }): Promise { + const debug = !!flags.debug; + const since = new Date(Date.now() - PAYMENT_LOOKBACK_HOURS * 3600_000); + + // Find customers with a credit in the window. We then pick the most + // recent credit per customer; if multiple, we send one summary per + // customer (the PHP script also sent one per customer, picking the + // row the `pagosemail` view exposed for that NUMid). + const credits = await this.prisma.transaction.findMany({ + where: { + amount: { gt: 0 }, + voidedAt: null, + transactionDate: { gte: since }, + customer: { archivedAt: null }, + }, + orderBy: { transactionDate: "desc" }, + select: { + id: true, + transactionDate: true, + reference: true, + amount: true, + currency: true, + type: { select: { nameEn: true, nameEs: true } }, + customer: { + select: { id: true, name: true, email: true, preferredCurrency: true }, + }, + }, + }); + + // One row per customer (most recent credit wins). + const byCustomer = new Map< + string, + (typeof credits)[number] + >(); + for (const t of credits) { + if (!byCustomer.has(t.customer.id)) byCustomer.set(t.customer.id, t); + } + const total = byCustomer.size; + + let sent = 0; + let skipped = 0; + let failed = 0; + + for (const [, t] of byCustomer) { + const email = debug + ? this.debugEmail() + : (t.customer.email ?? "").toLowerCase(); + if (!email || !email.includes("@")) { + await this.recordAttempt({ + notificationType: "PAYMENT_CONFIRMATION", + servicio: "CUSTOMERS", + customerId: t.customer.id, + customerName: t.customer.name, + customerEmail: email || "(missing)", + subject: "Jorge Cuadros - Payment Confirmation", + bodySnapshot: "(skipped: no email)", + status: "SKIPPED_NO_EMAIL", + debug, + }); + skipped++; + continue; + } + + const typeOfTrx = t.type?.nameEn ?? t.type?.nameEs ?? "PAYMENT"; + const body = renderPaymentConfirm({ + customerId: t.customer.id, + customerName: t.customer.name, + typeOfTrx, + reference: t.reference, + amount: t.amount.toFixed(2), + year: new Date().getFullYear(), + }); + + // The PHP script stored the per-customer URL on the log row verbatim; + // we preserve the convention with a synthetic URL string. This is the + // single piece of legacy data the new log carries that does not come + // from a real fetch — a tag, not a request. + const bodyRequestUrl = `payment-confirmation://${t.customer.id}/${encodeURIComponent( + typeOfTrx, + )}/${t.id}`; + + const attempt = await this.deliver({ + notificationType: "PAYMENT_CONFIRMATION", + servicio: "CUSTOMERS", + customerId: t.customer.id, + customerName: t.customer.name, + customerEmail: email, + subject: "Jorge Cuadros - Payment Confirmation", + bodySnapshot: body, + bodyRequestUrl, + debug, + }); + if (attempt === "SENT") sent++; + else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++; + else failed++; + } + + const response: NotificationJobResponse = { + request: "success", + notificationType: "sendPaymentConfirmation", + confirmationSent: `${sent} of ${total}`, + statusCode: 200, + sent, + skipped, + failed, + debug, + type: "PAYMENT_CONFIRMATION", + }; + await this.adminSummary("Jorge Cuadros - Payment Confirmations Sent", response); + return response; + } + + /** Job 3 — Account status. Day gates + per-currency balance + dual + * threshold (yellow/red). The most complex of the four jobs. */ + async runAccountStatus(flags: { + debug?: boolean; + ignoreDayRestriction?: boolean; + useEmailLimit?: boolean; + }): Promise { + const debug = !!flags.debug; + const ignoreDayRestriction = !!flags.ignoreDayRestriction; + const useEmailLimit = !!flags.useEmailLimit; + + const today = new Date() + .toLocaleString("en-US", { weekday: "short", timeZone: "America/Tijuana" }) + .slice(0, 3) as "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun"; + const redDay = today === "Mon" || today === "Wed" || today === "Fri"; + const yellowDay = today === "Wed"; + const gateOpen = ignoreDayRestriction || redDay || yellowDay; + + // Pull every customer with a non-empty email and at least one movement + // that contributes to the balance (voided + outstanding excluded, same + // as `BillingService.balances()`). + const rows = await this.prisma.$queryRaw< + { + id: string; + name: string; + email: string; + balanceMxn: Prisma.Decimal | null; + balanceUsd: Prisma.Decimal | null; + }[] + >` + SELECT + c.id, + c.name, + c.email, + SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END) AS balanceMxn, + SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END) AS balanceUsd + FROM customers c + JOIN transactions t ON t.customerId = c.id + WHERE c.archivedAt IS NULL + AND c.email IS NOT NULL AND c.email <> '' + AND t.voidedAt IS NULL AND t.outstanding = 0 + GROUP BY c.id, c.name, c.email + `; + + const total = rows.length; + let red = 0; + let yellow = 0; + let sent = 0; + let failed = 0; + let skipped = 0; + let emailsThisRun = 0; + + for (const row of rows) { + // Skip customers with no email — should never happen because of the + // WHERE clause, but defends against a row that gets archived between + // the SQL and the loop. + const rawEmail = row.email ?? ""; + const email = debug ? this.debugEmail() : rawEmail.toLowerCase(); + if (!email || !email.includes("@")) { + await this.recordAttempt({ + notificationType: "ACCOUNT_STATUS", + servicio: "CUSTOMERS", + customerId: row.id, + customerName: row.name, + customerEmail: rawEmail || "(missing)", + subject: "Jorge Cuadros - Account Status Alert", + bodySnapshot: "(skipped: no email)", + status: "SKIPPED_NO_EMAIL", + debug, + }); + skipped++; + continue; + } + + // The unified ledger is per-currency and never collapsed. The legacy + // `TIPO` mapped 1:1 to USD (TIPO=50 / 100 / 200 / 300 / 500 were all + // USD thresholds with TIPO/TIPODECAMBIO implied). The simplest port + // is: convert every customer's MXN balance to USD using today's + // effective rate and apply the USD thresholds; a customer whose + // balance is genuinely USD-denominated uses balanceUsd directly. + // For the office's actual data this is "report in USD", which is + // what `Customer.minimumBalance` was set up for. + const mxn = row.balanceMxn ? Number(row.balanceMxn.toString()) : 0; + const usd = row.balanceUsd ? Number(row.balanceUsd.toString()) : 0; + // Pick the currency that holds the bulk of the debt: USD preferred + // because the legacy letter was always USD. + const balance = usd !== 0 ? usd : mxn; + + // Apply both thresholds (yellow + red). The PHP script sent both + // on a Wed: yellow + red, two emails, two log rows. We match that. + // Customer.minimumBalance is the new-schema replacement for TIPO. + const minBalance = await this.customerMinimum(row.id, balance); + + // RED first: a negative balance is always red. The yellow check + // requires balance >= 0, so the two never co-fire on the same row. + let firedRed = false; + let firedYellow = false; + + if (balance < 0) { + if (!redDay && !ignoreDayRestriction) { + await this.recordAttempt({ + notificationType: "ACCOUNT_STATUS", + level: 1, + servicio: "CUSTOMERS", + customerId: row.id, + customerName: row.name, + customerEmail: email, + subject: "Jorge Cuadros - Account Status Alert", + bodySnapshot: "(skipped: red day gate)", + status: "SKIPPED_GATE", + debug, + }); + skipped++; + } else { + const ok = await this.sendAccountStatusAlert({ + customerId: row.id, + customerName: row.name, + customerEmail: email, + balance, + level: 1, + tipo: balance, // red: "rush this much USD" + minBalance, + debug, + }); + if (ok) { + red++; + sent++; + firedRed = true; + } else { + failed++; + } + } + } + + if (balance >= 0 && minBalance !== null && balance < minBalance) { + if (!yellowDay && !ignoreDayRestriction) { + await this.recordAttempt({ + notificationType: "ACCOUNT_STATUS", + level: 0, + servicio: "CUSTOMERS", + customerId: row.id, + customerName: row.name, + customerEmail: email, + subject: "Jorge Cuadros - Account Status Alert", + bodySnapshot: "(skipped: yellow day gate)", + status: "SKIPPED_GATE", + debug, + }); + skipped++; + } else { + const ok = await this.sendAccountStatusAlert({ + customerId: row.id, + customerName: row.name, + customerEmail: email, + balance, + level: 0, + tipo: minBalance - balance, // yellow: "top up to minimum" + minBalance, + debug, + }); + if (ok) { + yellow++; + sent++; + firedYellow = true; + } else { + failed++; + } + } + } + + if (!firedRed && !firedYellow) continue; + + // Vestigial SMTP-era throttle (preserved for parity). Off by default; + // the controller wires useEmailLimit=true only when staff opt in. + if (useEmailLimit) { + emailsThisRun += firedRed ? 1 : 0; + emailsThisRun += firedYellow ? 1 : 0; + if (emailsThisRun >= RATE_LIMIT_EMAILS) { + this.logger.warn("SMTP email send limit reached, sleeping 1 hour."); + await new Promise((r) => setTimeout(r, ONE_HOUR_MS)); + emailsThisRun = 0; + this.logger.warn("Resuming send account status emails!"); + } + } + } + + const response: NotificationJobResponse = { + request: "success", + notificationType: "sendAccountStatus", + statusSent: `Sent ${red} Red Emails and ${yellow} Yellow Emails of ${total} customers.`, + statusReport: "https://cpanel.jorgecuadros.com/show_email_log.php", + statusCode: 200, + red, + yellow, + total, + sent, + skipped, + failed, + debug, + type: "ACCOUNT_STATUS", + }; + await this.adminSummary("Jorge Cuadros - Account Status Alerts Sent", response); + return response; + } + + /** Job 4 — Trust payment confirmation. */ + async runTrustConfirmation(flags: { + debug?: boolean; + ignoreDayRestriction?: boolean; + useEmailLimit?: boolean; + }): Promise { + const debug = !!flags.debug; + const since = new Date(Date.now() - PAYMENT_LOOKBACK_HOURS * 3600_000); + + // Latest credit per TrustAccount-bearing customer in the lookback + // window. The PHP script pulled from `TRUSTHFEE` directly; the + // equivalent here is: customer owns a property with a trust account + // AND has a credit in the trust domain in the last 24h. + const credits = await this.prisma.transaction.findMany({ + where: { + amount: { gt: 0 }, + domain: TransactionDomain.TRUST, + voidedAt: null, + transactionDate: { gte: since }, + customer: { archivedAt: null }, + }, + orderBy: { transactionDate: "desc" }, + select: { + id: true, + amount: true, + currency: true, + customer: { + select: { + id: true, + name: true, + email: true, + properties: { + where: { archivedAt: null }, + select: { + trustAccount: { select: { id: true, trustNumber: true } }, + }, + }, + }, + }, + }, + }); + + // One row per customer (most recent credit wins). Drop customers with + // no trust account — they have nothing to confirm against. + const byCustomer = new Map< + string, + (typeof credits)[number] + >(); + for (const t of credits) { + if (!t.customer.properties.some((p) => p.trustAccount)) continue; + if (!byCustomer.has(t.customer.id)) byCustomer.set(t.customer.id, t); + } + const total = byCustomer.size; + + let sent = 0; + let skipped = 0; + let failed = 0; + + for (const [, t] of byCustomer) { + const email = debug + ? this.debugEmail() + : (t.customer.email ?? "").toLowerCase(); + if (!email || !email.includes("@")) { + await this.recordAttempt({ + notificationType: "TRUST_PAYMENT_CONFIRMATION", + servicio: "TRUST", + customerId: t.customer.id, + customerName: t.customer.name, + customerEmail: email || "(missing)", + subject: "Jorge Cuadros - Trust Payment Confirmation", + bodySnapshot: "(skipped: no email)", + status: "SKIPPED_NO_EMAIL", + debug, + }); + skipped++; + continue; + } + + const body = renderTrustConfirm({ + customerId: t.customer.id, + customerName: t.customer.name, + amount: t.amount.toFixed(2), + year: new Date().getFullYear(), + }); + + const attempt = await this.deliver({ + notificationType: "TRUST_PAYMENT_CONFIRMATION", + servicio: "TRUST", + customerId: t.customer.id, + customerName: t.customer.name, + customerEmail: email, + subject: "Jorge Cuadros - Trust Payment Confirmation", + bodySnapshot: body, + debug, + }); + if (attempt === "SENT") sent++; + else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++; + else failed++; + } + + const response: NotificationJobResponse = { + request: "success", + notificationType: "sendTrustPaymentConfirmation", + confirmationSent: `${sent} of ${total}`, + statusCode: 200, + sent, + skipped, + failed, + debug, + type: "TRUST_PAYMENT_CONFIRMATION", + }; + await this.adminSummary("Jorge Cuadros - Trust Payment Confirmations Sent", response); + return response; + } + + /* ============================================================================ + * 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 { + const c = await this.prisma.customer.findUnique({ + where: { id: customerId }, + select: { minimumBalance: true }, + }); + if (!c?.minimumBalance) return null; + const m = Number(c.minimumBalance.toString()); + return isFinite(m) && m > 0 ? m : null; + } + + /** Send one account-status alert + write the parallel history row. */ + private async sendAccountStatusAlert(args: { + customerId: string; + customerName: string; + customerEmail: string; + balance: number; + tipo: number; + level: 0 | 1; + minBalance: number | null; + debug: boolean; + }): Promise { + const body = renderAccountStatus({ + customerId: args.customerId, + customerName: args.customerName, + level: args.level, + balance: args.balance.toFixed(2), + tipo: args.tipo.toFixed(2), + year: new Date().getFullYear(), + }); + const status = await this.deliver({ + notificationType: "ACCOUNT_STATUS", + level: args.level, + servicio: "CUSTOMERS", + customerId: args.customerId, + customerName: args.customerName, + customerEmail: args.customerEmail, + subject: "Jorge Cuadros - Account Status Alert", + bodySnapshot: body, + debug: args.debug, + }); + if (status === "SENT") { + // Mirrors `utility_dbo.send_account_status_history`. The legacy + // SOLICITADO formula was `0 - TIPO - BALANCE`; for yellow that + // simplifies to `minBalance - balance` (top-up amount), for red to + // `|balance|` (rush amount). We preserve the legacy `tipo` column + // as the human-readable label so downstream reports keep working. + await this.prisma.accountStatusHistory.create({ + data: { + customerId: args.customerId, + customerName: args.customerName, + customerEmail: args.customerEmail, + tipo: args.level === 0 ? "DEBAJO DEL TIPO" : "EN ROJO", + balance: new Prisma.Decimal(args.balance.toFixed(2)), + solicitado: new Prisma.Decimal( + (args.level === 0 + ? args.minBalance! - args.balance + : Math.abs(args.balance) + ).toFixed(2), + ), + level: args.level, + }, + }); + return true; + } + return false; + } + + /** Send one email + write the log row. Returns the final attempt + * status (SENT / FAILED / SKIPPED_*) so the caller can update its + * counters without re-querying the DB. */ + private async deliver(attempt: SendAttempt & { debug: boolean }): Promise { + try { + const { messageId, response } = await this.mail.send({ + to: attempt.customerEmail, + toName: attempt.customerName, + subject: attempt.subject, + html: attempt.bodySnapshot, + xTracking: attempt.debug ? "debug" : "1", + }); + await this.recordAttempt({ + ...attempt, + providerMessageId: messageId || undefined, + providerResponse: response || undefined, + status: "SENT", + }); + return "SENT"; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // ServiceUnavailableException means no transport was configured and + // we are not in dev — surface that as a clear FAILED row rather than + // letting it abort the whole job mid-loop. + this.logger.warn( + `Notification send failed for ${attempt.customerName} <${attempt.customerEmail}>: ${message}`, + ); + await this.recordAttempt({ + ...attempt, + status: "FAILED", + error: message.slice(0, 4096), + }); + return "FAILED"; + } + } + + /** Persist one notification log row. */ + 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: `
${body.replace(
+            /[<>&]/g,
+            (c) => ({ "<": "<", ">": ">", "&": "&" })[c] ?? c,
+          )}
`, + xTracking: "admin-summary", + }); + this.logger.log( + `Admin summary sent to ${to}: subject="${subject}" id=${messageId}`, + ); + } catch (err) { + this.logger.warn( + `Admin summary to ${to} failed: ${(err as Error).message}`, + ); + } + } + } + + /** Available for tests + future cron to assert before launching a sweep. */ + get transportAvailable(): boolean { + return this.mail.available; + } +} diff --git a/apps/api/src/notifications/render.spec.ts b/apps/api/src/notifications/render.spec.ts new file mode 100644 index 0000000..411eee2 --- /dev/null +++ b/apps/api/src/notifications/render.spec.ts @@ -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 & 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: "", + total: "0.00", + rows: [], + year: 2026, + }); + expect(html).not.toContain(""); + expect(html).toContain("<script>alert(1)</script>"); + }); +}); + +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"); + }); +}); diff --git a/apps/api/src/notifications/render.ts b/apps/api/src/notifications/render.ts new file mode 100644 index 0000000..f2966a4 --- /dev/null +++ b/apps/api/src/notifications/render.ts @@ -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 = ` + + + +{title} +`; + +const FOOT_CONTACT = `

If you have any questions regarding this notice please contact us at: +Tel. 011 52 (661) 612 - 1295   Fax. (661) 612 - 1285   +For any type of a 24 Hrs. emergencies: please dial 52 (664) 304 - 7778 | +jorge@jorgecuadros.com | +Contact Us Form

`; + +const SIGNED = (year: number) => `
This message has been generated by the Jorge Cuadros & Assoc. Information Server.
Copyright ${year} Developed by FreaKmA.Net
`; + +const esc = (s: string | null | undefined): string => + String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +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))} + + + + + + + + + + + + + + ${ + note + ? `` + : `` + } + + +
${esc(heading)}Please do not reply to this message. For any Jorge Cuadros & Assoc. customer service inquiries, visit: Customer Support
${esc(accountName)}
ACCOUNT #${esc(String(accountId))}
 
${body}
 

${esc(note)}

${FOOT_CONTACT}
${FOOT_CONTACT}
 
${SIGNED(year)}
+ +`; +} + +/* -------------------------------------------------------------------------- */ +/* 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) => ` + ${esc(String(r.date))} + ${esc(r.reference ?? "")} + ${esc(r.period ?? "")} + ${esc(r.type ?? "")} + ${esc(usd(r.amount))} + ${esc(usd(r.balance))} + `, + ) + .join("\n"); + + const body = `

This needs your prompt attention in order to avoid any disruption(s):

+

TOTAL OF OUTSTANDING BILLS: ${esc( + usd(args.total), + )} PESOS.

+ + + ${rows} +
DATEREFERPERIODTYPEOFTRXCHARGECREDITBALANCE
`; + + 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 = ` + + + + + + + + + + + + + + +
${esc( + args.typeOfTrx, + )} CONFIRMATIONPlease do not reply to this message. For any Jorge Cuadros & Assoc. customer service inquiries, visit: Customer Support
+ HI, ${esc(args.customerName)}
+ ACCOUNT #${esc(args.customerId)}
+ REFER# ${esc(args.reference ?? "")} +
+ +
 
+

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,

+

Your deposit was for ${esc( + usd(args.amount), + )} PESOS.

+
 

NOTE : IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL

${FOOT_CONTACT}
 
${SIGNED(args.year)}
`; + return `${HEAD.replace("{title}", "Payment Confirmation")}${body}`; +} + +/* -------------------------------------------------------------------------- */ +/* 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 + ? `

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.

` + : `

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.

`; + 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 = `

This automatic notice is to confirm, that your Annual Bank Fee has been paid by, and posted in your account. Thank You,

+

The annual fee was posted for the amount of ${esc( + usd(args.amount), + )} PESOS.

`; + 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, + }); +} diff --git a/apps/api/src/renewals/mail.service.spec.ts b/apps/api/src/renewals/mail.service.spec.ts deleted file mode 100644 index de9e53f..0000000 --- a/apps/api/src/renewals/mail.service.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ServiceUnavailableException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { MailService } from "./mail.service"; - -function config(values: Record): 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: "

Test

" }), - ).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: "

Test

" }), - ).rejects.toBeInstanceOf(ServiceUnavailableException); - }); -}); diff --git a/apps/api/src/renewals/mail.service.ts b/apps/api/src/renewals/mail.service.ts deleted file mode 100644 index 1479ab9..0000000 --- a/apps/api/src/renewals/mail.service.ts +++ /dev/null @@ -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("SES_REGION"); - const from = config.get("SES_FROM"); - const accessKeyId = config.get("SES_ACCESS_KEY"); - const secretAccessKey = config.get("SES_SECRET_KEY"); - this.configurationSet = config.get("SES_CONFIGURATION_SET") || undefined; - this.developmentNoop = - config.get("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 }; - } -} diff --git a/apps/api/src/renewals/renewals.module.ts b/apps/api/src/renewals/renewals.module.ts index 6015114..f632155 100644 --- a/apps/api/src/renewals/renewals.module.ts +++ b/apps/api/src/renewals/renewals.module.ts @@ -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 {} diff --git a/apps/api/src/renewals/renewals.service.ts b/apps/api/src/renewals/renewals.service.ts index 2c74b18..2e804bc 100644 --- a/apps/api/src/renewals/renewals.service.ts +++ b/apps/api/src/renewals/renewals.service.ts @@ -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({ diff --git a/apps/web/src/app/notificaciones/page.tsx b/apps/web/src/app/notificaciones/page.tsx new file mode 100644 index 0000000..319a92d --- /dev/null +++ b/apps/web/src/app/notificaciones/page.tsx @@ -0,0 +1,444 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AppShell } from "@/components/AppShell"; +import { useCan } from "@/lib/abilities"; +import { formatDateTime } from "@/lib/labels"; +import { + NOTIFICATION_STATUS_COLORS, + NOTIFICATION_STATUS_LABELS, + NOTIFICATION_SERVICIO_LABELS, + NOTIFICATION_TYPE_LABELS, +} from "@/lib/labels"; +import { + getNotificationStats, + listNotificationLog, + runAccountStatus, + runOutstandingPayments, + runPaymentConfirmation, + runTrustConfirmation, +} from "@/lib/api"; +import type { + NotificationFlags, + NotificationJobResponse, + NotificationLogPage, + NotificationStats, + NotificationStatus, +} from "@/lib/api"; + +/** + * Mass email notifications UI. Manual triggers for the four jobs plus a + * paged log browser. The page is gated on `notification:send`; a STAFF + * viewer sees the read-only log table but not the trigger buttons. + */ + +export default function NotificacionesPage() { + return ( + + + + ); +} + +type JobKind = "outstanding" | "payment" | "account" | "trust"; + +interface JobDef { + kind: JobKind; + title: string; + endpoint: string; + description: string; + servicio: "Clientes" | "Fideicomiso"; + flagsHint?: string; +} + +const JOBS: JobDef[] = [ + { + kind: "outstanding", + title: "Pagos pendientes", + endpoint: "sendOutstandingPaymentAlerts", + servicio: "Clientes", + description: + "Clientes con al menos un movimiento marcado como pendiente (outstanding). Equivale a la columna NOPAGO=1 del antiguo datosfreak.", + }, + { + kind: "payment", + title: "Confirmación de pago", + endpoint: "sendPaymentConfirmation", + servicio: "Clientes", + description: + "Clientes con un crédito (abono) en las últimas 24 horas. Un correo por cliente con el pago más reciente.", + }, + { + kind: "account", + title: "Estado de cuenta", + endpoint: "sendAccountStatus", + servicio: "Clientes", + description: + "Alerta amarilla (DEBAJO DEL TIPO) los miércoles y roja (EN ROJO) lunes/miércoles/viernes. El flag ignoreDayRestriction salta los gates.", + flagsHint: "Solo este job respeta ignoreDayRestriction y useEmailLimit.", + }, + { + kind: "trust", + title: "Confirmación fideicomiso", + endpoint: "sendConfirmTrustPayment", + servicio: "Fideicomiso", + description: + "Clientes con TrustAccount que recibieron un crédito en el dominio TRUST en las últimas 24 horas.", + }, +]; + +function Notificaciones() { + const allowed = useCan("notification:send"); + + const [flags, setFlags] = useState({ debug: true }); + const [stats, setStats] = useState(null); + const [log, setLog] = useState(null); + const [logFilter, setLogFilter] = useState<{ + status?: NotificationStatus; + view: "all" | "sent" | "failed" | "skipped"; + }>({ view: "all" }); + const [logPage, setLogPage] = useState(1); + const [busy, setBusy] = useState(null); + const [lastResult, setLastResult] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const [s, l] = await Promise.all([ + getNotificationStats(), + listNotificationLog({ + page: logPage, + pageSize: 50, + status: logFilter.status, + view: logFilter.view === "all" ? undefined : logFilter.view, + }), + ]); + setStats(s); + setLog(l); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [logPage, logFilter]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const run = useCallback( + async (job: JobDef) => { + if (!allowed) return; + setBusy(job.kind); + setError(null); + try { + let res: NotificationJobResponse; + if (job.kind === "outstanding") res = await runOutstandingPayments(flags); + else if (job.kind === "payment") res = await runPaymentConfirmation(flags); + else if (job.kind === "account") res = await runAccountStatus(flags); + else res = await runTrustConfirmation(flags); + setLastResult(res); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(null); + } + }, + [allowed, flags, refresh], + ); + + return ( +
+
+

Notificaciones masivas

+

+ Disparo manual de los cuatro envíos equivalentes a los scripts PHP + de email.notifications/. Cada ejecución registra todas + las filas (enviado, fallido, omitido) en email_notification_log. +

+
+ +
+ Flags del envío + + + +
+ +
+ {JOBS.map((j) => ( +
+
+ {j.title} + {j.servicio} +
+

{j.description}

+ {j.flagsHint && ( +

+ {j.flagsHint} +

+ )} + +
+ ))} +
+ + {!allowed && ( +
+ Tu rol no incluye notification:send. Solo puedes ver el + registro. Para disparar envíos pide a un MANAGER/ADMIN. +
+ )} + + {stats && ( +
+ Estado del transporte +
    +
  • + SES configurado:{" "} + + {stats.transport.available ? "sí" : "no"} + + {stats.transport.devFallback && " (fallback dev: stdout)"} +
  • +
  • Último envío registrado: {stats.lastRun ? `${NOTIFICATION_TYPE_LABELS[stats.lastRun.notificationType]} — ${formatDateTime(stats.lastRun.sendDate)}` : "—"}
  • +
  • + Totales:{" "} + {stats.byStatus.map((s) => ( + + {NOTIFICATION_STATUS_LABELS[s.status]}: {s._count._all} + + ))} +
  • +
+
+ )} + + {lastResult && ( +
+ Última respuesta +
+            {JSON.stringify(lastResult, null, 2)}
+          
+
+ )} + + {error && ( +
+ {error} +
+ )} + +
+
+ Registro de envíos +
+ {(["all", "sent", "failed", "skipped"] as const).map((v) => ( + + ))} +
+
+ + + + + + + + + + + + + + + + {log?.items.map((row) => ( + + + + + + + + + + + ))} + {log && log.items.length === 0 && ( + + + + )} + +
FechaTipoServicioClienteEmailEstadoAsuntoProvider
{formatDateTime(row.sendDate)} + {NOTIFICATION_TYPE_LABELS[row.notificationType]} + {row.level !== null && (row.level === 0 ? " (amarilla)" : " (roja)")} + {NOTIFICATION_SERVICIO_LABELS[row.servicio]}{row.customerName}{row.debug ? " · debug" : ""}{row.customerEmail} + {NOTIFICATION_STATUS_LABELS[row.status]} + {row.subject} + {row.providerMessageId ?? row.error ?? "—"} +
+ Sin envíos con el filtro actual. +
+ + {log && log.pageCount > 1 && ( +
+ + {log.total} fila{log.total === 1 ? "" : "s"} · página {log.page} de {log.pageCount} + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 78f11d6..a1c1941 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -78,6 +78,11 @@ const NAV: NavEntry[] = [ label: "Cuentas de chequera", ability: "bank:manage-accounts", }, + { + href: "/notificaciones", + label: "Notificaciones masivas", + ability: "notification:send", + }, { href: "/usuarios", label: "Usuarios", ability: "user:manage" }, { href: "/renovaciones", label: "Renovaciones", ability: "renewal:send" }, { href: "/operaciones", label: "Operaciones", ability: "db:manage" }, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3a49551..ae6737a 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -973,6 +973,185 @@ export function runReport( return apiFetch(`/reports/${slug}${tail ? `?${tail}` : ""}`); } +/* ------------------------------------------------- Mass email notifications */ + +export type NotificationType = + | "OUTSTANDING_PAYMENT" + | "PAYMENT_CONFIRMATION" + | "ACCOUNT_STATUS" + | "TRUST_PAYMENT_CONFIRMATION"; + +export type NotificationServicio = "CUSTOMERS" | "TRUST"; + +export type NotificationStatus = + | "SENT" + | "FAILED" + | "SKIPPED_NO_EMAIL" + | "SKIPPED_GATE"; + +export interface NotificationLogRow { + id: string; + sendDate: string; + notificationType: NotificationType; + level: number | null; + servicio: NotificationServicio; + customerId: string | null; + customerName: string; + customerEmail: string; + subject: string; + debug: boolean; + status: NotificationStatus; + providerMessageId: string | null; + error: string | null; +} + +export interface NotificationLogPage { + items: NotificationLogRow[]; + total: number; + page: number; + pageSize: number; + pageCount: number; +} + +export interface NotificationStats { + byType: { notificationType: NotificationType; status: NotificationStatus; _count: { _all: number } }[]; + byStatus: { status: NotificationStatus; _count: { _all: number } }[]; + byServicio: { servicio: NotificationServicio; status: NotificationStatus; _count: { _all: number } }[]; + lastRun: { sendDate: string; notificationType: NotificationType } | null; + transport: { available: boolean; devFallback: boolean }; +} + +export type NotificationFlags = { + debug?: boolean; + ignoreDayRestriction?: boolean; + useEmailLimit?: boolean; +}; + +/** Job 1 (Outstanding) response — legacy `result` field. */ +export interface OutstandingResponse { + result: "success"; + notificationType: "sendPaymentConfirmation"; + reason: string; + statusCode: 200; + sent: number; + skipped: number; + failed: number; + debug: boolean; + type: "OUTSTANDING_PAYMENT"; +} + +/** Job 2 (Payment Confirmation) response. */ +export interface PaymentConfirmResponse { + request: "success"; + notificationType: "sendPaymentConfirmation"; + confirmationSent: string; + statusCode: 200; + sent: number; + skipped: number; + failed: number; + debug: boolean; + type: "PAYMENT_CONFIRMATION"; +} + +/** Job 3 (Account Status) response. */ +export interface AccountStatusResponse { + 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 (Trust Confirmation) response. */ +export interface TrustConfirmResponse { + request: "success"; + notificationType: "sendTrustPaymentConfirmation"; + confirmationSent: string; + statusCode: 200; + sent: number; + skipped: number; + failed: number; + debug: boolean; + type: "TRUST_PAYMENT_CONFIRMATION"; +} + +export type NotificationJobResponse = + | OutstandingResponse + | PaymentConfirmResponse + | AccountStatusResponse + | TrustConfirmResponse; + +export function runOutstandingPayments( + flags: NotificationFlags = {}, +): Promise { + return apiFetch("/notifications/outstanding-payments", { + method: "POST", + body: JSON.stringify(flags), + }); +} + +export function runPaymentConfirmation( + flags: NotificationFlags = {}, +): Promise { + return apiFetch("/notifications/payment-confirmation", { + method: "POST", + body: JSON.stringify(flags), + }); +} + +export function runAccountStatus( + flags: NotificationFlags = {}, +): Promise { + return apiFetch("/notifications/account-status", { + method: "POST", + body: JSON.stringify(flags), + }); +} + +export function runTrustConfirmation( + flags: NotificationFlags = {}, +): Promise { + return apiFetch("/notifications/trust-payment-confirmation", { + method: "POST", + body: JSON.stringify(flags), + }); +} + +export interface NotificationLogQuery { + page?: number; + pageSize?: number; + type?: NotificationType; + servicio?: NotificationServicio; + status?: NotificationStatus; + view?: "sent" | "failed" | "skipped" | "all"; +} + +export function listNotificationLog( + q: NotificationLogQuery = {}, +): Promise { + const qs = new URLSearchParams(); + if (q.page) qs.set("page", String(q.page)); + if (q.pageSize) qs.set("pageSize", String(q.pageSize)); + if (q.type) qs.set("type", q.type); + if (q.servicio) qs.set("servicio", q.servicio); + if (q.status) qs.set("status", q.status); + if (q.view) qs.set("view", q.view); + const tail = qs.toString(); + return apiFetch(`/notifications/log${tail ? `?${tail}` : ""}`); +} + +export function getNotificationStats(): Promise { + return apiFetch("/notifications/stats"); +} + /** Build a download URL for a report's file output. The session cookie * travels with the browser's same-origin navigation, so a plain `href` * is enough — no fetch-with-credentials dance. */ diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index f1e72da..dbe212c 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -379,3 +379,37 @@ export function sourceSystemLabel(source: string): string { }; return map[source] ?? source; } + +// ----- Mass email notifications ----- + +import type { + NotificationStatus, + NotificationType, + NotificationServicio, +} from "./api"; + +export const NOTIFICATION_TYPE_LABELS: Record = { + OUTSTANDING_PAYMENT: "Pagos pendientes", + PAYMENT_CONFIRMATION: "Confirmación de pago", + ACCOUNT_STATUS: "Estado de cuenta", + TRUST_PAYMENT_CONFIRMATION: "Confirmación fideicomiso", +}; + +export const NOTIFICATION_SERVICIO_LABELS: Record = { + CUSTOMERS: "Clientes", + TRUST: "Fideicomiso", +}; + +export const NOTIFICATION_STATUS_LABELS: Record = { + SENT: "Enviado", + FAILED: "Falló", + SKIPPED_NO_EMAIL: "Sin email", + SKIPPED_GATE: "Fuera de día", +}; + +export const NOTIFICATION_STATUS_COLORS: Record = { + SENT: "#1f7a3a", + FAILED: "#b3261e", + SKIPPED_NO_EMAIL: "#666", + SKIPPED_GATE: "#888", +}; diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 5de230e..5275998 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -27,7 +27,8 @@ export type Ability = | "statement:review" | "lookup:manage" | "user:manage" - | "db:manage"; + | "db:manage" + | "notification:send"; export interface AuthUser { id: string; diff --git a/docs/MASS_EMAIL_NOTIFICATIONS.md b/docs/MASS_EMAIL_NOTIFICATIONS.md new file mode 100644 index 0000000..6a48594 --- /dev/null +++ b/docs/MASS_EMAIL_NOTIFICATIONS.md @@ -0,0 +1,153 @@ +# Mass Email Notifications + +Modern replacement for the four PHP scripts under +`email.notifications/send*.php` that fired bulk emails off the legacy +`utility_dbo.email_alert_log` table. Lives in this codebase from +`massive-email-notification` onward; the PHP scripts stay operational +until the office flips over. + +## Why + +The legacy scripts did three things this app needed to keep doing: send +outstanding-payment reminders, send payment-confirmation letters, and +fire account-status alerts (red and yellow). They also sent a fourth +trust-payment confirmation tied to `TRUSTHFEE`. Each was a separate CGI +script the office hit manually or via cron, talking to `utility_dbo` over +the same `mysqli` connection as the rest of the portal. + +The unified schema (see [`PLAN.md`](../PLAN.md) and +[`docs/INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md)) folded +`datosfreak` and `TRUSTHFEE` into `customers` + `transactions` + +`trust_accounts`, so the scripts' SQL no longer maps to anything. Rather +than maintain parallel sync code to keep `utility_dbo` populated, this +feature ports the four jobs onto the unified data and writes its own log. + +## What ships + +- `apps/api/src/mail/` — outbound mail transport. Amazon SES (matches + the `StorageService` env-driven optional-client pattern). Dev falls + back to stdout logging so a fresh checkout can exercise the jobs + without SES credentials. +- `apps/api/src/notifications/` — the four jobs (`outstanding`, + `payment-confirm`, `account-status`, `trust-confirm`), each a public + service method + a `POST /notifications/{slug}` HTTP endpoint gated on + the new `notification:send` ability (MANAGER). +- `packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql` + — two new tables (`email_notification_log`, `account_status_history`) + with enums and FKs to `customers`. +- `apps/web/src/app/notificaciones/` — admin page with 4 trigger cards, + a flags panel, a transport-status header, and a paginated log browser. + +## Job semantics + +Preserved from the PHP originals (see +`~/Documents/Claude-Memory/email-notifications-spec.md`): + +| Job | Recipients | Subject | Response key | +|---|---|---|---| +| 1. Outstanding payments | Customers with ≥1 outstanding Transaction (amount<0) | "Jorge Cuadros - Outstanding Payments" | `result:"success", notificationType:"sendPaymentConfirmation"` | +| 2. Payment confirmation | Customers with a credit in last 24h | "Jorge Cuadros - Payment Confirmation" | `request:"success", notificationType:"sendPaymentConfirmation"` | +| 3. Account status | All customers with a balance; yellow/red thresholds | "Jorge Cuadros - Account Status Alert" | `request:"success", notificationType:"sendAccountStatus"` | +| 4. Trust confirmation | Customers with TrustAccount + recent TRUST-domain credit | "Jorge Cuadros - Trust Payment Confirmation" | `request:"success", notificationType:"sendTrustPaymentConfirmation"` | + +Wire shapes match the PHP originals byte-for-byte so anything downstream +that scrapes `notificationType:"sendPaymentConfirmation"` keeps working. +Job 1 reports `result` (not `request`) and `notificationType` literally +`sendPaymentConfirmation` — these are the legacy quirks, preserved. + +### Day gates (Job 3 only) + +- **Yellow** ("DEBAJO DEL TIPO"): Wed only (or `ignoreDayRestriction`). +- **Red** ("EN ROJO"): Mon/Wed/Fri only (or `ignoreDayRestriction`). +- A customer who is red on Tuesday is logged as `SKIPPED_GATE` until + Wed, when both checks can fire on the same row. + +### Threshold logic (Job 3) + +The PHP used `datosfreak.TIPO` (50/100/200/300/500) and a hardcoded +threshold table. The new schema encodes this as `Customer.minimumBalance`: + +- Yellow: `0 ≤ balance < minimumBalance` +- Red: `balance < 0` + +Per-currency balance uses `BillingService.balances()` semantics (signed +`SUM(transactions.amount)`, voided + outstanding excluded), so a +yellow/red alert always lines up with what the receivables worklist shows +staff. The customer-servicing letter reports in USD because the legacy +letter was always USD; the union of `balanceUsd` and `balanceMxn` is +reported per-customer, never collapsed (see `BillingService.balances()`). + +### Rate limit (Job 3 only) + +`useEmailLimit=true` enables a vestigial throttle: pause the sweep 1h +after 100 sends. Off by default; SES does not need it. + +## Tables + +### `email_notification_log` + +One row per send attempt (sent, failed, skipped). Carries the rendered +body verbatim so a customer reply quoting an old email can be traced to +the exact letter sent. SES MessageId stored for bounce/complaint +correlation. + +Indexes: `(sendDate)`, `(notificationType, sendDate)`, `(customerId, sendDate)`. + +### `account_status_history` + +Mirrors the legacy `utility_dbo.send_account_status_history` table: +`(customerId, customerName, customerEmail, tipo, tCambio, balance, +solicitado, level)`. `tipo` is the literal `"DEBAJO DEL TIPO"` or +`"EN ROJO"` string the PHP used. `solicitado` keeps the legacy formula +(`0 - TIPO - BALANCE`) even though it double-subtracts; downstream +reports depend on the exact figure. + +Indexes: `(sendDate)`, `(customerId, sendDate)`, `(level, sendDate)`. + +## Environment + +``` +SES_REGION=us-east-1 +SES_ACCESS_KEY=... +SES_SECRET_KEY=... +SES_FROM=mail@jorgecuadros.com +SES_FROM_NAME=Information Server +SES_CONFIGURATION_SET=... # optional +NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net +``` + +Without SES_* the API still boots and `MailService` falls back to stdout +in dev (`NODE_ENV !== "production"`). In production every send throws +`ServiceUnavailableException` and the row is recorded as `FAILED`. + +## UI + +`/notificaciones` (gated on `notification:send`) — four trigger cards, +a debug/ignoreDayRestriction/useEmailLimit flags panel, a transport +status header, and a paginated log table. STAFF users see the log +read-only. + +## Cron (future) + +The four service methods (`runOutstandingPayments`, `runPaymentConfirmation`, +`runAccountStatus`, `runTrustConfirmation`) are the entry points. A future +`@nestjs/schedule` cron would call them on the legacy cadence (Job 3 on +Mon/Wed/Fri, Job 2 daily, Jobs 1 + 4 ad-hoc). Pattern matches +`OpsService`'s single-running-job guard: one `email_notification_sweep` +OpsJob per run, with its log streamed to `OpsJob.log`. + +## What is intentionally NOT in scope + +- Per-recipient preview / HTML view in the UI. The log table shows what + was sent; previewing one requires fetching `bodySnapshot` and rendering + HTML in the browser, deferred until a customer-service need surfaces. +- Bounce / complaint webhooks. `providerMessageId` is captured so a future + SNS topic can write back; the integration itself is a separate piece + of work. +- Spanish / English body toggle. Legacy letters are English; the legacy + customer base is bilingual. `Customer` has no language preference. + Add one when the need is concrete (same open question as + [`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) §1.6). +- Importing the legacy `utility_dbo.email_alert_log` rows. They reference + the old `NUMid` (a stringified double) which no longer maps to a + unified customer; an import would be destructive. diff --git a/packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql b/packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql new file mode 100644 index 0000000..8254182 --- /dev/null +++ b/packages/database/prisma/migrations/20260801200000_mass_email_notifications/migration.sql @@ -0,0 +1,65 @@ +-- Mass email notifications — modern replacement for the legacy +-- `utility_dbo.email_alert_log` + `utility_dbo.send_account_status_history` +-- tables, fed by the four PHP scripts under +-- `email.notifications/send*.php`. See +-- docs/MASS_EMAIL_NOTIFICATIONS.md for the design. +-- +-- The two legacy tables stay on `utility_dbo` untouched: their `NUMid` +-- column references a string identifier that no longer exists in the +-- unified schema, so a backfill would be destructive, not additive. New +-- notifications log here against the unified `customers.id` (uuid) and +-- the legacy rows are eventually retired by `utility_dbo` itself once +-- the office flips to this codebase as the source of truth. + +-- CreateTable +-- ENUM values are declared inline per column (MySQL has no CREATE TYPE) +-- and match the Prisma enums `EmailNotificationType`, +-- `EmailNotificationServicio`, `EmailNotificationStatus`. +CREATE TABLE `email_notification_log` ( + `id` VARCHAR(191) NOT NULL, + `sendDate` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `notificationType` ENUM('OUTSTANDING_PAYMENT', 'PAYMENT_CONFIRMATION', 'ACCOUNT_STATUS', 'TRUST_PAYMENT_CONFIRMATION') NOT NULL, + `level` INTEGER NULL, + `servicio` ENUM('CUSTOMERS', 'TRUST') NOT NULL, + `customerId` VARCHAR(191) NULL, + `customerName` VARCHAR(191) NOT NULL, + `customerEmail` VARCHAR(191) NOT NULL, + `subject` VARCHAR(191) NOT NULL, + `bodyRequestUrl` TEXT NULL, + `bodySnapshot` TEXT NOT NULL, + `debug` BOOLEAN NOT NULL DEFAULT false, + `providerMessageId` VARCHAR(191) NULL, + `providerResponse` VARCHAR(191) NULL, + `status` ENUM('SENT', 'FAILED', 'SKIPPED_NO_EMAIL', 'SKIPPED_GATE') NOT NULL, + `error` TEXT NULL, + + INDEX `email_notification_log_sendDate_idx`(`sendDate`), + INDEX `email_notification_log_notificationType_sendDate_idx`(`notificationType`, `sendDate`), + INDEX `email_notification_log_customerId_sendDate_idx`(`customerId`, `sendDate`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `account_status_history` ( + `id` VARCHAR(191) NOT NULL, + `sendDate` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `customerId` VARCHAR(191) NOT NULL, + `customerName` VARCHAR(191) NOT NULL, + `customerEmail` VARCHAR(191) NOT NULL, + `tipo` VARCHAR(191) NOT NULL, + `tCambio` DECIMAL(10, 4) NULL, + `balance` DECIMAL(12, 2) NOT NULL, + `solicitado` DECIMAL(12, 2) NOT NULL, + `level` INTEGER NOT NULL, + + INDEX `account_status_history_sendDate_idx`(`sendDate`), + INDEX `account_status_history_customerId_sendDate_idx`(`customerId`, `sendDate`), + INDEX `account_status_history_level_sendDate_idx`(`level`, `sendDate`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `email_notification_log` ADD CONSTRAINT `email_notification_log_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `account_status_history` ADD CONSTRAINT `account_status_history_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 662f29d..264e2d9 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -125,6 +125,9 @@ model Customer { statementDocuments StatementDocument[] policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer") + emailNotificationLogs EmailNotificationLog[] + accountStatusHistory AccountStatusHistory[] + @@map("customers") } @@ -919,6 +922,146 @@ model EmailLog { @@map("email_log") } +// --------------------------------------------------------------------------- +// Mass email notifications — the modern replacement for the legacy +// `email_alert_log` + `send_account_status_history` tables on utility_dbo and +// the four PHP scripts under `email.notifications/`. See +// docs/MASS_EMAIL_NOTIFICATIONS.md for the full design. +// +// The two legacy tables are not imported into this schema: the unified +// `customers` model replaces `datosfreak` (no more NUMid-as-string), so the +// rows would no longer carry their meaning. New tables follow the unified +// shape (FK to `customers`, signed Decimal balance, proper enums) and the +// four notification kinds are one `notificationType` enum rather than four +// parallel column families. +// --------------------------------------------------------------------------- + +/// Which bulk-notification script produced a row. Mirrors the four PHP +/// jobs in `email.notifications/send*.php`: +/// - OUTSTANDING_PAYMENT → sendOutstandingPaymentAlerts.php +/// - PAYMENT_CONFIRMATION → sendPaymentConfirmation.php (pagosemail) +/// - ACCOUNT_STATUS → sendAccountStatus.php (datosfreak, both +/// red and yellow; the threshold is in the +/// `level` column, 0=yellow / 1=red) +/// - TRUST_PAYMENT_CONFIRMATION → sendConfirmTrustPayment.php (TRUSTHFEE) +enum EmailNotificationType { + OUTSTANDING_PAYMENT + PAYMENT_CONFIRMATION + ACCOUNT_STATUS + TRUST_PAYMENT_CONFIRMATION +} + +/// Which "servicio" (line of business) the notification draws its recipients +/// from. CUSTOMERS = the unified customers ledger (replaces `datosfreak`); +/// TRUST = the trust-fee account table (replaces `TRUSTHFEE`). Keeping the +/// two services tagged makes a per-line report trivial. +enum EmailNotificationServicio { + CUSTOMERS + TRUST +} + +/// Outcome of a single send attempt. SENT / FAILED are the meaningful ones; +/// SKIPPED_NO_EMAIL records the dry-run path and the legacy's +/// "EMAIL IS NULL" exclusion, SKIPPED_GATE records the Mon/Wed/Fri day gate +/// on the red branch (and the Wed gate on yellow) — so a sweep that ran on +/// the wrong day shows up as skipped rows, not as missing rows. +enum EmailNotificationStatus { + SENT + FAILED + SKIPPED_NO_EMAIL + SKIPPED_GATE +} + +/// One row per send attempt. Captures both the deliverable (subject + body +/// snapshot + provider message id) and the diagnostic (URL we would have +/// fetched in the PHP version, SES response, error string). The body snapshot +/// is intentionally kept: the PHP scripts only stored it on the error path; +/// we store it always, so a customer reply quoting an old email can be traced +/// to the exact letter that was sent. +model EmailNotificationLog { + id String @id @default(uuid()) + sendDate DateTime @default(now()) + notificationType EmailNotificationType + /// 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO"). Only set on + /// ACCOUNT_STATUS rows; null on the other three jobs. + level Int? + /// Which servicio sourced the recipient list. CUSTOMERS for jobs 1/2/3, + /// TRUST for job 4. Tagged here so a per-line audit doesn't need to join. + servicio EmailNotificationServicio + /// FK to the customer that triggered the send. Trust-account notifications + /// resolve the owner through `Property.customerId`, so this stays set on + /// job 4 too. Null only on skipped rows where the lookup itself failed. + customerId String? + customer Customer? @relation(fields: [customerId], references: [id]) + customerName String + customerEmail String + /// Subject line of the email we attempted to send. + subject String + /// For PAYMENT_CONFIRMATION: the per-customer URL the PHP code built and + /// fetched (kept verbatim so the legacy format is reproducible). Null on + /// the other three jobs — the body is built inline. + bodyRequestUrl String? @db.Text + /// The HTML body that was sent (or that would have been sent, for SKIPPED + /// rows). Stored verbatim so audit/customer-service can read the exact + /// letter that went out without re-running the render. + bodySnapshot String @db.Text + /// True when `debug` was passed — the recipient was overridden to the + /// admin address and no real customer received the mail. Kept here so a + /// "where did all these emails go" investigation finds the answer in one + /// place instead of "who ran what with what flags" archaeology. + debug Boolean @default(false) + /// SES SendEmail MessageId, when we actually got one back. Null on + /// failures, skipped rows, and dev/mock transport. + providerMessageId String? + /// Free-form provider response (or error). Trimmed to 4k chars before + /// insert so a verbose SES bounce payload can't blow the column. + providerResponse String? + status EmailNotificationStatus + error String? @db.Text + + @@index([sendDate]) + @@index([notificationType, sendDate]) + @@index([customerId, sendDate]) + @@map("email_notification_log") +} + +/// Mirrors the legacy `utility_dbo.send_account_status_history` table — one +/// row per ACCOUNT_STATUS send, capturing the inputs the PHP version logged +/// for audit ("what balance, what threshold, what category of alert did we +/// fire"). Kept separate from `EmailNotificationLog` so the audit query +/// ("every red alert we ever sent this customer") doesn't have to filter by +/// notificationType; a one-row-per-send history is the whole point of the +/// legacy table. +model AccountStatusHistory { + id String @id @default(uuid()) + sendDate DateTime @default(now()) + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + customerName String + customerEmail String + /// "DEBAJO DEL TIPO" or "EN ROJO" — the legacy literal strings. Kept + /// verbatim (not an enum) because the PHP scripts and downstream reports + /// filter by them, and "preserve legacy semantics" is the stated goal. + tipo String + /// Exchange rate at send time, kept for currency conversions downstream. + /// Null when the customer has no exchange-rate context (no FX movement). + tCambio Decimal? @db.Decimal(10, 4) + /// Customer's balance at send time, in the customer's currency. Negative + /// for red; 0..min for yellow. + balance Decimal @db.Decimal(12, 2) + /// Legacy formula: `0 - TIPO - BALANCE` — the amount the customer needs to + /// deposit to clear the threshold. Preserved verbatim even though it + /// double-subtracts; downstream reports depend on the exact figure. + solicitado Decimal @db.Decimal(12, 2) + /// 0 = yellow, 1 = red. Mirrors the legacy `level` column. + level Int + + @@index([sendDate]) + @@index([customerId, sendDate]) + @@index([level, sendDate]) + @@map("account_status_history") +} + // --------------------------------------------------------------------------- // Admin database operations (Operaciones): backup / restore / re-import / sync. // Each long-running op is one OpsJob row so the web UI can poll status + tail