feat: invoice due date email alerts, add favicon
- Daily cron (8:00 AM) checks created and received invoices - Alerts 3 days before due date and on due date - Summary email to INVOICE_ALERT_EMAIL with grouped tables - Tracks sent alerts in invoice_alert_log to prevent duplicates - node-cron scheduler runs inside the app process - Favicon files copied from PHP project Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,7 @@ export const config = {
|
||||
smtpFrom: process.env.SMTP_FROM || "",
|
||||
smtpFromName: process.env.SMTP_FROM_NAME || "BOHA Automation",
|
||||
leaveNotify: process.env.LEAVE_NOTIFY_EMAIL || "",
|
||||
invoiceAlert: process.env.INVOICE_ALERT_EMAIL || "",
|
||||
},
|
||||
|
||||
appUrl: process.env.APP_URL || "",
|
||||
|
||||
@@ -177,6 +177,21 @@ async function start() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Invoice alert cron (daily at 8:00 AM) ---
|
||||
if (config.email.invoiceAlert) {
|
||||
const cron = await import("node-cron");
|
||||
cron.default.schedule("0 8 * * *", async () => {
|
||||
try {
|
||||
const { checkInvoiceAlerts } =
|
||||
await import("./services/invoice-alerts");
|
||||
await checkInvoiceAlerts();
|
||||
} catch (err) {
|
||||
app.log.error(err, "Invoice alert cron failed");
|
||||
}
|
||||
});
|
||||
console.log("Invoice alert cron scheduled (daily 8:00)");
|
||||
}
|
||||
|
||||
// --- Start ---
|
||||
const port = config.isProduction ? config.port : 3000;
|
||||
try {
|
||||
|
||||
227
src/services/invoice-alerts.ts
Normal file
227
src/services/invoice-alerts.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { sendMail } from "./mailer";
|
||||
import { localDateCzStr, localDateStr } from "../utils/date";
|
||||
|
||||
interface AlertInvoice {
|
||||
id: number;
|
||||
type: "created" | "received";
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkInvoiceAlerts(): Promise<void> {
|
||||
const alertEmail = config.email.invoiceAlert;
|
||||
if (!alertEmail) return;
|
||||
|
||||
const today = new Date();
|
||||
const todayStr = localDateStr(today);
|
||||
const in3days = new Date(today);
|
||||
in3days.setDate(in3days.getDate() + 3);
|
||||
const in3daysStr = localDateStr(in3days);
|
||||
|
||||
const alerts: AlertInvoice[] = [];
|
||||
|
||||
// --- Created invoices (customer owes us) ---
|
||||
const createdInvoices = await prisma.invoices.findMany({
|
||||
where: {
|
||||
status: { in: ["issued", "overdue"] },
|
||||
due_date: { not: null },
|
||||
},
|
||||
include: {
|
||||
customers: { select: { name: true } },
|
||||
invoice_items: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const inv of createdInvoices) {
|
||||
if (!inv.due_date) continue;
|
||||
const dueDateStr = localDateStr(new Date(inv.due_date));
|
||||
|
||||
let alertType: string | null = null;
|
||||
let daysLabel = "";
|
||||
|
||||
if (dueDateStr === todayStr) {
|
||||
alertType = "due";
|
||||
daysLabel = "splatnost dnes";
|
||||
} else if (dueDateStr === in3daysStr) {
|
||||
alertType = "3days";
|
||||
daysLabel = "splatnost za 3 dny";
|
||||
}
|
||||
|
||||
if (!alertType) continue;
|
||||
|
||||
const alreadySent = await prisma.invoice_alert_log.findUnique({
|
||||
where: {
|
||||
invoice_type_invoice_id_alert_type: {
|
||||
invoice_type: "created",
|
||||
invoice_id: inv.id,
|
||||
alert_type: alertType,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (alreadySent) continue;
|
||||
|
||||
const subtotal = inv.invoice_items.reduce(
|
||||
(sum, item) => sum + Number(item.quantity) * Number(item.unit_price),
|
||||
0,
|
||||
);
|
||||
|
||||
alerts.push({
|
||||
id: inv.id,
|
||||
type: "created",
|
||||
number: inv.invoice_number || `#${inv.id}`,
|
||||
counterparty: inv.customers?.name || "—",
|
||||
amount: formatAmount(subtotal),
|
||||
currency: inv.currency || "CZK",
|
||||
due_date: localDateCzStr(new Date(inv.due_date)),
|
||||
days_label: daysLabel,
|
||||
});
|
||||
|
||||
await prisma.invoice_alert_log.create({
|
||||
data: {
|
||||
invoice_type: "created",
|
||||
invoice_id: inv.id,
|
||||
alert_type: alertType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Received invoices (we owe supplier) ---
|
||||
const receivedInvoices = await prisma.received_invoices.findMany({
|
||||
where: {
|
||||
status: "unpaid",
|
||||
due_date: { not: null },
|
||||
},
|
||||
});
|
||||
|
||||
for (const inv of receivedInvoices) {
|
||||
if (!inv.due_date) continue;
|
||||
const dueDateStr = localDateStr(new Date(inv.due_date));
|
||||
|
||||
let alertType: string | null = null;
|
||||
let daysLabel = "";
|
||||
|
||||
if (dueDateStr === todayStr) {
|
||||
alertType = "due";
|
||||
daysLabel = "splatnost dnes";
|
||||
} else if (dueDateStr === in3daysStr) {
|
||||
alertType = "3days";
|
||||
daysLabel = "splatnost za 3 dny";
|
||||
}
|
||||
|
||||
if (!alertType) continue;
|
||||
|
||||
const alreadySent = await prisma.invoice_alert_log.findUnique({
|
||||
where: {
|
||||
invoice_type_invoice_id_alert_type: {
|
||||
invoice_type: "received",
|
||||
invoice_id: inv.id,
|
||||
alert_type: alertType,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (alreadySent) continue;
|
||||
|
||||
alerts.push({
|
||||
id: inv.id,
|
||||
type: "received",
|
||||
number: inv.invoice_number || inv.supplier_name,
|
||||
counterparty: inv.supplier_name,
|
||||
amount: formatAmount(inv.amount),
|
||||
currency: inv.currency,
|
||||
due_date: localDateCzStr(new Date(inv.due_date)),
|
||||
days_label: daysLabel,
|
||||
});
|
||||
|
||||
await prisma.invoice_alert_log.create({
|
||||
data: {
|
||||
invoice_type: "received",
|
||||
invoice_id: inv.id,
|
||||
alert_type: alertType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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) => `
|
||||
<tr>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #ddd;">${escapeHtml(a.number)}</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #ddd;">${escapeHtml(a.counterparty)}</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #ddd; text-align: right;">${a.amount} ${a.currency}</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #ddd;">${a.due_date}</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #ddd; color: ${a.days_label.includes("dnes") ? "#dc3545" : "#e67e22"}; font-weight: bold;">${a.days_label}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<h3 style="color: #333; margin-top: 24px;">${escapeHtml(title)} (${items.length})</h3>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 12px 0;">
|
||||
<thead>
|
||||
<tr style="background: #f5f5f5;">
|
||||
<th style="padding: 8px; text-align: left;">Číslo</th>
|
||||
<th style="padding: 8px; text-align: left;">Firma</th>
|
||||
<th style="padding: 8px; text-align: right;">Částka</th>
|
||||
<th style="padding: 8px; text-align: left;">Splatnost</th>
|
||||
<th style="padding: 8px; text-align: left;">Stav</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
};
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<h2 style="color: #dc3545;">Upozornění na splatnost faktur</h2>
|
||||
<p>Následující faktury se blíží ke splatnosti nebo jsou dnes splatné:</p>
|
||||
${buildTable(createdAlerts, "Vydané faktury (neuhrazené zákazníkem)")}
|
||||
${buildTable(receivedAlerts, "Přijaté faktury (k úhradě)")}
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
|
||||
<p style="font-size: 12px; color: #999;">
|
||||
Automatické upozornění vygenerováno ${localDateCzStr(today)}.
|
||||
</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const sent = await sendMail(alertEmail, subject, html);
|
||||
if (!sent) {
|
||||
console.error(`InvoiceAlerts: Failed to send alert to ${alertEmail}`);
|
||||
} else {
|
||||
console.log(
|
||||
`InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user