From 30278a964235a5db9fa75fb1e76e048c72d31153 Mon Sep 17 00:00:00 2001 From: BOHA Date: Thu, 26 Mar 2026 11:02:22 +0100 Subject: [PATCH] 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) --- index.html | 43 ++-- package-lock.json | 18 ++ package.json | 2 + .../migration.sql | 9 + prisma/schema.prisma | 10 + public/favicon-96x96.png | Bin 0 -> 3853 bytes public/favicon.ico | Bin 0 -> 15086 bytes public/favicon.svg | 3 + src/config/env.ts | 1 + src/server.ts | 15 ++ src/services/invoice-alerts.ts | 227 ++++++++++++++++++ 11 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 prisma/migrations/20260326_add_invoice_alert_log/migration.sql create mode 100644 public/favicon-96x96.png create mode 100644 public/favicon.ico create mode 100644 public/favicon.svg create mode 100644 src/services/invoice-alerts.ts diff --git a/index.html b/index.html index e99868a..28613ec 100644 --- a/index.html +++ b/index.html @@ -1,19 +1,28 @@ - + - - - - - - - - - - - BOHA | Admin - - -
- - + + + + + + + + + + + + + + BOHA | Admin + + +
+ + diff --git a/package-lock.json b/package-lock.json index 0231da6..3fb6035 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "framer-motion": "^12.38.0", "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.3", + "node-cron": "^4.2.1", "nodemailer": "^8.0.2", "otpauth": "^9.5.0", "prisma": "^6.19.2", @@ -46,6 +47,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/mysql": "^2.15.27", "@types/node": "^25.5.0", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.11", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", @@ -1547,6 +1549,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/nodemailer": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", @@ -4232,6 +4241,15 @@ "node": ">= 0.4.0" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", diff --git a/package.json b/package.json index 22c9a52..ec6cda2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "framer-motion": "^12.38.0", "hi-base32": "^0.5.1", "jsonwebtoken": "^9.0.3", + "node-cron": "^4.2.1", "nodemailer": "^8.0.2", "otpauth": "^9.5.0", "prisma": "^6.19.2", @@ -61,6 +62,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/mysql": "^2.15.27", "@types/node": "^25.5.0", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^7.0.11", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", diff --git a/prisma/migrations/20260326_add_invoice_alert_log/migration.sql b/prisma/migrations/20260326_add_invoice_alert_log/migration.sql new file mode 100644 index 0000000..985fc41 --- /dev/null +++ b/prisma/migrations/20260326_add_invoice_alert_log/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE `invoice_alert_log` ( + `id` INT NOT NULL AUTO_INCREMENT, + `invoice_type` VARCHAR(20) NOT NULL, + `invoice_id` INT NOT NULL, + `alert_type` VARCHAR(20) NOT NULL, + `sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `invoice_type_invoice_id_alert_type` (`invoice_type`, `invoice_id`, `alert_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e5f5b32..6d60854 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -601,6 +601,16 @@ enum leave_requests_status { cancelled } +model invoice_alert_log { + id Int @id @default(autoincrement()) + invoice_type String @db.VarChar(20) // "created" or "received" + invoice_id Int + alert_type String @db.VarChar(20) // "3days" or "due" + sent_at DateTime @default(now()) @db.DateTime(0) + + @@unique([invoice_type, invoice_id, alert_type]) +} + enum received_invoices_status { unpaid paid diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..4fe2296072cd2b9492c7c1ac5f5037eeda0963c7 GIT binary patch literal 3853 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 P00000NkvXXu0mjfV`Yca literal 0 HcmV?d00001 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5a95c0a4aa995d33f5580913af2a02df0f2c7bdb GIT binary patch literal 15086 zcmeHOX-rgC6n<*|ID;^3?kKVdQWZr+6ciW+W&vRa1{joK&^E12n>0YgJ?sWM8DVq|51d-kUehKxNU#JSX8Lm&;?` zoAZ6=o^!YFA_&uj8N#Yn0_teti+Dju69geT+I_DRgq!%A4mE$i;|W1{3lAjX8N4FY zpzz-(a81{0wR1zX+E-EPLo}KWl)q8P&xLJv0K*x^0!(wjVfrXMrbP(CES*O4I-c+M zGuE+v15ChH%NW67+Ato|f`Wn~f%)WPhkInZpJ1%s9GDYup9lc#ayy-Hw*xoBbTMW| z@|2%_ENjNh*xk805J0|{{E2+voJOwh3^?p^tVn(2*#O3W2G~0T7QcF9lTv+x8sn-` z*r&cn?gysosaSZ(7+4aOPa882P)=4I>9gx7H|Hxd=nqp~?qSL|93f-gQ8MKpC1d_! zJhPY9rS77{cpJ@&TuM6aEY79 zSKlL->kq1_8ARPn)y_M3?johGeu=_^BUI&(7+*t0MNL#%+B+%-3ICt8ZQGyp)Kf$B z^wUG&(m`ueUO_tqtHvQAu7)-jHd0AR&j=2cm3PH}0DB)_04ev736Ind?9p`@gZB10Ebq%I2e zB8rbGCP&2`F(2-?!w}+WkTzz#t15o8=_YcZQ{-T?N$p^@wj!>BJh;MxXCuzeqaA+t z;ox%h)B1I9c%Jk3TrxhUnoP!_`qX>)GrNCVZjd=$C6aT&auk)4Tj&yWV$md2fN*u zgWcXt%a&I8 z{}|xU$@vvA)GBf?o27Ozn;R)KC=~t;p$OdqRg|55 z6)|+1^7HR=Fd7??mr*DSFa|^e=N_~{j}M26ihj)Xj)9|k^Ek$zm34)3b6b2km`tsd zll>#Qu$QWKk7u+IR&y&^&^J97~0Ta^EVA(J}{{ zwS|(GzpQHfM@Ma?%*@M(p{8+hkmngZz8UnVXm(hv>i9Do`+3r+hwI3Fz)qSD>HnX6z_}TfaUPFdSyiJnf}lGX?qlKp=29IsE(p z6Tl^(-*^K#{rKtGz{D}*_WSVj8xjNQr>x1p1|HnE^7x>}7$=QQi~$vJJKcSe^@qeu8u}XYO<7C&$;n3fh)&@^}8lHjrO}>oxq^1cFgwmIp4YWnPpABmnt1S(2e>< z46I>q-#!E#zKs^mPlXSZ+ZPIj{p{@1qW&Xk(mj;E_C08DJD~UPg5K8U!QgWBK|`@C zs-dv`tWBpxEzoN1!k&8pI3MND3yx#7HjtDyg3KQrTmsAWkQ?(5R>84cFoN=t7b2MmhZ zMn8>s4yO(4zZJC{)&!-!_dAG(Hjf6y=R`_No1k5p70*AmpPv4WsKrQo&uz2>`?X=g zk=WyX6?xI^)*}1RCiRoTe_h&fXeljj46T&0;TWZ_+XtU;f5e(DpEc>FqRkf+*G`y! zTI$!LmLO|VCgW}F!MCC<+7Xko2GUMz*StLm{tu-!DMz3O+(18Y6Pio|d~plhn}DI2 z3M?(q02&aZpHtW@<WGxw0J=-&K*sbW-jf= zv!?f;BJI^Hm-C!=ekf|rf!Ma%^G{xHsWJa%n}^(MfV+NZXWv+IuRnMVgtgFq*!TDP zYrnh>b+11e1N&hz*Pnlj>rbrxGiUu+sS6Vg?UR0&Q7&r?(hq=>Sr*%jBo9bl<=H554Pev4Ej2p9qYr~ zw}YbQr2zZb<4|J!4)Oel+4M85OnOno;czrl)ZFpCpAh#f#{M>OKFsHk%FBBwWBmu? zo_EK`JcIK=EjTB19@_j{;vBBH_!{0H8#~$*bATGmN3LOAb{+E&K5KUi=XT#2ce_RC z;@~SI)=FktywE}m=Wdwb+EJPlh~vI^rjqX;i2c#xSIWJI&L6KFWE|u5QInj%=pr8J F`VS;oy-)xE literal 0 HcmV?d00001 diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..ad0d6bb --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/config/env.ts b/src/config/env.ts index 49b35e8..da3356a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -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 || "", diff --git a/src/server.ts b/src/server.ts index 5a0beed..78f59f3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { diff --git a/src/services/invoice-alerts.ts b/src/services/invoice-alerts.ts new file mode 100644 index 0000000..9681110 --- /dev/null +++ b/src/services/invoice-alerts.ts @@ -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, """); +} + +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 { + 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) => ` + + ${escapeHtml(a.number)} + ${escapeHtml(a.counterparty)} + ${a.amount} ${a.currency} + ${a.due_date} + ${a.days_label} + `, + ) + .join(""); + + return ` +

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

+ + + + + + + + + + + ${rows} +
ČísloFirmaČástkaSplatnostStav
`; + }; + + 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}`); + } else { + console.log( + `InvoiceAlerts: Sent ${alerts.length} alert(s) to ${alertEmail}`, + ); + } +}