import prisma from "../config/database"; import { config } from "../config/env"; import { sendMail } from "./mailer"; import { localDateCzStr, localDateStr, utcMidnightOfLocalDay, } from "../utils/date"; import { getSystemSettings } from "./system-settings"; interface AlertInvoice { id: number; type: "created" | "received"; alert_type: "due" | "3days"; number: string; counterparty: string; amount: string; currency: string; due_date: string; days_label: string; } function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function formatAmount(n: number | { toNumber?: () => number }): string { const num = typeof n === "number" ? n : Number(n); return num.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2, }); } /** * Alert window [today, today+3] for the due-date queries + the local date * strings the classifier matches against. * * due_date is @db.Date: Prisma truncates the filter Dates to their UTC date * part, so the window boundaries must be UTC midnights of the LOCAL calendar * day. A local midnight (22:00/23:00 UTC of the previous day) shifted the * whole window a day early — the "splatnost za 3 dny" advance alert * (due == today+3) could then never fire. * * Exported (with an injectable `now`) so the window math is unit-testable. */ export function computeAlertWindow(now: Date = new Date()) { const today = utcMidnightOfLocalDay(now); const todayStr = localDateStr(now); const in3days = new Date(today); in3days.setUTCDate(in3days.getUTCDate() + 3); const in3daysStr = localDateStr( new Date(now.getFullYear(), now.getMonth(), now.getDate() + 3), ); return { today, todayStr, in3days, in3daysStr }; } export async function checkInvoiceAlerts(): Promise { const settings = await getSystemSettings(); const alertEmail = settings.invoice_alert_email || config.email.invoiceAlert; if (!alertEmail) return; const { today, todayStr, in3days, in3daysStr } = computeAlertWindow(); // Classify a due date into an alert type/label, or null if it doesn't match. const classify = ( dueDate: Date, ): { alertType: "due" | "3days"; daysLabel: string } | null => { const dueDateStr = localDateStr(dueDate); if (dueDateStr === todayStr) return { alertType: "due", daysLabel: "splatnost dnes" }; if (dueDateStr === in3daysStr) return { alertType: "3days", daysLabel: "splatnost za 3 dny" }; return null; }; // --- Gather candidates (created + received) before touching the alert log --- const [createdInvoices, receivedInvoices] = await Promise.all([ prisma.invoices.findMany({ where: { status: { in: ["issued", "overdue"] }, due_date: { gte: today, lte: in3days }, }, select: { id: true, invoice_number: true, due_date: true, currency: true, apply_vat: true, vat_rate: true, customers: { select: { name: true } }, invoice_items: { select: { quantity: true, unit_price: true, vat_rate: true }, }, }, }), prisma.received_invoices.findMany({ where: { status: "unpaid", due_date: { gte: today, lte: in3days }, }, select: { id: true, invoice_number: true, supplier_name: true, amount: true, currency: true, due_date: true, }, }), ]); // Build the candidate set (matched-on-date, not yet de-duped against the log) // so we can batch-load the alert log in a single query. type Candidate = AlertInvoice; const createdCandidates: Candidate[] = []; for (const inv of createdInvoices) { if (!inv.due_date) continue; const c = classify(new Date(inv.due_date)); if (!c) continue; // Gross total (subtotal + VAT) — consistent with the gross amounts shown // for received invoices. VAT is computed per line, rounded to 2 decimals. const subtotal = inv.invoice_items.reduce( (sum, item) => sum + Number(item.quantity) * Number(item.unit_price), 0, ); const vatTotal = inv.apply_vat ? inv.invoice_items.reduce((sum, item) => { const base = Number(item.quantity) * Number(item.unit_price); const rate = Number(item.vat_rate) || Number(inv.vat_rate) || 21; return sum + Math.round(base * (rate / 100) * 100) / 100; }, 0) : 0; const gross = Math.round((subtotal + vatTotal) * 100) / 100; createdCandidates.push({ id: inv.id, type: "created", alert_type: c.alertType, number: inv.invoice_number || `#${inv.id}`, counterparty: inv.customers?.name || "—", amount: formatAmount(gross), currency: inv.currency || "CZK", due_date: localDateCzStr(new Date(inv.due_date)), days_label: c.daysLabel, }); } const receivedCandidates: Candidate[] = []; for (const inv of receivedInvoices) { if (!inv.due_date) continue; const c = classify(new Date(inv.due_date)); if (!c) continue; receivedCandidates.push({ id: inv.id, type: "received", alert_type: c.alertType, number: inv.invoice_number || inv.supplier_name, counterparty: inv.supplier_name, // received_invoices.amount is GROSS (VAT-inclusive) by convention. amount: formatAmount(inv.amount), currency: inv.currency, due_date: localDateCzStr(new Date(inv.due_date)), days_label: c.daysLabel, }); } const candidates = [...createdCandidates, ...receivedCandidates]; if (candidates.length === 0) return; // Batch-load every already-sent log entry for the candidate set in ONE query // (instead of a findUnique per invoice). const sentLogs = await prisma.invoice_alert_log.findMany({ where: { OR: candidates.map((c) => ({ invoice_type: c.type, invoice_id: c.id, alert_type: c.alert_type, })), }, select: { invoice_type: true, invoice_id: true, alert_type: true }, }); const sentKeys = new Set( sentLogs.map((l) => `${l.invoice_type}:${l.invoice_id}:${l.alert_type}`), ); const alerts: AlertInvoice[] = candidates.filter( (c) => !sentKeys.has(`${c.type}:${c.id}:${c.alert_type}`), ); if (alerts.length === 0) return; // --- Build summary email --- const createdAlerts = alerts.filter((a) => a.type === "created"); const receivedAlerts = alerts.filter((a) => a.type === "received"); const subject = `Upozornění na splatnost faktur (${alerts.length})`; const buildTable = (items: AlertInvoice[], title: string) => { if (items.length === 0) return ""; const rows = items .map( (a) => ` ${escapeHtml(a.number)} ${escapeHtml(a.counterparty)} ${a.amount} ${a.currency} ${a.due_date} ${a.days_label} `, ) .join(""); return `

${escapeHtml(title)} (${items.length})

${rows}
Číslo Firma Částka Splatnost Stav
`; }; const html = `

Upozornění na splatnost faktur

Následující faktury se blíží ke splatnosti nebo jsou dnes splatné:

${buildTable(createdAlerts, "Vydané faktury (neuhrazené zákazníkem)")} ${buildTable(receivedAlerts, "Přijaté faktury (k úhradě)")}

Automatické upozornění vygenerováno ${localDateCzStr(today)}.

`; const sent = await sendMail(alertEmail, subject, html); if (!sent) { console.error(`InvoiceAlerts: Failed to send alert to ${alertEmail}`); return; } console.log(`InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`); // Mark alerts as sent only after successful delivery. One INSERT with // skipDuplicates so a unique-constraint hit (concurrent run) on one row // doesn't abort logging the rest. try { await prisma.invoice_alert_log.createMany({ data: alerts.map((a) => ({ invoice_type: a.type, invoice_id: a.id, alert_type: a.alert_type, })), skipDuplicates: true, }); } catch (err) { // Non-fatal: the email already went out. Log so a failed bookkeeping // write is visible (and may cause a duplicate alert on the next run). console.error("InvoiceAlerts: failed to record alert log:", err); } }