Files
app/src/services/invoice-alerts.ts
BOHA 6b31b2f74b feat: system settings, dynamic logos, template numbering, permission consolidation
- System settings page with tabs: Security, System, Firma
- Configurable attendance rules (break thresholds, rounding) from DB
- Configurable document numbering with template patterns ({YYYY}/{PREFIX}/{NNN})
- Dynamic logo upload (light/dark variants) served from DB instead of static files
- Email settings (SMTP from/name, alert/leave emails) configurable in UI
- Currency and VAT rate lists configurable, used across all modules
- Permissions simplified: offers.settings + settings.roles + settings.security → settings.manage
- Leaflet bundled locally, removed unpkg.com from CSP
- Silent catch blocks fixed with proper logging
- console.log replaced with app.log.info in server.ts
- Schema renamed: company-settings.schema → settings.schema
- App info section: version, Node.js, uptime, memory, DB status, NAS status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:15:47 +01:00

230 lines
6.9 KiB
TypeScript

import prisma from "../config/database";
import { config } from "../config/env";
import { sendMail } from "./mailer";
import { localDateCzStr, localDateStr } from "../utils/date";
import { getSystemSettings } from "./system-settings";
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 settings = await getSystemSettings();
const alertEmail = settings.invoice_alert_email || 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}`,
);
}
}