diff --git a/docs/architecture/regional-settings.md b/docs/architecture/regional-settings.md index 6cbfb226a..8af7b5fcc 100644 --- a/docs/architecture/regional-settings.md +++ b/docs/architecture/regional-settings.md @@ -103,6 +103,7 @@ Backend consumers of `resolveRegionalSnapshot()`: | `main/services/cash-close-export.ts` | Regional currency, locale, timezone, and formatting preferences when serializing X and Z report HTML without pre-resolved options. | | `main/services/refund.ts` | Refund minor units and the business timezone used for the refund window. | | `main/services/daily-sales-export.ts` | Currency minor-unit factor for the accounting export, and regional locale and formatting preferences for `serializeDailySalesExportHtml`. | +| `main/services/daily-invoices-export.ts` | Regional locale and formatting preferences for `buildDailyInvoicesExportDataset` and `serializeDailyInvoicesExportHtml`. | | `main/services/expenses.ts` | Store currency and business timezone for expense business-date resolution. | | `main/services/whatsapp.ts` | Store regional snapshot for WhatsApp message formatting. | | `main/routes/menu-csv.ts` | Amount formatting and separators for menu and add-on CSV export. | diff --git a/docs/reference/api.md b/docs/reference/api.md index f755bf777..75a24664d 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -868,6 +868,7 @@ day-boundary rules. | `GET` | `/z-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv`, `xlsx`, or `html`) | Exports the stored Z-report snapshot for a closed business date (or print-styled HTML for PDF/browser print when `format=html`). Returns `400` with `{ error: "format must be xlsx, csv, or html" }` when `format` is missing or invalid, and `404` with `{ error: "Day not closed" }` when the day has not been closed. Sets `Content-Disposition` to `attachment; filename="z-report-Z-."`. | | `POST` | `/cash-closes/:id/whatsapp` | `reports.view` + `whatsapp.use` | path: `id`; body: `phone_e164`, `body` (non-empty, max 4096 chars) | Sends the submitted report body and returns `{ success: true, messageId }`; when WhatsApp is disconnected, returns `{ fallback: true, reason: "not_connected" }` so the client can open WhatsApp for sharing. Invalid input returns `400`; an unknown day close returns `404`. | | `GET` | `/daily-sales/export` | `reports.daily-sales.export` (owner by default) | query: `?date`, `?from`, `?to`, `?format`, `?part` | See [GET `/api/reports/daily-sales/export`](#get-apireportsdaily-salesexport). | +| `GET` | `/daily-invoices/export` | `reports.daily-sales.export` (owner by default) | query: `?date`, `?from`, `?to`, `?format` | See [GET `/api/reports/daily-invoices/export`](#get-apireportsdaily-invoicesexport). | | `GET` | `/customer-sales/export` | `reports.customer-sales.export` (owner by default) | query: `?start_date`, `?end_date`, `?format` | One row per item sold with customer details, for a business-date range of at most 366 days. `format` is `xlsx` or `csv`; invalid format, reversed range, or oversized range returns `400`. See [customer sales export](daily-sales-export.md#customer-sales-export). | ### GET `/api/reports/daily-sales/export` @@ -895,6 +896,34 @@ reconciliation contract, the summary metric field order, and the worksheet/CSV c Errors: `400` for an invalid `format`, a CSV without `part`, mixed `date` and `from`/`to` arguments, an incomplete range, an invalid calendar date, `to < from`, a range exceeding 93 days, or an `html` export exceeding the 15 MB PDF size limit; `401` unauthenticated; `403` non-owner; `409` for a concurrent-write conflict; `500` otherwise. +### GET `/api/reports/daily-invoices/export` + +Compiled daily invoices export (`reports.daily-sales.export`, owner by default) for one tenant +business date (`?date=YYYY-MM-DD`, defaults to the current business date) or an inclusive +business-date range (`?from=YYYY-MM-DD&to=YYYY-MM-DD`, up to 366 calendar days). Returns a +print-styled A4 portrait HTML document (`format=html`, default when omitted) formatted with the +store's regional locale and preferences (`resolveRegionalSnapshot`), used by `exportReportAsPdf` for +desktop PDF export (`invoices-YYYY-MM-DD.pdf` or `invoices-YYYY-MM-DD_to_YYYY-MM-DD.pdf`) and +browser print fallback. + +Page 1 renders a bounded fiscal summary cover page (store legal name, tax registration ID with the +active country tax pack's `registrationNumberLabel` or country profile `taxIdLabel`, address, +business date/range, timezone, business-day start, currency, total invoices count, gross sales, net +base, tax collected, in-period refunds, net revenue, the VAT/tax summary table by rate, refund scope +reconciliation across in-period, prior-bill, outside-period, and lifetime totals, and summary +reconciliation metrics) reused from `buildDailySalesExportDataset` (see +[daily sales export](daily-sales-export.md)). When the window has issued invoices, page 2+ renders +all bills paid in the business-day window (`payment_status IN ('paid', 'partially_refunded', +'refunded')`) ordered by ascending business date and numeric `bill_number` sequence (`paid_at`, +`id`, and `bill_number` tie-breakers), with `page-break-inside: avoid; break-inside: avoid;` on each +`.invoice-card`, `[PAID]`, `[PARTIALLY REFUNDED]`, and `[REFUNDED]` status badges, non-excluded line +items (unit price, pre-tax subtotal, and after-tax total), tax components, payment details, and +`[In Period]` / `[Outside Period]` refund details. A trailing page-broken appendix renders the +per-day tax breakdown table and any prior-period invoices refunded during the selected period. + +- Returns `200` with `Content-Type: text/html; charset=utf-8` and `Content-Disposition: attachment; filename="invoices-YYYY-MM-DD.html"` (or `invoices-YYYY-MM-DD_to_YYYY-MM-DD.html`). +- Errors: `400` for an unsupported `format`, mixed `date` and `from`/`to` arguments, an incomplete or reversed range, an invalid calendar date, a range exceeding 366 days, more than 5,000 matching invoices (`MAX_DAILY_INVOICES_COUNT`), or an HTML export exceeding the 15 MB PDF size limit; `401` unauthenticated; `403` when lacking `reports.daily-sales.export`; `409` for unconfigured regional settings or a concurrent-write conflict; `500` otherwise. + ### Inline endpoints (registered outside a router) These seven are declared on `app` inside `registerRoutes` in `main/routes/index.ts`, not on a @@ -1169,17 +1198,17 @@ documents only the two routes. ## Coverage -This page lists 310 HTTP route registrations across 309 distinct rows, and one WebSocket contract: +This page lists 311 HTTP route registrations across 310 distinct rows, and one WebSocket contract: -- `281` on the main API: 273 router registrations, 7 inline registrations in `main/routes/index.ts`, +- `282` on the main API: 274 router registrations, 7 inline registrations in `main/routes/index.ts`, and `GET /api/health` in `main/server.ts`. - `8` REST routes on the KDS server, plus 3 static handlers. - `21` route registrations on the Server App, of which 13 forward to the main API. Two of those 21 are mutually exclusive `GET /` handlers under one path, which is why 21 registrations render as 20 rows. -Every one of the 309 rows is listed with its method, path, authorization gate, and the parameters -its handler reads. The **Response** column carries a note for 173 of them: 145 main-API endpoints, +Every one of the 310 rows is listed with its method, path, authorization gate, and the parameters +its handler reads. The **Response** column carries a note for 174 of them: 146 main-API endpoints, all 8 KDS-server routes, and the 20 Server App routes. The remaining 146 main-API endpoints are listed with method, path, gate, and parameters only, and this page asserts nothing about what they return. Those rows are complete as a route inventory and silent as a contract; the handler in diff --git a/frontend/src/app/(dashboard)/dashboard/page.tsx b/frontend/src/app/(dashboard)/dashboard/page.tsx index 7ff283599..751437367 100644 --- a/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -519,6 +519,36 @@ export default function DashboardPage() { } }; + const exportDailyInvoices = async () => { + if (isExporting) return; + setIsExporting(true); + try { + const dateParams = + periodMode === 'day' + ? { date: selectedDate } + : { from: range.startDate, to: range.endDate }; + const span = + periodMode === 'day' || range.startDate === range.endDate + ? range.startDate + : `${range.startDate}_to_${range.endDate}`; + + await exportReportAsPdf({ + endpoint: '/reports/daily-invoices/export', + params: dateParams, + filename: `invoices-${span}.pdf`, + pageSize: 'A4', + }); + } catch (error) { + toast.error( + error instanceof MenuPopupBlockedError + ? tProducts('menuPopupBlocked') + : tCommon('downloadFailed'), + ); + } finally { + setIsExporting(false); + } + }; + const exportCustomerSales = async (format: 'xlsx' | 'csv') => { if (isExporting) return; setIsExporting(true); @@ -900,6 +930,10 @@ export default function DashboardPage() { {t('exportPdf')} + exportDailyInvoices()}> + + {t('exportInvoicesPdf')} + {t('exportCustomerSales')} exportCustomerSales('xlsx')}> diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index d592a6c2b..e3b8ca66f 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -342,6 +342,7 @@ "exportXlsx": "إكسل (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "الفواتير (PDF)", "exportCsvHint": "يتم تنزيل خمسة ملفات بتنسيق CSV: «الملخص» و«اليومي» و«الضرائب» و«العناصر» و«المدفوعات».", "exportCsvStarted": "جاري تنزيل ملفات CSV الخاصة بالملخص واليومي والضرائب والعناصر والمدفوعات…", "exportCustomerSales": "مبيعات العملاء", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index 093bc073d..eaf0ac9b6 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -342,6 +342,7 @@ "exportXlsx": "এক্সেল (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "ইনভয়েস (PDF)", "exportCsvHint": "CSV পাঁচটি ফাইল ডাউনলোড করে: সারাংশ, দৈনিক, কর, আইটেম এবং পেমেন্ট।", "exportCsvStarted": "সারাংশ, দৈনিক, কর, আইটেম এবং পেমেন্ট CSV ফাইল ডাউনলোড করা হচ্ছে...", "exportCustomerSales": "গ্রাহক বিক্রয়", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index fb21f0547..fcf40260a 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Rechnungen (PDF)", "exportCsvHint": "Der CSV-Download enthält fünf Dateien: Übersicht, Täglich, Steuern, Artikel und Zahlungen.", "exportCsvStarted": "Übersichts-, Tages-, Steuer-, Artikel- und Zahlungs-CSV-Dateien werden heruntergeladen…", "exportCustomerSales": "Kundenumsätze", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index eee757908..f04f0a652 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Invoices (PDF)", "exportCsvHint": "CSV downloads five files: summary, daily, taxes, items and payments.", "exportCsvStarted": "Downloading summary, daily, taxes, items and payments CSV files…", "exportCustomerSales": "Customer sales", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index 3325e7145..c6e3de620 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Facturas (PDF)", "exportCsvHint": "El CSV descarga cinco archivos: resumen, diario, impuestos, artículos y pagos.", "exportCsvStarted": "Descargando archivos CSV de resumen, diario, impuestos, artículos y pagos…", "exportCustomerSales": "Ventas por cliente", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index 80a56532c..fe011b5fc 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -342,6 +342,7 @@ "exportXlsx": "اکسل (.xlsx)", "exportCsv": "CSV", "exportPdf": "PDF", + "exportInvoicesPdf": "فاکتورها (PDF)", "exportCsvHint": "CSV پنج فایل دانلود می‌کند: خلاصه، روزانه، مالیات‌ها، اقلام و پرداخت‌ها.", "exportCsvStarted": "در حال دانلود فایل‌های CSV خلاصه، روزانه، مالیات‌ها، اقلام و پرداخت‌ها…", "exportCustomerSales": "فروش مشتریان", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index 28b0913e4..4f540c7b3 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Mga Invoice (PDF)", "exportCsvHint": "Ang CSV ay magda-download ng limang file: summary, daily, taxes, items at payments.", "exportCsvStarted": "Dina-download ang summary, daily, taxes, items at payments na CSV…", "exportCustomerSales": "Benta ng customer", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index 38bdc3de1..92d89897d 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Factures (PDF)", "exportCsvHint": "Le CSV télécharge cinq fichiers : résumé, quotidien, taxes, articles et paiements.", "exportCsvStarted": "Téléchargement des fichiers CSV résumé, quotidien, taxes, articles et paiements…", "exportCustomerSales": "Ventes par client", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index 59c769f96..3f367177e 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "चालान (PDF)", "exportCsvHint": "सीएसवी पाँच फ़ाइलें डाउनलोड करता है: सारांश, दैनिक, कर, आइटम और भुगतान।", "exportCsvStarted": "सारांश, दैनिक, कर, आइटम और भुगतान CSV फ़ाइलें डाउनलोड हो रही हैं…", "exportCustomerSales": "ग्राहक बिक्री", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index 7360835be..d6932da3a 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Faktur (PDF)", "exportCsvHint": "CSV mengunduh lima berkas: ringkasan, harian, pajak, item, dan pembayaran.", "exportCsvStarted": "Mengunduh berkas CSV ringkasan, harian, pajak, item, dan pembayaran…", "exportCustomerSales": "Penjualan pelanggan", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index 6718362bd..9f6fc41c7 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Fatture (PDF)", "exportCsvHint": "Il CSV scarica cinque file: riepilogo, giornaliero, imposte, articoli e pagamenti.", "exportCsvStarted": "Download dei file CSV di riepilogo, giornaliero, imposte, articoli e pagamenti in corso…", "exportCustomerSales": "Vendite per cliente", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index c53391fa4..3b66c4b75 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -405,6 +405,7 @@ "exportXlsx": "Excel(.xlsx)", "exportCsv": "CSV(.csv)", "exportPdf": "PDF(.pdf)", + "exportInvoicesPdf": "請求書(PDF)", "exportCsvHint": "CSVでは概要、日次、税金、明細、支払いの5つのファイルをダウンロードします。", "exportCsvStarted": "概要、日次、税金、明細、支払いのCSVファイルをダウンロードしています…", "exportCustomerSales": "顧客別売上", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index 3692f8960..db4d0b9de 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "청구서 (PDF)", "exportCsvHint": "CSV는 요약, 일별, 세금, 항목, 결제 다섯 파일을 다운로드합니다.", "exportCsvStarted": "요약, 일별, 세금, 항목 및 결제 CSV 파일을 다운로드하는 중…", "exportCustomerSales": "고객별 매출", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index 5134891cb..5975be586 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -342,6 +342,7 @@ "exportXlsx": "एक्सेल (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "बीजकहरू (PDF)", "exportCsvHint": "CSV ले पाँच फाइलहरू डाउनलोड गर्छ: सारांश, दैनिक, करहरू, वस्तुहरू र भुक्तानीहरू।", "exportCsvStarted": "सारांश, दैनिक, करहरू, वस्तुहरू र भुक्तानीहरू CSV फाइलहरू डाउनलोड गर्दै...", "exportCustomerSales": "ग्राहक बिक्री", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index 3b4a18af5..41cc11ef0 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Facturen (PDF)", "exportCsvHint": "CSV downloadt vijf bestanden: samenvatting, dagelijks, belastingen, artikelen en betalingen.", "exportCsvStarted": "Samenvatting, dagelijks, belastingen, artikelen en betalingen CSV-bestanden downloaden…", "exportCustomerSales": "Klantverkoop", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index 31085d825..f7b92e279 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Faturas (PDF)", "exportCsvHint": "O CSV baixa cinco arquivos: resumo, diário, impostos, itens e pagamentos.", "exportCsvStarted": "Baixando arquivos CSV de resumo, diário, impostos, itens e pagamentos…", "exportCustomerSales": "Vendas por cliente", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index 32d48a48a..5374d1843 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Счета (PDF)", "exportCsvHint": "CSV загружает пять файлов: сводку, по дням, налоги, позиции и платежи.", "exportCsvStarted": "Загрузка CSV-файлов сводки, по дням, налогов, позиций и платежей…", "exportCustomerSales": "Продажи по клиентам", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index 221918e0e..1d8679449 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Faturat (PDF)", "exportCsvHint": "Shkarkimi CSV jep pesë skedarë: përmbledhjen, ditoren, taksat, artikujt dhe pagesat.", "exportCsvStarted": "Po shkarkohen skedarët CSV të përmbledhjes, ditores, taksave, artikujve dhe pagesave...", "exportCustomerSales": "Shitjet sipas klientit", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index 1232da0b4..5040eab7d 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "ใบแจ้งหนี้ (PDF)", "exportCsvHint": "CSV จะดาวน์โหลด 5 ไฟล์: สรุป รายวัน ภาษี รายการสินค้า และการชำระเงิน", "exportCsvStarted": "กำลังดาวน์โหลดไฟล์ CSV สรุป รายวัน ภาษี รายการสินค้า และการชำระเงิน…", "exportCustomerSales": "ยอดขายตามลูกค้า", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index e485b3b8e..6e3fc6b42 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Faturalar (PDF)", "exportCsvHint": "CSV beş dosya indirir: özet, günlük, vergiler, ürünler ve ödemeler.", "exportCsvStarted": "Özet, günlük, vergi, ürün ve ödeme CSV dosyaları indiriliyor…", "exportCustomerSales": "Müşteri satışları", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index ec46884c6..c8aaf7139 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -342,6 +342,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "انوائسز (PDF)", "exportCsvHint": "CSV پانچ فائلیں ڈاؤن لوڈ کرتا ہے: خلاصہ، روزانہ، ٹیکس، آئٹمز اور ادائیگیاں۔", "exportCsvStarted": "خلاصہ، روزانہ، ٹیکس، آئٹمز اور ادائیگیوں کی CSV فائلیں ڈاؤن لوڈ ہو رہی ہیں…", "exportCustomerSales": "کسٹمر سیلز", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index 1e6822835..a36ab1d97 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -341,6 +341,7 @@ "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", "exportPdf": "PDF (.pdf)", + "exportInvoicesPdf": "Hóa đơn (PDF)", "exportCsvHint": "Tải CSV gồm năm tệp: tổng hợp, theo ngày, thuế, chi tiết và thanh toán.", "exportCsvStarted": "Đang tải các tệp CSV tổng hợp, theo ngày, thuế, chi tiết và thanh toán…", "exportCustomerSales": "Doanh số theo khách hàng", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index 25ba5259f..1f19d65c5 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -405,6 +405,7 @@ "exportXlsx": "Excel(.xlsx)", "exportCsv": "CSV(.csv)", "exportPdf": "PDF(.pdf)", + "exportInvoicesPdf": "發票(PDF)", "exportCsvHint": "CSV會下載五個檔案:彙總、每日、稅項、明細和付款。", "exportCsvStarted": "正在下載彙總、每日、稅項、明細和付款CSV檔案…", "exportCustomerSales": "客戶銷售", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index 30e133f8d..1e34d3655 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -405,6 +405,7 @@ "exportXlsx": "Excel(.xlsx)", "exportCsv": "CSV(.csv)", "exportPdf": "PDF(.pdf)", + "exportInvoicesPdf": "发票(PDF)", "exportCsvHint": "CSV会下载五个文件:汇总、每日、税项、明细和付款。", "exportCsvStarted": "正在下载汇总、每日、税项、明细和付款CSV文件…", "exportCustomerSales": "客户销售", diff --git a/main/routes/reports.ts b/main/routes/reports.ts index 227138c6a..c1e883429 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -55,6 +55,12 @@ import { serializeDailySalesExportXlsx, type DailySalesExportCsvPart, } from '../services/daily-sales-export-files'; +import { + buildDailyInvoicesExportDataset, + DAILY_INVOICES_EXPORT_MAX_RANGE_DAYS, + dailyInvoicesExportFilename, + serializeDailyInvoicesExportHtml, +} from '../services/daily-invoices-export'; import { buildCustomerSalesDataset, customerSalesExportFilename, @@ -1886,6 +1892,60 @@ router.get( }, ); +// Owner-only compiled daily invoices export (print-styled A4 HTML for PDF/browser print across 1..366 business days). +router.get( + '/daily-invoices/export', + requirePermission('reports.daily-sales.export'), + async (req: Request, res: Response) => { + try { + const rawFormat = + req.query.format === undefined || req.query.format === '' ? 'html' : req.query.format; + if (rawFormat !== 'html') { + return res.status(400).json({ error: 'format must be html' }); + } + const range = resolveDailySalesExportRange( + { + date: req.query.date, + from: req.query.from, + to: req.query.to, + defaultDate: reportToday(), + }, + reportToday(), + DAILY_INVOICES_EXPORT_MAX_RANGE_DAYS, + ); + const dataset = buildDailyInvoicesExportDataset({ + from: range.fromDate, + to: range.toDate, + }); + const html = serializeDailyInvoicesExportHtml(dataset); + const filename = dailyInvoicesExportFilename( + { fromDate: range.fromDate, toDate: range.toDate }, + 'html', + ); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(html); + } catch (error: unknown) { + if (error instanceof DailySalesExportValidationError) { + return res.status(400).json({ error: error.message }); + } + const statusCode = + typeof error === 'object' && error !== null + ? (error as { statusCode?: number }).statusCode + : undefined; + const message = error instanceof Error ? error.message : 'Internal server error'; + if (statusCode === 400) { + return res.status(400).json({ error: message }); + } + if (statusCode === 409) { + return res.status(409).json({ error: message }); + } + console.error('[API] Daily invoices export error:', error); + res.status(statusCode || 500).json({ error: statusCode ? message : 'Internal server error' }); + } + }, +); + // Owner-only: one row per item sold with the bill's customer details (PII). router.get( '/customer-sales/export', diff --git a/main/services/daily-invoices-export.ts b/main/services/daily-invoices-export.ts new file mode 100644 index 000000000..833fec409 --- /dev/null +++ b/main/services/daily-invoices-export.ts @@ -0,0 +1,1704 @@ +import type { Database } from 'better-sqlite3'; +import Decimal from 'decimal.js'; +import { + dayBoundsInTimezone, + getDatabase, + getSettingValue, + parseDbTimestamp, + tenantBusinessDayStartTime, +} from '../db'; +import { + formatMoney, + formatNumber, + getCountryByCode, + getCurrencyMinorUnitFactor, + resolveRegionalSnapshot, + type LocalePreferences, +} from '../countries'; +import { getOrdersWithItemsForBills } from '../routes/bills'; +import { getTenantCurrency } from './refund'; +import { getActiveCountryPack } from './tax'; +import { resolveTaxComponents, type DisplayTaxComponent } from './tax-components'; +import { + buildDailySalesExportDataset, + DailySalesExportValidationError, + dailySalesSummaryMetricRows, + MAX_DAILY_SALES_HTML_BYTES, + resolveDailySalesExportRange, + resolveExportTimezone, + type DailySalesExportDataset, + type DailySalesExportQueryInput, +} from './daily-sales-export'; +import { isExcludedFromBillReceipt } from '../../shared/order-item-status'; + +export const DAILY_INVOICES_EXPORT_MAX_RANGE_DAYS = 366; +export const MAX_DAILY_INVOICES_COUNT = 5000; +const SQLITE_IN_CHUNK_SIZE = 500; + +export function dailyInvoicesExportFilename( + range: { fromDate: string; toDate: string } | string, + ext: 'html' | 'pdf' = 'html', +): string { + const fromDate = typeof range === 'string' ? range : range.fromDate; + const toDate = typeof range === 'string' ? range : range.toDate; + const span = fromDate === toDate ? fromDate : `${fromDate}_to_${toDate}`; + return `invoices-${span}.${ext}`; +} + +export type DailyInvoiceStatus = 'paid' | 'partially_refunded' | 'refunded'; + +export type DailyInvoiceLineItem = { + id: number; + product_id: string; + product_name: string; + product_sku: string | null; + quantity: number; + unit_price: number; + subtotal: number; + discount_amount: number; + tax_amount: number; + total: number; + variant_label: string; + addons_label: string; + status: string; +}; + +export type DailyInvoicePaymentLine = { + method: string; + amount: number; + transaction_id: string; + timestamp_local: string; +}; + +export type DailyInvoiceRefundLine = { + id: number; + amount: number; + method: string; + reason: string; + created_at_local: string; + in_window: boolean; + scope_label: 'In Period' | 'Outside Period'; + item_name: string | null; + item_quantity: number | null; + item_variant: string; +}; + +export type DailyInvoicePriorBillRefundLine = { + id: number; + bill_id: number; + bill_number: string; + bill_paid_at_local: string; + amount: number; + method: string; + reason: string; + created_at_local: string; + item_name: string | null; + item_quantity: number | null; + item_variant: string; +}; + +export type DailyInvoiceCard = { + id: number; + bill_number: string; + order_id: number; + order_number: string; + order_type: string; + table_number: string | null; + server_name: string | null; + customer_name: string | null; + customer_phone: string | null; + customer_email: string | null; + customer_tax_id: string | null; + status: DailyInvoiceStatus; + status_badge: '[PAID]' | '[PARTIALLY REFUNDED]' | '[REFUNDED]'; + created_at_local: string; + paid_at_local: string; + subtotal: number; + discount_amount: number; + service_charge: number; + packaging_charge: number; + delivery_charge: number; + tax_amount: number; + tax_components: DisplayTaxComponent[]; + tip_amount: number; + round_off: number; + total: number; + paid_amount: number; + items: DailyInvoiceLineItem[]; + payments: DailyInvoicePaymentLine[]; + refunds: DailyInvoiceRefundLine[]; + refund_total: number; + refund_in_window_total: number; + refund_out_of_window_total: number; +}; + +export type DailyInvoicesVatSummaryRow = { + tax_name: string; + tax_rate: number | null; + taxable_base: number | null; + tax_amount: number; + gross_amount: number | null; + shared_base: boolean; + base_additive: boolean; + incomplete_detail: boolean; +}; + +export type DailyInvoicesExportDataset = { + store: { + legal_name: string; + tax_id: string; + tax_id_label: string; + address: string; + country: string; + }; + currency: string; + locale: string; + locale_preferences: LocalePreferences; + timezone: string; + business_day_start: string; + business_date: string; + from_date: string; + to_date: string; + day_count: number; + total_invoices_count: number; + total_gross_sales: number; + net_base: number; + tax_collected: number; + total_refunds: number; + in_window_refunds_on_included_bills: number; + in_window_refunds_on_prior_bills: number; + post_window_refunds_on_included_bills: number; + lifetime_refunds_on_included_bills: number; + prior_bill_refunds: DailyInvoicePriorBillRefundLine[]; + net_revenue: number; + vat_summary: DailyInvoicesVatSummaryRow[]; + sales: DailySalesExportDataset; + invoices: DailyInvoiceCard[]; +}; + +export type DailyInvoicesExportOptions = { + maxInvoicesCount?: number; +}; + +export type DailyInvoicesHtmlOptions = { + locale?: string; + preferences?: LocalePreferences; +}; + +const REPORT_FONT_STACK = [ + 'system-ui', + '-apple-system', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + '"Noto Sans"', + '"Noto Sans Arabic"', + '"Noto Sans Hebrew"', + '"Noto Sans Devanagari"', + '"Noto Sans Bengali"', + '"Noto Sans Tamil"', + '"Noto Sans Thai"', + '"Noto Sans CJK SC"', + '"PingFang SC"', + '"Microsoft YaHei"', + '"Hiragino Sans"', + '"Malgun Gothic"', + 'sans-serif', +].join(', '); + +const REPORT_MONO_STACK = [ + 'ui-monospace', + 'SFMono-Regular', + 'Menlo', + 'Consolas', + '"Liberation Mono"', + 'monospace', + REPORT_FONT_STACK, +].join(', '); + +function escapeHtml(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function readDbSetting(db: Database, key: string): string { + try { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as + | { value?: unknown } + | undefined; + if (typeof row?.value === 'string' && row.value.trim()) { + return row.value.trim(); + } + } catch {} + const fallback = getSettingValue(key); + return typeof fallback === 'string' ? fallback.trim() : ''; +} + +function resolveTaxIdLabel(db: Database, storeCountry: string): string { + const normalizedCountry = storeCountry.trim().toUpperCase(); + try { + const row = db + .prepare( + ` + SELECT pack.id AS pack_id, pack.country AS pack_country, version.pack_json + FROM country_packs AS pack + JOIN country_pack_versions AS version ON version.id = pack.active_version_id + WHERE pack.country IN (?, '*') AND pack.status = 'active' + ORDER BY CASE WHEN pack.country = ? THEN 0 ELSE 1 END, pack.updated_at DESC + LIMIT 1 + `, + ) + .get(normalizedCountry, normalizedCountry) as + | { pack_id?: string; pack_country?: string; pack_json?: string } + | undefined; + if ( + row?.pack_json && + (row.pack_country === normalizedCountry || + (row.pack_country !== '*' && row.pack_id !== 'local-generic')) + ) { + const parsed = JSON.parse(row.pack_json) as { registrationNumberLabel?: unknown }; + if ( + typeof parsed?.registrationNumberLabel === 'string' && + parsed.registrationNumberLabel.trim() + ) { + return parsed.registrationNumberLabel.trim(); + } + } + } catch {} + + try { + const activePack = getActiveCountryPack(normalizedCountry); + if ( + activePack && + (activePack.country === normalizedCountry || + (activePack.country !== '*' && activePack.id !== 'local-generic')) && + typeof activePack.registrationNumberLabel === 'string' && + activePack.registrationNumberLabel.trim() + ) { + return activePack.registrationNumberLabel.trim(); + } + } catch {} + + return getCountryByCode(normalizedCountry)?.taxIdLabel || 'Tax ID'; +} + +function extractBillSequenceNumber(billNumber: string): number | null { + const match = /(?:^|[^0-9])(\d{1,12})$/.exec(String(billNumber ?? '').trim()); + if (!match) return null; + const parsed = Number.parseInt(match[1], 10); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +function resolveBusinessDateForTimestamp( + paidAtUtc: string, + dayWindows: Array<{ businessDate: string; start: string; end: string }>, +): string { + for (const w of dayWindows) { + if (paidAtUtc >= w.start && paidAtUtc < w.end) { + return w.businessDate; + } + } + return dayWindows[0]?.businessDate ?? ''; +} + +function storeLocalTimestamp(stored: string | null | undefined, timezone: string): string { + if (!stored) return ''; + const instant = parseDbTimestamp(stored); + if (Number.isNaN(instant.getTime())) return String(stored); + return new Intl.DateTimeFormat('sv-SE', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(instant); +} + +function formatVariantLabel(raw: unknown): string { + if (!raw) return ''; + let parsed: unknown = raw; + if (typeof raw === 'string') { + const trimmed = raw.trim(); + if (!trimmed) return ''; + try { + parsed = JSON.parse(trimmed); + } catch { + return trimmed; + } + } + if (Array.isArray(parsed)) { + return parsed + .map((entry) => { + if (!entry || typeof entry !== 'object') return String(entry ?? '').trim(); + const obj = entry as Record; + const name = String(obj.option_name ?? obj.name ?? obj.value ?? obj.label ?? '').trim(); + const group = String(obj.group_name ?? obj.group ?? '').trim(); + return group && name ? `${group}: ${name}` : name; + }) + .filter(Boolean) + .join(', '); + } + if (parsed && typeof parsed === 'object') { + const obj = parsed as Record; + if (typeof obj.name === 'string' || typeof obj.option_name === 'string') { + const name = String(obj.option_name ?? obj.name ?? '').trim(); + const group = String(obj.group_name ?? obj.group ?? '').trim(); + return group && name ? `${group}: ${name}` : name; + } + return Object.entries(obj) + .map(([k, v]) => { + if (v && typeof v === 'object') { + const inner = v as Record; + const val = String(inner.name ?? inner.option_name ?? inner.value ?? '').trim(); + return val ? `${k}: ${val}` : ''; + } + const val = String(v ?? '').trim(); + return val ? `${k}: ${val}` : ''; + }) + .filter(Boolean) + .join(', '); + } + return ''; +} + +function formatAddonsLabel(effectiveAddons: unknown, rawAddons: unknown): string { + const source = + Array.isArray(effectiveAddons) && effectiveAddons.length > 0 + ? effectiveAddons + : (() => { + if (Array.isArray(rawAddons)) return rawAddons; + if (typeof rawAddons === 'string' && rawAddons.trim()) { + try { + const parsed = JSON.parse(rawAddons); + if (Array.isArray(parsed)) return parsed; + } catch {} + } + return []; + })(); + if (!Array.isArray(source) || source.length === 0) return ''; + return source + .map((entry) => { + if (!entry || typeof entry !== 'object') return ''; + const obj = entry as Record; + const name = String(obj.addon_name ?? obj.name ?? '').trim(); + if (!name) return ''; + const qty = Number(obj.quantity ?? 1); + return Number.isFinite(qty) && qty > 1 ? `+ ${name} (x${qty})` : `+ ${name}`; + }) + .filter(Boolean) + .join(', '); +} + +function statusBadgeForPaymentStatus(status: string): { + normalized: DailyInvoiceStatus; + badge: '[PAID]' | '[PARTIALLY REFUNDED]' | '[REFUNDED]'; +} { + if (status === 'refunded') { + return { normalized: 'refunded', badge: '[REFUNDED]' }; + } + if (status === 'partially_refunded') { + return { normalized: 'partially_refunded', badge: '[PARTIALLY REFUNDED]' }; + } + return { normalized: 'paid', badge: '[PAID]' }; +} + +function aggregateVatSummaryByRate( + salesDataset: DailySalesExportDataset, +): DailyInvoicesVatSummaryRow[] { + const merged = new Map< + string, + { + tax_name: string; + tax_rate: number | null; + taxable_base: Decimal | null; + tax_amount: Decimal; + gross_amount: Decimal | null; + shared_base: boolean; + base_additive: boolean; + incomplete_detail: boolean; + } + >(); + + for (const row of salesDataset.taxes) { + const rateKey = row.tax_rate === null ? '' : String(row.tax_rate); + const key = `${row.tax_name}\u0000${rateKey}\u0000${row.incomplete_detail ? '1' : '0'}\u0000${row.shared_base ? '1' : '0'}`; + const existing = merged.get(key); + if (existing) { + existing.tax_amount = existing.tax_amount.plus(row.tax_amount); + if (existing.taxable_base !== null && row.taxable_base !== null) { + existing.taxable_base = existing.taxable_base.plus(row.taxable_base); + } else { + existing.taxable_base = null; + } + if (existing.gross_amount !== null && row.gross_amount !== null) { + existing.gross_amount = existing.gross_amount.plus(row.gross_amount); + } else { + existing.gross_amount = null; + } + existing.shared_base = existing.shared_base || row.shared_base; + existing.base_additive = existing.base_additive && row.base_additive; + existing.incomplete_detail = existing.incomplete_detail || row.incomplete_detail; + } else { + merged.set(key, { + tax_name: row.tax_name, + tax_rate: row.tax_rate, + taxable_base: row.taxable_base === null ? null : new Decimal(row.taxable_base), + tax_amount: new Decimal(row.tax_amount), + gross_amount: row.gross_amount === null ? null : new Decimal(row.gross_amount), + shared_base: row.shared_base, + base_additive: row.base_additive, + incomplete_detail: row.incomplete_detail, + }); + } + } + + return Array.from(merged.values()).map((entry) => ({ + tax_name: entry.tax_name, + tax_rate: entry.tax_rate, + taxable_base: + entry.taxable_base === null + ? null + : entry.taxable_base.toDecimalPlaces(6, Decimal.ROUND_HALF_UP).toNumber(), + tax_amount: entry.tax_amount.toDecimalPlaces(6, Decimal.ROUND_HALF_UP).toNumber(), + gross_amount: + entry.gross_amount === null + ? null + : entry.gross_amount.toDecimalPlaces(6, Decimal.ROUND_HALF_UP).toNumber(), + shared_base: entry.shared_base, + base_additive: entry.base_additive, + incomplete_detail: entry.incomplete_detail, + })); +} + +function isDatabaseInstance(value: unknown): value is Database { + return Boolean( + value && typeof value === 'object' && typeof (value as Database).prepare === 'function', + ); +} + +export function buildDailyInvoicesExportDataset( + inputOrDb: DailySalesExportQueryInput | Database, + dbOrInput?: Database | DailySalesExportQueryInput, + options?: DailyInvoicesExportOptions, +): DailyInvoicesExportDataset { + const db: Database = isDatabaseInstance(inputOrDb) + ? inputOrDb + : isDatabaseInstance(dbOrInput) + ? dbOrInput + : getDatabase(); + const rawInput: DailySalesExportQueryInput = isDatabaseInstance(inputOrDb) + ? ((dbOrInput as DailySalesExportQueryInput) ?? '') + : inputOrDb; + + const normalizedInput: DailySalesExportQueryInput = + typeof rawInput === 'object' && + rawInput !== null && + 'fromDate' in (rawInput as Record) && + 'toDate' in (rawInput as Record) + ? { + from: (rawInput as Record).fromDate, + to: (rawInput as Record).toDate, + } + : rawInput; + + const range = resolveDailySalesExportRange( + normalizedInput, + undefined, + DAILY_INVOICES_EXPORT_MAX_RANGE_DAYS, + ); + + const prelimTimezone = readDbSetting(db, 'timezone') || resolveExportTimezone(); + const prelimBusinessDayStart = tenantBusinessDayStartTime(db); + const prelimDayWindows = range.dates.map((businessDate) => { + const [start, end] = dayBoundsInTimezone(businessDate, prelimTimezone, prelimBusinessDayStart); + return { businessDate, start, end }; + }); + const prelimStartUtc = prelimDayWindows[0].start; + const prelimEndUtc = prelimDayWindows[prelimDayWindows.length - 1].end; + + const maxInvoicesCount = options?.maxInvoicesCount ?? MAX_DAILY_INVOICES_COUNT; + const countRow = db + .prepare( + ` + SELECT COUNT(*) AS count + FROM bills b + WHERE b.paid_at >= ? AND b.paid_at < ? + AND b.payment_status IN ('paid', 'partially_refunded', 'refunded') + `, + ) + .get(prelimStartUtc, prelimEndUtc) as { count?: number } | undefined; + const totalMatchingBills = Number(countRow?.count ?? 0); + if (totalMatchingBills > maxInvoicesCount) { + throw new DailySalesExportValidationError( + `Export exceeds maximum invoice count (${maxInvoicesCount}); please narrow the date range`, + ); + } + + const sales = buildDailySalesExportDataset( + range.isSingleDay ? { date: range.fromDate } : { from: range.fromDate, to: range.toDate }, + db, + { maxRangeDays: DAILY_INVOICES_EXPORT_MAX_RANGE_DAYS }, + ); + + const timezone = sales.summary.timezone || prelimTimezone; + const businessDayStart = sales.summary.business_day_start || prelimBusinessDayStart; + const currency = sales.summary.currency || getTenantCurrency(db); + const minorFactor = getCurrencyMinorUnitFactor(currency); + + const storeLegalName = readDbSetting(db, 'business_name') || 'FloCafe Store'; + const storeAddress = readDbSetting(db, 'business_address'); + const storeTaxId = readDbSetting(db, 'tax_registration_number'); + const storeCountry = readDbSetting(db, 'country'); + const taxIdLabel = resolveTaxIdLabel(db, storeCountry); + + let resolvedLocale = 'en-US'; + let resolvedPreferences: LocalePreferences = { + currencyDisplay: 'rial', + digits: 'locale', + calendar: 'locale', + }; + try { + const regionalSnapshot = resolveRegionalSnapshot({ + country: storeCountry || undefined, + currency: currency || undefined, + timezone: timezone || undefined, + currency_display: readDbSetting(db, 'currency_display') || undefined, + number_digits: readDbSetting(db, 'number_digits') || undefined, + calendar: readDbSetting(db, 'calendar') || undefined, + }); + resolvedLocale = regionalSnapshot.locale || 'en-US'; + resolvedPreferences = regionalSnapshot.preferences; + } catch {} + + const dayWindows = range.dates.map((businessDate) => { + const [start, end] = dayBoundsInTimezone(businessDate, timezone, businessDayStart); + return { businessDate, start, end }; + }); + const windowStartUtc = dayWindows[0].start; + const windowEndUtc = dayWindows[dayWindows.length - 1].end; + + type BillQueryRow = { + id: number; + bill_number: string; + order_id: number; + customer_id: string | null; + split_group_id: string | null; + subtotal: number; + tax_amount: number; + tax_breakdown: string | null; + tax_snapshot: string | null; + discount_amount: number; + delivery_charge: number; + packaging_charge: number; + service_charge: number; + round_off: number; + total: number; + paid_amount: number; + payment_status: string; + payment_details: string | null; + paid_at: string; + created_at: string; + order_number: string | null; + order_type: string | null; + table_number: string | null; + server_name: string | null; + customer_name: string | null; + customer_phone: string | null; + customer_email: string | null; + }; + + const rawBillRows = db + .prepare( + ` + SELECT + b.id, + b.bill_number, + b.order_id, + b.customer_id, + b.split_group_id, + COALESCE(b.subtotal, 0) AS subtotal, + COALESCE(b.tax_amount, 0) AS tax_amount, + b.tax_breakdown, + b.tax_snapshot, + COALESCE(b.discount_amount, 0) AS discount_amount, + COALESCE(b.delivery_charge, 0) AS delivery_charge, + COALESCE(b.packaging_charge, 0) AS packaging_charge, + COALESCE(b.service_charge, 0) AS service_charge, + COALESCE(b.round_off, 0) AS round_off, + COALESCE(b.total, 0) AS total, + COALESCE(b.paid_amount, 0) AS paid_amount, + b.payment_status, + b.payment_details, + b.paid_at, + b.created_at, + o.order_number AS order_number, + o.type AS order_type, + t.number AS table_number, + u.name AS server_name, + c.name AS customer_name, + c.phone AS customer_phone, + c.email AS customer_email + FROM bills b + LEFT JOIN orders o ON o.id = b.order_id + LEFT JOIN tables t ON t.id = o.table_id + LEFT JOIN users u ON u.id = o.user_id + LEFT JOIN customers c ON c.id = COALESCE(b.customer_id, o.customer_id) + WHERE b.paid_at >= ? AND b.paid_at < ? + AND b.payment_status IN ('paid', 'partially_refunded', 'refunded') + ORDER BY b.paid_at ASC, b.id ASC + `, + ) + .all(windowStartUtc, windowEndUtc) as BillQueryRow[]; + + const billSortMeta = new Map(); + for (const row of rawBillRows) { + billSortMeta.set(Number(row.id), { + businessDate: resolveBusinessDateForTimestamp(row.paid_at, dayWindows), + seq: extractBillSequenceNumber(row.bill_number), + }); + } + + const billRows = [...rawBillRows].sort((a, b) => { + const metaA = billSortMeta.get(Number(a.id))!; + const metaB = billSortMeta.get(Number(b.id))!; + if (metaA.businessDate !== metaB.businessDate) { + return metaA.businessDate < metaB.businessDate ? -1 : 1; + } + if (metaA.seq !== null && metaB.seq !== null && metaA.seq !== metaB.seq) { + return metaA.seq - metaB.seq; + } + if (a.paid_at !== b.paid_at) { + return a.paid_at < b.paid_at ? -1 : 1; + } + if (Number(a.id) !== Number(b.id)) { + return Number(a.id) - Number(b.id); + } + return String(a.bill_number).localeCompare(String(b.bill_number)); + }); + + const paymentMethodMap = new Map(); + try { + const pmRows = db.prepare('SELECT id, name FROM payment_methods').all() as Array<{ + id: number; + name: string; + }>; + for (const pm of pmRows) { + paymentMethodMap.set(Number(pm.id), String(pm.name)); + } + } catch {} + + const ordersByBillId = getOrdersWithItemsForBills(db, billRows); + + type RawRefundRow = { + id: number; + bill_id: number; + order_item_id: number | null; + amount_cents: number; + method: string | null; + reason: string | null; + created_at: string; + item_name: string | null; + item_quantity: number | null; + item_variant: string | null; + }; + + const refundsByBillId = new Map(); + const includedBillIds = billRows.map((b) => Number(b.id)); + const includedBillIdSet = new Set(includedBillIds); + + for (let i = 0; i < includedBillIds.length; i += SQLITE_IN_CHUNK_SIZE) { + const chunk = includedBillIds.slice(i, i + SQLITE_IN_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(', '); + const rows = db + .prepare( + ` + SELECT + r.id, + r.bill_id, + r.order_item_id, + r.amount_cents, + r.method, + r.reason, + r.created_at, + oi.product_name AS item_name, + oi.quantity AS item_quantity, + oi.variant_selection AS item_variant + FROM refunds r + LEFT JOIN order_items oi ON oi.id = r.order_item_id + WHERE r.bill_id IN (${placeholders}) + ORDER BY r.created_at ASC, r.id ASC + `, + ) + .all(...chunk) as RawRefundRow[]; + + for (const r of rows) { + const billId = Number(r.bill_id); + const list = refundsByBillId.get(billId) ?? []; + list.push(r); + refundsByBillId.set(billId, list); + } + } + + type RawWindowRefundRow = RawRefundRow & { + bill_number: string | null; + bill_paid_at: string | null; + }; + + const windowRefundRows = db + .prepare( + ` + SELECT + r.id, + r.bill_id, + b.bill_number, + b.paid_at AS bill_paid_at, + r.order_item_id, + r.amount_cents, + r.method, + r.reason, + r.created_at, + oi.product_name AS item_name, + oi.quantity AS item_quantity, + oi.variant_selection AS item_variant + FROM refunds r + LEFT JOIN bills b ON b.id = r.bill_id + LEFT JOIN order_items oi ON oi.id = r.order_item_id + WHERE r.created_at >= ? AND r.created_at < ? + ORDER BY r.created_at ASC, r.id ASC + `, + ) + .all(windowStartUtc, windowEndUtc) as RawWindowRefundRow[]; + + let priorBillsRefundDec = new Decimal(0); + const priorBillRefunds: DailyInvoicePriorBillRefundLine[] = []; + for (const r of windowRefundRows) { + if (includedBillIdSet.has(Number(r.bill_id))) continue; + const amountDec = new Decimal(Number(r.amount_cents || 0)).div(minorFactor); + priorBillsRefundDec = priorBillsRefundDec.plus(amountDec); + priorBillRefunds.push({ + id: Number(r.id), + bill_id: Number(r.bill_id), + bill_number: String(r.bill_number ?? `#${r.bill_id}`), + bill_paid_at_local: storeLocalTimestamp(r.bill_paid_at, timezone), + amount: amountDec.toDecimalPlaces(6, Decimal.ROUND_HALF_UP).toNumber(), + method: String(r.method || 'cash'), + reason: String(r.reason || ''), + created_at_local: storeLocalTimestamp(r.created_at, timezone), + item_name: r.item_name ? String(r.item_name) : null, + item_quantity: r.item_quantity == null ? null : Number(r.item_quantity), + item_variant: formatVariantLabel(r.item_variant), + }); + } + + let includedInWindowRefundsDec = new Decimal(0); + let includedOutWindowRefundsDec = new Decimal(0); + let includedLifetimeRefundsDec = new Decimal(0); + + const invoices: DailyInvoiceCard[] = billRows.map((bill) => { + const hydratedOrder = ordersByBillId.get(Number(bill.id)) as + | { + order_number?: string; + type?: string; + items?: Array>; + } + | undefined; + + const rawItems = Array.isArray(hydratedOrder?.items) ? hydratedOrder!.items : []; + const activeBillItems = rawItems.filter((item) => !isExcludedFromBillReceipt(item.status)); + + const items: DailyInvoiceLineItem[] = activeBillItems.map((item) => { + const qty = Number(item.quantity ?? 1); + const unitPrice = Number(item.unit_price ?? 0); + const subtotal = Number(item.subtotal ?? unitPrice * qty); + const discountAmount = Number(item.discount_amount ?? 0); + const taxAmount = Number(item.tax_amount ?? 0); + const total = Number(item.total ?? subtotal); + return { + id: Number(item.id ?? 0), + product_id: String(item.product_id ?? ''), + product_name: String(item.product_name ?? 'Item'), + product_sku: item.product_sku == null ? null : String(item.product_sku), + quantity: qty, + unit_price: unitPrice, + subtotal, + discount_amount: discountAmount, + tax_amount: taxAmount, + total, + variant_label: formatVariantLabel(item.variant_selection), + addons_label: formatAddonsLabel(item.effective_addons, item.addons), + status: String(item.status ?? 'ready'), + }; + }); + + const taxComponents = resolveTaxComponents({ + tax_amount: bill.tax_amount, + tax_snapshot: bill.tax_snapshot, + tax_breakdown: bill.tax_breakdown, + items: activeBillItems.map((item) => ({ + tax_snapshot: item.tax_snapshot, + tax_breakdown: item.tax_breakdown, + status: null, + })), + }); + + const payments: DailyInvoicePaymentLine[] = []; + let tipAmount = 0; + if (bill.payment_details) { + try { + const parsed = JSON.parse(bill.payment_details); + const entries = Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === 'object' + ? [parsed] + : []; + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const obj = entry as Record; + const pmId = Number(obj.payment_method_id); + const method = + (Number.isFinite(pmId) && paymentMethodMap.get(pmId)) || + (typeof obj.method === 'string' && obj.method.trim()) || + 'unknown'; + const amount = Number(obj.amount ?? 0); + const txId = typeof obj.transaction_id === 'string' ? obj.transaction_id.trim() : ''; + const tsRaw = + typeof obj.timestamp === 'string' && obj.timestamp.trim() + ? obj.timestamp + : bill.paid_at; + if (typeof obj.tip === 'number' && Number.isFinite(obj.tip)) { + tipAmount += obj.tip; + } else if (typeof obj.tip_amount === 'number' && Number.isFinite(obj.tip_amount)) { + tipAmount += obj.tip_amount; + } + payments.push({ + method, + amount, + transaction_id: txId, + timestamp_local: storeLocalTimestamp(tsRaw, timezone), + }); + } + } catch {} + } + + const rawRefunds = refundsByBillId.get(Number(bill.id)) ?? []; + + let refundTotalDec = new Decimal(0); + let refundInWindowDec = new Decimal(0); + let refundOutWindowDec = new Decimal(0); + + const refunds: DailyInvoiceRefundLine[] = rawRefunds.map((r) => { + const amountDec = new Decimal(Number(r.amount_cents || 0)).div(minorFactor); + const inWindow = r.created_at >= windowStartUtc && r.created_at < windowEndUtc; + refundTotalDec = refundTotalDec.plus(amountDec); + if (inWindow) { + refundInWindowDec = refundInWindowDec.plus(amountDec); + } else { + refundOutWindowDec = refundOutWindowDec.plus(amountDec); + } + return { + id: Number(r.id), + amount: amountDec.toDecimalPlaces(6, Decimal.ROUND_HALF_UP).toNumber(), + method: String(r.method || 'cash'), + reason: String(r.reason || ''), + created_at_local: storeLocalTimestamp(r.created_at, timezone), + in_window: inWindow, + scope_label: inWindow ? 'In Period' : 'Outside Period', + item_name: r.item_name ? String(r.item_name) : null, + item_quantity: r.item_quantity == null ? null : Number(r.item_quantity), + item_variant: formatVariantLabel(r.item_variant), + }; + }); + + includedInWindowRefundsDec = includedInWindowRefundsDec.plus(refundInWindowDec); + includedOutWindowRefundsDec = includedOutWindowRefundsDec.plus(refundOutWindowDec); + includedLifetimeRefundsDec = includedLifetimeRefundsDec.plus(refundTotalDec); + + const { normalized, badge } = statusBadgeForPaymentStatus(bill.payment_status); + + return { + id: Number(bill.id), + bill_number: String(bill.bill_number), + order_id: Number(bill.order_id), + order_number: String(bill.order_number ?? hydratedOrder?.order_number ?? `#${bill.order_id}`), + order_type: String(bill.order_type ?? hydratedOrder?.type ?? 'takeaway'), + table_number: bill.table_number ? String(bill.table_number) : null, + server_name: bill.server_name ? String(bill.server_name) : null, + customer_name: bill.customer_name ? String(bill.customer_name) : null, + customer_phone: bill.customer_phone ? String(bill.customer_phone) : null, + customer_email: bill.customer_email ? String(bill.customer_email) : null, + customer_tax_id: null, + status: normalized, + status_badge: badge, + created_at_local: storeLocalTimestamp(bill.created_at, timezone), + paid_at_local: storeLocalTimestamp(bill.paid_at, timezone), + subtotal: Number(bill.subtotal || 0), + discount_amount: Number(bill.discount_amount || 0), + service_charge: Number(bill.service_charge || 0), + packaging_charge: Number(bill.packaging_charge || 0), + delivery_charge: Number(bill.delivery_charge || 0), + tax_amount: Number(bill.tax_amount || 0), + tax_components: taxComponents, + tip_amount: tipAmount, + round_off: Number(bill.round_off || 0), + total: Number(bill.total || 0), + paid_amount: Number(bill.paid_amount || 0), + items, + payments, + refunds, + refund_total: refundTotalDec.toDecimalPlaces(6, Decimal.ROUND_HALF_UP).toNumber(), + refund_in_window_total: refundInWindowDec + .toDecimalPlaces(6, Decimal.ROUND_HALF_UP) + .toNumber(), + refund_out_of_window_total: refundOutWindowDec + .toDecimalPlaces(6, Decimal.ROUND_HALF_UP) + .toNumber(), + }; + }); + + const netBase = new Decimal(sales.summary.gross_collected) + .minus(sales.summary.tax_total) + .toDecimalPlaces(6, Decimal.ROUND_HALF_UP) + .toNumber(); + + const vatSummary = aggregateVatSummaryByRate(sales); + + return { + store: { + legal_name: storeLegalName, + tax_id: storeTaxId, + tax_id_label: taxIdLabel, + address: storeAddress, + country: storeCountry, + }, + currency, + locale: resolvedLocale, + locale_preferences: resolvedPreferences, + timezone, + business_day_start: businessDayStart, + business_date: sales.summary.business_date, + from_date: sales.summary.from_date, + to_date: sales.summary.to_date, + day_count: sales.summary.day_count, + total_invoices_count: invoices.length, + total_gross_sales: sales.summary.gross_collected, + net_base: netBase, + tax_collected: sales.summary.tax_total, + total_refunds: sales.summary.refunds_issued, + in_window_refunds_on_included_bills: includedInWindowRefundsDec + .toDecimalPlaces(6, Decimal.ROUND_HALF_UP) + .toNumber(), + in_window_refunds_on_prior_bills: priorBillsRefundDec + .toDecimalPlaces(6, Decimal.ROUND_HALF_UP) + .toNumber(), + post_window_refunds_on_included_bills: includedOutWindowRefundsDec + .toDecimalPlaces(6, Decimal.ROUND_HALF_UP) + .toNumber(), + lifetime_refunds_on_included_bills: includedLifetimeRefundsDec + .toDecimalPlaces(6, Decimal.ROUND_HALF_UP) + .toNumber(), + prior_bill_refunds: priorBillRefunds, + net_revenue: sales.summary.net_collected, + vat_summary: vatSummary, + sales, + invoices, + }; +} + +export function serializeDailyInvoicesExportHtml( + dataset: DailyInvoicesExportDataset, + options?: DailyInvoicesHtmlOptions, +): string { + let locale = options?.locale || dataset.locale || ''; + let preferences = options?.preferences || dataset.locale_preferences; + + if (!locale || !preferences) { + try { + const snapshot = resolveRegionalSnapshot({ + country: dataset.store?.country || (getSettingValue('country') ?? undefined), + currency: dataset.currency || (getSettingValue('currency') ?? undefined), + timezone: dataset.timezone || (getSettingValue('timezone') ?? undefined), + currency_display: getSettingValue('currency_display') ?? undefined, + number_digits: getSettingValue('number_digits') ?? undefined, + calendar: getSettingValue('calendar') ?? undefined, + }); + if (!locale) locale = snapshot.locale; + if (!preferences) preferences = snapshot.preferences; + } catch { + if (!locale) locale = 'en-US'; + } + } + + const resolvedLocale = locale || 'en-US'; + const resolvedPrefs: LocalePreferences = preferences ?? { + currencyDisplay: 'rial', + digits: 'locale', + calendar: 'locale', + }; + const currency = dataset.currency || 'USD'; + const numberingSystem = resolvedPrefs.digits === 'latin' ? 'latn' : undefined; + + const fmtMoney = (n: number | null | undefined): string => + n === null || n === undefined || !Number.isFinite(Number(n)) + ? '' + : formatMoney(Number(n), currency, resolvedLocale, resolvedPrefs); + + const fmtNum = (n: number | null | undefined): string => + n === null || n === undefined || !Number.isFinite(Number(n)) + ? '' + : formatNumber(Number(n), resolvedLocale, numberingSystem); + + const fmtRate = (n: number | null | undefined) => + n === null || n === undefined || !Number.isFinite(Number(n)) ? '-' : `${Number(n).toFixed(2)}%`; + + const summaryRowsHtml = dailySalesSummaryMetricRows(dataset.sales.summary) + .map(({ metric, value }) => { + const rawVal = value === null || value === undefined ? '' : String(value); + const displayVal = + typeof value === 'number' + ? metric.endsWith('_count') + ? fmtNum(value) + : metric.endsWith('_percent') + ? `${Number(value).toFixed(2)}%` + : fmtMoney(value) + : rawVal; + const valueClass = typeof value === 'number' ? 'num' : 'mono'; + return `${escapeHtml(metric)}${escapeHtml(displayVal)}`; + }) + .join('\n'); + + const refundReconciliationRows = [ + { + scope: 'in_window_refunds_total', + label: 'In-Period Refunds (Cover Total)', + value: dataset.total_refunds, + }, + { + scope: 'in_window_refunds_on_included_bills', + label: 'In-Period Refunds on Window Invoices', + value: dataset.in_window_refunds_on_included_bills ?? 0, + }, + { + scope: 'in_window_refunds_on_prior_bills', + label: 'In-Period Refunds on Prior-Period Invoices', + value: dataset.in_window_refunds_on_prior_bills ?? 0, + }, + { + scope: 'post_window_refunds_on_included_bills', + label: 'Outside-Period Refunds on Window Invoices', + value: dataset.post_window_refunds_on_included_bills ?? 0, + }, + { + scope: 'lifetime_refunds_on_included_bills', + label: 'Lifetime Refunds on Window Invoices', + value: dataset.lifetime_refunds_on_included_bills ?? 0, + }, + ] + .map( + (row) => + `${escapeHtml(row.label)}${escapeHtml(fmtMoney(row.value))}`, + ) + .join('\n'); + + const priorBillRefundsHtml = + Array.isArray(dataset.prior_bill_refunds) && dataset.prior_bill_refunds.length > 0 + ? `

Prior-Period Invoices Refunded in Selected Period

+ + + + + + + + + + + + ${dataset.prior_bill_refunds + .map((ref) => { + const details: string[] = [ref.method]; + if (ref.reason) details.push(`Reason: ${ref.reason}`); + if (ref.item_name) { + details.push( + `Item: ${ref.item_name}${ref.item_quantity != null ? ` (x${ref.item_quantity})` : ''}${ref.item_variant ? ` [${ref.item_variant}]` : ''}`, + ); + } + return ` + + + + + + `; + }) + .join('\n')} + +
InvoiceOriginal Paid AtRefunded AtMethod / Reason / ItemRefund Amount
${escapeHtml(ref.bill_number)}${escapeHtml(ref.bill_paid_at_local)}${escapeHtml(ref.created_at_local)}${escapeHtml(details.join(' · '))}${escapeHtml(fmtMoney(ref.amount))}
` + : ''; + + const vatRateRowsHtml = + dataset.vat_summary.length === 0 + ? 'No tax activity in this window.' + : dataset.vat_summary + .map( + ( + row, + ) => ` + ${escapeHtml(row.tax_name)} + ${escapeHtml(fmtRate(row.tax_rate))} + ${escapeHtml(fmtMoney(row.taxable_base))} + ${escapeHtml(fmtMoney(row.tax_amount))} + ${escapeHtml(fmtMoney(row.gross_amount))} + `, + ) + .join('\n'); + + const dailyTaxRowsHtml = + dataset.sales.taxes.length === 0 + ? 'No daily tax breakdown rows.' + : dataset.sales.taxes + .map( + ( + row, + ) => ` + ${escapeHtml(row.business_date)} + ${escapeHtml(row.tax_name)} + ${escapeHtml(fmtRate(row.tax_rate))} + ${escapeHtml(fmtMoney(row.taxable_base))} + ${escapeHtml(fmtMoney(row.tax_amount))} + ${escapeHtml(fmtMoney(row.gross_amount))} + `, + ) + .join('\n'); + + const coverPageBreakClass = + dataset.invoices.length > 0 ? 'cover-page has-invoices' : 'cover-page'; + + const invoiceCardsHtml = + dataset.invoices.length === 0 + ? '
No issued invoices for the selected business period.
' + : dataset.invoices + .map((inv) => { + const orderTypeDisplay = inv.table_number + ? `${inv.order_type} (Table ${inv.table_number})` + : inv.order_type; + + const customerParts: string[] = []; + if (inv.customer_name) customerParts.push(inv.customer_name); + if (inv.customer_phone) customerParts.push(inv.customer_phone); + if (inv.customer_tax_id) + customerParts.push(`${dataset.store.tax_id_label}: ${inv.customer_tax_id}`); + const customerDisplay = + customerParts.length > 0 ? customerParts.join(' · ') : 'Walk-in'; + + const itemRowsHtml = inv.items + .map((item) => { + const details: string[] = []; + if (item.variant_label) details.push(item.variant_label); + if (item.addons_label) details.push(item.addons_label); + const detailLine = + details.length > 0 + ? `
${escapeHtml(details.join(' | '))}
` + : ''; + return ` + ${escapeHtml(fmtNum(item.quantity))} + + ${escapeHtml(item.product_name)} + ${detailLine} + + ${escapeHtml(fmtMoney(item.unit_price))} + ${escapeHtml(fmtMoney(item.subtotal))} + ${escapeHtml(fmtMoney(item.total))} + `; + }) + .join('\n'); + + const taxComponentLines = + inv.tax_components.length > 0 + ? inv.tax_components + .map((comp) => { + const label = + comp.rate !== null + ? `${comp.title} (${Number(comp.rate).toFixed(2)}%)` + : comp.title; + return `
${escapeHtml(label)}${escapeHtml(fmtMoney(comp.amount))}
`; + }) + .join('\n') + : `
Tax${escapeHtml(fmtMoney(inv.tax_amount))}
`; + + const paymentLinesHtml = + inv.payments.length > 0 + ? inv.payments + .map((pm) => { + const txSuffix = pm.transaction_id ? ` (Tx: ${pm.transaction_id})` : ''; + return `${escapeHtml(pm.method)}: ${escapeHtml(fmtMoney(pm.amount))}${escapeHtml(txSuffix)}`; + }) + .join(' ') + : `Paid: ${escapeHtml(fmtMoney(inv.paid_amount))}`; + + const hasRefundBlock = + inv.status === 'refunded' || + inv.status === 'partially_refunded' || + inv.refunds.length > 0; + + const inWindowRefTotal = inv.refund_in_window_total ?? inv.refund_total; + const outOfWindowRefTotal = inv.refund_out_of_window_total ?? 0; + + const refundBlockHtml = hasRefundBlock + ? `
+
+ Refund Details (${escapeHtml(inv.status_badge)}) + Lifetime Refunded: ${escapeHtml(fmtMoney(inv.refund_total))} · In Selected Period: ${escapeHtml(fmtMoney(inWindowRefTotal))}${outOfWindowRefTotal > 0 ? ` · Outside Period: ${escapeHtml(fmtMoney(outOfWindowRefTotal))}` : ''} +
+ ${ + inv.refunds.length > 0 + ? `
    + ${inv.refunds + .map((ref) => { + const itemDesc = ref.item_name + ? ` · Item: ${ref.item_name}${ref.item_quantity != null ? ` (x${ref.item_quantity})` : ''}${ref.item_variant ? ` [${ref.item_variant}]` : ''}` + : ''; + const reasonDesc = ref.reason ? ` · Reason: ${ref.reason}` : ''; + const scopeAttr = + ref.in_window !== false ? 'in-window' : 'out-of-window'; + const scopeBadge = + ref.scope_label || + (ref.in_window !== false ? 'In Period' : 'Outside Period'); + return `
  • + [${escapeHtml(scopeBadge)}] ${escapeHtml(ref.created_at_local)} — + ${escapeHtml(fmtMoney(ref.amount))} + (${escapeHtml(ref.method)})${escapeHtml(reasonDesc)}${escapeHtml(itemDesc)} +
  • `; + }) + .join('\n')} +
` + : '' + } +
` + : ''; + + return `
+
+
+
${escapeHtml(dataset.store.legal_name)}
+
+ ${escapeHtml(dataset.store.tax_id_label)}: ${escapeHtml(dataset.store.tax_id || 'N/A')} + ${dataset.store.address ? ` · ${escapeHtml(dataset.store.address)}` : ''} +
+
+
+ ${escapeHtml(inv.status_badge)} +
Invoice #${escapeHtml(inv.bill_number)}
+
+
+ +
+
Order: ${escapeHtml(inv.order_number)} (${escapeHtml(orderTypeDisplay)})
+
Paid: ${escapeHtml(inv.paid_at_local)} (Created: ${escapeHtml(inv.created_at_local)})
+
Staff: ${escapeHtml(inv.server_name || 'N/A')}
+
Customer: ${escapeHtml(customerDisplay)}
+
+ + + + + + + + + + + + + ${itemRowsHtml} + +
QtyItemUnit (${escapeHtml(currency)})Subtotal (${escapeHtml(currency)})Total (${escapeHtml(currency)})
+ + + + ${refundBlockHtml} +
`; + }) + .join('\n'); + + const html = ` + + + +Compiled Daily Invoices — ${escapeHtml(dataset.business_date)} + + + +
+
+

Daily Invoices & Fiscal Summary Report

+
+ ${escapeHtml(dataset.store.legal_name)} + · ${escapeHtml(dataset.store.tax_id_label)}: ${escapeHtml(dataset.store.tax_id || 'N/A')} + ${dataset.store.address ? ` · ${escapeHtml(dataset.store.address)}` : ''} +
+
+ Business Date: ${escapeHtml(dataset.business_date)} + · Timezone: ${escapeHtml(dataset.timezone)} + · Day Start: ${escapeHtml(dataset.business_day_start)} + · Currency: ${escapeHtml(currency)} +
+
+ +
+
+
Total Invoices Count
+
${escapeHtml(fmtNum(dataset.total_invoices_count))}
+
+
+
Total Gross Sales (${escapeHtml(currency)})
+
${escapeHtml(fmtMoney(dataset.total_gross_sales))}
+
+
+
Net Base (${escapeHtml(currency)})
+
${escapeHtml(fmtMoney(dataset.net_base))}
+
+
+
Tax Collected (${escapeHtml(currency)})
+
${escapeHtml(fmtMoney(dataset.tax_collected))}
+
+
+
Total Refunds (In Period, ${escapeHtml(currency)})
+
${escapeHtml(fmtMoney(dataset.total_refunds))}
+
+
+
Net Revenue (${escapeHtml(currency)})
+
${escapeHtml(fmtMoney(dataset.net_revenue))}
+
+
+ +

VAT / Tax Summary Table by Rate

+ + + + + + + + + + + + ${vatRateRowsHtml} + +
Tax NameRateTaxable Base (${escapeHtml(currency)})Tax Amount (${escapeHtml(currency)})Gross Amount (${escapeHtml(currency)})
+ +

Refund Scope Reconciliation

+ + + + ${refundReconciliationRows} + +
ScopeAmount (${escapeHtml(currency)})
+ +

Summary Reconciliation Metrics

+ + + + ${summaryRowsHtml} + +
MetricValue
+
+ +
+

Issued Invoices (${escapeHtml(fmtNum(dataset.invoices.length))})

+ ${invoiceCardsHtml} +
+ +
+

Daily Tax Breakdown Appendix

+ + + + + + + + + + + + + ${dailyTaxRowsHtml} + +
DateTax NameRateTaxable BaseTax AmountGross Amount
+ ${priorBillRefundsHtml} +
+ + +`; + + if (Buffer.byteLength(html, 'utf8') > MAX_DAILY_SALES_HTML_BYTES) { + throw new DailySalesExportValidationError( + 'Export exceeds maximum printable PDF size; please narrow the date range', + ); + } + return html; +} diff --git a/main/services/daily-sales-export.ts b/main/services/daily-sales-export.ts index c8c14845d..a9aed560d 100644 --- a/main/services/daily-sales-export.ts +++ b/main/services/daily-sales-export.ts @@ -180,6 +180,7 @@ export function parseValidBusinessDate(value: unknown): Date | null { export function resolveDailySalesExportRange( input: DailySalesExportQueryInput, fallbackDate?: string, + maxRangeDays: number = DAILY_SALES_EXPORT_MAX_RANGE_DAYS, ): ResolvedDailySalesExportRange { if (typeof input === 'string') { const trimmed = input.trim(); @@ -233,9 +234,9 @@ export function resolveDailySalesExportRange( ); } const dayCount = Math.round((toUtc.getTime() - fromUtc.getTime()) / DAY_MS) + 1; - if (dayCount > DAILY_SALES_EXPORT_MAX_RANGE_DAYS) { + if (dayCount > maxRangeDays) { throw new DailySalesExportValidationError( - `Date range exceeds maximum of ${DAILY_SALES_EXPORT_MAX_RANGE_DAYS} days (requested ${dayCount} days)`, + `Date range exceeds maximum of ${maxRangeDays} days (requested ${dayCount} days)`, ); } const dates: string[] = []; @@ -1236,8 +1237,9 @@ function getDatabaseStateToken(db: Database): string { export function buildDailySalesExportDataset( input: DailySalesExportQueryInput, db: Database = getDatabase(), + options?: { maxRangeDays?: number }, ): DailySalesExportDataset { - const range = resolveDailySalesExportRange(input); + const range = resolveDailySalesExportRange(input, undefined, options?.maxRangeDays); const timezone = resolveExportTimezone(); const businessDayStart = tenantBusinessDayStartTime(db); const currency = getTenantCurrency(db); diff --git a/main/services/html-to-pdf.ts b/main/services/html-to-pdf.ts index 09b0e4ae1..5abe30ac1 100644 --- a/main/services/html-to-pdf.ts +++ b/main/services/html-to-pdf.ts @@ -2,7 +2,7 @@ import { BrowserWindow, app } from 'electron'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; export const MAX_PDF_HTML_BYTES = 15 * 1024 * 1024; @@ -16,6 +16,57 @@ export function setHtmlToPdfRendererForTests(renderer: HtmlToPdfRenderer | null) rendererOverride = renderer; } +export function isAllowedPdfStagingRequestUrl(requestUrl: string, htmlPath: string): boolean { + if (typeof requestUrl !== 'string' || typeof htmlPath !== 'string' || !htmlPath) { + return false; + } + if (requestUrl.startsWith('data:image/')) { + return true; + } + if (!requestUrl.startsWith('file://')) { + return false; + } + const isWindowsPath = process.platform === 'win32' || /^[a-zA-Z]:[\\/]/.test(htmlPath); + if (isWindowsPath) { + try { + const parsed = new URL(requestUrl); + if (parsed.protocol !== 'file:' || parsed.search || parsed.hash) { + return false; + } + if (parsed.hostname !== '' && parsed.hostname !== 'localhost') { + return false; + } + const rawUrlPath = + process.platform === 'win32' + ? fileURLToPath(parsed) + : decodeURIComponent(parsed.pathname).replace(/^\/([a-zA-Z]:[\\/])/, '$1'); + if (!/^[a-zA-Z]:[\\/]/.test(rawUrlPath)) { + return false; + } + return ( + path.win32.resolve(rawUrlPath).toLowerCase() === path.win32.resolve(htmlPath).toLowerCase() + ); + } catch { + return false; + } + } + + const resolvedHtmlPath = path.resolve(htmlPath); + const htmlUrl = pathToFileURL(resolvedHtmlPath).href; + if (requestUrl === htmlUrl) { + return true; + } + try { + const parsed = new URL(requestUrl); + if (parsed.protocol !== 'file:' || parsed.search || parsed.hash) { + return false; + } + return path.resolve(fileURLToPath(parsed)) === resolvedHtmlPath; + } catch { + return false; + } +} + export async function renderHtmlToPdfBuffer(input: { html: string; pageSize?: unknown; @@ -32,7 +83,8 @@ export async function renderHtmlToPdfBuffer(input: { fs.mkdirSync(stagingDirectory, { recursive: true, mode: 0o700 }); const htmlPath = path.join(stagingDirectory, `document-${crypto.randomUUID()}.html`); fs.writeFileSync(htmlPath, input.html, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); - const htmlUrl = pathToFileURL(htmlPath).href; + const resolvedHtmlPath = path.resolve(htmlPath); + const htmlUrl = pathToFileURL(resolvedHtmlPath).href; const pdfWindow = new BrowserWindow({ show: false, @@ -45,13 +97,14 @@ export async function renderHtmlToPdfBuffer(input: { }, }); pdfWindow.webContents.session.webRequest.onBeforeRequest((details, callback) => { - const allowed = details.url === htmlUrl || details.url.startsWith('data:image/'); + const allowed = + details.url === htmlUrl || isAllowedPdfStagingRequestUrl(details.url, resolvedHtmlPath); callback({ cancel: !allowed }); }); pdfWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); pdfWindow.webContents.on('will-navigate', (event) => event.preventDefault()); try { - await pdfWindow.loadFile(htmlPath); + await pdfWindow.loadFile(resolvedHtmlPath); return await pdfWindow.webContents.printToPDF({ printBackground: true, pageSize, @@ -59,7 +112,7 @@ export async function renderHtmlToPdfBuffer(input: { }); } finally { try { - fs.unlinkSync(htmlPath); + fs.unlinkSync(resolvedHtmlPath); } catch {} if (!pdfWindow.isDestroyed()) pdfWindow.destroy(); } diff --git a/package.json b/package.json index d8914a887..7facfed34 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "pretest": "npm run test:script-coverage && node scripts/run-test.cjs npm run test:payment-methods-split && node scripts/run-test.cjs npm run test:release-regressions && node scripts/run-test.cjs npm run test:product-variants-db && node scripts/run-test.cjs npm run test:product-variants-api", "start": "electron .", "rebuild": "HOME=~/.electron-gyp node-gyp rebuild --target=$(node -p \"require('./node_modules/electron/package.json').version\") --arch=$(node -p \"process.arch\") --dist-url=https://electronjs.org/headers --runtime=electron --directory node_modules/better-sqlite3", - "test": "node scripts/run-test.cjs npm run test:smoke && node scripts/run-test.cjs npm run test:server-port-collision && node scripts/run-test.cjs npm run test:kds-integration && node scripts/run-test.cjs npm run test:kds-broadcast-grouping && node scripts/run-test.cjs npm run test:kds-contract && node scripts/run-test.cjs npm run test:kds-frontend-conflict && node scripts/run-test.cjs npm run test:kds-window-hardening && node scripts/run-test.cjs npm run test:electron-api-contract && node scripts/run-test.cjs npm run test:titlebar-window-options && node scripts/run-test.cjs npm run test:update-ipc-sender-validation && node scripts/run-test.cjs npm run test:menu-surface && node scripts/run-test.cjs npm run test:pos-keyboard-shortcuts && node scripts/run-test.cjs npm run test:window-readiness && node scripts/run-test.cjs npm run test:window-load-retry && node scripts/run-test.cjs npm run test:cors && node scripts/run-test.cjs npm run test:csp-lan && node scripts/run-test.cjs npm run test:release-config && node scripts/run-test.cjs npm run test:update-state && node scripts/run-test.cjs npm run test:update-ui && node scripts/run-test.cjs npm run test:update-channel && node scripts/run-test.cjs npm run test:telemetry && node scripts/run-test.cjs npm run test:telemetry-install-provenance && node scripts/run-test.cjs npm run test:country-provenance && node scripts/run-test.cjs npm run test:first-run && node scripts/run-test.cjs npm run test:phase7-setup-i18n && node scripts/run-test.cjs npm run test:security && node scripts/run-test.cjs npm run test:jwt-secret-cache-identity && node scripts/run-test.cjs npm run test:jwt-secret-persist-before-cache && node scripts/run-test.cjs npm run test:jwt-secret-safe-storage && node scripts/run-test.cjs npm run test:staff-authz && node scripts/run-test.cjs npm run test:orders-authz && node scripts/run-test.cjs npm run test:order-item-cancel-restore && node scripts/run-test.cjs npm run test:order-item-edit && node scripts/run-test.cjs npm run test:item-price-override && node scripts/run-test.cjs npm run test:offline-mode && node scripts/run-test.cjs npm run test:product-variants-orders && node scripts/run-test.cjs npm run test:addon-inventory-pos-surface && node scripts/run-test.cjs npm run test:addon-inventory-orders && node scripts/run-test.cjs npm run test:addon-inventory-lifecycle && node scripts/run-test.cjs npm run test:order-totals-recompute && node scripts/run-test.cjs npm run test:issue-718-whatsapp-order-status && node scripts/run-test.cjs npm run test:order-audit-log && node scripts/run-test.cjs npm run test:authz-phase3 && node scripts/run-test.cjs npm run test:authorization-permissions && node scripts/run-test.cjs npm run test:auth-ui-deterministic && node scripts/run-test.cjs npm run test:desktop-trailing-slash-routing && node scripts/run-test.cjs npm run test:customer-auth && node scripts/run-test.cjs npm run test:customer-soft-reactivate-bindings && node scripts/run-test.cjs npm run test:customer-pagination && node scripts/run-test.cjs npm run test:customer-top-items && node scripts/run-test.cjs npm run test:server-app-server-role && node scripts/run-test.cjs npm run test:backup && node scripts/run-test.cjs npm run test:issue-278-fail-closed-db && node scripts/run-test.cjs npm run test:recovery-cloud && node scripts/run-test.cjs npm run test:cloud-account-status && node scripts/run-test.cjs npm run test:cloud-live-orders-batching && node scripts/run-test.cjs npm run test:printer && node scripts/run-test.cjs npm run test:printing-settings && node scripts/run-test.cjs npm run test:printer-width-refresh && node scripts/run-test.cjs npm run test:printer-migrations && node scripts/run-test.cjs npm run test:printer-text-size && node scripts/run-test.cjs npm run test:print-parity && node scripts/run-test.cjs npm run test:delivery-slip && node scripts/run-test.cjs npm run test:delivery-address && node scripts/run-test.cjs npm run test:thermal-capabilities && node scripts/run-test.cjs npm run test:raster && node scripts/run-test.cjs npm run test:merchant-print-templates && node scripts/run-test.cjs npm run test:merchant-template-transfer && node scripts/run-test.cjs npm run test:print-document && node scripts/run-test.cjs npm run test:menu-printing && node scripts/run-test.cjs npm run test:print-kernel && node scripts/run-test.cjs npm run test:translations && node scripts/run-test.cjs npm run test:print-labels && node scripts/run-test.cjs npm run test:locale-chunks && node scripts/run-test.cjs npm run test:rtl-foundation && node scripts/run-test.cjs npm run test:rtl-setup-auth-settings && node scripts/run-test.cjs npm run test:rtl-dashboard-pos-common && node scripts/run-test.cjs npm run test:rtl-kds-server-whatsapp && node scripts/run-test.cjs npm run test:phone && node scripts/run-test.cjs npm run test:country-localization && node scripts/run-test.cjs npm run test:e2e-argentina-flow && node scripts/run-test.cjs npm run test:charges && node scripts/run-test.cjs npm run test:charges-engine && node scripts/run-test.cjs npm run test:currency && node scripts/run-test.cjs npm run test:tax-engine && node scripts/run-test.cjs npm run test:tax-components && node scripts/run-test.cjs npm run test:tax-pack-catalog && node scripts/run-test.cjs npm run test:tax-pack-management && node scripts/run-test.cjs npm run test:manual-tax-config && node scripts/run-test.cjs npm run test:legacy-tax-pack-digest && node scripts/run-test.cjs npm run test:community-tax-packs && node scripts/run-test.cjs npm run test:support-ticket && node scripts/run-test.cjs npm run test:customer-phone-search && node scripts/run-test.cjs npm run test:phone-search-integration && node scripts/run-test.cjs npm run test:orders-search && node scripts/run-test.cjs npm run test:orders-layout-settings && node scripts/run-test.cjs npm run test:pos-catalog-sort && node scripts/run-test.cjs npm run test:pos-sort-settings && node scripts/run-test.cjs npm run test:receipt-column-width && node scripts/run-test.cjs npm run test:receipt-column-oracle && node scripts/run-test.cjs npm run test:notes-validation && node scripts/run-test.cjs npm run test:receipt-printing && node scripts/run-test.cjs npm run test:cancel-override && node scripts/run-test.cjs npm run test:refunds && node scripts/run-test.cjs npm run test:async-refund-pin-event-loop && node scripts/run-test.cjs npm run test:refund-completed-orders && node scripts/run-test.cjs npm run test:cash-closures && node scripts/run-test.cjs npm run test:cash-sessions && node scripts/run-test.cjs npm run test:kitchen-addons && node scripts/run-test.cjs npm run test:kot-order-notes && node scripts/run-test.cjs npm run test:pos-info-network-ranking && node scripts/run-test.cjs npm run test:order-item-addons && node scripts/run-test.cjs npm run test:issue-125-addon-reads && node scripts/run-test.cjs npm run test:windows-country-code-crash && node scripts/run-test.cjs npm run test:reports-insights && node scripts/run-test.cjs npm run test:reports-daily-stats-table-turn && node scripts/run-test.cjs npm run test:timezone-report-boundaries && node scripts/run-test.cjs npm run test:business-date && node scripts/run-test.cjs npm run test:dashboard-business-date && node scripts/run-test.cjs npm run test:sequence && node scripts/run-test.cjs npm run test:order-numbering-settings && node scripts/run-test.cjs npm run test:integration-happy && node scripts/run-test.cjs npm run test:integration-order-hold-payment && node scripts/run-test.cjs npm run test:integration-tax && node scripts/run-test.cjs npm run test:integration-payments && node scripts/run-test.cjs npm run test:kitchen-delivery-enforcement && node scripts/run-test.cjs npm run test:issue-214 && node scripts/run-test.cjs npm run test:issue-214-auth && node scripts/run-test.cjs npm run test:issue-214-migration && node scripts/run-test.cjs npm run test:issue-255 && node scripts/run-test.cjs npm run test:cart-variant-identity && node scripts/run-test.cjs npm run test:cart-price-override && node scripts/run-test.cjs npm run test:product-variants-screen && node scripts/run-test.cjs npm run test:variant-pos-surface && node scripts/run-test.cjs npm run test:issue-788-order-attempt-storage && node scripts/run-test.cjs npm run test:integration-lifecycle && node scripts/run-test.cjs npm run test:integration-reconciliation && node scripts/run-test.cjs npm run test:integration-loyalty && node scripts/run-test.cjs npm run test:integration-discount && node scripts/run-test.cjs npm run test:loyalty-toggle && node scripts/run-test.cjs npm run test:discount-system && node scripts/run-test.cjs npm run test:integration-discount-settings && node scripts/run-test.cjs npm run test:integration-loyalty-global && node scripts/run-test.cjs npm run test:issue-248-csv && node scripts/run-test.cjs npm run test:integration-loyalty-redemption && node scripts/run-test.cjs npm run test:bills-print-api && node scripts/run-test.cjs npm run test:issue-24 && node scripts/run-test.cjs npm run test:issue-134-routing && node scripts/run-test.cjs npm run test:server-app-station-routing && node scripts/run-test.cjs npm run test:issue-134-mgmt && node scripts/run-test.cjs npm run test:issue-137-barcode && node scripts/run-test.cjs npm run test:issue-244-product-addon-links && node scripts/run-test.cjs npm run test:issue-245-246-addon-groups && node scripts/run-test.cjs npm run test:issue-247-catalog-invariants && node scripts/run-test.cjs npm run test:issue-250-catalog-perf && node scripts/run-test.cjs npm run test:issue-252 && node scripts/run-test.cjs npm run test:inventory-ledger && node scripts/run-test.cjs npm run test:issue-printing-and-kds-resilience && node scripts/run-test.cjs npm run test:issue-258-bill-pagination && node scripts/run-test.cjs npm run test:issue-265-morocco-profile && node scripts/run-test.cjs npm run test:issue-266-currency-symbol-print && node scripts/run-test.cjs npm run test:tables-string-ids && node scripts/run-test.cjs npm run test:issue-646-floor-management && node scripts/run-test.cjs npm run test:held-orders && node scripts/run-test.cjs npm run test:product-images && node scripts/run-test.cjs npm run test:schema-health && node scripts/run-test.cjs npm run test:migration-registry && node scripts/run-test.cjs npm run test:upgrade-path && node scripts/run-test.cjs npm run test:upgrade-matrix-harness && node scripts/run-test.cjs npm run test:migration-v56-v57 && node scripts/run-test.cjs npm run test:migration-v71-repair && node scripts/run-test.cjs npm run test:migration-v80-cash-drawer-pulse && node scripts/run-test.cjs npm run test:migration-v81-cash-closures && node scripts/run-test.cjs npm run test:master-pin && node scripts/run-test.cjs npm run test:google-drive && node scripts/run-test.cjs npm run test:google-drive-qr-menu && node scripts/run-test.cjs npm run test:database-tools-api && node scripts/run-test.cjs npm run test:phone-validation && node scripts/run-test.cjs npm run test:phone-migration && node scripts/run-test.cjs npm run test:issue-133-kds-kot-toggles && node scripts/run-test.cjs npm run test:whatsapp-schema && node scripts/run-test.cjs npm run test:whatsapp-service && node scripts/run-test.cjs npm run test:whatsapp-middleware && node scripts/run-test.cjs npm run test:issue-127-password-recovery && node scripts/run-test.cjs npm run test:dev-tooling && node scripts/run-test.cjs npm run test:windows-uninstaller && node scripts/run-test.cjs npm run test:shutdown-lifecycle && node scripts/run-test.cjs npm run test:redos-hardening && node scripts/run-test.cjs npm run test:startup-cache && node scripts/run-test.cjs npm run test:service-worker && node scripts/run-test.cjs npm run test:issue-389-timezone-override && node scripts/run-test.cjs npm run test:issue-390-locale-preference-invariants && node scripts/run-test.cjs npm run test:issue-475-picker-highlight && node scripts/run-test.cjs npm run test:theme-mode-settings && node scripts/run-test.cjs npm run test:theme-fouc-script && node scripts/run-test.cjs npm run test:ui-regressions-621-623-626 && node scripts/run-test.cjs npm run test:issue-zero-balance-settlement && node scripts/run-test.cjs npm run test:daily-sales-export && node scripts/run-test.cjs npm run test:customer-sales-export && node scripts/run-test.cjs npm run test:card-transaction-id && node scripts/run-test.cjs npm run test:issue-terminal-state-guards && node scripts/run-test.cjs npm run test:issue-exact-balance-settlement && node scripts/run-test.cjs npm run test:financial-safety-remediation && node scripts/run-test.cjs npm run test:issue-payload-and-discount-bounds && node scripts/run-test.cjs npm run test:customer-display-config && node scripts/run-test.cjs npm run test:customer-display-settings-route && node scripts/run-test.cjs npm run test:customer-display-pairing && node scripts/run-test.cjs npm run test:customer-display-feed && node scripts/run-test.cjs npm run test:units && node scripts/run-test.cjs npm run test:supplies-service && node scripts/run-test.cjs npm run test:recipe-order-lifecycle && node scripts/run-test.cjs npm run test:ingredient-cost && node scripts/run-test.cjs npm run test:lint-engine && node scripts/run-test.cjs npm run test:oxc-contributor-checks && node scripts/run-test.cjs npm run test:expenses && node scripts/run-test.cjs npm run test:diagnostics && node scripts/run-test.cjs npm run test:reports-export && node scripts/run-test.cjs npm run test:category-addon-groups && node scripts/run-test.cjs npm run test:addon-inventory-db && node scripts/run-test.cjs npm run test:addon-inventory-api && node scripts/run-test.cjs npm run test:discount-kitchen-approver-identity && node scripts/run-test.cjs npm run test:staff-approvers-directory && node scripts/run-test.cjs npm run test:local-backup && npm run test:password-policy && npm run test:cloud-password-recovery", + "test": "node scripts/run-test.cjs npm run test:smoke && node scripts/run-test.cjs npm run test:server-port-collision && node scripts/run-test.cjs npm run test:kds-integration && node scripts/run-test.cjs npm run test:kds-broadcast-grouping && node scripts/run-test.cjs npm run test:kds-contract && node scripts/run-test.cjs npm run test:kds-frontend-conflict && node scripts/run-test.cjs npm run test:kds-window-hardening && node scripts/run-test.cjs npm run test:electron-api-contract && node scripts/run-test.cjs npm run test:titlebar-window-options && node scripts/run-test.cjs npm run test:update-ipc-sender-validation && node scripts/run-test.cjs npm run test:menu-surface && node scripts/run-test.cjs npm run test:pos-keyboard-shortcuts && node scripts/run-test.cjs npm run test:window-readiness && node scripts/run-test.cjs npm run test:window-load-retry && node scripts/run-test.cjs npm run test:cors && node scripts/run-test.cjs npm run test:csp-lan && node scripts/run-test.cjs npm run test:release-config && node scripts/run-test.cjs npm run test:update-state && node scripts/run-test.cjs npm run test:update-ui && node scripts/run-test.cjs npm run test:update-channel && node scripts/run-test.cjs npm run test:telemetry && node scripts/run-test.cjs npm run test:telemetry-install-provenance && node scripts/run-test.cjs npm run test:country-provenance && node scripts/run-test.cjs npm run test:first-run && node scripts/run-test.cjs npm run test:phase7-setup-i18n && node scripts/run-test.cjs npm run test:security && node scripts/run-test.cjs npm run test:jwt-secret-cache-identity && node scripts/run-test.cjs npm run test:jwt-secret-persist-before-cache && node scripts/run-test.cjs npm run test:jwt-secret-safe-storage && node scripts/run-test.cjs npm run test:staff-authz && node scripts/run-test.cjs npm run test:orders-authz && node scripts/run-test.cjs npm run test:order-item-cancel-restore && node scripts/run-test.cjs npm run test:order-item-edit && node scripts/run-test.cjs npm run test:item-price-override && node scripts/run-test.cjs npm run test:offline-mode && node scripts/run-test.cjs npm run test:product-variants-orders && node scripts/run-test.cjs npm run test:addon-inventory-pos-surface && node scripts/run-test.cjs npm run test:addon-inventory-orders && node scripts/run-test.cjs npm run test:addon-inventory-lifecycle && node scripts/run-test.cjs npm run test:order-totals-recompute && node scripts/run-test.cjs npm run test:issue-718-whatsapp-order-status && node scripts/run-test.cjs npm run test:order-audit-log && node scripts/run-test.cjs npm run test:authz-phase3 && node scripts/run-test.cjs npm run test:authorization-permissions && node scripts/run-test.cjs npm run test:auth-ui-deterministic && node scripts/run-test.cjs npm run test:desktop-trailing-slash-routing && node scripts/run-test.cjs npm run test:customer-auth && node scripts/run-test.cjs npm run test:customer-soft-reactivate-bindings && node scripts/run-test.cjs npm run test:customer-pagination && node scripts/run-test.cjs npm run test:customer-top-items && node scripts/run-test.cjs npm run test:server-app-server-role && node scripts/run-test.cjs npm run test:backup && node scripts/run-test.cjs npm run test:issue-278-fail-closed-db && node scripts/run-test.cjs npm run test:recovery-cloud && node scripts/run-test.cjs npm run test:cloud-account-status && node scripts/run-test.cjs npm run test:cloud-live-orders-batching && node scripts/run-test.cjs npm run test:printer && node scripts/run-test.cjs npm run test:printing-settings && node scripts/run-test.cjs npm run test:printer-width-refresh && node scripts/run-test.cjs npm run test:printer-migrations && node scripts/run-test.cjs npm run test:printer-text-size && node scripts/run-test.cjs npm run test:print-parity && node scripts/run-test.cjs npm run test:delivery-slip && node scripts/run-test.cjs npm run test:delivery-address && node scripts/run-test.cjs npm run test:thermal-capabilities && node scripts/run-test.cjs npm run test:raster && node scripts/run-test.cjs npm run test:merchant-print-templates && node scripts/run-test.cjs npm run test:merchant-template-transfer && node scripts/run-test.cjs npm run test:print-document && node scripts/run-test.cjs npm run test:menu-printing && node scripts/run-test.cjs npm run test:print-kernel && node scripts/run-test.cjs npm run test:translations && node scripts/run-test.cjs npm run test:print-labels && node scripts/run-test.cjs npm run test:locale-chunks && node scripts/run-test.cjs npm run test:rtl-foundation && node scripts/run-test.cjs npm run test:rtl-setup-auth-settings && node scripts/run-test.cjs npm run test:rtl-dashboard-pos-common && node scripts/run-test.cjs npm run test:rtl-kds-server-whatsapp && node scripts/run-test.cjs npm run test:phone && node scripts/run-test.cjs npm run test:country-localization && node scripts/run-test.cjs npm run test:e2e-argentina-flow && node scripts/run-test.cjs npm run test:charges && node scripts/run-test.cjs npm run test:charges-engine && node scripts/run-test.cjs npm run test:currency && node scripts/run-test.cjs npm run test:tax-engine && node scripts/run-test.cjs npm run test:tax-components && node scripts/run-test.cjs npm run test:tax-pack-catalog && node scripts/run-test.cjs npm run test:tax-pack-management && node scripts/run-test.cjs npm run test:manual-tax-config && node scripts/run-test.cjs npm run test:legacy-tax-pack-digest && node scripts/run-test.cjs npm run test:community-tax-packs && node scripts/run-test.cjs npm run test:support-ticket && node scripts/run-test.cjs npm run test:customer-phone-search && node scripts/run-test.cjs npm run test:phone-search-integration && node scripts/run-test.cjs npm run test:orders-search && node scripts/run-test.cjs npm run test:orders-layout-settings && node scripts/run-test.cjs npm run test:pos-catalog-sort && node scripts/run-test.cjs npm run test:pos-sort-settings && node scripts/run-test.cjs npm run test:receipt-column-width && node scripts/run-test.cjs npm run test:receipt-column-oracle && node scripts/run-test.cjs npm run test:notes-validation && node scripts/run-test.cjs npm run test:receipt-printing && node scripts/run-test.cjs npm run test:cancel-override && node scripts/run-test.cjs npm run test:refunds && node scripts/run-test.cjs npm run test:async-refund-pin-event-loop && node scripts/run-test.cjs npm run test:refund-completed-orders && node scripts/run-test.cjs npm run test:cash-closures && node scripts/run-test.cjs npm run test:cash-sessions && node scripts/run-test.cjs npm run test:kitchen-addons && node scripts/run-test.cjs npm run test:kot-order-notes && node scripts/run-test.cjs npm run test:pos-info-network-ranking && node scripts/run-test.cjs npm run test:order-item-addons && node scripts/run-test.cjs npm run test:issue-125-addon-reads && node scripts/run-test.cjs npm run test:windows-country-code-crash && node scripts/run-test.cjs npm run test:reports-insights && node scripts/run-test.cjs npm run test:reports-daily-stats-table-turn && node scripts/run-test.cjs npm run test:timezone-report-boundaries && node scripts/run-test.cjs npm run test:business-date && node scripts/run-test.cjs npm run test:dashboard-business-date && node scripts/run-test.cjs npm run test:sequence && node scripts/run-test.cjs npm run test:order-numbering-settings && node scripts/run-test.cjs npm run test:integration-happy && node scripts/run-test.cjs npm run test:integration-order-hold-payment && node scripts/run-test.cjs npm run test:integration-tax && node scripts/run-test.cjs npm run test:integration-payments && node scripts/run-test.cjs npm run test:kitchen-delivery-enforcement && node scripts/run-test.cjs npm run test:issue-214 && node scripts/run-test.cjs npm run test:issue-214-auth && node scripts/run-test.cjs npm run test:issue-214-migration && node scripts/run-test.cjs npm run test:issue-255 && node scripts/run-test.cjs npm run test:cart-variant-identity && node scripts/run-test.cjs npm run test:cart-price-override && node scripts/run-test.cjs npm run test:product-variants-screen && node scripts/run-test.cjs npm run test:variant-pos-surface && node scripts/run-test.cjs npm run test:issue-788-order-attempt-storage && node scripts/run-test.cjs npm run test:integration-lifecycle && node scripts/run-test.cjs npm run test:integration-reconciliation && node scripts/run-test.cjs npm run test:integration-loyalty && node scripts/run-test.cjs npm run test:integration-discount && node scripts/run-test.cjs npm run test:loyalty-toggle && node scripts/run-test.cjs npm run test:discount-system && node scripts/run-test.cjs npm run test:integration-discount-settings && node scripts/run-test.cjs npm run test:integration-loyalty-global && node scripts/run-test.cjs npm run test:issue-248-csv && node scripts/run-test.cjs npm run test:integration-loyalty-redemption && node scripts/run-test.cjs npm run test:bills-print-api && node scripts/run-test.cjs npm run test:issue-24 && node scripts/run-test.cjs npm run test:issue-134-routing && node scripts/run-test.cjs npm run test:server-app-station-routing && node scripts/run-test.cjs npm run test:issue-134-mgmt && node scripts/run-test.cjs npm run test:issue-137-barcode && node scripts/run-test.cjs npm run test:issue-244-product-addon-links && node scripts/run-test.cjs npm run test:issue-245-246-addon-groups && node scripts/run-test.cjs npm run test:issue-247-catalog-invariants && node scripts/run-test.cjs npm run test:issue-250-catalog-perf && node scripts/run-test.cjs npm run test:issue-252 && node scripts/run-test.cjs npm run test:inventory-ledger && node scripts/run-test.cjs npm run test:issue-printing-and-kds-resilience && node scripts/run-test.cjs npm run test:issue-258-bill-pagination && node scripts/run-test.cjs npm run test:issue-265-morocco-profile && node scripts/run-test.cjs npm run test:issue-266-currency-symbol-print && node scripts/run-test.cjs npm run test:tables-string-ids && node scripts/run-test.cjs npm run test:issue-646-floor-management && node scripts/run-test.cjs npm run test:held-orders && node scripts/run-test.cjs npm run test:product-images && node scripts/run-test.cjs npm run test:schema-health && node scripts/run-test.cjs npm run test:migration-registry && node scripts/run-test.cjs npm run test:upgrade-path && node scripts/run-test.cjs npm run test:upgrade-matrix-harness && node scripts/run-test.cjs npm run test:migration-v56-v57 && node scripts/run-test.cjs npm run test:migration-v71-repair && node scripts/run-test.cjs npm run test:migration-v80-cash-drawer-pulse && node scripts/run-test.cjs npm run test:migration-v81-cash-closures && node scripts/run-test.cjs npm run test:master-pin && node scripts/run-test.cjs npm run test:google-drive && node scripts/run-test.cjs npm run test:google-drive-qr-menu && node scripts/run-test.cjs npm run test:database-tools-api && node scripts/run-test.cjs npm run test:phone-validation && node scripts/run-test.cjs npm run test:phone-migration && node scripts/run-test.cjs npm run test:issue-133-kds-kot-toggles && node scripts/run-test.cjs npm run test:whatsapp-schema && node scripts/run-test.cjs npm run test:whatsapp-service && node scripts/run-test.cjs npm run test:whatsapp-middleware && node scripts/run-test.cjs npm run test:issue-127-password-recovery && node scripts/run-test.cjs npm run test:dev-tooling && node scripts/run-test.cjs npm run test:windows-uninstaller && node scripts/run-test.cjs npm run test:shutdown-lifecycle && node scripts/run-test.cjs npm run test:redos-hardening && node scripts/run-test.cjs npm run test:startup-cache && node scripts/run-test.cjs npm run test:service-worker && node scripts/run-test.cjs npm run test:issue-389-timezone-override && node scripts/run-test.cjs npm run test:issue-390-locale-preference-invariants && node scripts/run-test.cjs npm run test:issue-475-picker-highlight && node scripts/run-test.cjs npm run test:theme-mode-settings && node scripts/run-test.cjs npm run test:theme-fouc-script && node scripts/run-test.cjs npm run test:ui-regressions-621-623-626 && node scripts/run-test.cjs npm run test:issue-zero-balance-settlement && node scripts/run-test.cjs npm run test:daily-sales-export && node scripts/run-test.cjs npm run test:daily-invoices-export && node scripts/run-test.cjs npm run test:customer-sales-export && node scripts/run-test.cjs npm run test:card-transaction-id && node scripts/run-test.cjs npm run test:issue-terminal-state-guards && node scripts/run-test.cjs npm run test:issue-exact-balance-settlement && node scripts/run-test.cjs npm run test:financial-safety-remediation && node scripts/run-test.cjs npm run test:issue-payload-and-discount-bounds && node scripts/run-test.cjs npm run test:customer-display-config && node scripts/run-test.cjs npm run test:customer-display-settings-route && node scripts/run-test.cjs npm run test:customer-display-pairing && node scripts/run-test.cjs npm run test:customer-display-feed && node scripts/run-test.cjs npm run test:units && node scripts/run-test.cjs npm run test:supplies-service && node scripts/run-test.cjs npm run test:recipe-order-lifecycle && node scripts/run-test.cjs npm run test:ingredient-cost && node scripts/run-test.cjs npm run test:lint-engine && node scripts/run-test.cjs npm run test:oxc-contributor-checks && node scripts/run-test.cjs npm run test:expenses && node scripts/run-test.cjs npm run test:diagnostics && node scripts/run-test.cjs npm run test:reports-export && node scripts/run-test.cjs npm run test:category-addon-groups && node scripts/run-test.cjs npm run test:addon-inventory-db && node scripts/run-test.cjs npm run test:addon-inventory-api && node scripts/run-test.cjs npm run test:discount-kitchen-approver-identity && node scripts/run-test.cjs npm run test:staff-approvers-directory && node scripts/run-test.cjs npm run test:local-backup && npm run test:password-policy && npm run test:cloud-password-recovery", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:runtime-recovery && npm run test:backend-health && npm run test:url-allowlist && npm run test:static-routes", "test:script-coverage": "node scripts/ci/validate-test-script-coverage.cjs && node tests/test-script-coverage.test.cjs && ts-node --transpile-only -P tests/tsconfig.json tests/test-harness-assertions.test.ts", "test:lint-engine": "ts-node --transpile-only -P tests/tsconfig.json tests/lint-engine-parity.test.ts", @@ -74,6 +74,7 @@ "test:smoke": "node tests/run-electron-node-test.cjs tests/smoke-test.test.ts", "test:server-port-collision": "node tests/run-electron-node-test.cjs tests/server-port-collision.test.ts", "test:daily-sales-export": "node tests/run-electron-node-test.cjs tests/daily-sales-export.test.ts", + "test:daily-invoices-export": "node tests/run-electron-node-test.cjs tests/daily-invoices-export.test.ts", "test:customer-sales-export": "node tests/run-electron-node-test.cjs tests/customer-sales-export.test.ts", "test:card-transaction-id": "node tests/run-electron-node-test.cjs tests/card-transaction-id.test.ts", "test:reports-export": "node tests/run-electron-node-test.cjs tests/reports-export.test.ts", diff --git a/tests/daily-invoices-export.test.ts b/tests/daily-invoices-export.test.ts new file mode 100644 index 000000000..8c106fd3a --- /dev/null +++ b/tests/daily-invoices-export.test.ts @@ -0,0 +1,1247 @@ +/** + * Owner-only compiled daily invoices PDF/HTML export — GET /api/reports/daily-invoices/export. + * + * Covers: + * 1. Sequential bill_number ascending ordering across multiple bills (paid, partially_refunded, refunded). + * 2. [REFUNDED] and [PARTIALLY REFUNDED] status badges and refund details are present and never filtered out. + * 3. Cover-page summary totals and VAT/Tax Summary Table match the daily sales .xlsx export for the same business date / range. + * 4. Cross-midnight business_day_start_time (day_close_hour) boundary filtering includes post-midnight pre-cutoff bills and excludes out-of-window bills. + * 5. 403 rejection for non-owner roles (manager, cashier, server/waiter, chef/kitchen) and 401 for unauthenticated requests. + * 6. save-html-as-pdf desktop IPC and verifyBrowserPrintFallback produce valid PDF output (%PDF-) with .invoice-card break-inside: avoid. + * + * Usage: node tests/run-electron-node-test.cjs tests/daily-invoices-export.test.ts + */ + +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const Module = require('module'); +const originalLoad = Module._load; + +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-daily-invoices-export-')); +const registeredIpcHandlers = new Map any>(); +let saveDialogResult: { canceled: boolean; filePath?: string } = { canceled: false }; +let lastLoadedHtmlPath = ''; +let lastLoadedHtmlContent = ''; + +class FakePdfBrowserWindow { + static allWindows: FakePdfBrowserWindow[] = []; + destroyed = false; + webContents: any; + + constructor(_options?: any) { + const allowedUrls: string[] = []; + let beforeRequestHandler: + | ((details: { url: string }, cb: (res: { cancel: boolean }) => void) => void) + | null = null; + this.webContents = { + session: { + webRequest: { + onBeforeRequest: ( + handler: (details: { url: string }, cb: (res: { cancel: boolean }) => void) => void, + ) => { + beforeRequestHandler = handler; + }, + }, + }, + setWindowOpenHandler: () => undefined, + on: () => undefined, + printToPDF: async (options: { pageSize?: string; preferCSSPageSize?: boolean }) => { + return Buffer.from( + `%PDF-1.4\n% Fake PDF pageSize=${options.pageSize} preferCSSPageSize=${String(options.preferCSSPageSize)} allowedUrls=${allowedUrls.length}\n${lastLoadedHtmlContent}\n%%EOF\n`, + 'utf8', + ); + }, + _triggerBeforeRequest: (url: string): boolean => { + let allowed = true; + if (beforeRequestHandler) { + beforeRequestHandler({ url }, (res) => { + allowed = !res.cancel; + }); + } + if (allowed) allowedUrls.push(url); + return allowed; + }, + }; + FakePdfBrowserWindow.allWindows.push(this); + } + + async loadFile(filePath: string) { + lastLoadedHtmlPath = filePath; + assert.ok(fs.existsSync(filePath), 'staging HTML file exists during loadFile'); + lastLoadedHtmlContent = fs.readFileSync(filePath, 'utf8'); + const { pathToFileURL } = require('node:url'); + const allowed = this.webContents._triggerBeforeRequest(pathToFileURL(filePath).href); + assert.equal(allowed, true, 'staged HTML file URL is allowed by onBeforeRequest'); + } + + isDestroyed() { + return this.destroyed; + } + + destroy() { + this.destroyed = true; + } + + static getAllWindows() { + return FakePdfBrowserWindow.allWindows; + } + + static fromWebContents(sender: any) { + return sender?.ownerWindow ?? null; + } +} + +Module._load = function (request: string, parent: unknown, isMain: boolean) { + if (request === 'electron') { + return { + app: { + isPackaged: true, + getPath: () => testDir, + getVersion: () => 'test', + getName: () => 'FloCafe', + }, + BrowserWindow: FakePdfBrowserWindow, + ipcMain: { + on: () => undefined, + handle: (channel: string, listener: (...args: any[]) => any) => { + registeredIpcHandlers.set(channel, listener); + }, + }, + dialog: { + showSaveDialog: async () => saveDialogResult, + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + showMessageBox: async () => ({ response: 1 }), + }, + }; + } + return originalLoad.apply(this, arguments as any); +}; + +process.env.JWT_SECRET = 'test-secret-daily-invoices-export'; + +const { + initTestDb, + createApp, + startServer, + seedOwnerUser, + seedManagerUser, + seedCategory, + seedProduct, + closeDatabase, + now, +} = require('./helpers/test-setup'); +const { getJWTSecret } = require('../main/routes/auth'); +const jwt = require('jsonwebtoken'); +const { reportRoutes } = require('../main/routes/reports'); +const { registerIpcHandlers } = require('../main/ipc'); +const ExcelJS = require('exceljs'); + +function seedRoleUser( + db: any, + userId: string, + name: string, + email: string, + role: 'cashier' | 'server' | 'chef', +): { userId: string; token: string; authHeader: Record } { + db.prepare( + `INSERT OR IGNORE INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES (?, ?, ?, 'hashed', ?, 1, ?, ?)`, + ).run(userId, name, email, role, now(), now()); + const token = jwt.sign({ userId, email, role }, getJWTSecret(), { expiresIn: '1h' }); + return { userId, token, authHeader: { Authorization: `Bearer ${token}` } }; +} + +function unescapeHtml(value: string): string { + return value + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); +} + +function parseHtmlSummaryMetrics(html: string): Map { + const map = new Map(); + const re = / { + const rows: Array<{ + date: string; + name: string; + rate: string; + base: string; + tax: string; + gross: string; + }> = []; + const re = + / { + const cards: Array<{ billNumber: string; status: string }> = []; + const re = + / { + const { exportHtmlDocumentAsPdf } = require('../frontend/src/lib/export-pdf'); + const originalWindow = (globalThis as any).window; + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + + const createdUrls: string[] = []; + const revokedUrls: string[] = []; + URL.createObjectURL = () => { + const url = `blob:daily-invoices-pdf-${createdUrls.length + 1}`; + createdUrls.push(url); + return url; + }; + URL.revokeObjectURL = (url: string) => { + revokedUrls.push(url); + }; + + const actions: string[] = []; + let listeners = new Map void>>(); + const popup: any = { + closed: false, + document: { readyState: 'complete' }, + location: { + href: 'about:blank', + replace(url: string) { + actions.push(`replace:${url}`); + popup.location.href = url; + listeners = new Map void>>(); + popup.document = { readyState: 'loading' }; + }, + }, + addEventListener(type: string, callback: () => void) { + const list = listeners.get(type) || []; + list.push(callback); + listeners.set(type, list); + }, + focus() { + actions.push('focus'); + }, + print() { + actions.push('print'); + }, + close() { + actions.push('close'); + popup.closed = true; + }, + dispatchLoad() { + popup.document.readyState = 'complete'; + for (const cb of [...(listeners.get('load') || [])]) cb(); + }, + dispatchAfterPrint() { + for (const cb of [...(listeners.get('afterprint') || [])]) cb(); + }, + }; + + try { + (globalThis as any).window = { + open: () => popup, + }; + await exportHtmlDocumentAsPdf({ + html, + filename, + pageSize: 'A4', + }); + assert.deepEqual( + actions, + ['replace:blob:daily-invoices-pdf-1'], + 'browser fallback navigates opened popup to HTML blob URL', + ); + popup.dispatchLoad(); + assert.deepEqual( + actions, + ['replace:blob:daily-invoices-pdf-1', 'focus', 'print'], + 'browser fallback focuses popup and triggers window.print() once loaded', + ); + popup.dispatchAfterPrint(); + assert.equal(popup.closed, true, 'browser fallback closes popup after print completes'); + assert.deepEqual( + revokedUrls, + ['blob:daily-invoices-pdf-1'], + 'browser fallback revokes blob URL after print completes', + ); + } finally { + if (originalWindow === undefined) delete (globalThis as any).window; + else (globalThis as any).window = originalWindow; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + } +} + +async function main() { + console.log('Integration Test: Daily Invoices Compiled PDF/HTML Export (#1397)'); + console.log('='.repeat(68)); + + const db = initTestDb(); + const owner = seedOwnerUser(db); + const manager = seedManagerUser(db); + const cashier = seedRoleUser( + db, + 'cashier-test-001', + 'Test Cashier', + 'cashier@test.local', + 'cashier', + ); + const waiter = seedRoleUser(db, 'server-test-001', 'Test Waiter', 'waiter@test.local', 'server'); + const kitchen = seedRoleUser(db, 'chef-test-001', 'Test Chef', 'chef@test.local', 'chef'); + + // Configure store metadata + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_name', 'FloCafe Artisan Roasters ', CURRENT_TIMESTAMP)", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_address', '42 Espresso Lane, Bengaluru', CURRENT_TIMESTAMP)", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('tax_registration_number', '29ABCDE1234F1Z5', CURRENT_TIMESTAMP)", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'IN', CURRENT_TIMESTAMP)", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('currency', 'INR', CURRENT_TIMESTAMP)", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('timezone', 'UTC', CURRENT_TIMESTAMP)", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_day_start_time', '00:00', CURRENT_TIMESTAMP)", + ).run(); + + seedCategory(db, 'cat-inv', 'Coffee & Pastry'); + seedProduct(db, 'prod-latte', 'cat-inv', 'Cappuccino', 100); + seedProduct(db, 'prod-croissant', 'cat-inv', 'Butter Croissant', 80); + seedProduct(db, 'prod-cake', 'cat-inv', 'Basque Cheesecake', 200); + + db.prepare( + "INSERT INTO customers (id, name, phone, email, country_code) VALUES ('cust-1', 'Aarav Sharma', '+919876543210', 'aarav@example.com', '+91')", + ).run(); + + const businessDate = '2026-09-15'; + + function insertBillWithOrder(opts: { + billNumber: string; + orderNumber: string; + paidAt: string; + paymentStatus: 'paid' | 'partially_refunded' | 'refunded'; + subtotal: number; + taxAmount: number; + taxRate: number; + taxTitle: string; + discountAmount?: number; + serviceCharge?: number; + total: number; + method: string; + transactionId?: string; + customerId?: string | null; + items: Array<{ + productId: string; + productName: string; + qty: number; + unitPrice: number; + subtotal: number; + taxAmount: number; + total: number; + status?: string; + variant?: string; + }>; + }): { billId: number; orderId: number; itemIds: number[] } { + const discountAmount = opts.discountAmount ?? 0; + const serviceCharge = opts.serviceCharge ?? 0; + const taxBreakdown = JSON.stringify([ + { title: opts.taxTitle, rate: opts.taxRate, amount: opts.taxAmount }, + ]); + const taxSnapshot = JSON.stringify({ + lines: [ + { + taxableBase: opts.subtotal - discountAmount, + components: [ + { + label: opts.taxTitle, + rate: opts.taxRate, + amount: opts.taxAmount, + }, + ], + }, + ], + }); + + db.prepare(` + INSERT INTO orders ( + order_number, user_id, customer_id, type, status, + subtotal, discount_amount, service_charge, tax_amount, tax_breakdown, tax_snapshot, total, + created_at, updated_at, completed_at + ) + VALUES (?, 'owner-test-001', ?, 'dine-in', 'completed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + opts.orderNumber, + opts.customerId ?? null, + opts.subtotal, + discountAmount, + serviceCharge, + opts.taxAmount, + taxBreakdown, + taxSnapshot, + opts.total, + opts.paidAt, + opts.paidAt, + opts.paidAt, + ); + + const orderId = Number( + ( + db.prepare('SELECT id FROM orders WHERE order_number = ?').get(opts.orderNumber) as { + id: number; + } + ).id, + ); + + const itemIds: number[] = []; + for (const item of opts.items) { + const itemSnap = JSON.stringify({ + lines: [ + { + taxableBase: item.subtotal, + components: [ + { + label: opts.taxTitle, + rate: opts.taxRate, + amount: item.taxAmount, + }, + ], + }, + ], + }); + const res = db + .prepare(` + INSERT INTO order_items ( + order_id, product_id, product_name, quantity, unit_price, + subtotal, tax_amount, tax_breakdown, tax_snapshot, total, + variant_selection, status, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + orderId, + item.productId, + item.productName, + item.qty, + item.unitPrice, + item.subtotal, + item.taxAmount, + JSON.stringify([{ title: opts.taxTitle, rate: opts.taxRate, amount: item.taxAmount }]), + itemSnap, + item.total, + item.variant ?? null, + item.status ?? 'ready', + opts.paidAt, + opts.paidAt, + ); + itemIds.push(Number(res.lastInsertRowid)); + } + + const billRes = db + .prepare(` + INSERT INTO bills ( + bill_number, order_id, customer_id, subtotal, discount_amount, service_charge, + tax_amount, tax_breakdown, tax_snapshot, total, paid_amount, balance, + payment_status, payment_details, paid_at, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?) + `) + .run( + opts.billNumber, + orderId, + opts.customerId ?? null, + opts.subtotal, + discountAmount, + serviceCharge, + opts.taxAmount, + taxBreakdown, + taxSnapshot, + opts.total, + opts.total, + opts.paymentStatus, + JSON.stringify([ + { + method: opts.method, + amount: opts.total, + timestamp: opts.paidAt, + ...(opts.transactionId ? { transaction_id: opts.transactionId } : {}), + }, + ]), + opts.paidAt, + opts.paidAt, + opts.paidAt, + ); + + return { billId: Number(billRes.lastInsertRowid), orderId, itemIds }; + } + + // Seed 3 bills out of bill_number order (INV-0003 first, INV-0001 second, INV-0002 third) + // 1) INV-0003: fully refunded bill + const b3 = insertBillWithOrder({ + billNumber: 'INV-0003', + orderNumber: 'ORD-0003', + paidAt: `${businessDate} 14:30:00`, + paymentStatus: 'refunded', + subtotal: 200, + taxAmount: 10, + taxRate: 5, + taxTitle: 'GST 5%', + total: 210, + method: 'card', + transactionId: 'TXN-9003', + items: [ + { + productId: 'prod-cake', + productName: 'Basque Cheesecake', + qty: 1, + unitPrice: 200, + subtotal: 200, + taxAmount: 10, + total: 210, + status: 'refunded', + }, + ], + }); + db.prepare(` + INSERT INTO refunds (bill_id, order_item_id, amount_cents, method, reason, shift_id, approved_by, created_by, created_at) + VALUES (?, ?, 21000, 'card', 'Customer cancelled dessert', NULL, 'owner-test-001', 'owner-test-001', ?) + `).run(b3.billId, b3.itemIds[0], `${businessDate} 15:00:00`); + + // 2) INV-0001: paid bill with customer + insertBillWithOrder({ + billNumber: 'INV-0001', + orderNumber: 'ORD-0001', + paidAt: `${businessDate} 10:15:00`, + paymentStatus: 'paid', + subtotal: 100, + taxAmount: 5, + taxRate: 5, + taxTitle: 'GST 5%', + total: 105, + method: 'cash', + customerId: 'cust-1', + items: [ + { + productId: 'prod-latte', + productName: 'Cappuccino', + qty: 1, + unitPrice: 100, + subtotal: 100, + taxAmount: 5, + total: 105, + variant: JSON.stringify([{ group_name: 'Size', option_name: 'Regular' }]), + }, + ], + }); + + // 3) INV-0002: partially_refunded bill + const b2 = insertBillWithOrder({ + billNumber: 'INV-0002', + orderNumber: 'ORD-0002', + paidAt: `${businessDate} 12:00:00`, + paymentStatus: 'partially_refunded', + subtotal: 180, + taxAmount: 32.4, + taxRate: 18, + taxTitle: 'GST 18%', + total: 212.4, + method: 'cash', + items: [ + { + productId: 'prod-latte', + productName: 'Cappuccino', + qty: 1, + unitPrice: 100, + subtotal: 100, + taxAmount: 18, + total: 118, + }, + { + productId: 'prod-croissant', + productName: 'Butter Croissant', + qty: 1, + unitPrice: 80, + subtotal: 80, + taxAmount: 14.4, + total: 94.4, + status: 'refunded', + }, + ], + }); + db.prepare(` + INSERT INTO refunds (bill_id, order_item_id, amount_cents, method, reason, shift_id, approved_by, created_by, created_at) + VALUES (?, ?, 9440, 'cash', 'Croissant cold', NULL, 'owner-test-001', 'owner-test-001', ?) + `).run(b2.billId, b2.itemIds[1], `${businessDate} 12:30:00`); + + const app = createApp({ '/api/reports': reportRoutes }); + const { baseUrl, server } = await startServer(app); + + try { + console.log('\n─── 1. Role gating (403 for non-owner roles, 401 unauthenticated) ───'); + { + const unauth = await fetch( + `${baseUrl}/api/reports/daily-invoices/export?date=${businessDate}&format=html`, + ); + assert.equal(unauth.status, 401, 'unauthenticated request returns 401'); + + for (const [roleName, staff] of [ + ['manager', manager], + ['cashier', cashier], + ['waiter/server', waiter], + ['kitchen/chef', kitchen], + ] as const) { + const res = await fetch( + `${baseUrl}/api/reports/daily-invoices/export?date=${businessDate}&format=html`, + { headers: staff.authHeader }, + ); + assert.equal(res.status, 403, `${roleName} role is rejected with 403`); + } + + const badFormat = await fetch( + `${baseUrl}/api/reports/daily-invoices/export?date=${businessDate}&format=xlsx`, + { headers: owner.authHeader }, + ); + assert.equal(badFormat.status, 400, 'unsupported format=xlsx returns 400'); + + const reversedRange = await fetch( + `${baseUrl}/api/reports/daily-invoices/export?from=2026-09-16&to=2026-09-15&format=html`, + { headers: owner.authHeader }, + ); + assert.equal(reversedRange.status, 400, 'reversed date range returns 400'); + + const oversizedRange = await fetch( + `${baseUrl}/api/reports/daily-invoices/export?from=2025-01-01&to=2026-01-02&format=html`, + { headers: owner.authHeader }, + ); + assert.equal(oversizedRange.status, 400, 'range > 366 days returns 400'); + } + console.log(' ✓ Role gating and input validation verified'); + + console.log( + '\n─── 2. Sequential bill_number ordering & [REFUNDED] / [PARTIALLY REFUNDED] badges ───', + ); + let compiledHtml = ''; + { + const res = await fetch( + `${baseUrl}/api/reports/daily-invoices/export?date=${businessDate}&format=html`, + { headers: owner.authHeader }, + ); + assert.equal(res.status, 200, 'owner export returns 200'); + assert.match(res.headers.get('content-type') || '', /text\/html/); + assert.match( + res.headers.get('content-disposition') || '', + new RegExp(`invoices-${businessDate}\\.html`), + ); + compiledHtml = await res.text(); + + // Verify XSS escaping on store name + assert.ok( + compiledHtml.includes('FloCafe Artisan Roasters <script>alert(1)</script>'), + 'store legal name is HTML-escaped', + ); + assert.ok( + !compiledHtml.includes(''), + 'raw script tag is never emitted', + ); + assert.ok( + compiledHtml.includes('GSTIN: 29ABCDE1234F1Z5'), + 'country taxIdLabel (GSTIN) and tax ID are rendered', + ); + assert.ok(compiledHtml.includes('42 Espresso Lane, Bengaluru'), 'store address is rendered'); + + // Verify strict ascending bill_number order + const cards = parseInvoiceCardOrder(compiledHtml); + assert.deepEqual( + cards, + [ + { billNumber: 'INV-0001', status: 'paid' }, + { billNumber: 'INV-0002', status: 'partially_refunded' }, + { billNumber: 'INV-0003', status: 'refunded' }, + ], + 'invoices are rendered in strict ascending bill_number order including paid, partially_refunded, and refunded bills', + ); + + // Verify status badges and refund details + assert.ok(compiledHtml.includes('[PAID]'), '[PAID] status badge is present'); + assert.ok( + compiledHtml.includes('[PARTIALLY REFUNDED]'), + '[PARTIALLY REFUNDED] status badge is present', + ); + assert.ok(compiledHtml.includes('[REFUNDED]'), '[REFUNDED] status badge is present'); + assert.ok( + compiledHtml.includes('Croissant cold') && + compiledHtml.includes('Butter Croissant (x1)') && + compiledHtml.includes('94.40'), + 'partial refund reason, refunded item/qty, and amount are present on INV-0002', + ); + assert.ok( + compiledHtml.includes('Customer cancelled dessert') && + compiledHtml.includes('Basque Cheesecake (x1)') && + compiledHtml.includes('210.00'), + 'full refund reason, refunded item/qty, and amount are present on INV-0003', + ); + assert.ok( + compiledHtml.includes('TXN-9003'), + 'card transaction_id is rendered in payment breakdown', + ); + assert.ok( + compiledHtml.includes('Aarav Sharma') && compiledHtml.includes('+919876543210'), + 'customer details are rendered on INV-0001', + ); + assert.ok( + compiledHtml.includes('page-break-inside: avoid;') && + compiledHtml.includes('break-inside: avoid;'), + '.invoice-card enforces page-break-inside: avoid; break-inside: avoid;', + ); + } + console.log(' ✓ Sequential bill_number ordering and refund badges/details verified'); + + console.log('\n─── 3. Cover-page reconciliation with Daily Sales .xlsx export ───'); + { + const xlsxRes = await fetch( + `${baseUrl}/api/reports/daily-sales/export?date=${businessDate}&format=xlsx`, + { headers: owner.authHeader }, + ); + assert.equal(xlsxRes.status, 200, 'daily-sales .xlsx returns 200'); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(Buffer.from(await xlsxRes.arrayBuffer())); + + const xlsxSummary = new Map(); + workbook.getWorksheet('Summary').eachRow((row: any, rowNumber: number) => { + if (rowNumber > 1) { + xlsxSummary.set(String(row.getCell(1).value), String(row.getCell(2).value ?? '')); + } + }); + + const htmlSummary = parseHtmlSummaryMetrics(compiledHtml); + assert.equal( + htmlSummary.size, + xlsxSummary.size, + 'cover page summary metric count matches .xlsx Summary sheet', + ); + for (const [metric, xlsxVal] of xlsxSummary.entries()) { + assert.equal( + htmlSummary.get(metric), + xlsxVal, + `cover page summary metric "${metric}" matches .xlsx (${xlsxVal})`, + ); + } + + // Reconcile VAT/Tax Summary Table with .xlsx Taxes sheet + const xlsxTaxRows: Array<{ + date: string; + name: string; + rate: string; + base: string; + tax: string; + gross: string; + }> = []; + workbook.getWorksheet('Taxes').eachRow((row: any, rowNumber: number) => { + if (rowNumber > 1) { + xlsxTaxRows.push({ + date: String(row.getCell(1).value ?? ''), + name: String(row.getCell(2).value ?? ''), + rate: + row.getCell(3).value === null || row.getCell(3).value === '' + ? '' + : String(Number(row.getCell(3).value)), + base: + row.getCell(4).value === null || row.getCell(4).value === '' + ? '' + : String(Number(row.getCell(4).value)), + tax: String(Number(row.getCell(5).value ?? 0)), + gross: + row.getCell(6).value === null || row.getCell(6).value === '' + ? '' + : String(Number(row.getCell(6).value)), + }); + } + }); + + const htmlTaxRows = parseHtmlTaxRows(compiledHtml); + assert.deepEqual( + htmlTaxRows, + xlsxTaxRows, + 'cover page tax breakdown rows match .xlsx Taxes sheet exactly', + ); + } + console.log(' ✓ Cover-page summary and VAT/Tax Summary Table reconcile 100% with .xlsx'); + + console.log('\n─── 4. Cross-midnight business_day_start_time boundary filtering ───'); + { + // Configure business_day_start_time = 04:00 UTC + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_day_start_time', '04:00', CURRENT_TIMESTAMP)", + ).run(); + + const shiftDate = '2026-09-20'; + // Window for 2026-09-20 with 04:00 start is [2026-09-20 04:00:00, 2026-09-21 04:00:00) + // Pre-window bill at 2026-09-20 02:30:00 (belongs to 2026-09-19, must be excluded) + insertBillWithOrder({ + billNumber: 'INV-CM-PRE', + orderNumber: 'ORD-CM-PRE', + paidAt: '2026-09-20 02:30:00', + paymentStatus: 'paid', + subtotal: 50, + taxAmount: 2.5, + taxRate: 5, + taxTitle: 'GST 5%', + total: 52.5, + method: 'cash', + items: [ + { + productId: 'prod-latte', + productName: 'Cappuccino', + qty: 1, + unitPrice: 50, + subtotal: 50, + taxAmount: 2.5, + total: 52.5, + }, + ], + }); + + // Post-midnight pre-cutoff bill at 2026-09-21 02:15:00 (belongs to 2026-09-20, must be included) + insertBillWithOrder({ + billNumber: 'INV-CM-IN-2', + orderNumber: 'ORD-CM-IN-2', + paidAt: '2026-09-21 02:15:00', + paymentStatus: 'paid', + subtotal: 100, + taxAmount: 5, + taxRate: 5, + taxTitle: 'GST 5%', + total: 105, + method: 'cash', + items: [ + { + productId: 'prod-latte', + productName: 'Cappuccino', + qty: 1, + unitPrice: 100, + subtotal: 100, + taxAmount: 5, + total: 105, + }, + ], + }); + + // Afternoon bill at 2026-09-20 18:00:00 (belongs to 2026-09-20, must be included) + insertBillWithOrder({ + billNumber: 'INV-CM-IN-1', + orderNumber: 'ORD-CM-IN-1', + paidAt: '2026-09-20 18:00:00', + paymentStatus: 'paid', + subtotal: 80, + taxAmount: 4, + taxRate: 5, + taxTitle: 'GST 5%', + total: 84, + method: 'cash', + items: [ + { + productId: 'prod-croissant', + productName: 'Butter Croissant', + qty: 1, + unitPrice: 80, + subtotal: 80, + taxAmount: 4, + total: 84, + }, + ], + }); + + // Post-cutoff bill at 2026-09-21 04:05:00 (belongs to 2026-09-21, must be excluded) + insertBillWithOrder({ + billNumber: 'INV-CM-POST', + orderNumber: 'ORD-CM-POST', + paidAt: '2026-09-21 04:05:00', + paymentStatus: 'paid', + subtotal: 200, + taxAmount: 10, + taxRate: 5, + taxTitle: 'GST 5%', + total: 210, + method: 'cash', + items: [ + { + productId: 'prod-cake', + productName: 'Basque Cheesecake', + qty: 1, + unitPrice: 200, + subtotal: 200, + taxAmount: 10, + total: 210, + }, + ], + }); + + const cmRes = await fetch( + `${baseUrl}/api/reports/daily-invoices/export?date=${shiftDate}&format=html`, + { headers: owner.authHeader }, + ); + assert.equal(cmRes.status, 200, 'cross-midnight export returns 200'); + const cmHtml = await cmRes.text(); + const cmCards = parseInvoiceCardOrder(cmHtml); + assert.deepEqual( + cmCards.map((c) => c.billNumber), + ['INV-CM-IN-1', 'INV-CM-IN-2'], + 'cross-midnight export includes post-midnight pre-cutoff bill and excludes out-of-window bills', + ); + + // Reset business_day_start_time back to 00:00 + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_day_start_time', '00:00', CURRENT_TIMESTAMP)", + ).run(); + } + console.log(' ✓ Cross-midnight business_day_start_time boundary filtering verified'); + + console.log('\n─── 5. Desktop save-html-as-pdf IPC & Browser Print Fallback ───'); + { + const mainFrame = { frameToken: 'frame-main', detached: false }; + const mainWin = { isDestroyed: () => false }; + const trustedSender = { + sender: { + ownerWindow: mainWin, + mainFrame, + getURL: () => 'http://localhost:3001/', + }, + senderFrame: mainFrame, + }; + registerIpcHandlers(undefined, () => mainWin as any); + const saveHtmlAsPdfIpc = registeredIpcHandlers.get('save-html-as-pdf'); + assert.equal( + typeof saveHtmlAsPdfIpc, + 'function', + 'save-html-as-pdf IPC handler is registered', + ); + + const pdfOutPath = path.join(testDir, `invoices-${businessDate}.pdf`); + saveDialogResult = { canceled: false, filePath: pdfOutPath }; + const ipcResult = await saveHtmlAsPdfIpc!(trustedSender, { + html: compiledHtml, + defaultFileName: `invoices-${businessDate}.pdf`, + pageSize: 'A4', + }); + assert.deepEqual( + ipcResult, + { success: true, path: pdfOutPath }, + 'save-html-as-pdf writes compiled invoices PDF file', + ); + const pdfBytes = fs.readFileSync(pdfOutPath); + assert.equal( + pdfBytes.subarray(0, 5).toString('ascii'), + '%PDF-', + 'compiled invoices PDF starts with %PDF- magic header', + ); + assert.ok( + pdfBytes.toString('utf8').includes('break-inside: avoid;'), + 'compiled PDF payload preserves .invoice-card break-inside: avoid rule', + ); + assert.ok( + lastLoadedHtmlPath.length > 0 && !fs.existsSync(lastLoadedHtmlPath), + 'staging HTML file is cleaned up after printToPDF', + ); + + await verifyBrowserPrintFallback(compiledHtml, `invoices-${businessDate}.pdf`); + } + console.log(' ✓ save-html-as-pdf desktop IPC and verifyBrowserPrintFallback verified'); + + console.log( + '\n─── 6. Qodo review invariants: refund scope reconciliation, locale/lang, active tax pack label, cover pagination, item total vs subtotal, >9999 sequence & prefix changes, and invoice count cap ───', + ); + { + const { + buildDailyInvoicesExportDataset, + serializeDailyInvoicesExportHtml, + } = require('../main/services/daily-invoices-export'); + const { DailySalesExportValidationError } = require('../main/services/daily-sales-export'); + + // (a) HTML lang attribute, locale-aware money formatting, item subtotal vs total, and bounded cover page + assert.ok( + compiledHtml.includes(''), + 'HTML lang attribute uses resolved regional locale (en-IN) instead of hardcoded en', + ); + assert.ok( + compiledHtml.includes('₹105.00') && compiledHtml.includes('₹212.40'), + 'invoice amounts use locale-aware formatMoney with currency symbol', + ); + assert.ok( + compiledHtml.includes('data-item-subtotal="100" data-item-total="105"') && + compiledHtml.includes('₹100.00') && + compiledHtml.includes('₹105.00'), + 'invoice line item table renders both pre-tax Subtotal (₹100.00) and after-tax Total (₹105.00) matching receipts', + ); + const coverEndIdx = compiledHtml.indexOf(''); + const invoicesSectionIdx = compiledHtml.indexOf('data-section="invoices"'); + const appendixSectionIdx = compiledHtml.indexOf('data-section="daily-tax-appendix"'); + const dailyTaxesTableIdx = compiledHtml.indexOf(''); + assert.ok( + coverEndIdx > 0 && + invoicesSectionIdx > coverEndIdx && + appendixSectionIdx > invoicesSectionIdx && + dailyTaxesTableIdx > appendixSectionIdx, + 'per-day tax breakdown table is placed in the post-invoices appendix so the cover page remains bounded to page 1', + ); + + // (b) Custom active tax pack registrationNumberLabel overrides static country taxIdLabel + const customPackPayload = JSON.stringify({ + country: 'IN', + name: 'India Custom Pack', + version: '1.0.0', + registrationNumberLabel: 'Custom-TRN', + rules: [], + }); + db.prepare( + `INSERT OR REPLACE INTO country_packs ( + id, publisher, country, jurisdiction, active_version_id, status, created_at, updated_at + ) VALUES ('pack-in-custom', 'local', 'IN', '*', 'ver-in-custom-1', 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, + ).run(); + db.prepare( + `INSERT OR REPLACE INTO country_pack_versions ( + id, pack_id, version, schema_version, manifest_json, pack_json, digest, signature, + effective_from, effective_to, min_flo_version, published_at, status, created_at + ) VALUES ( + 'ver-in-custom-1', 'pack-in-custom', '1.0.0', 1, '{}', ?, 'sha256:test', NULL, + '2026-01-01', NULL, '2.4.0', '2026-01-01', 'active', CURRENT_TIMESTAMP + )`, + ).run(customPackPayload); + const customPackDataset = buildDailyInvoicesExportDataset({ date: businessDate }, db); + assert.equal( + customPackDataset.store.tax_id_label, + 'Custom-TRN', + 'active tax pack registrationNumberLabel overrides static country taxIdLabel', + ); + const customPackHtml = serializeDailyInvoicesExportHtml(customPackDataset, { + locale: 'de-DE', + }); + assert.ok( + customPackHtml.includes('') && + customPackHtml.includes('Custom-TRN: 29ABCDE1234F1Z5'), + 'custom locale option and active tax pack registrationNumberLabel render in HTML', + ); + db.prepare(`DELETE FROM country_pack_versions WHERE id = 'ver-in-custom-1'`).run(); + db.prepare(`DELETE FROM country_packs WHERE id = 'pack-in-custom'`).run(); + + // (c) Refund scope reconciliation across period boundaries (prior-bill refund in window + post-window refund on window bill) + const priorBill = insertBillWithOrder({ + billNumber: 'INV-PRIOR-0001', + orderNumber: 'ORD-PRIOR-0001', + paidAt: '2026-09-10 11:00:00', + paymentStatus: 'partially_refunded', + subtotal: 100, + taxAmount: 5, + taxRate: 5, + taxTitle: 'GST 5%', + total: 105, + method: 'cash', + items: [ + { + productId: 'prod-latte', + productName: 'Cappuccino', + qty: 1, + unitPrice: 100, + subtotal: 100, + taxAmount: 5, + total: 105, + }, + ], + }); + // In-window refund on 2026-09-15 against the older 2026-09-10 bill (50.00 INR) + db.prepare( + `INSERT INTO refunds (bill_id, order_item_id, amount_cents, method, reason, shift_id, approved_by, created_by, created_at) + VALUES (?, ?, 5000, 'cash', 'Prior bill goodwill adjustment', NULL, 'owner-test-001', 'owner-test-001', ?)`, + ).run(priorBill.billId, priorBill.itemIds[0], `${businessDate} 16:00:00`); + + // Post-window refund on 2026-09-16 against INV-0002 (paid on 2026-09-15, 25.00 INR) + db.prepare( + `INSERT INTO refunds (bill_id, order_item_id, amount_cents, method, reason, shift_id, approved_by, created_by, created_at) + VALUES (?, ?, 2500, 'cash', 'Next-day partial adjustment', NULL, 'owner-test-001', 'owner-test-001', '2026-09-16 09:00:00')`, + ).run(b2.billId, b2.itemIds[0]); + + const reconciledDataset = buildDailyInvoicesExportDataset({ date: businessDate }, db); + assert.equal( + reconciledDataset.in_window_refunds_on_included_bills, + 304.4, + 'in-window refunds on included bills equals 94.40 + 210.00', + ); + assert.equal( + reconciledDataset.in_window_refunds_on_prior_bills, + 50, + 'in-window refunds on prior-period bills equals 50.00', + ); + assert.equal( + reconciledDataset.total_refunds, + 354.4, + 'cover total_refunds equals in_window_refunds_on_included_bills (304.40) + in_window_refunds_on_prior_bills (50.00)', + ); + assert.equal( + reconciledDataset.post_window_refunds_on_included_bills, + 25, + 'post-window refunds on included bills equals 25.00', + ); + assert.equal( + reconciledDataset.lifetime_refunds_on_included_bills, + 329.4, + 'lifetime refunds on included bills equals in-window (304.40) + post-window (25.00)', + ); + const reconciledHtml = serializeDailyInvoicesExportHtml(reconciledDataset); + assert.ok( + reconciledHtml.includes('data-scope="in_window_refunds_on_prior_bills" data-value="50"') && + reconciledHtml.includes( + 'data-scope="post_window_refunds_on_included_bills" data-value="25"', + ) && + reconciledHtml.includes('data-bill-number="INV-PRIOR-0001"') && + reconciledHtml.includes('[Outside Period]') && + reconciledHtml.includes('[In Period]'), + 'HTML explicitly reconciles in-period, prior-bill, and outside-period refund scopes', + ); + + // (d) Sequence ordering across 9999 -> 10000 and mid-range prefix change + const seqDate = '2026-09-25'; + insertBillWithOrder({ + billNumber: 'INV-10000', + orderNumber: 'ORD-10000', + paidAt: `${seqDate} 11:00:00`, + paymentStatus: 'paid', + subtotal: 100, + taxAmount: 5, + taxRate: 5, + taxTitle: 'GST 5%', + total: 105, + method: 'cash', + items: [ + { + productId: 'prod-latte', + productName: 'Cappuccino', + qty: 1, + unitPrice: 100, + subtotal: 100, + taxAmount: 5, + total: 105, + }, + ], + }); + insertBillWithOrder({ + billNumber: 'INV-9999', + orderNumber: 'ORD-9999', + paidAt: `${seqDate} 10:00:00`, + paymentStatus: 'paid', + subtotal: 100, + taxAmount: 5, + taxRate: 5, + taxTitle: 'GST 5%', + total: 105, + method: 'cash', + items: [ + { + productId: 'prod-latte', + productName: 'Cappuccino', + qty: 1, + unitPrice: 100, + subtotal: 100, + taxAmount: 5, + total: 105, + }, + ], + }); + insertBillWithOrder({ + billNumber: 'BILL-10001', + orderNumber: 'ORD-10001', + paidAt: `${seqDate} 12:00:00`, + paymentStatus: 'paid', + subtotal: 100, + taxAmount: 5, + taxRate: 5, + taxTitle: 'GST 5%', + total: 105, + method: 'cash', + items: [ + { + productId: 'prod-latte', + productName: 'Cappuccino', + qty: 1, + unitPrice: 100, + subtotal: 100, + taxAmount: 5, + total: 105, + }, + ], + }); + const seqDataset = buildDailyInvoicesExportDataset({ date: seqDate }, db); + assert.deepEqual( + seqDataset.invoices.map((i: any) => i.bill_number), + ['INV-9999', 'INV-10000', 'BILL-10001'], + 'numeric sequence ordering places INV-9999 before INV-10000 and preserves sequence across mid-range prefix change (BILL-10001)', + ); + + // (e) Pre-hydration invoice count cap throws DailySalesExportValidationError before hydrating + assert.throws( + () => buildDailyInvoicesExportDataset({ date: seqDate }, db, { maxInvoicesCount: 2 }), + (err: unknown) => + err instanceof DailySalesExportValidationError && + err.statusCode === 400 && + /maximum invoice count \(2\)/.test(err.message), + 'exceeding maxInvoicesCount throws DailySalesExportValidationError (400) before hydration', + ); + } + console.log(' ✓ All 7 Qodo review invariants verified'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + closeDatabase(); + try { + fs.rmSync(testDir, { recursive: true, force: true }); + } catch {} + } + + console.log('\n✅ All daily-invoices-export tests passed!'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/reports-export.test.ts b/tests/reports-export.test.ts index f2b692d04..03d6d2f22 100644 --- a/tests/reports-export.test.ts +++ b/tests/reports-export.test.ts @@ -198,7 +198,11 @@ const { getJWTSecret } = require('../main/routes/auth'); const jwt = require('jsonwebtoken'); const { reportRoutes } = require('../main/routes/reports'); const { cashClosureRoutes } = require('../main/routes/cash-closures'); -const { MAX_PDF_HTML_BYTES, renderHtmlToPdfBuffer } = require('../main/services/html-to-pdf'); +const { + isAllowedPdfStagingRequestUrl, + MAX_PDF_HTML_BYTES, + renderHtmlToPdfBuffer, +} = require('../main/services/html-to-pdf'); const { registerIpcHandlers } = require('../main/ipc'); const request = require('supertest'); const ExcelJS = require('exceljs'); @@ -312,14 +316,24 @@ function extractPdfInfo(buf: Buffer): ExtractedPdfInfo { } const bfrangeRe = /beginbfrange([\s\S]*?)endbfrange/g; while ((bm = bfrangeRe.exec(cmapStr)) !== null) { - const rangeRe = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g; + const entryRe = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*(?:<([0-9A-Fa-f]+)>|\[([\s\S]*?)\])/g; let rm: RegExpExecArray | null; - while ((rm = rangeRe.exec(bm[1])) !== null) { + while ((rm = entryRe.exec(bm[1])) !== null) { const start = parseInt(rm[1], 16); const end = parseInt(rm[2], 16); - const baseUcs = parseInt(rm[3], 16); - for (let k = start; k <= end; k++) { - map.set(k, String.fromCharCode(baseUcs + (k - start))); + if (rm[3] !== undefined) { + const baseUcs = parseInt(rm[3], 16); + for (let k = start; k <= end; k++) { + map.set(k, String.fromCharCode(baseUcs + (k - start))); + } + } else if (rm[4] !== undefined) { + const itemRe = /<([0-9A-Fa-f]+)>/g; + let im: RegExpExecArray | null; + let k = start; + while ((im = itemRe.exec(rm[4])) !== null && k <= end) { + map.set(k, decodeHexUcs(im[1])); + k++; + } } } } @@ -1143,6 +1157,52 @@ async function run(): Promise { 'renderHtmlToPdfBuffer oversize check opens no BrowserWindow', ); + // Windows 8.3 short-path tilde vs %7E file:// URL allowlist check (issue #1397 / build-windows-x64) + const winStagedHtmlPath = + 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\flo-reports-export\\pdf-staging\\document-123.html'; + assert.equal( + isAllowedPdfStagingRequestUrl( + 'file:///C:/Users/RUNNER~1/AppData/Local/Temp/flo-reports-export/pdf-staging/document-123.html', + winStagedHtmlPath, + ), + true, + 'unencoded tilde file:///C:/Users/RUNNER~1/... is allowed for staged HTML file', + ); + assert.equal( + isAllowedPdfStagingRequestUrl( + 'file:///C:/Users/RUNNER%7E1/AppData/Local/Temp/flo-reports-export/pdf-staging/document-123.html', + winStagedHtmlPath, + ), + true, + 'percent-encoded tilde file:///C:/Users/RUNNER%7E1/... is allowed for staged HTML file', + ); + assert.equal( + isAllowedPdfStagingRequestUrl('data:image/png;base64,iVBORw0KGgo=', winStagedHtmlPath), + true, + 'inline data:image/ URL remains allowed', + ); + assert.equal( + isAllowedPdfStagingRequestUrl( + 'file:///C:/Users/RUNNER~1/AppData/Local/Temp/flo-reports-export/pdf-staging/other.html', + winStagedHtmlPath, + ), + false, + 'different file:// URL in same directory is rejected', + ); + assert.equal( + isAllowedPdfStagingRequestUrl( + 'file:///C:/Users/RUNNER~1/AppData/Local/Temp/flo-reports-export/pdf-staging/document-123.html?exfil=1', + winStagedHtmlPath, + ), + false, + 'file:// URL with query string is rejected', + ); + assert.equal( + isAllowedPdfStagingRequestUrl('https://example.com/exfil.png', winStagedHtmlPath), + false, + 'remote https:// URL is rejected', + ); + // (3) Execute save-html-as-pdf on X report, Z report, and date-range daily-sales report const xPdfPath = path.join(testDir, `x-report-${businessDate}.pdf`); saveDialogResult = { canceled: false, filePath: xPdfPath };