Files
app/src/services/invoice-alerts.ts
BOHA 975a555af5 fix(dates): @db.Date filters built from local midnight queried the wrong day
Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to
its UTC date part. Under TZ=Europe/Prague a local-midnight boundary
(new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as
the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of
every @db.Date filter in the codebase. New shared helper
utcMidnightOfLocalDay() in src/utils/date.ts.

Fixed (all previously off by one day):
- attendance.service: getStatus today+month windows; getWorkfund (last day
  of each month was double-counted across months); getPrintData (monthly
  print included prev month's last day); listAttendance (admin month view
  included prev month's last day AND dropped the selected month's last
  day); createAttendance duplicate/overlap validation (checked only the
  PREVIOUS day - same-day duplicates were never caught, neighbors falsely
  rejected)
- invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired
  (due==today+3 was outside the fetched window); window computation
  extracted as computeAlertWindow() + pure tests
- invoices.service: month list/totals filter dropped invoices issued on the
  month's last day; getInvoiceStats month/year bounds; markOverdueInvoices
  boundary; auto paid_date could store yesterday during 00:00-02:00
- received-invoices auto paid_date, issued-orders default order_date: same
  night-window write hazard, now via the helper
- attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD)

+9 regression tests (month boundaries, same-day duplicate, last-day-of-month
invoice in list+totals, alert window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:28 +02:00

277 lines
9.5 KiB
TypeScript

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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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<void> {
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) => `
<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}`);
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);
}
}