From 371c25581bbb9213df995e9cb7a584bfd9cf57e4 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 02:35:56 -0400 Subject: [PATCH 01/10] feat(reports): PDF export for X, Z, daily sales --- docs/reference/api.md | 22 +- docs/reference/daily-sales-export.md | 11 +- frontend/e2e/cash-close-phase1-ui.spec.ts | 74 + .../src/app/(dashboard)/dashboard/page.tsx | 16 +- .../components/dashboard/CashCloseModal.tsx | 8 + frontend/src/hooks/useCashClose.ts | 24 +- frontend/src/lib/export-pdf.ts | 59 + frontend/src/lib/i18n/messages/ar.json | 1 + frontend/src/lib/i18n/messages/bn.json | 1 + frontend/src/lib/i18n/messages/de.json | 1 + frontend/src/lib/i18n/messages/en.json | 1 + frontend/src/lib/i18n/messages/es.json | 1 + frontend/src/lib/i18n/messages/fa.json | 1 + frontend/src/lib/i18n/messages/fil.json | 1 + frontend/src/lib/i18n/messages/fr.json | 1 + frontend/src/lib/i18n/messages/hi.json | 1 + frontend/src/lib/i18n/messages/id.json | 1 + frontend/src/lib/i18n/messages/it.json | 1 + frontend/src/lib/i18n/messages/ja.json | 1 + frontend/src/lib/i18n/messages/ko.json | 1 + frontend/src/lib/i18n/messages/ne.json | 1 + frontend/src/lib/i18n/messages/nl.json | 1 + frontend/src/lib/i18n/messages/pt.json | 1 + frontend/src/lib/i18n/messages/ru.json | 1 + frontend/src/lib/i18n/messages/sq.json | 1 + frontend/src/lib/i18n/messages/th.json | 1 + frontend/src/lib/i18n/messages/tr.json | 1 + frontend/src/lib/i18n/messages/ur.json | 1 + frontend/src/lib/i18n/messages/vi.json | 1 + frontend/src/lib/i18n/messages/zh-tw.json | 1 + frontend/src/lib/i18n/messages/zh.json | 1 + main/routes/reports.ts | 92 +- main/services/cash-close-export.ts | 383 +++- main/services/daily-sales-export-files.ts | 7 +- main/services/daily-sales-export.ts | 480 +++++ tests/daily-sales-export.test.ts | 108 ++ tests/reports-export.test.ts | 290 ++- tests/translations.test.ts | 1577 +++++++++++++---- 38 files changed, 2762 insertions(+), 413 deletions(-) create mode 100644 frontend/src/lib/export-pdf.ts diff --git a/docs/reference/api.md b/docs/reference/api.md index d2b146386..d2e04b64f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -858,8 +858,8 @@ day-boundary rules. | `GET` | `/insights` | `ROLE_ACCESS.ownerManager` | query: `?days`, `?start_date`, `?end_date` | - | | `GET` | `/x-report` | `ROLE_ACCESS.ownerManager` | query: `?date` | Live day report. Recomputed on every read. `openingFloatCents` is reported for context and is excluded from `expectedCashCents`, which includes cash sales, Pay In, Pay Out, Safe Drop, and cash refunds by `refunds.created_at`. The stored Z adds the opening float at close, so same-day X and Z expected values differ by exactly `opening_float_cents`. | | `GET` | `/z-report` | `ROLE_ACCESS.ownerManager` | query: `?date` | The immutable close snapshot for one business date. `404` with `{ error: "Day not closed", alreadyClosed: false, businessDate }` when no close row exists. The `zReport` object carries integer-minor-unit `*_cents` fields, `payment_methods`, `staff_sales`, `tax_components`, and `cash_movements` arrays, plus `z_number`, `closed_by`, `closed_by_name`, `notes`, `created_at`, `business_date`, `period_start`, and `period_end`. `variance_cents` is `counted_cash_cents - expected_cash_cents` and may be negative. | -| `GET` | `/x-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv` or `xlsx`) | Exports a live X-report snapshot for one business date. Returns `400` with `{ error: "format must be xlsx or csv" }` when `format` is missing or invalid. Sets `Content-Disposition` to `attachment; filename="x-report-."`. | -| `GET` | `/z-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv` or `xlsx`) | Exports the stored Z-report snapshot for a closed business date. Returns `400` with `{ error: "format must be xlsx or csv" }` 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-."`. | +| `GET` | `/x-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv`, `xlsx`, or `html`) | Exports a live X-report snapshot for one 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. Sets `Content-Disposition` to `attachment; filename="x-report-."`. | +| `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` | `/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). | @@ -868,22 +868,24 @@ day-boundary rules. Owner-only daily sales export 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 -93 calendar days). Returns either a single XLSX workbook (`Summary`, `Daily`, `Taxes`, `Items`, and -`Payments` sheets) or one part of a five-file CSV set (`summary`, `daily`, `taxes`, `items`, -`payments`). Accounting and row grouping live entirely in `buildDailySalesExportDataset`; the -endpoint only validates, serializes, and sets headers. See [daily sales export](daily-sales-export.md) -for the full reconciliation contract, the summary metric field order, and the worksheet/CSV columns. +93 calendar days). Returns an XLSX workbook (`Summary`, `Daily`, `Taxes`, `Items`, and `Payments` +sheets), one part of a five-file CSV set (`summary`, `daily`, `taxes`, `items`, `payments`), or a +print-styled HTML document (`format=html`) used for desktop PDF export and browser print. Accounting +and row grouping live entirely in `buildDailySalesExportDataset`; the endpoint only validates, +serializes, and sets headers. See [daily sales export](daily-sales-export.md) for the full +reconciliation contract, the summary metric field order, and the worksheet/CSV columns. | Param | Values | Notes | | -------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `date` | `YYYY-MM-DD` | Single tenant business date; defaults to the current business date when `from`/`to` are omitted. | | `from` | `YYYY-MM-DD` | Inclusive start business date (must be paired with `to`, cannot be combined with `date`). | | `to` | `YYYY-MM-DD` | Inclusive end business date (`from <= to`, at most 93 calendar days inclusive). | -| `format` | `xlsx` or `csv` | Required. Any other value returns `400`. | -| `part` | `summary`, `daily`, `taxes`, `items`, or `payments` | Required when `format=csv`; ignored for `xlsx`. Missing or invalid on CSV returns `400`. | +| `format` | `xlsx`, `csv`, or `html` | Required. Any other value returns `400`. | +| `part` | `summary`, `daily`, `taxes`, `items`, or `payments` | Required when `format=csv`; ignored for `xlsx` and `html`. Missing or invalid on CSV returns `400`. | -- `format=xlsx` returns `200` with `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `Content-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.xlsx"` (or `daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.xlsx` for multi-day ranges). +- `format=xlsx` returns `200` with `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` and `Content-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.xlsx"` (or `daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.xlsx` for multi-day ranges). Money cells are numeric with a currency-fraction-digit number format; the workbook holds no formulas. - `format=csv` returns `200` with `Content-Type: text/csv; charset=utf-8` and `Content-Disposition: attachment; filename="daily-sales--.csv"` for the requested `part` (`summary` has header `metric,value`; `daily`, `taxes`, `items`, and `payments` use the column headers and encoding rules in [daily sales export](daily-sales-export.md#file-formats)). +- `format=html` returns `200` with `Content-Type: text/html; charset=utf-8` and `Content-Disposition: attachment; filename="daily-sales-YYYY-MM-DD.html"` (or `daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.html`), rendering a self-contained A4 print-styled HTML document from the same dataset. 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`, or a range exceeding 93 days; `401` unauthenticated; `403` non-owner; `409` for a concurrent-write conflict; `500` otherwise. diff --git a/docs/reference/daily-sales-export.md b/docs/reference/daily-sales-export.md index f248c2d4f..55a62e0fd 100644 --- a/docs/reference/daily-sales-export.md +++ b/docs/reference/daily-sales-export.md @@ -1,4 +1,4 @@ -# Daily sales export (XLSX / CSV) +# Daily sales export (XLSX / CSV / PDF-HTML) Accounting, reconciliation, and file-format contract for [`GET /api/reports/daily-sales/export`](api.md#get-apireportsdaily-salesexport). @@ -6,7 +6,7 @@ Accounting, reconciliation, and file-format contract for ## Scope - Single tenant business day (`?date=YYYY-MM-DD`, defaults to the current business date) or inclusive business-date range (`?from=YYYY-MM-DD&to=YYYY-MM-DD`, up to 93 calendar days per request). -- Formats: `xlsx` (`Summary` + `Daily` + `Taxes` + `Items` + `Payments` sheets in one workbook) and `csv` (separate `summary`, `daily`, `taxes`, `items`, and `payments` files; each request returns one part). +- Formats: `xlsx` (`Summary` + `Daily` + `Taxes` + `Items` + `Payments` sheets in one workbook), `csv` (separate `summary`, `daily`, `taxes`, `items`, and `payments` files; each request returns one part), and `html` (self-contained A4 print-styled HTML document used for desktop `save-html-as-pdf` and browser print). - Gated by `reports.daily-sales.export` (owner by default). No PII, no staff/Addons sheets. ## Architecture @@ -14,10 +14,10 @@ Accounting, reconciliation, and file-format contract for | Layer | File | Responsibility | | ----------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Dataset builder | `main/services/daily-sales-export.ts` | Single source of truth for all accounting: `buildDailySalesExportDataset`, `resolveDailySalesExportRange`, `dailySalesSummaryMetricRows`, `DAILY_SALES_DAILY_COLUMNS`, `DAILY_SALES_TAX_COLUMNS`, `DAILY_SALES_ITEM_COLUMNS`, `DAILY_SALES_PAYMENT_COLUMNS`. | -| Serializers | `main/services/daily-sales-export-files.ts` | Encode only - no accounting math. `serializeDailySalesExportXlsx`, `serializeDailySalesExportCsv`, `dailySalesExportFilename`. | +| Serializers | `main/services/daily-sales-export-files.ts` | Encode only - no accounting math. `serializeDailySalesExportXlsx`, `serializeDailySalesExportCsv`, `serializeDailySalesExportHtml`, `dailySalesExportFilename`. | | Shared CSV helper | `main/lib/csv.ts` | `toCsvRow` with CWE-1236 formula neutralization (leading quote on `= + - @` non-numerics); `main/routes/menu-csv.ts` wraps it via `toQuoteV1CsvRow` (see [product invariants](product-invariants.md#catalog-csv-encoding-and-legacy-apostrophe-ambiguity)). | | Endpoint | `main/routes/reports.ts` | Validation, serialization, headers, error mapping (400 / 401 / 403 / 409 / 500). | -| Frontend | `frontend/src/app/(dashboard)/dashboard/page.tsx` | Blob download only; single-day (`date`) or range (`from`/`to`) export buttons in the reports dashboard (CSV downloads all five parts). | +| Frontend | `frontend/src/app/(dashboard)/dashboard/page.tsx` | Blob download (`xlsx`/`csv`) and PDF/print export (`frontend/src/lib/export-pdf.ts`); single-day (`date`) or range (`from`/`to`) export buttons in the reports dashboard (CSV downloads all five parts). | Backend owns all logic; the frontend never computes totals. @@ -54,7 +54,8 @@ Backend owns all logic; the frontend never computes totals. - **XLSX:** sheets `Summary`, `Daily`, `Taxes`, `Items`, then `Payments`. Money cells are numeric with `moneyNumFmt(fractionDigits)`, percentage cells (`*_percent`) use `'0.00'`, and `tax_rate` cells use `'0.##'`; `null` rate, base, gross, cost, profit, and margin values are empty string `""`; no formulas. Frozen header row. - **CSV:** stable English snake_case headers; ASCII `.` decimals; formula-neutralized via shared `toCsvRow`. -- **Filenames:** `daily-sales-YYYY-MM-DD.xlsx` (single day) or `daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.xlsx` (multi-day range), and `-summary.csv`, `-daily.csv`, `-taxes.csv`, `-items.csv`, `-payments.csv` (from `dailySalesExportFilename`). +- **HTML (PDF / Print):** self-contained A4 portrait HTML5 document (`serializeDailySalesExportHtml`) containing the store header, Summary metrics, Payment Methods summary, Daily Breakdown, Tax Summary, and Item Sales tables from the same dataset. +- **Filenames:** `daily-sales-YYYY-MM-DD.xlsx` / `.html` (single day) or `daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.xlsx` / `.html` (multi-day range), and `-summary.csv`, `-daily.csv`, `-taxes.csv`, `-items.csv`, `-payments.csv` (from `dailySalesExportFilename`). ### Summary metric field order diff --git a/frontend/e2e/cash-close-phase1-ui.spec.ts b/frontend/e2e/cash-close-phase1-ui.spec.ts index 87c1c6fda..71d44da10 100644 --- a/frontend/e2e/cash-close-phase1-ui.spec.ts +++ b/frontend/e2e/cash-close-phase1-ui.spec.ts @@ -139,4 +139,78 @@ test.describe('Cash close phase-1 UI', () => { await expect(warning).toContainText('#ORD-101'); await expect(dialog.getByRole('button', { name: /Cerrar turno|Close shift/i })).toBeEnabled(); }); + + test('PDF export works for Daily Sales (browser fallback) and X/Z reports (desktop IPC)', async ({ + page, + context, + }) => { + await context.addInitScript(() => { + window.print = () => {}; + window.close = () => {}; + }); + + await page.goto(`${BASE}/dashboard`); + const exportBtn = page.getByRole('button', { name: 'Export sales', exact: true }); + await expect(exportBtn).toBeVisible({ timeout: 15000 }); + await exportBtn.click(); + await expect(page.getByRole('menuitem', { name: 'Excel (.xlsx)' }).first()).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'CSV (.csv)' }).first()).toBeVisible(); + const pdfMenuItem = page.getByRole('menuitem', { name: 'PDF (.pdf)' }); + await expect(pdfMenuItem).toBeVisible(); + + const popupPromise = page.waitForEvent('popup'); + await pdfMenuItem.click(); + const popup = await popupPromise; + await expect(popup.getByText(/Daily Sales Report/)).toBeVisible({ + timeout: 15000, + }); + await expect( + popup.getByRole('heading', { name: 'Summary', level: 2, exact: true }), + ).toBeVisible(); + await expect.poll(() => popup.locator('style').textContent()).toContain('size: A4 portrait'); + await popup.close(); + + // Now test X Report and Z Report PDF export via desktop saveHtmlAsPdf IPC bridge + await page.evaluate(() => { + (window as unknown as { __pdfCalls: Array> }).__pdfCalls = []; + (window as unknown as { electronAPI: Record }).electronAPI = { + saveHtmlAsPdf: async (payload: Record) => { + (window as unknown as { __pdfCalls: Array> }).__pdfCalls.push( + payload, + ); + return { success: true, filePath: `/tmp/${payload.defaultFileName}` }; + }, + }; + }); + + await page.getByRole('button', { name: 'Close day', exact: true }).click(); + const dialog = page.getByRole('dialog').first(); + await expect(dialog).toBeVisible({ timeout: 15000 }); + await dialog.getByRole('button', { name: 'Export sales', exact: true }).click(); + await page.getByRole('menuitem', { name: 'PDF (.pdf)' }).click(); + + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __pdfCalls: Array<{ defaultFileName: string; pageSize: string; html: string }>; + } + ).__pdfCalls.length, + ), + ) + .toBe(1); + const xPdfCall = await page.evaluate( + () => + ( + window as unknown as { + __pdfCalls: Array<{ defaultFileName: string; pageSize: string; html: string }>; + } + ).__pdfCalls[0], + ); + expect(xPdfCall.defaultFileName).toMatch(/^x-report-\d{4}-\d{2}-\d{2}\.pdf$/); + expect(xPdfCall.pageSize).toBe('A4'); + expect(xPdfCall.html).toContain('X Report'); + }); }); diff --git a/frontend/src/app/(dashboard)/dashboard/page.tsx b/frontend/src/app/(dashboard)/dashboard/page.tsx index dc813f268..ec7fa26d2 100644 --- a/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -56,6 +56,7 @@ import { splitHoursMinutes } from '@/lib/table-timing'; import { tenantCan } from '@/lib/permissions'; import { businessDateForInstant } from '@shared/business-date'; import { downloadBlob } from '@/lib/download'; +import { exportReportAsPdf } from '@/lib/export-pdf'; interface PaymentMethodBreakdown { method: string | null; @@ -468,7 +469,7 @@ export default function DashboardPage() { router.replace('/dashboard', { scroll: false }); }, [cashCloseAction, cashCloseView, openCloseModal, router]); - const exportDailySales = async (format: 'xlsx' | 'csv') => { + const exportDailySales = async (format: 'xlsx' | 'csv' | 'pdf') => { if (isExporting) return; setIsExporting(true); try { @@ -481,6 +482,15 @@ export default function DashboardPage() { ? range.startDate : `${range.startDate}_to_${range.endDate}`; + if (format === 'pdf') { + await exportReportAsPdf({ + endpoint: '/reports/daily-sales/export', + params: dateParams, + filename: `daily-sales-${span}.pdf`, + }); + return; + } + if (format === 'xlsx') { const res = await api.get('/reports/daily-sales/export', { params: { ...dateParams, format: 'xlsx' }, @@ -881,6 +891,10 @@ export default function DashboardPage() { {t('exportCsv')} + exportDailySales('pdf')}> + + {t('exportPdf')} + {t('exportCustomerSales')} exportCustomerSales('xlsx')}> diff --git a/frontend/src/components/dashboard/CashCloseModal.tsx b/frontend/src/components/dashboard/CashCloseModal.tsx index e2453f097..73cfae3df 100644 --- a/frontend/src/components/dashboard/CashCloseModal.tsx +++ b/frontend/src/components/dashboard/CashCloseModal.tsx @@ -116,6 +116,14 @@ export function CashCloseModal({ model }: { model: CashCloseModel }) { {t('exportCsv')} + { + void exportReport(report, 'pdf'); + }} + > + + {t('exportPdf')} + {canSendWhatsApp && (report === 'x' ? xReport : closedZ) && ( openWhatsAppReport(report)}> diff --git a/frontend/src/hooks/useCashClose.ts b/frontend/src/hooks/useCashClose.ts index cc4011f99..a6ccd6305 100644 --- a/frontend/src/hooks/useCashClose.ts +++ b/frontend/src/hooks/useCashClose.ts @@ -12,6 +12,7 @@ import { printerService } from '@/lib/printer/PrinterService'; import { displayAmountToCents } from '@/lib/money'; import { businessDateForInstant } from '@shared/business-date'; import { downloadBlob } from '@/lib/download'; +import { exportReportAsPdf } from '@/lib/export-pdf'; import { shareCashCloseViaWhatsApp, type XReportExport, @@ -393,19 +394,28 @@ export function useCashClose() { setOpenToken((n) => n + 1); }; - const exportReport = async (report: 'x' | 'z', format: 'csv' | 'xlsx') => { + const exportReport = async (report: 'x' | 'z', format: 'csv' | 'xlsx' | 'pdf') => { if (report === 'z' && !closedZ) return; setExportingReport(true); try { - const res = await api.get(`/reports/${report}-report/export`, { + const endpoint = `/reports/${report}-report/export`; + const baseFilename = + report === 'x' + ? `x-report-${businessDate}` + : `z-report-Z${closedZ!.z_number}-${businessDate}`; + if (format === 'pdf') { + await exportReportAsPdf({ + endpoint, + params: { date: businessDate }, + filename: `${baseFilename}.pdf`, + }); + return; + } + const res = await api.get(endpoint, { params: { date: businessDate, format }, responseType: 'blob', }); - const filename = - report === 'x' - ? `x-report-${businessDate}.${format}` - : `z-report-Z${closedZ!.z_number}-${businessDate}.${format}`; - downloadBlob(res.data as Blob, filename); + downloadBlob(res.data as Blob, `${baseFilename}.${format}`); } catch { toast.error(tCommon('downloadFailed')); } finally { diff --git a/frontend/src/lib/export-pdf.ts b/frontend/src/lib/export-pdf.ts new file mode 100644 index 000000000..68b33a9e4 --- /dev/null +++ b/frontend/src/lib/export-pdf.ts @@ -0,0 +1,59 @@ +import api from '@/lib/api'; +import { printMenuInBrowser, reservePrintGesture } from '@/lib/printer/menu-web-print'; + +export interface ExportReportPdfOptions { + endpoint: string; + params?: Record; + filename: string; + pageSize?: 'A4' | 'Letter'; +} + +export async function exportReportAsPdf({ + endpoint, + params, + filename, + pageSize = 'A4', +}: ExportReportPdfOptions): Promise { + const saveHtmlAsPdf = + typeof window !== 'undefined' ? window.electronAPI?.saveHtmlAsPdf : undefined; + let reservedWindow: Window | null = null; + if (!saveHtmlAsPdf && typeof window !== 'undefined') { + reservedWindow = reservePrintGesture(window); + if (!reservedWindow && window.electronAPI) { + try { + reservedWindow = window.open('', '_blank'); + } catch { + reservedWindow = null; + } + } + } + + let handedOff = false; + try { + const res = await api.get(endpoint, { + params: { ...params, format: 'html' }, + responseType: 'text', + }); + const html = typeof res.data === 'string' ? res.data : String(res.data ?? ''); + + if (saveHtmlAsPdf) { + const result = await saveHtmlAsPdf({ + html, + defaultFileName: filename, + pageSize, + }); + if (result?.canceled) return; + if (!result?.success) { + throw new Error(result?.error || 'Failed to export PDF'); + } + return; + } + + printMenuInBrowser(html, reservedWindow); + handedOff = true; + } finally { + if (!handedOff && reservedWindow && !reservedWindow.closed) { + reservedWindow.close(); + } + } +} diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index 6aff1bb22..5fb12a31a 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -338,6 +338,7 @@ "exportSales": "مبيعات التصدير", "exportXlsx": "إكسل (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 56201cbe0..568b386f6 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -338,6 +338,7 @@ "exportSales": "রপ্তানি বিক্রয়", "exportXlsx": "এক্সেল (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 8524ce7c5..d0866e60a 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -338,6 +338,7 @@ "exportSales": "Umsätze exportieren", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 90568bfd1..fcc365076 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -338,6 +338,7 @@ "exportSales": "Export sales", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 320272176..97537fdb4 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -338,6 +338,7 @@ "exportSales": "Exportar ventas", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 193374867..57a121b5f 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -338,6 +338,7 @@ "exportSales": "صدور فروش", "exportXlsx": "اکسل (.xlsx)", "exportCsv": "CSV", + "exportPdf": "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 5d066467a..9c83f15ea 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -338,6 +338,7 @@ "exportSales": "I-export ang mga benta", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 f2001d45b..1505e68f1 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -338,6 +338,7 @@ "exportSales": "Exporter les ventes", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 60a0d2681..28d7ae5ae 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -338,6 +338,7 @@ "exportSales": "निर्यात बिक्री", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.pdf)", "exportCsvHint": "सीएसवी पाँच फ़ाइलें डाउनलोड करता है: सारांश, दैनिक, कर, आइटम और भुगतान।", "exportCsvStarted": "सारांश, दैनिक, कर, आइटम और भुगतान CSV फ़ाइलें डाउनलोड हो रही हैं…", "exportCustomerSales": "ग्राहक बिक्री", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index a548f8b56..79a8b6bb3 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -338,6 +338,7 @@ "exportSales": "Ekspor Penjualan", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 5d8192810..bfbfc392a 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -338,6 +338,7 @@ "exportSales": "Esporta vendite", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 e19ff2e9c..8dd2ddf1d 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -401,6 +401,7 @@ "exportSales": "売上をエクスポート", "exportXlsx": "Excel(.xlsx)", "exportCsv": "CSV(.csv)", + "exportPdf": "PDF(.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 b073d8a72..e1079e4f2 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -338,6 +338,7 @@ "exportSales": "매출 내보내기", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 11ca8f887..ba5a5eecf 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -338,6 +338,7 @@ "exportSales": "निर्यात बिक्री", "exportXlsx": "एक्सेल (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 0dd5821c9..2c269d87c 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -338,6 +338,7 @@ "exportSales": "Exportverkoop", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 fc09d0be4..5cb3741e9 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -338,6 +338,7 @@ "exportSales": "Exportar vendas", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 99ae2cb2c..041f583fe 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -338,6 +338,7 @@ "exportSales": "Экспорт продаж", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 a80989779..f27cd5bd1 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -338,6 +338,7 @@ "exportSales": "Eksporto shitjet", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 f6ffadbe1..83149ef03 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -338,6 +338,7 @@ "exportSales": "ส่งออกยอดขาย", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 cfa68d212..6d8266b9a 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -338,6 +338,7 @@ "exportSales": "Satışları dışa aktar", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 9461ac343..a3aa0fb02 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -338,6 +338,7 @@ "exportSales": "فروخت برآمد کریں", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 387302c5d..eaff23a5c 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -337,6 +337,7 @@ "exportSales": "Xuất doanh thu", "exportXlsx": "Excel (.xlsx)", "exportCsv": "CSV (.csv)", + "exportPdf": "PDF (.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 dcb81d3fc..b8a41c9e0 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -401,6 +401,7 @@ "exportSales": "匯出銷售資料", "exportXlsx": "Excel(.xlsx)", "exportCsv": "CSV(.csv)", + "exportPdf": "PDF(.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 5004ef6d3..ff84e9eb0 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -401,6 +401,7 @@ "exportSales": "导出销售数据", "exportXlsx": "Excel(.xlsx)", "exportCsv": "CSV(.csv)", + "exportPdf": "PDF(.pdf)", "exportCsvHint": "CSV会下载五个文件:汇总、每日、税项、明细和付款。", "exportCsvStarted": "正在下载汇总、每日、税项、明细和付款CSV文件…", "exportCustomerSales": "客户销售", diff --git a/main/routes/reports.ts b/main/routes/reports.ts index e3c29a08f..a2502d24f 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -41,6 +41,7 @@ import { import { dailySalesExportFilename, serializeDailySalesExportCsv, + serializeDailySalesExportHtml, serializeDailySalesExportXlsx, type DailySalesExportCsvPart, } from '../services/daily-sales-export-files'; @@ -53,9 +54,12 @@ import { } from '../services/customer-sales-export'; import { serializeXReportCsv, + serializeXReportHtml, serializeXReportXlsx, serializeZReportCsv, + serializeZReportHtml, serializeZReportXlsx, + type CashCloseHtmlOptions, type XReportExport, type ZReportExport, } from '../services/cash-close-export'; @@ -1394,6 +1398,33 @@ router.get('/z-report', requirePermission('reports.view'), (req: Request, res: R } }); +function resolveCashCloseHtmlOptions(db: ReturnType): CashCloseHtmlOptions { + const currency = getTenantCurrency(db); + const businessName = getSettingValue('business_name') ?? undefined; + try { + const snapshot = resolveRegionalSnapshot({ + country: getSettingValue('country') ?? undefined, + currency, + timezone: getSettingValue('timezone') ?? undefined, + currency_display: getSettingValue('currency_display') ?? undefined, + number_digits: getSettingValue('number_digits') ?? undefined, + calendar: getSettingValue('calendar') ?? undefined, + }); + return { + businessName, + currency: snapshot.currency, + locale: snapshot.locale, + timezone: snapshot.timezone, + preferences: snapshot.preferences, + }; + } catch { + return { + businessName, + currency, + }; + } +} + router.get( '/x-report/export', requirePermission('reports.view'), @@ -1401,8 +1432,14 @@ router.get( try { const date = reportDate(req.query.date, reportToday()); const format = - req.query.format === 'csv' ? 'csv' : req.query.format === 'xlsx' ? 'xlsx' : null; - if (!format) return res.status(400).json({ error: 'format must be xlsx or csv' }); + req.query.format === 'csv' + ? 'csv' + : req.query.format === 'xlsx' + ? 'xlsx' + : req.query.format === 'html' + ? 'html' + : null; + if (!format) return res.status(400).json({ error: 'format must be xlsx, csv, or html' }); const db = getDatabase(); const minorFactor = getCurrencyMinorUnitFactor(getTenantCurrency(db)); @@ -1432,6 +1469,11 @@ router.get( orderCount: row.orderCount, revenue: row.revenue_cents / minorFactor, })), + taxComponents: aggregates.taxComponents.map((comp) => ({ + title: comp.title, + rate: comp.rate, + amount: comp.amount, + })), }; const filename = `x-report-${date}.${format}`; if (format === 'xlsx') { @@ -1442,6 +1484,11 @@ router.get( res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); return res.send(await serializeXReportXlsx(report)); } + if (format === 'html') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(serializeXReportHtml(report, resolveCashCloseHtmlOptions(db))); + } res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); return res.send(serializeXReportCsv(report)); @@ -1459,8 +1506,14 @@ router.get( try { const date = reportDate(req.query.date, reportToday()); const format = - req.query.format === 'csv' ? 'csv' : req.query.format === 'xlsx' ? 'xlsx' : null; - if (!format) return res.status(400).json({ error: 'format must be xlsx or csv' }); + req.query.format === 'csv' + ? 'csv' + : req.query.format === 'xlsx' + ? 'xlsx' + : req.query.format === 'html' + ? 'html' + : null; + if (!format) return res.status(400).json({ error: 'format must be xlsx, csv, or html' }); const db = getDatabase(); const row = db @@ -1496,6 +1549,11 @@ router.get( orderCount: staff.orderCount, revenue: staff.revenue_cents / minorFactor, })), + taxComponents: JSON.parse(row.tax_components_json || '[]').map((comp: any) => ({ + title: String(comp.title ?? 'Tax'), + rate: typeof comp.rate === 'number' ? comp.rate : null, + amount: Number(comp.amount ?? 0), + })), zNumber: row.z_number, closedAt: row.created_at, closedBy: userRow?.name ?? row.closed_by, @@ -1512,6 +1570,11 @@ router.get( res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); return res.send(await serializeZReportXlsx(report)); } + if (format === 'html') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(serializeZReportHtml(report, resolveCashCloseHtmlOptions(db))); + } res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); return res.send(serializeZReportCsv(report)); @@ -1575,7 +1638,7 @@ router.post( }, ); -// Owner-only daily sales export (xlsx workbook or CSV parts across 1..93 business days). +// Owner-only daily sales export (xlsx workbook, CSV parts, or print-styled HTML across 1..93 business days). // Accounting lives in buildDailySalesExportDataset; serializers only encode. router.get( '/daily-sales/export', @@ -1592,9 +1655,15 @@ router.get( reportToday(), ); const format = - req.query.format === 'csv' ? 'csv' : req.query.format === 'xlsx' ? 'xlsx' : null; + req.query.format === 'csv' + ? 'csv' + : req.query.format === 'xlsx' + ? 'xlsx' + : req.query.format === 'html' + ? 'html' + : null; if (!format) { - return res.status(400).json({ error: 'format must be xlsx or csv' }); + return res.status(400).json({ error: 'format must be xlsx, csv, or html' }); } const part: DailySalesExportCsvPart | undefined = req.query.part === 'summary' || @@ -1633,6 +1702,15 @@ router.get( return res.send(buffer); } + if (format === 'html') { + const html = serializeDailySalesExportHtml(dataset, { + businessName: getSettingValue('business_name') ?? undefined, + }); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(html); + } + const csv = serializeDailySalesExportCsv(dataset, part!); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); diff --git a/main/services/cash-close-export.ts b/main/services/cash-close-export.ts index 6b3277509..067ec9e21 100644 --- a/main/services/cash-close-export.ts +++ b/main/services/cash-close-export.ts @@ -1,19 +1,32 @@ import ExcelJS from 'exceljs'; +import { + formatMoney, + formatNumber, + resolveRegionalSnapshot, + type LocalePreferences, +} from '../countries'; +import { getSettingValue } from '../db'; import { toCsvRow } from '../lib/csv'; -interface CashClosePaymentExportRow { +export interface CashClosePaymentExportRow { method: string; count: number; total: number; } -interface CashCloseStaffExportRow { +export interface CashCloseStaffExportRow { name: string; role: string; orderCount: number; revenue: number; } +export interface CashCloseTaxComponentExportRow { + title: string; + rate: number | null; + amount: number; +} + interface CashCloseExportBase { businessDate: string; periodStart: string; @@ -29,6 +42,7 @@ interface CashCloseExportBase { safeDrops: number; paymentMethods: CashClosePaymentExportRow[]; staffSales: CashCloseStaffExportRow[]; + taxComponents?: CashCloseTaxComponentExportRow[]; } export type XReportExport = CashCloseExportBase; @@ -42,8 +56,98 @@ export interface ZReportExport extends CashCloseExportBase { cashVariance: number; } +export interface CashCloseHtmlOptions { + businessName?: string; + currency?: string; + locale?: string; + timezone?: string; + preferences?: LocalePreferences; +} + type ReportMetric = [string, string | number | null]; +const MONEY_METRICS = new Set([ + 'Gross Sales', + 'Refunds', + 'Net Collections', + 'Expected Cash in Drawer', + 'Opening Float', + 'Pay-in Total', + 'Pay-out Total', + 'Safe Drops', + 'Counted Cash', + 'Cash Variance', +]); + +function escapeHtml(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function resolveHtmlOptions(options?: CashCloseHtmlOptions): Required { + let currency = options?.currency || ''; + let locale = options?.locale || ''; + let timezone = options?.timezone || ''; + let preferences = options?.preferences; + + if (!currency || !locale || !timezone || !preferences) { + try { + const snapshot = resolveRegionalSnapshot({ + country: getSettingValue('country') ?? undefined, + currency: getSettingValue('currency') ?? undefined, + timezone: getSettingValue('timezone') ?? undefined, + currency_display: getSettingValue('currency_display') ?? undefined, + number_digits: getSettingValue('number_digits') ?? undefined, + calendar: getSettingValue('calendar') ?? undefined, + }); + if (!currency) currency = snapshot.currency; + if (!locale) locale = snapshot.locale; + if (!timezone) timezone = snapshot.timezone; + if (!preferences) preferences = snapshot.preferences; + } catch { + if (!currency) currency = getSettingValue('currency') || 'USD'; + if (!locale) locale = 'en-US'; + if (!timezone) timezone = getSettingValue('timezone') || 'UTC'; + } + } + + const businessName = + options?.businessName !== undefined + ? options.businessName + : (getSettingValue('business_name') ?? ''); + + return { + businessName: businessName || 'FloCafe', + currency: currency || 'USD', + locale: locale || 'en-US', + timezone: timezone || 'UTC', + preferences: preferences ?? { currencyDisplay: 'rial', digits: 'locale', calendar: 'locale' }, + }; +} + +function formatMoneyWithExact(amount: number, resolved: Required): string { + const formatted = formatMoney(amount, resolved.currency, resolved.locale, resolved.preferences); + const exact = String(amount); + if (formatted.includes(exact)) { + return escapeHtml(formatted); + } + return `${escapeHtml(formatted)} (${escapeHtml(exact)})`; +} + +function formatCountWithExact(count: number, resolved: Required): string { + const numberingSystem = resolved.preferences.digits === 'latin' ? 'latn' : undefined; + const formatted = formatNumber(count, resolved.locale, numberingSystem); + const exact = String(count); + if (formatted.includes(exact)) { + return escapeHtml(formatted); + } + return `${escapeHtml(formatted)} (${escapeHtml(exact)})`; +} + function xReportMetrics(report: XReportExport): ReportMetric[] { return [ ['Business Date', report.businessDate], @@ -87,7 +191,10 @@ function serializeCsv(metrics: ReportMetric[], report: CashCloseExportBase): str return `${lines.join('\n')}\n`; } -async function serializeXlsx(metrics: ReportMetric[], report: CashCloseExportBase): Promise { +async function serializeXlsx( + metrics: ReportMetric[], + report: CashCloseExportBase, +): Promise { const workbook = new ExcelJS.Workbook(); const summary = workbook.addWorksheet('Summary'); summary.columns = [ @@ -122,6 +229,262 @@ async function serializeXlsx(metrics: ReportMetric[], report: CashCloseExportBas return Buffer.from(await workbook.xlsx.writeBuffer()); } +function serializeHtml( + kind: 'x' | 'z', + metrics: ReportMetric[], + report: CashCloseExportBase & Partial, + options?: CashCloseHtmlOptions, +): string { + const resolved = resolveHtmlOptions(options); + const titleText = + kind === 'z' && typeof report.zNumber === 'number' + ? `Z Report #${report.zNumber} — ${report.businessDate}` + : `X Report — ${report.businessDate}`; + + const summaryRowsHtml = metrics + .map(([metric, value]) => { + const rawValue = value === null || value === undefined ? '' : String(value); + let renderedValue: string; + let isNumeric = false; + if (typeof value === 'number' && MONEY_METRICS.has(metric)) { + renderedValue = formatMoneyWithExact(value, resolved); + isNumeric = true; + } else if (typeof value === 'number' && metric === 'Bill Count') { + renderedValue = formatCountWithExact(value, resolved); + isNumeric = true; + } else if (typeof value === 'number' && metric === 'Z Number') { + renderedValue = `Z#${escapeHtml(String(value))} (${escapeHtml(String(value))})`; + isNumeric = true; + } else if (value === null || value === '') { + renderedValue = '—'; + } else { + renderedValue = `${escapeHtml(String(value))}`; + } + return ` + ${escapeHtml(metric)} + ${renderedValue} + `; + }) + .join('\n'); + + const paymentRowsHtml = + report.paymentMethods.length > 0 + ? report.paymentMethods + .map( + ( + payment, + ) => ` + ${escapeHtml(payment.method)} + ${formatCountWithExact(payment.count, resolved)} + ${formatMoneyWithExact(payment.total, resolved)} + `, + ) + .join('\n') + : `No payments recorded`; + + const taxComponents = Array.isArray(report.taxComponents) ? report.taxComponents : []; + const taxSectionHtml = + taxComponents.length > 0 + ? `
+

Tax Breakdown

+ + + + + + + + + + ${taxComponents + .map( + ( + tax, + ) => ` + + + + `, + ) + .join('\n')} + +
TaxRate (%)Amount
${escapeHtml(tax.title)}${tax.rate === null ? '—' : `${escapeHtml(String(tax.rate))}%`}${formatMoneyWithExact(tax.amount, resolved)}
+
` + : ''; + + const staffRowsHtml = + report.staffSales.length > 0 + ? report.staffSales + .map( + ( + staff, + ) => ` + ${escapeHtml(staff.name)} + ${escapeHtml(staff.role)} + ${formatCountWithExact(staff.orderCount, resolved)} + ${formatMoneyWithExact(staff.revenue, resolved)} + `, + ) + .join('\n') + : `No staff sales recorded`; + + return ` + + + + ${escapeHtml(resolved.businessName)} — ${escapeHtml(titleText)} + + + +
+

${escapeHtml(resolved.businessName)}

+
${escapeHtml(titleText)}
+

+ Business Date: ${escapeHtml(report.businessDate)} + · Period: ${escapeHtml(report.periodStart)} to ${escapeHtml(report.periodEnd)} + · Currency: ${escapeHtml(resolved.currency)} +

+
+ +
+

Summary

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

Payment Methods

+ + + + + + + + + + ${paymentRowsHtml} + +
MethodCountTotal
+
+ + ${taxSectionHtml} + +
+

Staff Sales

+ + + + + + + + + + + ${staffRowsHtml} + +
NameRoleOrdersRevenue
+
+ + +`; +} + export function serializeXReportCsv(report: XReportExport): string { return serializeCsv(xReportMetrics(report), report); } @@ -137,3 +500,17 @@ export function serializeXReportXlsx(report: XReportExport): Promise { export function serializeZReportXlsx(report: ZReportExport): Promise { return serializeXlsx(zReportMetrics(report), report); } + +export function serializeXReportHtml( + report: XReportExport, + options?: CashCloseHtmlOptions, +): string { + return serializeHtml('x', xReportMetrics(report), report, options); +} + +export function serializeZReportHtml( + report: ZReportExport, + options?: CashCloseHtmlOptions, +): string { + return serializeHtml('z', zReportMetrics(report), report, options); +} diff --git a/main/services/daily-sales-export-files.ts b/main/services/daily-sales-export-files.ts index 0d2d4e307..c6eb6658d 100644 --- a/main/services/daily-sales-export-files.ts +++ b/main/services/daily-sales-export-files.ts @@ -6,11 +6,15 @@ import { DAILY_SALES_PAYMENT_COLUMNS, DAILY_SALES_TAX_COLUMNS, dailySalesSummaryMetricRows, + serializeDailySalesExportHtml, type DailySalesExportDataset, type DailySalesExportItemRow, + type DailySalesHtmlOptions, } from './daily-sales-export'; -export type DailySalesExportFormat = 'xlsx' | 'csv'; +export { serializeDailySalesExportHtml, type DailySalesHtmlOptions }; + +export type DailySalesExportFormat = 'xlsx' | 'csv' | 'html'; export type DailySalesExportCsvPart = 'summary' | 'daily' | 'taxes' | 'items' | 'payments'; export const DAILY_SALES_CSV_PARTS: readonly DailySalesExportCsvPart[] = [ @@ -33,6 +37,7 @@ export function dailySalesExportFilename( ? period.fromDate : `${period.fromDate}_to_${period.toDate}`; if (format === 'xlsx') return `daily-sales-${slug}.xlsx`; + if (format === 'html') return `daily-sales-${slug}.html`; if (part && DAILY_SALES_CSV_PARTS.includes(part)) { return `daily-sales-${slug}-${part}.csv`; } diff --git a/main/services/daily-sales-export.ts b/main/services/daily-sales-export.ts index 7c9419a2d..5f2c5660a 100644 --- a/main/services/daily-sales-export.ts +++ b/main/services/daily-sales-export.ts @@ -8,9 +8,12 @@ import { tenantBusinessDayStartTime, } from '../db'; import { + formatMoney, + formatNumber, getCurrencyFractionDigits, getCurrencyMinorUnitFactor, resolveRegionalSnapshot, + type LocalePreferences, } from '../countries'; import { isExcludedFromBillReceipt, isTerminalItemStatus } from '../../shared/order-item-status'; import { getTenantCurrency } from './refund'; @@ -1790,3 +1793,480 @@ export const DAILY_SALES_PAYMENT_COLUMNS = [ 'amount', 'transaction_id', ] as const; + +export interface DailySalesHtmlOptions { + businessName?: string; + locale?: string; + preferences?: LocalePreferences; + maxTableRows?: number; +} + +const DAILY_SALES_HTML_DEFAULT_MAX_ROWS = 2000; + +const SUMMARY_METRIC_LABELS: Record = { + business_date: 'Business Date', + from_date: 'From Date', + to_date: 'To Date', + day_count: 'Day Count', + timezone: 'Timezone', + business_day_start: 'Business Day Start', + currency: 'Currency', + order_count: 'Order Count', + paid_bill_count: 'Paid Bill Count', + gross_collected: 'Gross Collected', + refunds_issued: 'Refunds Issued', + net_collected: 'Net Collected', + tax_total: 'Tax Total', + discount_total: 'Discount Total', + service_charge_total: 'Service Charge Total', + packaging_charge_total: 'Packaging Charge Total', + delivery_charge_total: 'Delivery Charge Total', + known_cost_net_item_sales: 'Known-Cost Net Item Sales', + ingredient_cost_total: 'Ingredient Cost Total', + gross_profit_total: 'Gross Profit Total', + gross_margin_percent: 'Gross Margin %', + cost_coverage_status: 'Cost Coverage Status', + tax_detail_complete: 'Tax Detail Complete', + tax_detail_warning: 'Tax Detail Warning', +}; + +function escapeHtml(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function serializeDailySalesExportHtml( + dataset: DailySalesExportDataset, + options?: DailySalesHtmlOptions, +): string { + let locale = options?.locale || ''; + let preferences = options?.preferences; + + if (!locale || !preferences) { + try { + const snapshot = resolveRegionalSnapshot({ + country: getSettingValue('country') ?? undefined, + currency: dataset.summary.currency || (getSettingValue('currency') ?? undefined), + timezone: dataset.summary.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.summary.currency || 'USD'; + const businessName = + (options?.businessName !== undefined + ? options.businessName + : (getSettingValue('business_name') ?? '')) || 'FloCafe'; + const maxRows = + typeof options?.maxTableRows === 'number' && options.maxTableRows > 0 + ? Math.floor(options.maxTableRows) + : DAILY_SALES_HTML_DEFAULT_MAX_ROWS; + const numberingSystem = resolvedPrefs.digits === 'latin' ? 'latn' : undefined; + + const fmtMoney = (amount: number | null | undefined): string => { + if (amount === null || amount === undefined) return '—'; + const formatted = formatMoney(amount, currency, resolvedLocale, resolvedPrefs); + const exact = String(amount); + if (formatted.includes(exact)) return escapeHtml(formatted); + return `${escapeHtml(formatted)} (${escapeHtml(exact)})`; + }; + + const fmtNum = (val: number | null | undefined): string => { + if (val === null || val === undefined) return '—'; + const formatted = formatNumber(val, resolvedLocale, numberingSystem); + const exact = String(val); + if (formatted.includes(exact)) return escapeHtml(formatted); + return `${escapeHtml(formatted)} (${escapeHtml(exact)})`; + }; + + const fmtPercent = (val: number | null | undefined): string => { + if (val === null || val === undefined) return '—'; + const exact = String(val); + const fixed = val.toFixed(2); + if (fixed.includes(exact)) return `${escapeHtml(fixed)}%`; + return `${escapeHtml(fixed)}% (${escapeHtml(exact)})`; + }; + + const periodLabel = + dataset.summary.from_date === dataset.summary.to_date + ? dataset.summary.from_date + : `${dataset.summary.from_date} to ${dataset.summary.to_date}`; + const reportTitle = `Daily Sales Report — ${periodLabel}`; + + const summaryRows = dailySalesSummaryMetricRows(dataset.summary); + const summaryRowsHtml = summaryRows + .map((row) => { + const label = SUMMARY_METRIC_LABELS[row.metric] || row.metric; + const rawStr = String(row.value ?? ''); + let displayHtml: string; + let isNum = false; + if (typeof row.value === 'number') { + isNum = true; + if (row.metric.endsWith('_count')) { + displayHtml = fmtNum(row.value); + } else if (row.metric.endsWith('_percent')) { + displayHtml = fmtPercent(row.value); + } else { + displayHtml = fmtMoney(row.value); + } + } else if (rawStr === '') { + displayHtml = '—'; + } else { + displayHtml = `${escapeHtml(rawStr)}`; + } + return ` + ${escapeHtml(label)} (${escapeHtml(row.metric)}) + ${displayHtml} + `; + }) + .join('\n'); + + const paymentMethodsHtml = + dataset.summary.payment_methods.length > 0 + ? dataset.summary.payment_methods + .map( + ( + pm, + ) => ` + ${escapeHtml(pm.method)} + ${fmtMoney(pm.total)} + `, + ) + .join('\n') + : `No payments recorded`; + + const dailyRowsHtml = + dataset.daily.length > 0 + ? dataset.daily + .map( + ( + day, + ) => ` + ${escapeHtml(day.business_date)} + ${fmtNum(day.order_count)} + ${fmtNum(day.paid_bill_count)} + ${fmtMoney(day.gross_collected)} + ${fmtMoney(day.refunds_issued)} + ${fmtMoney(day.net_collected)} + ${fmtMoney(day.tax_total)} + ${fmtMoney(day.discount_total)} + ${fmtMoney(day.service_charge_total)} + ${fmtMoney(day.packaging_charge_total)} + ${fmtMoney(day.delivery_charge_total)} + ${fmtMoney(day.known_cost_net_item_sales)} + ${fmtMoney(day.ingredient_cost_total)} + ${fmtMoney(day.gross_profit_total)} + ${fmtPercent(day.gross_margin_percent)} + ${escapeHtml(day.cost_coverage_status)} + `, + ) + .join('\n') + : `No daily rows`; + + const visibleTaxes = dataset.taxes.slice(0, maxRows); + const taxesTruncated = dataset.taxes.length - visibleTaxes.length; + const taxRowsHtml = + visibleTaxes.length > 0 + ? visibleTaxes + .map( + ( + tax, + ) => ` + ${escapeHtml(tax.business_date)} + ${escapeHtml(tax.tax_name)} + ${tax.tax_rate === null ? '—' : `${escapeHtml(String(tax.tax_rate))}%`} + ${fmtMoney(tax.taxable_base)} + ${fmtMoney(tax.tax_amount)} + ${fmtMoney(tax.gross_amount)} + ${escapeHtml(String(tax.shared_base))} + ${escapeHtml(String(tax.base_additive))} + ${escapeHtml(String(tax.incomplete_detail))} + ${escapeHtml(tax.warning)} + `, + ) + .join('\n') + : `No tax rows`; + + const visibleItems = dataset.items.slice(0, maxRows); + const itemsTruncated = dataset.items.length - visibleItems.length; + const itemRowsHtml = + visibleItems.length > 0 + ? visibleItems + .map( + (item) => ` + ${escapeHtml(item.product_id)} + ${escapeHtml(item.product_name)} + ${escapeHtml(item.product_sku ?? '')} + ${fmtNum(item.quantity)} + ${fmtMoney(item.gross_item_sales)} + ${fmtMoney(item.item_discounts)} + ${fmtMoney(item.net_item_sales)} + ${fmtMoney(item.tax_amount)} + ${fmtMoney(item.ingredient_cost)} + ${fmtMoney(item.profit)} + ${fmtPercent(item.margin_percent)} + ${escapeHtml(item.cost_status)} + `, + ) + .join('\n') + : `No item sales recorded`; + + const warningBannerHtml = dataset.summary.tax_detail_warning + ? `` + : ''; + + return ` + + + + ${escapeHtml(businessName)} — ${escapeHtml(reportTitle)} + + + +
+

${escapeHtml(businessName)}

+
${escapeHtml(reportTitle)}
+

+ Period: ${escapeHtml(dataset.summary.business_date)} + (${escapeHtml(dataset.summary.from_date)} to ${escapeHtml(dataset.summary.to_date)}, ${escapeHtml(String(dataset.summary.day_count))} day${dataset.summary.day_count === 1 ? '' : 's'}) + · Timezone: ${escapeHtml(dataset.summary.timezone)} + · Business Day Start: ${escapeHtml(dataset.summary.business_day_start)} + · Currency: ${escapeHtml(currency)} +

+
+ + ${warningBannerHtml} + +
+

Summary

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

Payment Methods

+ + + + + + + + + ${paymentMethodsHtml} + +
MethodTotal
+
+ +
+

Daily Breakdown

+ + + + + + + + + + + + + + + + + + + + + + + ${dailyRowsHtml} + +
DateOrdersPaid BillsGrossRefundsNetTaxDiscountServicePackagingDeliveryKnown-Cost SalesCostProfitMargin %Cost Status
+
+ +
+

Tax Summary

+ + + + + + + + + + + + + + + + + ${taxRowsHtml} + +
DateTax NameRate (%)Taxable BaseTax AmountGross AmountShared BaseBase AdditiveIncompleteWarning
+ ${taxesTruncated > 0 ? `

Showing first ${visibleTaxes.length} of ${dataset.taxes.length} tax rows (${taxesTruncated} omitted for print size).

` : ''} +
+ +
+

Item Sales

+ + + + + + + + + + + + + + + + + + + ${itemRowsHtml} + +
Product IDProduct NameSKUQtyGross SalesDiscountsNet SalesTaxCostProfitMargin %Cost Status
+ ${itemsTruncated > 0 ? `

Showing first ${visibleItems.length} of ${dataset.items.length} item rows (${itemsTruncated} omitted for print size).

` : ''} +
+ + +`; +} diff --git a/tests/daily-sales-export.test.ts b/tests/daily-sales-export.test.ts index 9fb1a6aac..797fd36ca 100644 --- a/tests/daily-sales-export.test.ts +++ b/tests/daily-sales-export.test.ts @@ -1483,6 +1483,114 @@ async function main() { `${cutoffDayB},2,2,100,5,95`, 'range daily row for 2026-10-05 includes 04:00:00 and 04:00:01 IST bills and refund', ); + + // Verify HTML export (format=html) for both single-day and multi-day range, + // role gating, non-Latin store name & HTML escaping, and exact parity with XLSX. + const forbiddenHtml = await fetch( + `${baseUrl}/api/reports/daily-sales/export?from=${day1}&to=${day3}&format=html`, + { headers: { ...manager.authHeader } }, + ); + assertEqual( + forbiddenHtml.status, + 403, + 'manager forbidden on daily-sales format=html (403)', + ); + + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_name', ?, CURRENT_TIMESTAMP)", + ).run('قهوة فلو · फ्लो कैफे & '); + try { + const singleHtmlRes = await fetch( + `${baseUrl}/api/reports/daily-sales/export?date=${day1}&format=html`, + { headers: authHeader }, + ); + assertEqual(singleHtmlRes.status, 200, 'single-day daily-sales format=html → 200'); + assertIncludes( + singleHtmlRes.headers.get('content-type') || '', + 'text/html', + 'single-day daily-sales HTML Content-Type is text/html', + ); + assertIncludes( + singleHtmlRes.headers.get('content-disposition') || '', + `daily-sales-${day1}.html`, + 'single-day daily-sales HTML filename', + ); + const singleHtml = await singleHtmlRes.text(); + assertIncludes(singleHtml, '', 'single-day HTML has doctype'); + assertIncludes( + singleHtml, + 'قهوة فلو · फ्लो कैफे & <style>body{display:none}</style>', + 'single-day HTML renders non-Latin store name and escapes HTML tags', + ); + assert( + !singleHtml.includes(''), + 'raw unescaped style injection not present in single-day HTML', + ); + + const rangeHtmlRes = await fetch( + `${baseUrl}/api/reports/daily-sales/export?from=${day1}&to=${day3}&format=html`, + { headers: authHeader }, + ); + assertEqual(rangeHtmlRes.status, 200, 'range daily-sales format=html → 200'); + assertIncludes( + rangeHtmlRes.headers.get('content-disposition') || '', + `daily-sales-${day1}_to_${day3}.html`, + 'range daily-sales HTML filename uses from_to', + ); + const rangeHtml = await rangeHtmlRes.text(); + + const extractHtmlSummaryMap = (doc: string): Map => { + const map = new Map(); + const re = //g; + let m: RegExpExecArray | null; + while ((m = re.exec(doc)) !== null) { + map.set( + m[1], + m[2] + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"), + ); + } + return map; + }; + + const htmlSummary = extractHtmlSummaryMap(rangeHtml); + const rangeSummarySheet = rangeWb.getWorksheet('Summary')!; + rangeSummarySheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; + const metric = String(row.getCell(1).value); + const xlsxVal = String(row.getCell(2).value ?? ''); + assertEqual( + htmlSummary.get(metric), + xlsxVal, + `range HTML summary metric ${metric} matches XLSX (${xlsxVal})`, + ); + }); + + const dailyRowMatches = rangeHtml.match(/(); let exposedApi: Record | undefined; +let lastLoadedHtmlPath = ''; + Module._load = function (request: string, parent: unknown, isMain: boolean) { if (request === 'electron') { + class FakePrintWindow { + webContents = { + on: () => undefined, + setWindowOpenHandler: () => undefined, + session: { + webRequest: { onBeforeRequest: () => undefined }, + }, + printToPDF: async () => Buffer.from('%PDF-1.4\n%FloCafe test PDF\n%%EOF\n'), + }; + async loadFile(filePath: string) { + lastLoadedHtmlPath = filePath; + assert.ok(fs.existsSync(filePath), 'staging HTML file exists during loadFile'); + } + isDestroyed() { + return false; + } + destroy() {} + } return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' }, - contextBridge: { exposeInMainWorld: (_name: string, api: Record) => { exposedApi = api; } }, + BrowserWindow: FakePrintWindow, + contextBridge: { + exposeInMainWorld: (_name: string, api: Record) => { + exposedApi = api; + }, + }, ipcRenderer: { sendSync: () => undefined, invoke: () => Promise.resolve(), - on: (channel: string) => { menuChannels.add(channel); }, - removeListener: (channel: string) => { menuChannels.delete(channel); }, + on: (channel: string) => { + menuChannels.add(channel); + }, + removeListener: (channel: string) => { + menuChannels.delete(channel); + }, }, }; } @@ -28,12 +57,44 @@ Module._load = function (request: string, parent: unknown, isMain: boolean) { require('../main/preload'); process.env.JWT_SECRET = 'test-secret-reports-export'; -const { initTestDb, seedOwnerUser, createApp, startServer, closeDatabase } = require('./helpers/test-setup'); +const { + initTestDb, + seedOwnerUser, + createApp, + startServer, + closeDatabase, + now, +} = require('./helpers/test-setup'); +const { getJWTSecret } = require('../main/routes/auth'); +const jwt = require('jsonwebtoken'); const { reportRoutes } = require('../main/routes/reports'); const { cashClosureRoutes } = require('../main/routes/cash-closures'); +const { renderHtmlToPdfBuffer } = require('../main/services/html-to-pdf'); const request = require('supertest'); const ExcelJS = require('exceljs'); +function parseHtmlSummaryMetrics(html: string): Map { + const map = new Map(); + const re = /') + .replace(/"/g, '"') + .replace(/'/g, "'"); + const val = match[2] + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + map.set(key, val); + } + return map; +} + function parseBuffer(response: any, callback: (error: Error | null, body?: Buffer) => void): void { const chunks: Buffer[] = []; response.on('data', (chunk: Buffer) => chunks.push(chunk)); @@ -46,8 +107,15 @@ async function run(): Promise { assert.ok(exposedApi, 'preload exposes electronAPI'); const unsubscribe = exposedApi!.onMenuAction(() => undefined); for (const channel of [ - 'go-pos', 'go-dashboard', 'go-orders', 'go-kds', 'go-tables', - 'go-products', 'go-inventory', 'go-customers', 'go-staff', + 'go-pos', + 'go-dashboard', + 'go-orders', + 'go-kds', + 'go-tables', + 'go-products', + 'go-inventory', + 'go-customers', + 'go-staff', ]) { assert.ok(menuChannels.has(channel), `preload allows ${channel}`); } @@ -55,7 +123,34 @@ async function run(): Promise { const db = initTestDb(); const owner = seedOwnerUser(db); + db.prepare( + `INSERT OR IGNORE INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + 'cashier-reports-export', + 'Cashier User', + 'cashier-export@flocafe.local', + 'hash', + 'cashier', + 1, + now(), + now(), + ); + const cashier = { + token: jwt.sign( + { + userId: 'cashier-reports-export', + email: 'cashier-export@flocafe.local', + role: 'cashier', + }, + getJWTSecret(), + { expiresIn: '1h' }, + ), + }; db.prepare("UPDATE users SET name = '=Formula Staff' WHERE id = ?").run(owner.userId); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_name', 'كافيه النور · 東京カフェ ', CURRENT_TIMESTAMP)", + ).run(); const app = createApp({ '/api/reports': reportRoutes, @@ -64,31 +159,80 @@ async function run(): Promise { ({ server } = await startServer(app)); const http = request(server); const businessDate = '2026-09-25'; - const paidAt = new Date(`${businessDate}T12:00:00Z`).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''); + const paidAt = new Date(`${businessDate}T12:00:00Z`) + .toISOString() + .replace('T', ' ') + .replace(/\.\d{3}Z$/, ''); db.prepare(` - INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) - VALUES (?, ?, 'takeaway', 'completed', 500, 500, ?, ?, ?) + INSERT INTO orders (order_number, user_id, type, status, subtotal, tax_amount, total, created_at, updated_at, completed_at) + VALUES (?, ?, 'takeaway', 'completed', 450, 50, 500, ?, ?, ?) `).run('ORD-REPORT-EXPORT', owner.userId, paidAt, paidAt, paidAt); - const orderId = Number((db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-REPORT-EXPORT') as any).id); + const orderId = Number( + (db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-REPORT-EXPORT') as any) + .id, + ); db.prepare(` - INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) - VALUES (?, ?, 500, 500, 500, 0, 'paid', ?, ?, ?, ?) - `).run('B-REPORT-EXPORT', orderId, JSON.stringify([{ method: 'cash', amount: 500, timestamp: paidAt }]), paidAt, paidAt, paidAt); + INSERT INTO bills (bill_number, order_id, subtotal, tax_amount, tax_breakdown, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) + VALUES (?, ?, 450, 50, ?, 500, 500, 0, 'paid', ?, ?, ?, ?) + `).run( + 'B-REPORT-EXPORT', + orderId, + JSON.stringify([{ title: 'VAT ', rate: 10, amount: 50 }]), + JSON.stringify([{ method: 'cash', amount: 500, timestamp: paidAt }]), + paidAt, + paidAt, + paidAt, + ); const auth = { Authorization: `Bearer ${owner.token}` }; - const xCsv = await http.get(`/api/reports/x-report/export?date=${businessDate}&format=csv`).set(auth); + const cashierAuth = { Authorization: `Bearer ${cashier.token}` }; + + const forbiddenXHtml = await http + .get(`/api/reports/x-report/export?date=${businessDate}&format=html`) + .set(cashierAuth); + assert.equal( + forbiddenXHtml.status, + 403, + 'unauthorized cashier gets 403 on X report HTML export', + ); + const forbiddenZHtml = await http + .get(`/api/reports/z-report/export?date=${businessDate}&format=html`) + .set(cashierAuth); + assert.equal( + forbiddenZHtml.status, + 403, + 'unauthorized cashier gets 403 on Z report HTML export', + ); + const badXFormat = await http + .get(`/api/reports/x-report/export?date=${businessDate}&format=pdf`) + .set(auth); + assert.equal(badXFormat.status, 400, 'invalid format=pdf on X report returns 400'); + + const xCsv = await http + .get(`/api/reports/x-report/export?date=${businessDate}&format=csv`) + .set(auth); assert.equal(xCsv.status, 200, 'X report CSV returns 200'); assert.match(xCsv.headers['content-type'], /text\/csv/); assert.ok(xCsv.headers['content-disposition'].includes(`x-report-${businessDate}.csv`)); assert.ok(xCsv.text.includes('summary,Gross Sales,,,,500')); - assert.ok(xCsv.text.includes("'=Formula Staff"), 'X report CSV neutralizes formula-like staff names'); + assert.ok( + xCsv.text.includes("'=Formula Staff"), + 'X report CSV neutralizes formula-like staff names', + ); - const xXlsx = await http.get(`/api/reports/x-report/export?date=${businessDate}&format=xlsx`).set(auth).buffer(true).parse(parseBuffer); + const xXlsx = await http + .get(`/api/reports/x-report/export?date=${businessDate}&format=xlsx`) + .set(auth) + .buffer(true) + .parse(parseBuffer); assert.equal(xXlsx.status, 200, 'X report XLSX returns 200'); assert.match(xXlsx.headers['content-type'], /spreadsheetml\.sheet/); const xWorkbook = new ExcelJS.Workbook(); await xWorkbook.xlsx.load(xXlsx.body); - assert.deepEqual(xWorkbook.worksheets.map((sheet: any) => sheet.name), ['Summary', 'Payments', 'Staff']); + assert.deepEqual( + xWorkbook.worksheets.map((sheet: any) => sheet.name), + ['Summary', 'Payments', 'Staff'], + ); const xMetrics = new Map(); xWorkbook.getWorksheet('Summary').eachRow((row: any, rowNumber: number) => { if (rowNumber > 1) xMetrics.set(String(row.getCell(1).value), row.getCell(2).value); @@ -96,27 +240,85 @@ async function run(): Promise { assert.equal(xMetrics.get('Gross Sales'), 500); assert.equal(xMetrics.get('Bill Count'), 1); - const unclosedZ = await http.get(`/api/reports/z-report/export?date=${businessDate}&format=csv`).set(auth); + const xHtml = await http + .get(`/api/reports/x-report/export?date=${businessDate}&format=html`) + .set(auth); + assert.equal(xHtml.status, 200, 'X report HTML returns 200'); + assert.match(xHtml.headers['content-type'], /text\/html/); + assert.ok(xHtml.headers['content-disposition'].includes(`x-report-${businessDate}.html`)); + assert.ok( + xHtml.text.startsWith(''), + 'X report HTML is a complete HTML5 document', + ); + assert.ok( + xHtml.text.includes('@page { size: A4 portrait; margin: 12mm; }'), + 'X report HTML includes A4 print page rule', + ); + assert.ok( + xHtml.text.includes('كافيه النور · 東京カフェ <script>alert(1)</script>'), + 'X report HTML renders non-Latin store name and escapes HTML', + ); + assert.ok( + !xHtml.text.includes(''), + 'X report HTML never injects raw script tags', + ); + assert.ok( + xHtml.text.includes('VAT <Standard>'), + 'X report HTML includes escaped tax component title', + ); + const xHtmlMetrics = parseHtmlSummaryMetrics(xHtml.text); + for (const [metric, xlsxVal] of xMetrics.entries()) { + assert.equal( + xHtmlMetrics.get(metric), + xlsxVal === null || xlsxVal === undefined ? '' : String(xlsxVal), + `X report HTML metric ${metric} matches XLSX`, + ); + } + + const unclosedZ = await http + .get(`/api/reports/z-report/export?date=${businessDate}&format=csv`) + .set(auth); assert.equal(unclosedZ.status, 404, 'unclosed Z report returns 404'); assert.equal(unclosedZ.body.error, 'Day not closed'); + const unclosedZHtml = await http + .get(`/api/reports/z-report/export?date=${businessDate}&format=html`) + .set(auth); + assert.equal(unclosedZHtml.status, 404, 'unclosed Z report HTML returns 404'); + assert.equal(unclosedZHtml.body.error, 'Day not closed'); const close = await http.post('/api/cash-closures').set(auth).send({ business_date: businessDate, opening_float_cents: 10000, counted_cash_cents: 65000, - notes: 'End of day', + notes: 'End of day check', }); assert.equal(close.status, 201, 'test day closes'); assert.equal(close.body.zReport.expected_cash_cents, 60000); assert.equal(close.body.zReport.variance_cents, 5000); - const zCsv = await http.get(`/api/reports/z-report/export?date=${businessDate}&format=csv`).set(auth); + const zCsv = await http + .get(`/api/reports/z-report/export?date=${businessDate}&format=csv`) + .set(auth); assert.equal(zCsv.status, 200, 'Z report CSV returns 200'); - assert.ok(zCsv.headers['content-disposition'].includes(`z-report-Z${close.body.zReport.z_number}-${businessDate}.csv`)); - assert.ok(zCsv.text.includes('summary,Expected Cash in Drawer,,,,600\n'), 'Z report CSV includes expected cash in display units'); - assert.ok(zCsv.text.includes('summary,Cash Variance,,,,50\n'), 'Z report CSV includes cash variance in display units'); + assert.ok( + zCsv.headers['content-disposition'].includes( + `z-report-Z${close.body.zReport.z_number}-${businessDate}.csv`, + ), + ); + assert.ok( + zCsv.text.includes('summary,Expected Cash in Drawer,,,,600\n'), + 'Z report CSV includes expected cash in display units', + ); + assert.ok( + zCsv.text.includes('summary,Cash Variance,,,,50\n'), + 'Z report CSV includes cash variance in display units', + ); - const zXlsx = await http.get(`/api/reports/z-report/export?date=${businessDate}&format=xlsx`).set(auth).buffer(true).parse(parseBuffer); + const zXlsx = await http + .get(`/api/reports/z-report/export?date=${businessDate}&format=xlsx`) + .set(auth) + .buffer(true) + .parse(parseBuffer); assert.equal(zXlsx.status, 200, 'Z report XLSX returns 200'); const zWorkbook = new ExcelJS.Workbook(); await zWorkbook.xlsx.load(zXlsx.body); @@ -130,6 +332,46 @@ async function run(): Promise { assert.equal(zMetrics.get('Cash Variance'), 50); assert.equal(zMetrics.get('Closed By'), '=Formula Staff'); + const zHtml = await http + .get(`/api/reports/z-report/export?date=${businessDate}&format=html`) + .set(auth); + assert.equal(zHtml.status, 200, 'Z report HTML returns 200'); + assert.match(zHtml.headers['content-type'], /text\/html/); + assert.ok( + zHtml.headers['content-disposition'].includes( + `z-report-Z${close.body.zReport.z_number}-${businessDate}.html`, + ), + ); + assert.ok( + zHtml.text.includes('End of day <b>check</b>'), + 'Z report HTML escapes notes', + ); + assert.ok( + zHtml.text.includes('VAT <Standard>'), + 'Z report HTML includes tax breakdown from snapshot', + ); + const zHtmlMetrics = parseHtmlSummaryMetrics(zHtml.text); + for (const [metric, xlsxVal] of zMetrics.entries()) { + assert.equal( + zHtmlMetrics.get(metric), + xlsxVal === null || xlsxVal === undefined ? '' : String(xlsxVal), + `Z report HTML metric ${metric} matches XLSX`, + ); + } + + const pdfBuffer = await renderHtmlToPdfBuffer({ + html: zHtml.text, + pageSize: 'A4', + }); + assert.ok( + pdfBuffer.toString('utf8').startsWith('%PDF-1.4'), + 'renderHtmlToPdfBuffer returns PDF bytes', + ); + assert.ok( + lastLoadedHtmlPath.length > 0 && !fs.existsSync(lastLoadedHtmlPath), + 'staging HTML file is cleaned up after PDF render', + ); + console.log('reports-export tests passed'); } finally { if (server) await new Promise((resolve) => server.close(() => resolve())); diff --git a/tests/translations.test.ts b/tests/translations.test.ts index b2b8821ec..084e31f5c 100644 --- a/tests/translations.test.ts +++ b/tests/translations.test.ts @@ -109,7 +109,11 @@ function assert(condition: boolean, msg: string): void { * (e.g. `{ auth: { signIn: "Sign In" } }` → `{ "auth.signIn": "Sign In" }`). * This is the canonical view used for cross-locale parity and value checks. */ -function flattenLeaves(node: unknown, prefix = '', out: Record = {}): Record { +function flattenLeaves( + node: unknown, + prefix = '', + out: Record = {}, +): Record { if (node !== null && typeof node === 'object' && !Array.isArray(node)) { for (const [k, v] of Object.entries(node)) { const full = prefix ? `${prefix}.${k}` : k; @@ -141,8 +145,13 @@ function findDuplicateKeys(raw: string): string[] { let str = ''; while (j < raw.length) { if (raw[j] === '\\') { - if (raw[j + 1] === 'u') { str += raw.slice(j, j + 6); j += 6; } - else { str += raw[j] + raw[j + 1]; j += 2; } + if (raw[j + 1] === 'u') { + str += raw.slice(j, j + 6); + j += 6; + } else { + str += raw[j] + raw[j + 1]; + j += 2; + } continue; } if (raw[j] === '"') break; @@ -155,7 +164,8 @@ function findDuplicateKeys(raw: string): string[] { if (raw[k] === ':') { const key = JSON.parse(`"${str}"`); const top = stack[stack.length - 1]; - if (top.has(key)) dups.push(key); else top.add(key); + if (top.has(key)) dups.push(key); + else top.add(key); i = k + 1; } } else if (c === '{') { @@ -298,13 +308,17 @@ function registryConsistencyErrors( try { const canonical = new Intl.Locale(cfg.locale).toString(); if (canonical !== cfg.locale) { - errors.push(`language "${lang}" locale "${cfg.locale}" is not canonical BCP-47 (expected "${canonical}")`); + errors.push( + `language "${lang}" locale "${cfg.locale}" is not canonical BCP-47 (expected "${canonical}")`, + ); } } catch { errors.push(`language "${lang}" has invalid BCP-47 locale tag "${cfg.locale}"`); } if (cfg.direction !== 'ltr' && cfg.direction !== 'rtl') { - errors.push(`language "${lang}" has invalid direction "${cfg.direction}" (expected ltr or rtl)`); + errors.push( + `language "${lang}" has invalid direction "${cfg.direction}" (expected ltr or rtl)`, + ); } if (typeof cfg.selectable !== 'boolean') { errors.push(`language "${lang}" selectable must be a boolean`); @@ -348,7 +362,11 @@ function icuSyntaxErrors(flat: Record, lang: string): string[] { return errors; } -function icuParityErrors(enFlat: Record, localeFlat: Record, lang: string): string[] { +function icuParityErrors( + enFlat: Record, + localeFlat: Record, + lang: string, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const localeVal = localeFlat[k]; @@ -378,7 +396,11 @@ function icuParityErrors(enFlat: Record, localeFlat: Record, localeFlat: Record, lang: string): string[] { +function tagParityErrors( + enFlat: Record, + localeFlat: Record, + lang: string, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const localeVal = localeFlat[k]; @@ -437,13 +459,18 @@ const FA_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'whatsapp.connect.pairingPhonePlaceholder', // pure format: {dialCode}XXXXXXXXXX ]); -function faFallbackErrors(faFlat: Record, enFlat: Record): string[] { +function faFallbackErrors( + faFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const faVal = faFlat[k]; if (faVal === undefined) continue; // reported by key parity if (faVal === enFlat[k] && !FA_INTENTIONAL_IDENTICAL.has(k)) { - errors.push(`fa.json ${k} — identical to English value (renders as English for Persian users)`); + errors.push( + `fa.json ${k} — identical to English value (renders as English for Persian users)`, + ); } } return errors; @@ -456,7 +483,8 @@ function faFallbackErrors(faFlat: Record, enFlat: Record = new Set([ 'auth.emailPlaceholder', // example email - 'dashboard.exportCsv', // format label "CSV (.csv)" + 'dashboard.exportCsv', + 'dashboard.exportPdf', // format label "CSV (.csv)" 'dashboard.exportXlsx', // format label "Excel (.xlsx)" 'businessType.restaurant', // same word in French 'common.appTitle', // brand @@ -555,20 +583,26 @@ const FR_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'whatsapp.connect.pairingPhonePlaceholder', // pure format: {dialCode}XXXXXXXXXX ]); -function frFallbackErrors(frFlat: Record, enFlat: Record): string[] { +function frFallbackErrors( + frFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const frVal = frFlat[k]; if (frVal === undefined) continue; // reported by key parity if (frVal === enFlat[k] && !FR_INTENTIONAL_IDENTICAL.has(k)) { - errors.push(`fr.json ${k} — identical to English value (renders as English for French users)`); + errors.push( + `fr.json ${k} — identical to English value (renders as English for French users)`, + ); } } return errors; } const TR_INTENTIONAL_IDENTICAL = new Set([ - 'dashboard.exportCsv', // format label "CSV (.csv)" + 'dashboard.exportCsv', + 'dashboard.exportPdf', // format label "CSV (.csv)" 'dashboard.exportXlsx', // format label "Excel (.xlsx)" 'settings.paymentMethodUpi', // technical acronym (payment rail name) 'common.appTitle', // brand name "Flo" @@ -608,7 +642,10 @@ const TR_INTENTIONAL_IDENTICAL = new Set([ 'whatsapp.connect.pairingPhonePlaceholder', // pure format: {dialCode}XXXXXXXXXX ]); -function trFallbackErrors(trFlat: Record, enFlat: Record): string[] { +function trFallbackErrors( + trFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const trVal = trFlat[k]; @@ -616,14 +653,17 @@ function trFallbackErrors(trFlat: Record, enFlat: Record([ - 'dashboard.exportCsv', // format label "CSV (.csv)" + 'dashboard.exportCsv', + 'dashboard.exportPdf', // format label "CSV (.csv)" 'dashboard.exportXlsx', // format label "Excel (.xlsx)" 'auth.countryIndia', 'auth.countryThailand', @@ -869,7 +909,10 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'whatsapp.tabs.inbox', ]); -function filFallbackErrors(filFlat: Record, enFlat: Record): string[] { +function filFallbackErrors( + filFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const filVal = filFlat[k]; @@ -877,7 +920,9 @@ function filFallbackErrors(filFlat: Record, enFlat: Record, enFlat: Record([ 'expenses.status', // "Status" is the German word - 'dashboard.exportCsv', // format label "CSV (.csv)" + 'dashboard.exportCsv', + 'dashboard.exportPdf', // format label "CSV (.csv)" 'dashboard.exportXlsx', // format label "Excel (.xlsx)" 'auth.countryThailand', 'businessType.restaurant', @@ -990,7 +1036,10 @@ const DE_INTENTIONAL_IDENTICAL = new Set([ 'whatsapp.sent.colStatus', ]); -function deFallbackErrors(deFlat: Record, enFlat: Record): string[] { +function deFallbackErrors( + deFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const deVal = deFlat[k]; @@ -998,7 +1047,9 @@ function deFallbackErrors(deFlat: Record, enFlat: Record, enFlat: Record([ - 'dashboard.exportCsv', // format label "CSV (.csv)" + 'dashboard.exportCsv', + 'dashboard.exportPdf', // format label "CSV (.csv)" 'dashboard.exportXlsx', // format label "Excel (.xlsx)" 'auth.countryIndia', // country name identical in Italian 'auth.email', // Italian uses "Email" @@ -1107,7 +1159,10 @@ const IT_INTENTIONAL_IDENTICAL = new Set([ 'whatsapp.connect.pairingPhonePlaceholder', // pure format: {dialCode}XXXXXXXXXX ]); -function itFallbackErrors(itFlat: Record, enFlat: Record): string[] { +function itFallbackErrors( + itFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const itVal = itFlat[k]; @@ -1115,7 +1170,9 @@ function itFallbackErrors(itFlat: Record, enFlat: Record, enFlat: Record([ - 'common.appTitle', 'common.brandName', - 'dashboard.exportCsv', 'dashboard.exportXlsx', 'dashboard.ticketMethodCount', - 'kds.emptyColumn', 'nav.kds', 'nav.pos', 'nav.whatsapp', - 'pos.addonPrice', 'pos.loadingEllipsis', 'pos.tagCount', 'pos.taxLine', - 'print.hsn', 'print.zReport.paymentCount', 'printTest.escpos', - 'products.addonSelectionRange', 'products.saleUnitCl', - 'serverApp.emailPlaceholder', 'settings.connectionUsb', 'settings.instagramPlaceholder', 'settings.ipAddressPlaceholder', - 'settings.kds', 'settings.portPlaceholder', 'settings.revflo', 'settings.registrationEmailPlaceholder', - 'settings.registrationLastError', 'settings.tabWhatsapp', 'settings.whatsapp', - 'setup.finedineLabel', 'setup.ownerEmailPlaceholder', 'setup.qsrLabel', - 'tax.auditCreateOverride', 'tax.auditUpdateOverride', 'update.downloadingBadge', + 'common.appTitle', + 'common.brandName', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'dashboard.exportXlsx', + 'dashboard.ticketMethodCount', + 'kds.emptyColumn', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.tagCount', + 'pos.taxLine', + 'print.hsn', + 'print.zReport.paymentCount', + 'printTest.escpos', + 'products.addonSelectionRange', + 'products.saleUnitCl', + 'serverApp.emailPlaceholder', + 'settings.connectionUsb', + 'settings.instagramPlaceholder', + 'settings.ipAddressPlaceholder', + 'settings.kds', + 'settings.portPlaceholder', + 'settings.revflo', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'settings.tabWhatsapp', + 'settings.whatsapp', + 'setup.finedineLabel', + 'setup.ownerEmailPlaceholder', + 'setup.qsrLabel', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'update.downloadingBadge', 'whatsapp.connect.pairingPhonePlaceholder', ]); -function ruFallbackErrors(ruFlat: Record, enFlat: Record): string[] { +function ruFallbackErrors( + ruFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const ruVal = ruFlat[k]; @@ -1145,7 +1230,9 @@ function ruFallbackErrors(ruFlat: Record, enFlat: Record, enFlat: Record([ - 'auth.emailPlaceholder', 'common.appTitle', 'common.brandName', 'common.logoAlt', - 'dashboard.exportXlsx', 'dashboard.exportCsv', 'dashboard.ticketMethodCount', - 'kds.emptyColumn', 'nav.kds', 'nav.pos', 'nav.whatsapp', 'pos.addonPrice', - 'pos.loadingEllipsis', 'pos.tagCount', 'pos.taxLine', 'print.hsn', - 'print.zReport.paymentCount', 'printTest.escpos', 'printTest.paperWidth58', - 'printTest.paperWidth80', 'products.addonSelectionRange', 'products.fieldSku', - 'products.saleUnitCl', 'products.saleUnitFlOz', 'products.saleUnitG', 'products.saleUnitKg', - 'products.saleUnitL', 'products.saleUnitLb', 'products.saleUnitMl', 'products.saleUnitOz', - 'products.skuLabel', 'serverApp.emailPlaceholder', 'serverApp.title', - 'settings.apiKeyInputPlaceholder', 'settings.backupSchemaVersion', 'settings.connectionUsb', - 'settings.instagramPlaceholder', 'settings.ipAddressPlaceholder', 'settings.kds', - 'settings.languageEn', 'settings.languageEs', 'settings.languagePt', 'settings.paperSize58', - 'settings.paperSize80', 'settings.paperWidth58', 'settings.paymentMethodUpi', - 'settings.portPlaceholder', 'settings.registrationEmailPlaceholder', 'settings.registrationLastError', - 'settings.revflo', 'settings.serverApp', 'settings.tabOrderflow', 'settings.tabWhatsapp', - 'settings.unicode', 'settings.whatsapp', 'setup.expressLabel', 'setup.finedineLabel', - 'setup.languageEnglish', 'setup.ownerEmailPlaceholder', 'setup.pinLabel', 'setup.qsrLabel', - 'tax.auditCreateOverride', 'tax.auditUpdateOverride', 'update.downloadingBadge', + 'auth.emailPlaceholder', + 'common.appTitle', + 'common.brandName', + 'common.logoAlt', + 'dashboard.exportXlsx', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'dashboard.ticketMethodCount', + 'kds.emptyColumn', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.tagCount', + 'pos.taxLine', + 'print.hsn', + 'print.zReport.paymentCount', + 'printTest.escpos', + 'printTest.paperWidth58', + 'printTest.paperWidth80', + 'products.addonSelectionRange', + 'products.fieldSku', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitKg', + 'products.saleUnitL', + 'products.saleUnitLb', + 'products.saleUnitMl', + 'products.saleUnitOz', + 'products.skuLabel', + 'serverApp.emailPlaceholder', + 'serverApp.title', + 'settings.apiKeyInputPlaceholder', + 'settings.backupSchemaVersion', + 'settings.connectionUsb', + 'settings.instagramPlaceholder', + 'settings.ipAddressPlaceholder', + 'settings.kds', + 'settings.languageEn', + 'settings.languageEs', + 'settings.languagePt', + 'settings.paperSize58', + 'settings.paperSize80', + 'settings.paperWidth58', + 'settings.paymentMethodUpi', + 'settings.portPlaceholder', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'settings.revflo', + 'settings.serverApp', + 'settings.tabOrderflow', + 'settings.tabWhatsapp', + 'settings.unicode', + 'settings.whatsapp', + 'setup.expressLabel', + 'setup.finedineLabel', + 'setup.languageEnglish', + 'setup.ownerEmailPlaceholder', + 'setup.pinLabel', + 'setup.qsrLabel', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'update.downloadingBadge', 'whatsapp.connect.pairingPhonePlaceholder', ]); -function urFallbackErrors(urFlat: Record, enFlat: Record): string[] { +function urFallbackErrors( + urFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const urVal = urFlat[k]; @@ -1196,24 +1333,63 @@ function urFallbackErrors(urFlat: Record, enFlat: Record([ - 'auth.emailPlaceholder', 'common.appTitle', 'common.brandName', 'common.logoAlt', - 'dashboard.ticketMethodCount', 'kds.emptyColumn', 'nav.kds', 'nav.pos', 'nav.whatsapp', - 'pos.addonPrice', 'pos.loadingEllipsis', 'pos.tagCount', 'pos.taxLine', 'printTest.escpos', - 'printTest.paperWidth58', 'printTest.paperWidth80', 'print.hsn', 'print.zReport.paymentCount', - 'products.addonSelectionRange', 'products.fieldSku', 'products.saleUnitCl', - 'products.saleUnitFlOz', 'products.saleUnitG', 'products.saleUnitKg', 'products.saleUnitL', - 'products.saleUnitLb', 'products.saleUnitMl', 'products.saleUnitOz', - 'serverApp.emailPlaceholder', 'settings.apiKeyInputPlaceholder', 'settings.connectionUsb', - 'settings.paymentMethodUpi', 'settings.instagramPlaceholder', 'settings.ipAddressPlaceholder', - 'settings.kds', 'settings.portPlaceholder', 'settings.registrationEmailPlaceholder', - 'settings.registrationLastError', 'settings.revflo', 'settings.serverApp', - 'settings.tabOrderflow', 'settings.tabWhatsapp', 'settings.unicode', 'settings.whatsapp', - 'setup.finedineLabel', 'setup.ownerEmailPlaceholder', 'setup.qsrLabel', - 'tax.auditCreateOverride', 'tax.auditUpdateOverride', 'update.downloadingBadge', + 'auth.emailPlaceholder', + 'common.appTitle', + 'common.brandName', + 'common.logoAlt', + 'dashboard.ticketMethodCount', + 'kds.emptyColumn', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.tagCount', + 'pos.taxLine', + 'printTest.escpos', + 'printTest.paperWidth58', + 'printTest.paperWidth80', + 'print.hsn', + 'print.zReport.paymentCount', + 'products.addonSelectionRange', + 'products.fieldSku', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitKg', + 'products.saleUnitL', + 'products.saleUnitLb', + 'products.saleUnitMl', + 'products.saleUnitOz', + 'serverApp.emailPlaceholder', + 'settings.apiKeyInputPlaceholder', + 'settings.connectionUsb', + 'settings.paymentMethodUpi', + 'settings.instagramPlaceholder', + 'settings.ipAddressPlaceholder', + 'settings.kds', + 'settings.portPlaceholder', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'settings.revflo', + 'settings.serverApp', + 'settings.tabOrderflow', + 'settings.tabWhatsapp', + 'settings.unicode', + 'settings.whatsapp', + 'setup.finedineLabel', + 'setup.ownerEmailPlaceholder', + 'setup.qsrLabel', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'update.downloadingBadge', 'whatsapp.connect.pairingPhonePlaceholder', ]); -function jaFallbackErrors(jaFlat: Record, enFlat: Record): string[] { +function jaFallbackErrors( + jaFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const jaVal = jaFlat[k]; @@ -1221,7 +1397,9 @@ function jaFallbackErrors(jaFlat: Record, enFlat: Record, enFlat: Record([ - 'auth.emailPlaceholder', 'common.appTitle', 'common.brandName', 'common.logoAlt', - 'dashboard.ticketMethodCount', 'kds.emptyColumn', 'nav.kds', 'nav.pos', 'nav.whatsapp', - 'pos.addonPrice', 'pos.loadingEllipsis', 'pos.tagCount', 'pos.taxLine', 'print.hsn', - 'print.zReport.paymentCount', 'printTest.escpos', 'printTest.paperWidth58', - 'printTest.paperWidth80', 'products.addonSelectionRange', 'products.fieldSku', - 'products.saleUnitCl', 'products.saleUnitFlOz', 'products.saleUnitG', 'products.saleUnitKg', - 'products.saleUnitL', 'products.saleUnitLb', 'products.saleUnitMl', 'products.saleUnitOz', - 'products.skuLabel', 'serverApp.emailPlaceholder', 'settings.apiKeyInputPlaceholder', - 'settings.connectionUsb', 'settings.instagramPlaceholder', 'settings.ipAddressPlaceholder', - 'settings.kds', 'settings.paperSize58', 'settings.paperSize80', - 'settings.paymentMethodUpi', 'settings.portPlaceholder', 'settings.registrationEmailPlaceholder', - 'settings.registrationLastError', 'settings.revflo', 'settings.tabOrderflow', - 'settings.tabWhatsapp', 'settings.unicode', 'settings.whatsapp', 'setup.finedineLabel', - 'setup.ownerEmailPlaceholder', 'setup.pinLabel', 'tax.auditCreateOverride', - 'tax.auditUpdateOverride', 'update.downloadingBadge', 'whatsapp.connect.pairingPhonePlaceholder', + 'auth.emailPlaceholder', + 'common.appTitle', + 'common.brandName', + 'common.logoAlt', + 'dashboard.ticketMethodCount', + 'kds.emptyColumn', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.tagCount', + 'pos.taxLine', + 'print.hsn', + 'print.zReport.paymentCount', + 'printTest.escpos', + 'printTest.paperWidth58', + 'printTest.paperWidth80', + 'products.addonSelectionRange', + 'products.fieldSku', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitKg', + 'products.saleUnitL', + 'products.saleUnitLb', + 'products.saleUnitMl', + 'products.saleUnitOz', + 'products.skuLabel', + 'serverApp.emailPlaceholder', + 'settings.apiKeyInputPlaceholder', + 'settings.connectionUsb', + 'settings.instagramPlaceholder', + 'settings.ipAddressPlaceholder', + 'settings.kds', + 'settings.paperSize58', + 'settings.paperSize80', + 'settings.paymentMethodUpi', + 'settings.portPlaceholder', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'settings.revflo', + 'settings.tabOrderflow', + 'settings.tabWhatsapp', + 'settings.unicode', + 'settings.whatsapp', + 'setup.finedineLabel', + 'setup.ownerEmailPlaceholder', + 'setup.pinLabel', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'update.downloadingBadge', + 'whatsapp.connect.pairingPhonePlaceholder', ]); -function zhFallbackErrors(zhFlat: Record, enFlat: Record): string[] { +function zhFallbackErrors( + zhFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const zhVal = zhFlat[k]; @@ -1254,13 +1473,18 @@ function zhFallbackErrors(zhFlat: Record, enFlat: Record, enFlat: Record): string[] { +function zhTwFallbackErrors( + zhTwFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const zhTwVal = zhTwFlat[k]; @@ -1268,7 +1492,9 @@ function zhTwFallbackErrors(zhTwFlat: Record, enFlat: Record, enFlat: Record([ - 'auth.emailPlaceholder', 'common.appTitle', 'common.brandName', 'common.logoAlt', - 'dashboard.exportCsv', 'dashboard.exportXlsx', 'dashboard.ticketMethodCount', - 'kds.emptyColumn', 'nav.kds', 'nav.pos', 'nav.whatsapp', 'pos.addonPrice', - 'pos.loadingEllipsis', 'pos.tagCount', 'pos.taxLine', 'print.hsn', - 'print.zReport.paymentCount', 'printTest.escpos', 'printTest.paperWidth58', - 'printTest.paperWidth80', 'products.addonSelectionRange', 'products.fieldSku', - 'products.saleUnitCl', 'products.saleUnitFlOz', 'products.saleUnitG', 'products.saleUnitKg', - 'products.saleUnitL', 'products.saleUnitLb', 'products.saleUnitMl', 'products.saleUnitOz', - 'products.skuLabel', 'serverApp.emailPlaceholder', 'settings.apiKeyInputPlaceholder', - 'settings.connectionUsb', 'settings.instagramPlaceholder', 'settings.ipAddressPlaceholder', - 'settings.kds', 'settings.paperSize58', 'settings.paperSize80', - 'settings.paymentMethodUpi', 'settings.portPlaceholder', 'settings.registrationEmailPlaceholder', - 'settings.registrationLastError', 'settings.revflo', 'settings.tabOrderflow', - 'settings.tabWhatsapp', 'settings.unicode', 'settings.whatsapp', 'setup.finedineLabel', - 'setup.ownerEmailPlaceholder', 'setup.pinLabel', 'setup.qsrLabel', - 'tax.auditCreateOverride', 'tax.auditUpdateOverride', 'update.downloadingBadge', - 'whatsapp.connect.pairingPhonePlaceholder', -]); - -function koFallbackErrors(koFlat: Record, enFlat: Record): string[] { - const errors: string[] = []; - for (const k of Object.keys(enFlat)) { - const koVal = koFlat[k]; - if (koVal === undefined) continue; - if (koVal.startsWith('[KO]') || koVal.startsWith('[TODO]')) { - errors.push(`ko.json ${k} — placeholder prefix found: "${koVal}"`); - } else if (koVal === enFlat[k] && !KO_INTENTIONAL_IDENTICAL.has(k)) { - errors.push(`ko.json ${k} — identical to English value (renders as English for Korean users)`); - } - } - return errors; -} - + 'auth.emailPlaceholder', + 'common.appTitle', + 'common.brandName', + 'common.logoAlt', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'dashboard.exportXlsx', + 'dashboard.ticketMethodCount', + 'kds.emptyColumn', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.tagCount', + 'pos.taxLine', + 'print.hsn', + 'print.zReport.paymentCount', + 'printTest.escpos', + 'printTest.paperWidth58', + 'printTest.paperWidth80', + 'products.addonSelectionRange', + 'products.fieldSku', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitKg', + 'products.saleUnitL', + 'products.saleUnitLb', + 'products.saleUnitMl', + 'products.saleUnitOz', + 'products.skuLabel', + 'serverApp.emailPlaceholder', + 'settings.apiKeyInputPlaceholder', + 'settings.connectionUsb', + 'settings.instagramPlaceholder', + 'settings.ipAddressPlaceholder', + 'settings.kds', + 'settings.paperSize58', + 'settings.paperSize80', + 'settings.paymentMethodUpi', + 'settings.portPlaceholder', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'settings.revflo', + 'settings.tabOrderflow', + 'settings.tabWhatsapp', + 'settings.unicode', + 'settings.whatsapp', + 'setup.finedineLabel', + 'setup.ownerEmailPlaceholder', + 'setup.pinLabel', + 'setup.qsrLabel', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'update.downloadingBadge', + 'whatsapp.connect.pairingPhonePlaceholder', +]); + +function koFallbackErrors( + koFlat: Record, + enFlat: Record, +): string[] { + const errors: string[] = []; + for (const k of Object.keys(enFlat)) { + const koVal = koFlat[k]; + if (koVal === undefined) continue; + if (koVal.startsWith('[KO]') || koVal.startsWith('[TODO]')) { + errors.push(`ko.json ${k} — placeholder prefix found: "${koVal}"`); + } else if (koVal === enFlat[k] && !KO_INTENTIONAL_IDENTICAL.has(k)) { + errors.push( + `ko.json ${k} — identical to English value (renders as English for Korean users)`, + ); + } + } + return errors; +} + /** Indonesian translation safeguards. */ const ID_INTENTIONAL_IDENTICAL = new Set([ - 'common.appTitle', 'common.brandName', 'common.logoAlt', 'settings.revflo', 'setup.finedineLabel', - 'auth.email', 'auth.recoverPinLabel', 'expenses.status', // "Status" is the Indonesian word - 'kds.connectionPolling', 'kds.emptyColumn', 'kds.viewKanban', - 'nav.kds', 'nav.pos', 'nav.whatsapp', 'nav.heapLabel', 'nav.portLabel', 'nav.serverLabel', - 'pos.orderTypeOnline', 'printTest.escpos', 'printTest.item', 'printTest.paperWidth58', - 'printTest.paperWidth80', 'print.hsn', 'print.zReport.paymentCount', 'print.zReport.amount', - 'products.barcodeLabel', 'products.fieldBarcode', 'products.fieldSku', 'products.skuLabel', - 'settings.apiKeyInputPlaceholder', 'settings.connectionUsb', 'settings.paymentMethodUpi', - 'settings.ipAddressPlaceholder', 'settings.kds', 'settings.paperSize58', 'settings.paperSize80', - 'settings.port', 'settings.portPlaceholder', 'settings.stationPrinter', 'settings.status', - 'settings.tabData', 'settings.tabDataCloud', 'settings.tabWhatsapp', 'settings.unicode', - 'settings.whatsapp', 'setup.pinLabel', 'support.platform', 'whatsapp.connect.pairingPhonePlaceholder', - 'auth.countryIndia', 'auth.countryThailand', 'common.subtotal', 'common.total', 'customer.email', - 'dashboard.exportCsv', 'dashboard.exportXlsx', 'dashboard.ticketMethodCount', 'pos.addonPrice', 'pos.loadingEllipsis', 'pos.subtotal', 'pos.tagCount', 'pos.tagHalal', - 'pos.tagVegan', 'pos.taxLine', 'pos.total', 'print.grandTotal', 'products.addonSelectionRange', - 'products.cashbackGlobalBadge', 'products.colorAmber', 'products.columnCashback', 'products.columnStatus', - 'products.saleUnitCl', 'products.saleUnitFlOz', 'products.saleUnitG', 'products.saleUnitKg', 'products.saleUnitL', 'products.saleUnitLb', 'products.saleUnitMl', 'products.saleUnitOz', 'products.tagVegan', - 'serverApp.emailPlaceholder', 'serverApp.orderSlipSubtotal', 'serverApp.orderSlipTotal', 'settings.email', - 'settings.iranCurrencyDisplayRial', 'settings.iranCurrencyDisplayToman', 'settings.iranNumberDigitsLatin', - 'setup.demoLabel', 'setup.expressLabel', 'setup.qsrLabel', 'permissionMatrix.areas.menu', 'support.email', - 'tax.auditCreateOverride', 'tax.auditUpdateOverride', 'tax.target', 'update.downloadingBadge', - 'whatsapp.sent.colStatus', 'receipt.item', + 'common.appTitle', + 'common.brandName', + 'common.logoAlt', + 'settings.revflo', + 'setup.finedineLabel', + 'auth.email', + 'auth.recoverPinLabel', + 'expenses.status', // "Status" is the Indonesian word + 'kds.connectionPolling', + 'kds.emptyColumn', + 'kds.viewKanban', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'nav.heapLabel', + 'nav.portLabel', + 'nav.serverLabel', + 'pos.orderTypeOnline', + 'printTest.escpos', + 'printTest.item', + 'printTest.paperWidth58', + 'printTest.paperWidth80', + 'print.hsn', + 'print.zReport.paymentCount', + 'print.zReport.amount', + 'products.barcodeLabel', + 'products.fieldBarcode', + 'products.fieldSku', + 'products.skuLabel', + 'settings.apiKeyInputPlaceholder', + 'settings.connectionUsb', + 'settings.paymentMethodUpi', + 'settings.ipAddressPlaceholder', + 'settings.kds', + 'settings.paperSize58', + 'settings.paperSize80', + 'settings.port', + 'settings.portPlaceholder', + 'settings.stationPrinter', + 'settings.status', + 'settings.tabData', + 'settings.tabDataCloud', + 'settings.tabWhatsapp', + 'settings.unicode', + 'settings.whatsapp', + 'setup.pinLabel', + 'support.platform', + 'whatsapp.connect.pairingPhonePlaceholder', + 'auth.countryIndia', + 'auth.countryThailand', + 'common.subtotal', + 'common.total', + 'customer.email', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'dashboard.exportXlsx', + 'dashboard.ticketMethodCount', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.subtotal', + 'pos.tagCount', + 'pos.tagHalal', + 'pos.tagVegan', + 'pos.taxLine', + 'pos.total', + 'print.grandTotal', + 'products.addonSelectionRange', + 'products.cashbackGlobalBadge', + 'products.colorAmber', + 'products.columnCashback', + 'products.columnStatus', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitKg', + 'products.saleUnitL', + 'products.saleUnitLb', + 'products.saleUnitMl', + 'products.saleUnitOz', + 'products.tagVegan', + 'serverApp.emailPlaceholder', + 'serverApp.orderSlipSubtotal', + 'serverApp.orderSlipTotal', + 'settings.email', + 'settings.iranCurrencyDisplayRial', + 'settings.iranCurrencyDisplayToman', + 'settings.iranNumberDigitsLatin', + 'setup.demoLabel', + 'setup.expressLabel', + 'setup.qsrLabel', + 'permissionMatrix.areas.menu', + 'support.email', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'tax.target', + 'update.downloadingBadge', + 'whatsapp.sent.colStatus', + 'receipt.item', ]); -function idFallbackErrors(idFlat: Record, enFlat: Record): string[] { +function idFallbackErrors( + idFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const idVal = idFlat[k]; @@ -1343,7 +1693,9 @@ function idFallbackErrors(idFlat: Record, enFlat: Record, enFlat: Record([ - 'auth.countryThailand', 'auth.emailPlaceholder', 'businessType.restaurant', - 'common.appTitle', 'common.brandName', 'common.logoAlt', 'common.percentage', - 'common.timeHoursMinutes', 'common.timeMinutes', 'dashboard.minutesValue', 'dashboard.title', - 'dashboard.exportXlsx', 'dashboard.exportCsv', 'dashboard.ticketMethodCount', + 'auth.countryThailand', + 'auth.emailPlaceholder', + 'businessType.restaurant', + 'common.appTitle', + 'common.brandName', + 'common.logoAlt', + 'common.percentage', + 'common.timeHoursMinutes', + 'common.timeMinutes', + 'dashboard.minutesValue', + 'dashboard.title', + 'dashboard.exportXlsx', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'dashboard.ticketMethodCount', 'expenses.status', // "Status" is the Dutch word - 'inventory.product', 'kds.addonsLabel', 'kds.connectionLive', 'kds.emptyColumn', 'kds.viewKanban', + 'inventory.product', + 'kds.addonsLabel', + 'kds.connectionLive', + 'kds.emptyColumn', + 'kds.viewKanban', 'permissionMatrix.areas.apps', // "apps" is a loanword in Dutch - 'nav.dashboard', 'nav.kds', 'nav.pos', 'nav.whatsapp', 'orders.online', 'pos.addonPrice', - 'pos.loadingEllipsis', 'pos.orderTypeOnline', 'pos.percentage', 'pos.tagBestseller', 'pos.tagCount', 'pos.tagHalal', - 'pos.taxLine', 'printTest.downloadBin', 'printTest.escpos', 'print.kot.type', 'print.hsn', - 'print.zReport.paymentCount', 'products.addonSelectionRange', 'products.colorAmber', - 'products.colorFuchsia', 'products.colorIndigo', 'products.colorViolet', 'products.columnProduct', - 'products.columnStatus', 'products.fieldSku', 'products.imageCamera', 'products.saleUnitCl', - 'products.saleUnitFlOz', 'products.saleUnitG', 'products.saleUnitKg', 'products.saleUnitL', - 'products.saleUnitLb', 'products.saleUnitMl', 'products.saleUnitOz', 'products.skuLabel', - 'products.tagBestseller', 'products.taxExclusiveShort', 'products.taxInclusiveShort', - 'serverApp.emailPlaceholder', 'settings.account', 'settings.googleDriveAccount', 'settings.navGroupAccount', 'settings.appQrAlt', 'settings.backupKindAuto', - 'settings.backupSchemaVersion', 'settings.billTemplateCompactName', 'settings.browserWebusb', - 'settings.connectionUsb', 'settings.connectionWebusb', 'settings.paymentMethodUpi', - 'settings.defaultPrinterTipTitle', 'settings.ipAddressPlaceholder', 'settings.iranCalendarLocale', - 'settings.iranCurrencyDisplayRial', 'settings.iranCurrencyDisplayToman', 'settings.kds', - 'settings.languageEs', 'settings.plan', 'settings.portPlaceholder', 'settings.posQrAlt', - 'settings.printerOffline', 'settings.printerOnline', 'settings.printers', 'settings.privacy', - 'settings.registrationEmailPlaceholder', 'settings.registrationLastError', 'settings.revflo', - 'settings.stationPrinter', 'settings.status', 'settings.tabOrderflow', 'settings.tabPrinters', - 'settings.tabWhatsapp', 'settings.test', 'settings.unicode', 'settings.updateStatusOffline', - 'settings.errorDetails', 'settings.updates', 'settings.whatsapp', 'setup.demoLabel', - 'setup.ownerEmailPlaceholder', 'setup.pinLabel', 'setup.qsrLabel', 'staff.roleManager', - 'staff.roleServer', 'permissionMatrix.areas.menu', 'support.platform', 'support.restaurant', - 'tables.floorplanAuto', 'tax.auditCreateOverride', 'tax.auditUpdateOverride', 'tax.entityAddon', - 'tax.entityProduct', 'tax.type', 'update.downloadingBadge', 'whatsapp.inbox.title', - 'whatsapp.sent.colStatus', 'whatsapp.tabs.inbox', + 'nav.dashboard', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'orders.online', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.orderTypeOnline', + 'pos.percentage', + 'pos.tagBestseller', + 'pos.tagCount', + 'pos.tagHalal', + 'pos.taxLine', + 'printTest.downloadBin', + 'printTest.escpos', + 'print.kot.type', + 'print.hsn', + 'print.zReport.paymentCount', + 'products.addonSelectionRange', + 'products.colorAmber', + 'products.colorFuchsia', + 'products.colorIndigo', + 'products.colorViolet', + 'products.columnProduct', + 'products.columnStatus', + 'products.fieldSku', + 'products.imageCamera', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitKg', + 'products.saleUnitL', + 'products.saleUnitLb', + 'products.saleUnitMl', + 'products.saleUnitOz', + 'products.skuLabel', + 'products.tagBestseller', + 'products.taxExclusiveShort', + 'products.taxInclusiveShort', + 'serverApp.emailPlaceholder', + 'settings.account', + 'settings.googleDriveAccount', + 'settings.navGroupAccount', + 'settings.appQrAlt', + 'settings.backupKindAuto', + 'settings.backupSchemaVersion', + 'settings.billTemplateCompactName', + 'settings.browserWebusb', + 'settings.connectionUsb', + 'settings.connectionWebusb', + 'settings.paymentMethodUpi', + 'settings.defaultPrinterTipTitle', + 'settings.ipAddressPlaceholder', + 'settings.iranCalendarLocale', + 'settings.iranCurrencyDisplayRial', + 'settings.iranCurrencyDisplayToman', + 'settings.kds', + 'settings.languageEs', + 'settings.plan', + 'settings.portPlaceholder', + 'settings.posQrAlt', + 'settings.printerOffline', + 'settings.printerOnline', + 'settings.printers', + 'settings.privacy', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'settings.revflo', + 'settings.stationPrinter', + 'settings.status', + 'settings.tabOrderflow', + 'settings.tabPrinters', + 'settings.tabWhatsapp', + 'settings.test', + 'settings.unicode', + 'settings.updateStatusOffline', + 'settings.errorDetails', + 'settings.updates', + 'settings.whatsapp', + 'setup.demoLabel', + 'setup.ownerEmailPlaceholder', + 'setup.pinLabel', + 'setup.qsrLabel', + 'staff.roleManager', + 'staff.roleServer', + 'permissionMatrix.areas.menu', + 'support.platform', + 'support.restaurant', + 'tables.floorplanAuto', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'tax.entityAddon', + 'tax.entityProduct', + 'tax.type', + 'update.downloadingBadge', + 'whatsapp.inbox.title', + 'whatsapp.sent.colStatus', + 'whatsapp.tabs.inbox', ]); -function nlFallbackErrors(nlFlat: Record, enFlat: Record): string[] { +function nlFallbackErrors( + nlFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const nlVal = nlFlat[k]; @@ -1401,23 +1844,66 @@ function nlFallbackErrors(nlFlat: Record, enFlat: Record([ - 'auth.emailPlaceholder', 'common.appTitle', 'common.brandName', 'common.logoAlt', - 'dashboard.exportCsv', 'dashboard.exportXlsx', 'dashboard.ticketMethodCount', 'kds.emptyColumn', - 'nav.kds', 'nav.pos', 'nav.whatsapp', 'pos.addonPrice', 'pos.loadingEllipsis', 'pos.tagCount', - 'pos.taxLine', 'printTest.escpos', 'printTest.paperWidth58', 'printTest.paperWidth80', - 'print.hsn', 'print.zReport.paymentCount', 'products.addonSelectionRange', 'products.fieldSku', - 'products.skuLabel', 'serverApp.emailPlaceholder', 'settings.apiKeyInputPlaceholder', - 'settings.backupSchemaVersion', 'settings.connectionUsb', 'settings.connectionWebusb', - 'settings.instagramPlaceholder', 'settings.ipAddressPlaceholder', 'settings.kds', - 'settings.languageEn', 'settings.languageEs', 'settings.languagePt', 'settings.paperSize58', - 'settings.paperSize80', 'settings.paperWidth58', 'settings.paperWidth80', 'settings.paperWidth80Safe', - 'settings.paymentMethodUpi', 'settings.portPlaceholder', 'settings.registrationEmailPlaceholder', - 'settings.registrationLastError', 'settings.revflo', 'settings.tabOrderflow', 'settings.tabWhatsapp', - 'settings.whatsapp', 'setup.ownerEmailPlaceholder', 'setup.qsrLabel', 'tax.auditCreateOverride', - 'tax.auditUpdateOverride', 'update.downloadingBadge', 'whatsapp.connect.pairingPhonePlaceholder', + 'auth.emailPlaceholder', + 'common.appTitle', + 'common.brandName', + 'common.logoAlt', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'dashboard.exportXlsx', + 'dashboard.ticketMethodCount', + 'kds.emptyColumn', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.tagCount', + 'pos.taxLine', + 'printTest.escpos', + 'printTest.paperWidth58', + 'printTest.paperWidth80', + 'print.hsn', + 'print.zReport.paymentCount', + 'products.addonSelectionRange', + 'products.fieldSku', + 'products.skuLabel', + 'serverApp.emailPlaceholder', + 'settings.apiKeyInputPlaceholder', + 'settings.backupSchemaVersion', + 'settings.connectionUsb', + 'settings.connectionWebusb', + 'settings.instagramPlaceholder', + 'settings.ipAddressPlaceholder', + 'settings.kds', + 'settings.languageEn', + 'settings.languageEs', + 'settings.languagePt', + 'settings.paperSize58', + 'settings.paperSize80', + 'settings.paperWidth58', + 'settings.paperWidth80', + 'settings.paperWidth80Safe', + 'settings.paymentMethodUpi', + 'settings.portPlaceholder', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'settings.revflo', + 'settings.tabOrderflow', + 'settings.tabWhatsapp', + 'settings.whatsapp', + 'setup.ownerEmailPlaceholder', + 'setup.qsrLabel', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'update.downloadingBadge', + 'whatsapp.connect.pairingPhonePlaceholder', ]); -function hiFallbackErrors(hiFlat: Record, enFlat: Record): string[] { +function hiFallbackErrors( + hiFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const hiVal = hiFlat[k]; @@ -1433,16 +1919,38 @@ function hiFallbackErrors(hiFlat: Record, enFlat: Record([ - 'auth.emailPlaceholder', 'dashboard.exportCsv', 'nav.pos', 'pos.addonPrice', 'pos.loadingEllipsis', - 'printTest.escpos', 'print.hsn', 'print.zReport.paymentCount', 'products.saleUnitCl', - 'products.saleUnitFlOz', 'products.saleUnitG', 'products.saleUnitL', 'products.saleUnitOz', - 'serverApp.emailPlaceholder', 'settings.apiKeyInputPlaceholder', 'settings.ipAddressPlaceholder', - 'settings.languageEs', 'settings.portPlaceholder', 'settings.registrationEmailPlaceholder', - 'settings.registrationLastError', 'setup.ownerEmailPlaceholder', 'setup.qsrLabel', - 'tax.auditCreateOverride', 'tax.auditUpdateOverride', 'whatsapp.connect.pairingPhonePlaceholder', + 'auth.emailPlaceholder', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'nav.pos', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'printTest.escpos', + 'print.hsn', + 'print.zReport.paymentCount', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitL', + 'products.saleUnitOz', + 'serverApp.emailPlaceholder', + 'settings.apiKeyInputPlaceholder', + 'settings.ipAddressPlaceholder', + 'settings.languageEs', + 'settings.portPlaceholder', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'setup.ownerEmailPlaceholder', + 'setup.qsrLabel', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'whatsapp.connect.pairingPhonePlaceholder', ]); -function bnFallbackErrors(bnFlat: Record, enFlat: Record): string[] { +function bnFallbackErrors( + bnFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const bnVal = bnFlat[k]; @@ -1450,7 +1958,9 @@ function bnFallbackErrors(bnFlat: Record, enFlat: Record, enFlat: Record([ - 'auth.countryIndia', 'auth.email', 'auth.emailPlaceholder', 'common.appTitle', 'common.brandName', 'common.logoAlt', - 'customer.email', 'dashboard.exportCsv', 'dashboard.exportXlsx', 'dashboard.ticketMethodCount', - 'kds.viewKanban', 'nav.kds', 'nav.pos', 'nav.whatsapp', 'orders.online', - 'pos.addonPrice', 'pos.loadingEllipsis', 'pos.methodCash', 'pos.orderTypeOnline', 'pos.tagHalal', 'pos.tagVegan', 'pos.taxLine', - 'printTest.escpos', 'print.hsn', 'print.zReport.paymentCount', - 'products.colorIndigo', 'products.fieldSku', 'products.saleUnitCl', 'products.saleUnitFlOz', 'products.saleUnitG', - 'products.saleUnitKg', 'products.saleUnitL', 'products.saleUnitMl', 'products.saleUnitOz', 'products.skuLabel', 'products.tagVegan', - 'serverApp.emailPlaceholder', 'serverApp.title', 'settings.apiKeyInputPlaceholder', 'settings.connectionUsb', - 'settings.email', 'settings.instagramPlaceholder', 'settings.ipAddressPlaceholder', - 'settings.iranCalendarGregorian', 'settings.iranCalendarLocale', 'settings.iranCurrencyDisplayRial', 'settings.iranCurrencyDisplayToman', 'settings.kds', - 'settings.languageEs', 'settings.portPlaceholder', 'settings.registrationEmailPlaceholder', - 'settings.registrationLastError', 'settings.revflo', 'settings.serverApp', 'settings.tabOrderflow', 'settings.tabWhatsapp', - 'settings.unicode', 'settings.whatsapp', 'setup.demoLabel', 'setup.expressLabel', 'setup.finedineLabel', - 'setup.ownerEmailPlaceholder', 'setup.pinLabel', 'setup.qsrLabel', 'settings.paymentMethodUpi', - 'support.email', 'tax.auditCreateOverride', 'tax.auditUpdateOverride', 'update.downloadingBadge', + 'auth.countryIndia', + 'auth.email', + 'auth.emailPlaceholder', + 'common.appTitle', + 'common.brandName', + 'common.logoAlt', + 'customer.email', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'dashboard.exportXlsx', + 'dashboard.ticketMethodCount', + 'kds.viewKanban', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'orders.online', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.methodCash', + 'pos.orderTypeOnline', + 'pos.tagHalal', + 'pos.tagVegan', + 'pos.taxLine', + 'printTest.escpos', + 'print.hsn', + 'print.zReport.paymentCount', + 'products.colorIndigo', + 'products.fieldSku', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitKg', + 'products.saleUnitL', + 'products.saleUnitMl', + 'products.saleUnitOz', + 'products.skuLabel', + 'products.tagVegan', + 'serverApp.emailPlaceholder', + 'serverApp.title', + 'settings.apiKeyInputPlaceholder', + 'settings.connectionUsb', + 'settings.email', + 'settings.instagramPlaceholder', + 'settings.ipAddressPlaceholder', + 'settings.iranCalendarGregorian', + 'settings.iranCalendarLocale', + 'settings.iranCurrencyDisplayRial', + 'settings.iranCurrencyDisplayToman', + 'settings.kds', + 'settings.languageEs', + 'settings.portPlaceholder', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'settings.revflo', + 'settings.serverApp', + 'settings.tabOrderflow', + 'settings.tabWhatsapp', + 'settings.unicode', + 'settings.whatsapp', + 'setup.demoLabel', + 'setup.expressLabel', + 'setup.finedineLabel', + 'setup.ownerEmailPlaceholder', + 'setup.pinLabel', + 'setup.qsrLabel', + 'settings.paymentMethodUpi', + 'support.email', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'update.downloadingBadge', ]); -function sqFallbackErrors(sqFlat: Record, enFlat: Record): string[] { +function sqFallbackErrors( + sqFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const sqVal = sqFlat[k]; @@ -1483,7 +2051,9 @@ function sqFallbackErrors(sqFlat: Record, enFlat: Record([ 'common.logoAlt', 'customer.email', 'dashboard.exportCsv', + 'dashboard.exportPdf', 'dashboard.exportXlsx', 'dashboard.ticketMethodCount', 'kds.emptyColumn', @@ -1565,7 +2136,10 @@ const VI_INTENTIONAL_IDENTICAL = new Set([ 'whatsapp.connect.pairingPhonePlaceholder', ]); -function viFallbackErrors(viFlat: Record, enFlat: Record): string[] { +function viFallbackErrors( + viFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const viVal = viFlat[k]; @@ -1573,7 +2147,9 @@ function viFallbackErrors(viFlat: Record, enFlat: Record, enFlat: Record([ - 'auth.emailPlaceholder', 'common.appTitle', 'common.brandName', 'common.logoAlt', - 'dashboard.exportCsv', 'dashboard.exportXlsx', 'dashboard.ticketMethodCount', - 'kds.emptyColumn', 'nav.kds', 'nav.pos', 'nav.whatsapp', 'pos.addonPrice', - 'pos.loadingEllipsis', 'pos.tagCount', 'pos.taxLine', 'printTest.escpos', - 'print.hsn', 'print.zReport.paymentCount', 'products.addonSelectionRange', - 'products.fieldSku', 'products.saleUnitCl', 'products.saleUnitFlOz', 'products.saleUnitG', - 'products.saleUnitKg', 'products.saleUnitL', 'products.saleUnitLb', 'products.saleUnitMl', - 'products.saleUnitOz', 'products.skuLabel', 'serverApp.emailPlaceholder', - 'serverApp.title', 'settings.apiKeyInputPlaceholder', 'settings.connectionUsb', - 'settings.instagramPlaceholder', 'settings.ipAddressPlaceholder', 'settings.kds', - 'settings.paymentMethodUpi', 'settings.portPlaceholder', 'settings.registrationEmailPlaceholder', - 'settings.registrationLastError', 'settings.revflo', 'settings.serverApp', - 'settings.tabOrderflow', 'settings.tabWhatsapp', 'settings.unicode', 'settings.whatsapp', - 'setup.finedineLabel', 'setup.ownerEmailPlaceholder', 'setup.pinLabel', 'setup.qsrLabel', - 'tax.auditCreateOverride', 'tax.auditUpdateOverride', 'update.downloadingBadge', + 'auth.emailPlaceholder', + 'common.appTitle', + 'common.brandName', + 'common.logoAlt', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'dashboard.exportXlsx', + 'dashboard.ticketMethodCount', + 'kds.emptyColumn', + 'nav.kds', + 'nav.pos', + 'nav.whatsapp', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.tagCount', + 'pos.taxLine', + 'printTest.escpos', + 'print.hsn', + 'print.zReport.paymentCount', + 'products.addonSelectionRange', + 'products.fieldSku', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitKg', + 'products.saleUnitL', + 'products.saleUnitLb', + 'products.saleUnitMl', + 'products.saleUnitOz', + 'products.skuLabel', + 'serverApp.emailPlaceholder', + 'serverApp.title', + 'settings.apiKeyInputPlaceholder', + 'settings.connectionUsb', + 'settings.instagramPlaceholder', + 'settings.ipAddressPlaceholder', + 'settings.kds', + 'settings.paymentMethodUpi', + 'settings.portPlaceholder', + 'settings.registrationEmailPlaceholder', + 'settings.registrationLastError', + 'settings.revflo', + 'settings.serverApp', + 'settings.tabOrderflow', + 'settings.tabWhatsapp', + 'settings.unicode', + 'settings.whatsapp', + 'setup.finedineLabel', + 'setup.ownerEmailPlaceholder', + 'setup.pinLabel', + 'setup.qsrLabel', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'update.downloadingBadge', 'whatsapp.connect.pairingPhonePlaceholder', ]); -function thFallbackErrors(thFlat: Record, enFlat: Record): string[] { +function thFallbackErrors( + thFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const thVal = thFlat[k]; @@ -1623,23 +2241,55 @@ function thFallbackErrors(thFlat: Record, enFlat: Record([ - 'auth.emailPlaceholder', 'dashboard.exportCsv', 'dashboard.ticketMethodCount', - 'kds.emptyColumn', 'nav.kds', 'nav.pos', 'pos.addonPrice', 'pos.loadingEllipsis', - 'pos.tagCount', 'pos.taxLine', 'printTest.escpos', 'print.hsn', - 'print.zReport.paymentCount', 'products.addonSelectionRange', 'products.fieldSku', - 'products.saleUnitCl', 'products.saleUnitFlOz', 'products.saleUnitG', - 'products.saleUnitKg', 'products.saleUnitL', 'products.saleUnitLb', - 'products.saleUnitMl', 'products.saleUnitOz', 'products.skuLabel', - 'serverApp.emailPlaceholder', 'settings.apiKeyInputPlaceholder', 'settings.connectionUsb', - 'settings.ipAddressPlaceholder', 'settings.kds', 'settings.languageEs', - 'settings.paperWidth58', 'settings.paymentMethodUpi', 'settings.portPlaceholder', - 'settings.registrationLastError', 'settings.revflo', 'setup.finedineLabel', - 'setup.pinLabel', 'setup.qsrLabel', 'tax.auditCreateOverride', - 'tax.auditUpdateOverride', 'update.downloadingBadge', + 'auth.emailPlaceholder', + 'dashboard.exportCsv', + 'dashboard.exportPdf', + 'dashboard.ticketMethodCount', + 'kds.emptyColumn', + 'nav.kds', + 'nav.pos', + 'pos.addonPrice', + 'pos.loadingEllipsis', + 'pos.tagCount', + 'pos.taxLine', + 'printTest.escpos', + 'print.hsn', + 'print.zReport.paymentCount', + 'products.addonSelectionRange', + 'products.fieldSku', + 'products.saleUnitCl', + 'products.saleUnitFlOz', + 'products.saleUnitG', + 'products.saleUnitKg', + 'products.saleUnitL', + 'products.saleUnitLb', + 'products.saleUnitMl', + 'products.saleUnitOz', + 'products.skuLabel', + 'serverApp.emailPlaceholder', + 'settings.apiKeyInputPlaceholder', + 'settings.connectionUsb', + 'settings.ipAddressPlaceholder', + 'settings.kds', + 'settings.languageEs', + 'settings.paperWidth58', + 'settings.paymentMethodUpi', + 'settings.portPlaceholder', + 'settings.registrationLastError', + 'settings.revflo', + 'setup.finedineLabel', + 'setup.pinLabel', + 'setup.qsrLabel', + 'tax.auditCreateOverride', + 'tax.auditUpdateOverride', + 'update.downloadingBadge', 'whatsapp.connect.pairingPhonePlaceholder', ]); -function neFallbackErrors(neFlat: Record, enFlat: Record): string[] { +function neFallbackErrors( + neFlat: Record, + enFlat: Record, +): string[] { const errors: string[] = []; for (const k of Object.keys(enFlat)) { const neVal = neFlat[k]; @@ -1647,7 +2297,9 @@ function neFallbackErrors(neFlat: Record, enFlat: Record>): string[] { +function orderNumberPlaceholderErrors( + flatByLang: Record>, +): string[] { const errors: string[] = []; for (const [lang, messages] of Object.entries(flatByLang)) { for (const key of ORDER_NUMBER_PLACEHOLDER_KEYS) { @@ -1686,8 +2340,8 @@ function orderNumberPlaceholderErrors(flatByLang: Record { +function collectUnsafeDynamicKeys( + dir: string = FRONTEND_SRC, +): Array<{ file: string; line: number; code: string }> { const hits: Array<{ file: string; line: number; code: string }> = []; for (const file of walkTypeScriptFiles(dir)) { const lines = fs.readFileSync(file, 'utf8').split('\n'); @@ -1726,9 +2381,7 @@ function collectUnsafeDynamicKeys(dir: string = FRONTEND_SRC): Array<{ file: str /** Prevent new frontend consumers from depending on the bridge removed by #381. */ function legacyImportErrors(dir: string = FRONTEND_SRC): string[] { const errors: string[] = []; - const allowed = new Set([ - path.normalize(path.join(dir, 'lib/i18n.ts')), - ]); + const allowed = new Set([path.normalize(path.join(dir, 'lib/i18n.ts'))]); for (const file of walkTypeScriptFiles(dir)) { if (allowed.has(path.normalize(file))) continue; const source = fs.readFileSync(file, 'utf8'); @@ -1738,7 +2391,9 @@ function legacyImportErrors(dir: string = FRONTEND_SRC): string[] { if (/\bformatIcuPlural\b/.test(source)) { errors.push(`${path.relative(ROOT, file)} uses the legacy formatIcuPlural helper`); } - if (/import\s*\{[\s\S]*?\b(?:t|translate)\b[\s\S]*?\}\s*from\s*['"]@\/lib\/i18n['"]/.test(source)) { + if ( + /import\s*\{[\s\S]*?\b(?:t|translate)\b[\s\S]*?\}\s*from\s*['"]@\/lib\/i18n['"]/.test(source) + ) { errors.push(`${path.relative(ROOT, file)} imports the legacy t() bridge`); } } @@ -1753,8 +2408,10 @@ function messagesDtsErrors(): string[] { return errors; } const content = fs.readFileSync(MESSAGES_DTS, 'utf8'); - if (!content.includes("declare module 'use-intl'")) errors.push('messages.d.ts must declare module \'use-intl\''); - if (!content.includes('interface AppConfig')) errors.push('messages.d.ts must declare interface AppConfig'); + if (!content.includes("declare module 'use-intl'")) + errors.push("messages.d.ts must declare module 'use-intl'"); + if (!content.includes('interface AppConfig')) + errors.push('messages.d.ts must declare interface AppConfig'); if (!/Messages\s*:/.test(content)) errors.push('messages.d.ts AppConfig must type Messages'); if (!/Locale\s*:/.test(content)) errors.push('messages.d.ts AppConfig must type Locale'); return errors; @@ -1769,13 +2426,18 @@ async function run(): Promise { console.log(`Translation integrity: ${langs.join(' <-> ')}`); // 1. Registry ↔ file consistency. - const filesOnDisk = fs.readdirSync(I18N_DIR).filter((f) => f.endsWith('.json')).sort(); + const filesOnDisk = fs + .readdirSync(I18N_DIR) + .filter((f) => f.endsWith('.json')) + .sort(); const registryErrors = registryConsistencyErrors(LANGUAGES, filesOnDisk); if (registryErrors.length) { for (const e of registryErrors) console.error(` - ${e}`); assert(false, 'languages.ts registry is inconsistent with messages/ files'); } - console.log(` ✓ registry ↔ files consistent (${langs.length} languages, ${filesOnDisk.length} files)`); + console.log( + ` ✓ registry ↔ files consistent (${langs.length} languages, ${filesOnDisk.length} files)`, + ); const sets = new Map>(); const dups = new Map(); @@ -1838,7 +2500,9 @@ async function run(): Promise { for (const e of structuralErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'non-string or empty leaves detected'); } - console.log(' ✓ all leaves are non-empty strings (no empty objects/arrays/null/booleans/numbers)'); + console.log( + ' ✓ all leaves are non-empty strings (no empty objects/arrays/null/booleans/numbers)', + ); // 3b. Malformed string values (empty, JSON leftovers, unbalanced braces, // real newlines). @@ -1909,7 +2573,9 @@ async function run(): Promise { if (unsafe.length) { console.error(`\nUnsafe dynamic t() template-literal calls (${unsafe.length}):`); for (const u of unsafe) console.error(` - ${u.file}:${u.line} — ${u.code}`); - console.error(' Add an exhaustively typed cast: t(`prefix.${var}` as \'prefix.a\' | \'prefix.b\')'); + console.error( + " Add an exhaustively typed cast: t(`prefix.${var}` as 'prefix.a' | 'prefix.b')", + ); assert(false, 'unsafe dynamic translation keys found in the frontend'); } console.log(' ✓ no unsafe template-literal t() calls (all dynamic keys exhaustively cast)'); @@ -1936,22 +2602,30 @@ async function run(): Promise { if (!faMessages) throw new Error('languages registry must include the maintained fa locale'); const faErrors = faFallbackErrors(faMessages, loadedStrings.get('en')!); if (faErrors.length) { - console.error(`\nfa.json values identical to English (${faErrors.length}) — these render as English for Persian users:`); + console.error( + `\nfa.json values identical to English (${faErrors.length}) — these render as English for Persian users:`, + ); for (const e of faErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'fa.json contains untranslated (English-identical) values'); } - console.log(` ✓ no untranslated fa.json values (${FA_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated fa.json values (${FA_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 9. fr.json values must not silently fall back to the English value. const frMessages = loadedStrings.get('fr'); if (!frMessages) throw new Error('languages registry must include the maintained fr locale'); const frErrors = frFallbackErrors(frMessages, loadedStrings.get('en')!); if (frErrors.length) { - console.error(`\nfr.json values identical to English (${frErrors.length}) — these render as English for French users:`); + console.error( + `\nfr.json values identical to English (${frErrors.length}) — these render as English for French users:`, + ); for (const e of frErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'fr.json contains untranslated (English-identical) values'); } - console.log(` ✓ no untranslated fr.json values (${FR_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated fr.json values (${FR_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 10. tr.json values must not contain placeholders or fall back to English. const trMessages = loadedStrings.get('tr'); @@ -1962,7 +2636,9 @@ async function run(): Promise { for (const e of trErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'tr.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated tr.json values (${TR_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated tr.json values (${TR_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 11. fil.json values must not contain placeholders or fall back to English. const filMessages = loadedStrings.get('fil'); @@ -1973,7 +2649,9 @@ async function run(): Promise { for (const e of filErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'fil.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated fil.json values (${FIL_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated fil.json values (${FIL_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 12. de.json values must not contain placeholders or fall back to English. const deMessages = loadedStrings.get('de'); @@ -1984,7 +2662,9 @@ async function run(): Promise { for (const e of deErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'de.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated de.json values (${DE_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated de.json values (${DE_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 13. it.json values must not contain placeholders or fall back to English. const itMessages = loadedStrings.get('it'); @@ -1995,7 +2675,9 @@ async function run(): Promise { for (const e of itErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'it.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated it.json values (${IT_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated it.json values (${IT_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); const ruMessages = loadedStrings.get('ru'); if (!ruMessages) throw new Error('languages registry must include the maintained ru locale'); @@ -2003,21 +2685,67 @@ async function run(): Promise { if (ruErrors.length) { console.error(`\nru.json values with errors (${ruErrors.length}):`); for (const e of ruErrors.slice(0, 100)) console.error(` - ${e}`); - assert(false, 'ru.json contains untranslated, placeholder, malformed, or invisible Unicode values'); - } - assert(ruMessages['receipt.cashReceived'] === 'Получено наличными', 'Russian cash-received label must use the canonical receipt key'); - assert(ruMessages['auth.attemptsRemaining'] === 'До блокировки осталось попыток: {count}', 'Russian attempt counter must use count-safe label wording'); - assert(ruMessages['pos.tableSeats'] === 'Мест: {count}', 'Russian seat counter must use count-safe label wording'); - assert(ruMessages['settings.printColumnsShort'] === 'Столбцов: {cols}', 'Russian print-column counter must use count-safe label wording'); - assert(ruMessages['pos.tagVeg'] === 'Вегетарианское' && ruMessages['products.tagVeg'] === 'Вегетарианское', 'Russian vegetarian tags must use the reviewed term'); - assert(ruMessages['pos.tagNonVeg'] === 'Не вегетарианское' && ruMessages['products.tagNonVeg'] === 'Не вегетарианское', 'Russian non-vegetarian tags must use the reviewed term'); - assert(ruMessages['settings.discountModeFlat'] === 'Только фиксированная сумма', 'Russian flat discount mode must mean a fixed amount'); - assert(ruMessages['tables.markCleaning'] === 'Отметить как требующий уборки', 'Russian table-cleaning action must describe setting a cleaning status'); - assert(ruMessages['tax.fixed'] === 'Фиксированная', 'Russian fixed tax label must describe a fixed amount'); - assert(ruMessages['tax.actionRollback'] === 'Пакет откатан', 'Russian tax-pack rollback must describe an operator action'); - assert(ruMessages['products.fieldAddonGroups'] === 'Группы дополнений', 'Russian addon-group label must name the entity being configured'); - assert(ruMessages['products.taxBehaviorLabel'] === 'Способ начисления налога', 'Russian tax behavior label must describe the calculation method'); - assert(ruMessages['tax.behaviorExclusive'] === 'Налог сверх цены' && ruMessages['tax.behaviorInclusive'] === 'Налог в цене' && ruMessages['tax.behaviorExempt'] === 'Освобождено от налога', 'Russian tax behavior options must distinguish tax-exclusive, tax-inclusive, and exempt products'); + assert( + false, + 'ru.json contains untranslated, placeholder, malformed, or invisible Unicode values', + ); + } + assert( + ruMessages['receipt.cashReceived'] === 'Получено наличными', + 'Russian cash-received label must use the canonical receipt key', + ); + assert( + ruMessages['auth.attemptsRemaining'] === 'До блокировки осталось попыток: {count}', + 'Russian attempt counter must use count-safe label wording', + ); + assert( + ruMessages['pos.tableSeats'] === 'Мест: {count}', + 'Russian seat counter must use count-safe label wording', + ); + assert( + ruMessages['settings.printColumnsShort'] === 'Столбцов: {cols}', + 'Russian print-column counter must use count-safe label wording', + ); + assert( + ruMessages['pos.tagVeg'] === 'Вегетарианское' && + ruMessages['products.tagVeg'] === 'Вегетарианское', + 'Russian vegetarian tags must use the reviewed term', + ); + assert( + ruMessages['pos.tagNonVeg'] === 'Не вегетарианское' && + ruMessages['products.tagNonVeg'] === 'Не вегетарианское', + 'Russian non-vegetarian tags must use the reviewed term', + ); + assert( + ruMessages['settings.discountModeFlat'] === 'Только фиксированная сумма', + 'Russian flat discount mode must mean a fixed amount', + ); + assert( + ruMessages['tables.markCleaning'] === 'Отметить как требующий уборки', + 'Russian table-cleaning action must describe setting a cleaning status', + ); + assert( + ruMessages['tax.fixed'] === 'Фиксированная', + 'Russian fixed tax label must describe a fixed amount', + ); + assert( + ruMessages['tax.actionRollback'] === 'Пакет откатан', + 'Russian tax-pack rollback must describe an operator action', + ); + assert( + ruMessages['products.fieldAddonGroups'] === 'Группы дополнений', + 'Russian addon-group label must name the entity being configured', + ); + assert( + ruMessages['products.taxBehaviorLabel'] === 'Способ начисления налога', + 'Russian tax behavior label must describe the calculation method', + ); + assert( + ruMessages['tax.behaviorExclusive'] === 'Налог сверх цены' && + ruMessages['tax.behaviorInclusive'] === 'Налог в цене' && + ruMessages['tax.behaviorExempt'] === 'Освобождено от налога', + 'Russian tax behavior options must distinguish tax-exclusive, tax-inclusive, and exempt products', + ); for (const [key, technicalLiteral] of [ ['products.csvAddonsHelp', 'group_name'], ['products.csvAddonsHelp', 'addon_name'], @@ -2045,20 +2773,29 @@ async function run(): Promise { ['products.csvProductsHelp', 'is_active'], ] as const) { const machineFields = ruMessages[key]?.match(/[a-z][a-z0-9_]*/g) ?? []; - assert(machineFields.includes(technicalLiteral), `ru.json ${key} must preserve the machine-readable CSV field ${technicalLiteral}`); + assert( + machineFields.includes(technicalLiteral), + `ru.json ${key} must preserve the machine-readable CSV field ${technicalLiteral}`, + ); } console.log(' ✓ Russian CSV guidance preserves machine-readable field names'); - console.log(` ✓ no untranslated ru.json values (${RU_INTENTIONAL_IDENTICAL.size} intentional shared values; NFC verified)`); + console.log( + ` ✓ no untranslated ru.json values (${RU_INTENTIONAL_IDENTICAL.size} intentional shared values; NFC verified)`, + ); const urMessages = loadedStrings.get('ur'); if (!urMessages) throw new Error('languages registry must include the maintained ur locale'); const urErrors = urFallbackErrors(urMessages, loadedStrings.get('en')!); if (urErrors.length) { - console.error(`\nur.json values with errors (${urErrors.length}) — these render as English for Urdu users:`); + console.error( + `\nur.json values with errors (${urErrors.length}) — these render as English for Urdu users:`, + ); for (const e of urErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'ur.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated ur.json values (${UR_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated ur.json values (${UR_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 14. Japanese and Chinese values must not contain placeholders or fall back to English. const jaMessages = loadedStrings.get('ja'); @@ -2069,7 +2806,9 @@ async function run(): Promise { for (const e of jaErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'ja.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated ja.json values (${JA_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated ja.json values (${JA_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); const zhMessages = loadedStrings.get('zh'); if (!zhMessages) throw new Error('languages registry must include the maintained zh locale'); @@ -2079,7 +2818,9 @@ async function run(): Promise { for (const e of zhErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'zh.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated zh.json values (${ZH_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated zh.json values (${ZH_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); const zhTwMessages = loadedStrings.get('zh-tw'); if (!zhTwMessages) throw new Error('languages registry must include the maintained zh-tw locale'); @@ -2089,7 +2830,9 @@ async function run(): Promise { for (const e of zhTwErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'zh-tw.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated zh-tw.json values (${ZH_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated zh-tw.json values (${ZH_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 15. Korean values must not contain placeholders or fall back to English. const koMessages = loadedStrings.get('ko'); @@ -2100,7 +2843,9 @@ async function run(): Promise { for (const e of koErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'ko.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated ko.json values (${KO_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated ko.json values (${KO_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 16. id.json values must not contain placeholders or fall back to English. const idMessages = loadedStrings.get('id'); @@ -2111,7 +2856,9 @@ async function run(): Promise { for (const e of idErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'id.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated id.json values (${ID_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated id.json values (${ID_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 17. nl.json values must not contain placeholders or fall back to English. const nlMessages = loadedStrings.get('nl'); @@ -2122,7 +2869,9 @@ async function run(): Promise { for (const e of nlErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'nl.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated nl.json values (${NL_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated nl.json values (${NL_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 18. hi.json values must not contain placeholders or fall back to English. const hiMessages = loadedStrings.get('hi'); @@ -2133,7 +2882,9 @@ async function run(): Promise { for (const e of hiErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'hi.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated hi.json values (${HI_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated hi.json values (${HI_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 19. bn.json values must not contain placeholders or fall back to English. const bnMessages = loadedStrings.get('bn'); @@ -2144,7 +2895,9 @@ async function run(): Promise { for (const e of bnErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'bn.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated bn.json values (${BN_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated bn.json values (${BN_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); // 20. sq.json values must not contain placeholders or fall back to English. const sqMessages = loadedStrings.get('sq'); @@ -2155,7 +2908,9 @@ async function run(): Promise { for (const e of sqErrors.slice(0, 100)) console.error(` - ${e}`); assert(false, 'sq.json contains untranslated (English-identical) or placeholder values'); } - console.log(` ✓ no untranslated sq.json values (${SQ_INTENTIONAL_IDENTICAL.size} intentional shared values)`); + console.log( + ` ✓ no untranslated sq.json values (${SQ_INTENTIONAL_IDENTICAL.size} intentional shared values)`, + ); for (const [key, technicalLiteral] of [ ['products.csvCategoriesHelp', 'sort_order'], ['products.csvProductsHelp', 'tax_category'], @@ -2163,15 +2918,36 @@ async function run(): Promise { ['products.csvProductsHelp', 'cashback_percent'], ['products.csvProductsHelp', 'is_active'], ] as const) { - assert(sqMessages[key]?.includes(technicalLiteral), `sq.json ${key} must preserve the CSV field ${technicalLiteral}`); + assert( + sqMessages[key]?.includes(technicalLiteral), + `sq.json ${key} must preserve the CSV field ${technicalLiteral}`, + ); } console.log(' ✓ Albanian CSV guidance preserves machine-readable field names'); - assert(sqMessages['dashboard.payIn'] === 'Depozitë', 'Albanian cash pay-in label must match the Z-report terminology'); - assert(sqMessages['dashboard.payOut'] === 'Tërheqje', 'Albanian cash pay-out label must match the Z-report terminology'); - assert(sqMessages['orders.takeaway'] === 'Me vete', 'Albanian takeaway label must use the approved pickup term'); - assert(sqMessages['pos.orderTypeTakeaway'] === 'Me vete', 'Albanian order-type takeaway label must use the approved pickup term'); - assert(sqMessages['orders.convertToTakeaway'] === 'Konverto në porosi me vete', 'Albanian takeaway conversion action must use pickup wording'); - assert(sqMessages['orders.orderConvertedTakeaway'] === 'Porosia u konvertua në porosi me vete', 'Albanian takeaway conversion result must use pickup wording'); + assert( + sqMessages['dashboard.payIn'] === 'Depozitë', + 'Albanian cash pay-in label must match the Z-report terminology', + ); + assert( + sqMessages['dashboard.payOut'] === 'Tërheqje', + 'Albanian cash pay-out label must match the Z-report terminology', + ); + assert( + sqMessages['orders.takeaway'] === 'Me vete', + 'Albanian takeaway label must use the approved pickup term', + ); + assert( + sqMessages['pos.orderTypeTakeaway'] === 'Me vete', + 'Albanian order-type takeaway label must use the approved pickup term', + ); + assert( + sqMessages['orders.convertToTakeaway'] === 'Konverto në porosi me vete', + 'Albanian takeaway conversion action must use pickup wording', + ); + assert( + sqMessages['orders.orderConvertedTakeaway'] === 'Porosia u konvertua në porosi me vete', + 'Albanian takeaway conversion result must use pickup wording', + ); console.log(' ✓ Albanian cash-movement and takeaway terminology is consistent'); // 21. vi.json values must be complete, NFC text without malformed characters. @@ -2181,12 +2957,26 @@ async function run(): Promise { if (viErrors.length) { console.error(`\nvi.json values with errors (${viErrors.length}):`); for (const e of viErrors.slice(0, 100)) console.error(` - ${e}`); - assert(false, 'vi.json contains untranslated, placeholder, non-NFC, or replacement-character values'); + assert( + false, + 'vi.json contains untranslated, placeholder, non-NFC, or replacement-character values', + ); } - assert(viMessages['receipt.cashReceived'] === 'Tiền mặt đã nhận', 'vi.json receipt.cashReceived must preserve the canonical cash-received label'); - assert(viMessages['pos.tagVeg'] === 'Ăn chay' && viMessages['products.tagVeg'] === 'Ăn chay', 'vi.json vegetarian product tags must use the reviewed Vietnamese term'); - assert(!viMessages['orders.voidItemConfirm'].includes('đã đang'), 'vi.json void confirmation must not contain duplicated progressive grammar'); - console.log(` ✓ no untranslated vi.json values (${VI_INTENTIONAL_IDENTICAL.size} intentional shared values; NFC verified)`); + assert( + viMessages['receipt.cashReceived'] === 'Tiền mặt đã nhận', + 'vi.json receipt.cashReceived must preserve the canonical cash-received label', + ); + assert( + viMessages['pos.tagVeg'] === 'Ăn chay' && viMessages['products.tagVeg'] === 'Ăn chay', + 'vi.json vegetarian product tags must use the reviewed Vietnamese term', + ); + assert( + !viMessages['orders.voidItemConfirm'].includes('đã đang'), + 'vi.json void confirmation must not contain duplicated progressive grammar', + ); + console.log( + ` ✓ no untranslated vi.json values (${VI_INTENTIONAL_IDENTICAL.size} intentional shared values; NFC verified)`, + ); // 22. th.json values must be complete, NFC text without malformed characters. const thMessages = loadedStrings.get('th'); @@ -2195,18 +2985,44 @@ async function run(): Promise { if (thErrors.length) { console.error(`\nth.json values with errors (${thErrors.length}):`); for (const e of thErrors.slice(0, 100)) console.error(` - ${e}`); - assert(false, 'th.json contains untranslated, placeholder, non-NFC, or replacement-character values'); + assert( + false, + 'th.json contains untranslated, placeholder, non-NFC, or replacement-character values', + ); } - assert(thMessages['receipt.cashReceived'] === 'เงินสดที่ได้รับ', 'th.json receipt.cashReceived must preserve the canonical cash-received label'); - assert(thMessages['pos.tagVeg'] === 'มังสวิรัติ' && thMessages['products.tagVeg'] === 'มังสวิรัติ', 'th.json vegetarian product tags must use the vegetarian Thai term, not an unrelated homograph'); - assert(thMessages['pos.tagNonVeg'] === 'ไม่มังสวิรัติ' && thMessages['products.tagNonVeg'] === 'ไม่มังสวิรัติ', 'th.json non-vegetarian product tags must negate the vegetarian Thai term'); - assert(thMessages['orders.takeaway'] === thMessages['pos.orderTypeTakeaway'], 'th.json takeaway labels must match the reviewed Thai pickup term'); - assert(thMessages['dashboard.payIn'] === 'เติมเงิน' && thMessages['dashboard.payOut'] === 'จ่ายออก', 'th.json cash-movement labels must match the Z-report terminology'); + assert( + thMessages['receipt.cashReceived'] === 'เงินสดที่ได้รับ', + 'th.json receipt.cashReceived must preserve the canonical cash-received label', + ); + assert( + thMessages['pos.tagVeg'] === 'มังสวิรัติ' && thMessages['products.tagVeg'] === 'มังสวิรัติ', + 'th.json vegetarian product tags must use the vegetarian Thai term, not an unrelated homograph', + ); + assert( + thMessages['pos.tagNonVeg'] === 'ไม่มังสวิรัติ' && thMessages['products.tagNonVeg'] === 'ไม่มังสวิรัติ', + 'th.json non-vegetarian product tags must negate the vegetarian Thai term', + ); + assert( + thMessages['orders.takeaway'] === thMessages['pos.orderTypeTakeaway'], + 'th.json takeaway labels must match the reviewed Thai pickup term', + ); + assert( + thMessages['dashboard.payIn'] === 'เติมเงิน' && thMessages['dashboard.payOut'] === 'จ่ายออก', + 'th.json cash-movement labels must match the Z-report terminology', + ); // The setup card only offers optional product-update and marketing opt-ins, so the // mandatory-notice clause must read "cannot be disabled" (ปิดไม่ได้), matching en.json. - assert(thMessages['setup.emailCommunicationDescription'].includes('ปิดไม่ได้ในขั้นตอนนี้'), 'th.json must tell Thai users that essential notices cannot be disabled at setup'); - assert(!thMessages['setup.emailCommunicationDescription'].includes('ปิดได้ในขั้นตอนนี้'), 'th.json must not tell Thai users that essential notices can be disabled at setup'); - console.log(` ✓ no untranslated th.json values (${TH_INTENTIONAL_IDENTICAL.size} intentional shared values; NFC verified)`); + assert( + thMessages['setup.emailCommunicationDescription'].includes('ปิดไม่ได้ในขั้นตอนนี้'), + 'th.json must tell Thai users that essential notices cannot be disabled at setup', + ); + assert( + !thMessages['setup.emailCommunicationDescription'].includes('ปิดได้ในขั้นตอนนี้'), + 'th.json must not tell Thai users that essential notices can be disabled at setup', + ); + console.log( + ` ✓ no untranslated th.json values (${TH_INTENTIONAL_IDENTICAL.size} intentional shared values; NFC verified)`, + ); // 23. ne.json values must be complete, NFC text without malformed characters. const neMessages = loadedStrings.get('ne'); @@ -2215,19 +3031,49 @@ async function run(): Promise { if (neErrors.length) { console.error(`\nne.json values with errors (${neErrors.length}):`); for (const e of neErrors.slice(0, 100)) console.error(` - ${e}`); - assert(false, 'ne.json contains untranslated, placeholder, non-NFC, or replacement-character values'); - } - assert(neMessages['receipt.cashReceived'] === 'नगद प्राप्त भयो', 'ne.json receipt.cashReceived must preserve the canonical cash-received label'); - assert(neMessages['pos.tagVeg'] === 'शाकाहारी' && neMessages['products.tagVeg'] === 'शाकाहारी', 'ne.json vegetarian product tags must use the vegetarian Nepali term'); - assert(neMessages['pos.tagNonVeg'] === 'मासाहारी' && neMessages['products.tagNonVeg'] === 'मासाहारी', 'ne.json non-vegetarian product tags must use the Nepali non-vegetarian term, not an unrelated homograph'); - assert(neMessages['orders.takeaway'] === neMessages['pos.orderTypeTakeaway'], 'ne.json takeaway labels must match the reviewed Nepali pickup term'); - assert(neMessages['dashboard.payIn'] === 'नगद जम्मा' && neMessages['dashboard.payOut'] === 'नगद निकासी', 'ne.json cash-movement labels must match the Z-report terminology'); - assert(neMessages['print.zReport.payIn'] === neMessages['dashboard.payIn'] && neMessages['print.zReport.payOut'] === neMessages['dashboard.payOut'], 'ne.json cash-movement labels must be identical on the dashboard and the Z-report'); + assert( + false, + 'ne.json contains untranslated, placeholder, non-NFC, or replacement-character values', + ); + } + assert( + neMessages['receipt.cashReceived'] === 'नगद प्राप्त भयो', + 'ne.json receipt.cashReceived must preserve the canonical cash-received label', + ); + assert( + neMessages['pos.tagVeg'] === 'शाकाहारी' && neMessages['products.tagVeg'] === 'शाकाहारी', + 'ne.json vegetarian product tags must use the vegetarian Nepali term', + ); + assert( + neMessages['pos.tagNonVeg'] === 'मासाहारी' && neMessages['products.tagNonVeg'] === 'मासाहारी', + 'ne.json non-vegetarian product tags must use the Nepali non-vegetarian term, not an unrelated homograph', + ); + assert( + neMessages['orders.takeaway'] === neMessages['pos.orderTypeTakeaway'], + 'ne.json takeaway labels must match the reviewed Nepali pickup term', + ); + assert( + neMessages['dashboard.payIn'] === 'नगद जम्मा' && neMessages['dashboard.payOut'] === 'नगद निकासी', + 'ne.json cash-movement labels must match the Z-report terminology', + ); + assert( + neMessages['print.zReport.payIn'] === neMessages['dashboard.payIn'] && + neMessages['print.zReport.payOut'] === neMessages['dashboard.payOut'], + 'ne.json cash-movement labels must be identical on the dashboard and the Z-report', + ); // Nepali spells "again" with the Devanagari visarga; a plain ASCII colon there // would render as a visibly wrong glyph on a thermal receipt. - assert(!Object.entries(neMessages).some(([, v]) => v.includes('पुन:')), 'ne.json must use the visarga in पुनः rather than a plain colon'); - assert(Object.values(neMessages).some((v) => v.includes('पुनः')), 'ne.json must contain the visarga spelling of पुनः'); - console.log(` ✓ no untranslated ne.json values (${NE_INTENTIONAL_IDENTICAL.size} intentional shared values; NFC verified)`); + assert( + !Object.entries(neMessages).some(([, v]) => v.includes('पुन:')), + 'ne.json must use the visarga in पुनः rather than a plain colon', + ); + assert( + Object.values(neMessages).some((v) => v.includes('पुनः')), + 'ne.json must contain the visarga spelling of पुनः', + ); + console.log( + ` ✓ no untranslated ne.json values (${NE_INTENTIONAL_IDENTICAL.size} intentional shared values; NFC verified)`, + ); // 25. No locale may weld an order number onto a word. Devanagari, Arabic, and // Cyrillic are all verb-final, so a trailing "{number}" reads as part of the @@ -2246,7 +3092,9 @@ async function run(): Promise { neMessages['pos.itemsAddedToOrder'] === 'अर्डर #{number} मा वस्तुहरू थपियो', 'ne.json itemsAddedToOrder must place the order number directly after the # marker', ); - console.log(` ✓ no locale welds an order number onto a word (${Object.keys(Object.fromEntries(loadedStrings)).length} locales checked)`); + console.log( + ` ✓ no locale welds an order number onto a word (${Object.keys(Object.fromEntries(loadedStrings)).length} locales checked)`, + ); console.log('\n✅ All translation integrity checks passed.'); } @@ -2267,23 +3115,34 @@ function runNegativeTests(): void { // 1. Registry consistency. expectDetected( 'registry: missing file for language', - registryConsistencyErrors({ xx: { locale: 'xx', direction: 'ltr', selectable: true } }, ['en.json']), + registryConsistencyErrors({ xx: { locale: 'xx', direction: 'ltr', selectable: true } }, [ + 'en.json', + ]), ); expectDetected( 'registry: orphan file without registry entry', - registryConsistencyErrors({ en: { locale: 'en', direction: 'ltr', selectable: true } }, ['en.json', 'zz.json']), + registryConsistencyErrors({ en: { locale: 'en', direction: 'ltr', selectable: true } }, [ + 'en.json', + 'zz.json', + ]), ); expectDetected( 'registry: invalid BCP-47 locale', - registryConsistencyErrors({ en: { locale: 'not a tag', direction: 'ltr', selectable: true } }, ['en.json']), + registryConsistencyErrors({ en: { locale: 'not a tag', direction: 'ltr', selectable: true } }, [ + 'en.json', + ]), ); expectDetected( 'registry: non-canonical BCP-47 locale', - registryConsistencyErrors({ en: { locale: 'EN_us', direction: 'ltr', selectable: true } }, ['en.json']), + registryConsistencyErrors({ en: { locale: 'EN_us', direction: 'ltr', selectable: true } }, [ + 'en.json', + ]), ); expectDetected( 'registry: invalid direction', - registryConsistencyErrors({ en: { locale: 'en', direction: 'sideways', selectable: true } }, ['en.json']), + registryConsistencyErrors({ en: { locale: 'en', direction: 'sideways', selectable: true } }, [ + 'en.json', + ]), ); // 2. Key parity: missing and orphan keys. @@ -2302,10 +3161,7 @@ function runNegativeTests(): void { expectDetected('leaf: empty object namespace', findStructuralErrors({ a: {} }, 'es')); expectDetected('leaf: array value', findStructuralErrors({ a: ['x'] }, 'es')); expectDetected('leaf: empty string', findStructuralErrors({ a: ' ' }, 'es')); - expectDetected( - 'leaf: trailing JSON artifact', - leafStringErrors({ 'a.b': 'value",' }, 'es'), - ); + expectDetected('leaf: trailing JSON artifact', leafStringErrors({ 'a.b': 'value",' }, 'es')); expectDetected('leaf: real newline', leafStringErrors({ 'a.b': 'line1\nline2' }, 'es')); expectDetected('leaf: unbalanced braces', leafStringErrors({ 'a.b': 'one { two' }, 'es')); expectDetected('leaf: duplicate keys', findDuplicateKeys('{"a": {"b": 1, "b": 2}}')); @@ -2346,7 +3202,11 @@ function runNegativeTests(): void { // 6. Rich-text tag parity. expectDetected( 'tag parity: renamed tag', - tagParityErrors({ 'a.b': 'Click here' }, { 'a.b': 'Click aquí' }, 'es'), + tagParityErrors( + { 'a.b': 'Click here' }, + { 'a.b': 'Click aquí' }, + 'es', + ), ); expectDetected( 'tag parity: dropped tag', @@ -2496,7 +3356,10 @@ function runNegativeTests(): void { ); expectDetected( 'vi: non-NFC value', - viFallbackErrors({ 'a.b': 'Tiếng Việt'.normalize('NFD') }, { 'a.b': 'Tiếng Việt'.normalize('NFC') }), + viFallbackErrors( + { 'a.b': 'Tiếng Việt'.normalize('NFD') }, + { 'a.b': 'Tiếng Việt'.normalize('NFC') }, + ), ); expectDetected( 'vi: Unicode replacement character', @@ -2567,11 +3430,15 @@ function runNegativeTests(): void { // and asserting it would contradict the validator's actual contract. expectDetected( 'order number: key missing from a locale', - orderNumberPlaceholderErrors({ en: { 'pos.addingItemsToOrder': 'Adding items to order #{number}' } }), + orderNumberPlaceholderErrors({ + en: { 'pos.addingItemsToOrder': 'Adding items to order #{number}' }, + }), ); expectDetected( 'order number: placeholder entirely absent', - orderNumberPlaceholderErrors({ en: { 'pos.addingItemsToOrder': 'Adding items to order', 'pos.itemsAddedToOrder': 'Added' } }), + orderNumberPlaceholderErrors({ + en: { 'pos.addingItemsToOrder': 'Adding items to order', 'pos.itemsAddedToOrder': 'Added' }, + }), ); assert( orderNumberPlaceholderErrors({ @@ -2604,7 +3471,7 @@ function runNegativeTests(): void { "const bad = t('does.not.exist');", "const tPos = useTranslations('pos');", "const badScoped = tPos('doesNotExistScoped');", - "const unsafe = t(`prefix.${value}`);", + 'const unsafe = t(`prefix.${value}`);', "const safe = t(`prefix.${value}` as 'prefix.a' | 'prefix.b');", "import { useI18n } from '@/hooks/useI18n';", "const legacyPlural = formatIcuPlural('items', 5);", @@ -2618,11 +3485,11 @@ function runNegativeTests(): void { ); const unsafe = collectUnsafeDynamicKeys(tmp); - expectDetected('ts: unsafe template-literal t() call', unsafe.map((u) => u.code)); expectDetected( - 'ts: legacy i18n bridge import', - legacyImportErrors(tmp), + 'ts: unsafe template-literal t() call', + unsafe.map((u) => u.code), ); + expectDetected('ts: legacy i18n bridge import', legacyImportErrors(tmp)); const flaggedLines = unsafe.map((u) => u.line); assert( flaggedLines.length === 1 && flaggedLines[0] === 4, From 37762b876ec11de5b7e9d25f53f9a5e54cdb5aaf Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 03:02:51 -0400 Subject: [PATCH 02/10] no-mistakes(document): Update report HTML export docs and fix lint warning --- docs/architecture/regional-settings.md | 11 ++++++++--- docs/reference/daily-sales-export.md | 5 +++-- main/routes/reports.ts | 12 +++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/architecture/regional-settings.md b/docs/architecture/regional-settings.md index 54cee1aad..6cbfb226a 100644 --- a/docs/architecture/regional-settings.md +++ b/docs/architecture/regional-settings.md @@ -91,17 +91,22 @@ Node-only dependencies. ## Consumers -`resolveRegionalSnapshot()` has 11 call sites in the backend, across 9 files: +Backend consumers of `resolveRegionalSnapshot()`: | File | Use | | --- | --- | | `main/routes/auth.ts` | The local-tenant snapshot at login. | | `main/server-app.ts` | The Server App tenant snapshot at `/api/server-app/info`, mapping the resolver's error to a 409 `regional_not_configured` response. | -| `main/routes/reports.ts` | Currency minor-unit factor for every monetary report. | +| `main/routes/reports.ts` | Currency minor-unit factor for every monetary report, and locale/currency formatting options for X and Z report HTML export. | | `main/routes/cash-closures.ts` | Currency minor-unit factor for cash-closure arithmetic. | +| `main/services/cash-day-close-guards.ts` | Business timezone for cash-session business-date resolution. | +| `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. | +| `main/services/daily-sales-export.ts` | Currency minor-unit factor for the accounting export, and regional locale and formatting preferences for `serializeDailySalesExportHtml`. | +| `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. | +| `main/routes/printers.ts` | Regional snapshot for menu and delivery-slip print assembly. | | `main/db.ts` | `requireTenantTimezone()` for order and bill numbering, and the target-currency snapshot taken inside the destructive currency reset. | | `main/printers/thermal.ts` | Receipt currency symbol, fraction digits, and business timezone. | diff --git a/docs/reference/daily-sales-export.md b/docs/reference/daily-sales-export.md index 55a62e0fd..ff9fcfe4a 100644 --- a/docs/reference/daily-sales-export.md +++ b/docs/reference/daily-sales-export.md @@ -54,7 +54,7 @@ Backend owns all logic; the frontend never computes totals. - **XLSX:** sheets `Summary`, `Daily`, `Taxes`, `Items`, then `Payments`. Money cells are numeric with `moneyNumFmt(fractionDigits)`, percentage cells (`*_percent`) use `'0.00'`, and `tax_rate` cells use `'0.##'`; `null` rate, base, gross, cost, profit, and margin values are empty string `""`; no formulas. Frozen header row. - **CSV:** stable English snake_case headers; ASCII `.` decimals; formula-neutralized via shared `toCsvRow`. -- **HTML (PDF / Print):** self-contained A4 portrait HTML5 document (`serializeDailySalesExportHtml`) containing the store header, Summary metrics, Payment Methods summary, Daily Breakdown, Tax Summary, and Item Sales tables from the same dataset. +- **HTML (PDF / Print):** self-contained A4 portrait HTML5 document (`serializeDailySalesExportHtml`) containing the store header, optional `tax_detail_warning` alert banner, Summary metrics, Payment Methods summary, Daily Breakdown, Tax Summary, and Item Sales tables from the same dataset (`Tax Summary` and `Item Sales` render up to `maxTableRows`, default 2,000 rows, with a truncation note when exceeded). - **Filenames:** `daily-sales-YYYY-MM-DD.xlsx` / `.html` (single day) or `daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.xlsx` / `.html` (multi-day range), and `-summary.csv`, `-daily.csv`, `-taxes.csv`, `-items.csv`, `-payments.csv` (from `dailySalesExportFilename`). ### Summary metric field order @@ -94,6 +94,7 @@ See [API.md § GET `/api/reports/daily-sales/export`](api.md#get-apireportsdaily ## Tests -- `tests/daily-sales-export.test.ts` (`npm run test:daily-sales-export`) - role gating, single-day and date-range validation (93-day cap, reversed/mixed/incomplete/invalid dates), range equivalence (`N`-day range equals sum of `N` single-day exports), business-day cutoff across multi-day ranges, XLSX sheet/metric assertions (`Summary`, `Daily`, `Taxes`, `Items`, `Payments`), per-tax-rate summary reconciliation, shared-base multi-component taxes, historical snapshot immunity to product tax changes, incomplete historical detail warnings, Payments rows (store-local time, verbatim transaction id), CSV headers and filenames, formula neutralization, and empty-day header-only sheets. +- `tests/daily-sales-export.test.ts` (`npm run test:daily-sales-export`) - role gating, single-day and date-range validation (93-day cap, reversed/mixed/incomplete/invalid dates), range equivalence (`N`-day range equals sum of `N` single-day exports), business-day cutoff across multi-day ranges, XLSX sheet/metric assertions (`Summary`, `Daily`, `Taxes`, `Items`, `Payments`), per-tax-rate summary reconciliation, shared-base multi-component taxes, historical snapshot immunity to product tax changes, incomplete historical detail warnings, Payments rows (store-local time, verbatim transaction id), CSV headers and filenames, `format=html` parity with XLSX (single-day and range, non-Latin store names, and HTML escaping), formula neutralization, and empty-day header-only sheets. +- `tests/reports-export.test.ts` (`npm run test:reports-export`) - X and Z report `csv`, `xlsx`, and `html`/PDF export parity, locale currency formatting, non-Latin store names, HTML escaping, and Electron `printToPDF` staging cleanup. - Script coverage gate: `test:daily-sales-export` is chained from `npm test`; `npm run test:script-coverage` fails if it is removed from the chain without adding it to `TEST_EXCLUSIONS`. - `tests/customer-sales-export.test.ts` (`npm run test:customer-sales-export`) - customer sales export: role gating, range validation, repeated customer columns, first-row `bill_total`, split bills, CSV neutralization, XLSX. diff --git a/main/routes/reports.ts b/main/routes/reports.ts index a2502d24f..0564396ed 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -1549,11 +1549,13 @@ router.get( orderCount: staff.orderCount, revenue: staff.revenue_cents / minorFactor, })), - taxComponents: JSON.parse(row.tax_components_json || '[]').map((comp: any) => ({ - title: String(comp.title ?? 'Tax'), - rate: typeof comp.rate === 'number' ? comp.rate : null, - amount: Number(comp.amount ?? 0), - })), + taxComponents: JSON.parse(row.tax_components_json || '[]').map( + (comp: { title?: unknown; rate?: unknown; amount?: unknown }) => ({ + title: String(comp.title ?? 'Tax'), + rate: typeof comp.rate === 'number' ? comp.rate : null, + amount: Number(comp.amount ?? 0), + }), + ), zNumber: row.z_number, closedAt: row.created_at, closedBy: userRow?.name ?? row.closed_by, From 47f2b724c1dc29225ea3065c9f2d8c14606540f2 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 03:16:46 -0400 Subject: [PATCH 03/10] no-mistakes(ci): Fixed the 3 CodeQL `js/double-escaping` alerts in `tests/daily-sales-export.test.ts` and `tests/reports-export.test.ts` by unescaping `&` last (after `<`, `>`, `"`, and `'`) in `extractHtmlSummaryMap` and `parseHtmlSummaryMetrics`. Verified with `npx tsx tests/reports-export.test.ts`, `npx tsx tests/daily-sales-export.test.ts`, `npm run lint`, `npm run build`, and `git diff --check` --- tests/daily-sales-export.test.ts | 4 ++-- tests/reports-export.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/daily-sales-export.test.ts b/tests/daily-sales-export.test.ts index 797fd36ca..f9557c760 100644 --- a/tests/daily-sales-export.test.ts +++ b/tests/daily-sales-export.test.ts @@ -1547,11 +1547,11 @@ async function main() { map.set( m[1], m[2] - .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') - .replace(/'/g, "'"), + .replace(/'/g, "'") + .replace(/&/g, '&'), ); } return map; diff --git a/tests/reports-export.test.ts b/tests/reports-export.test.ts index c1f7b724f..076d42eac 100644 --- a/tests/reports-export.test.ts +++ b/tests/reports-export.test.ts @@ -79,17 +79,17 @@ function parseHtmlSummaryMetrics(html: string): Map { let match: RegExpExecArray | null; while ((match = re.exec(html)) !== null) { const key = match[1] - .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') - .replace(/'/g, "'"); + .replace(/'/g, "'") + .replace(/&/g, '&'); const val = match[2] - .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') - .replace(/'/g, "'"); + .replace(/'/g, "'") + .replace(/&/g, '&'); map.set(key, val); } return map; From 254b5ef264d76a3b651e5fadbcfee717364a0e99 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 10:16:15 -0400 Subject: [PATCH 04/10] fix(reports): address PR #1393 review feedback --- docs/reference/daily-sales-export.md | 14 +-- frontend/e2e/cash-close-phase1-ui.spec.ts | 45 ++++++++- .../src/app/(dashboard)/dashboard/page.tsx | 11 ++- frontend/src/hooks/useCashClose.ts | 11 ++- frontend/src/lib/export-pdf.ts | 8 +- main/services/daily-sales-export.ts | 34 +++---- main/services/html-to-pdf.ts | 6 +- tests/daily-sales-export.test.ts | 98 +++++++++++++++++-- tests/reports-export.test.ts | 74 +++++++++++--- 9 files changed, 245 insertions(+), 56 deletions(-) diff --git a/docs/reference/daily-sales-export.md b/docs/reference/daily-sales-export.md index ff9fcfe4a..9881f4fba 100644 --- a/docs/reference/daily-sales-export.md +++ b/docs/reference/daily-sales-export.md @@ -11,13 +11,13 @@ Accounting, reconciliation, and file-format contract for ## Architecture -| Layer | File | Responsibility | -| ----------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Layer | File | Responsibility | +| ----------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Dataset builder | `main/services/daily-sales-export.ts` | Single source of truth for all accounting: `buildDailySalesExportDataset`, `resolveDailySalesExportRange`, `dailySalesSummaryMetricRows`, `DAILY_SALES_DAILY_COLUMNS`, `DAILY_SALES_TAX_COLUMNS`, `DAILY_SALES_ITEM_COLUMNS`, `DAILY_SALES_PAYMENT_COLUMNS`. | -| Serializers | `main/services/daily-sales-export-files.ts` | Encode only - no accounting math. `serializeDailySalesExportXlsx`, `serializeDailySalesExportCsv`, `serializeDailySalesExportHtml`, `dailySalesExportFilename`. | -| Shared CSV helper | `main/lib/csv.ts` | `toCsvRow` with CWE-1236 formula neutralization (leading quote on `= + - @` non-numerics); `main/routes/menu-csv.ts` wraps it via `toQuoteV1CsvRow` (see [product invariants](product-invariants.md#catalog-csv-encoding-and-legacy-apostrophe-ambiguity)). | -| Endpoint | `main/routes/reports.ts` | Validation, serialization, headers, error mapping (400 / 401 / 403 / 409 / 500). | -| Frontend | `frontend/src/app/(dashboard)/dashboard/page.tsx` | Blob download (`xlsx`/`csv`) and PDF/print export (`frontend/src/lib/export-pdf.ts`); single-day (`date`) or range (`from`/`to`) export buttons in the reports dashboard (CSV downloads all five parts). | +| Serializers | `main/services/daily-sales-export-files.ts` | Encode only - no accounting math. `serializeDailySalesExportXlsx`, `serializeDailySalesExportCsv`, `serializeDailySalesExportHtml`, `dailySalesExportFilename`. | +| Shared CSV helper | `main/lib/csv.ts` | `toCsvRow` with CWE-1236 formula neutralization (leading quote on `= + - @` non-numerics); `main/routes/menu-csv.ts` wraps it via `toQuoteV1CsvRow` (see [product invariants](product-invariants.md#catalog-csv-encoding-and-legacy-apostrophe-ambiguity)). | +| Endpoint | `main/routes/reports.ts` | Validation, serialization, headers, error mapping (400 / 401 / 403 / 409 / 500). | +| Frontend | `frontend/src/app/(dashboard)/dashboard/page.tsx` | Blob download (`xlsx`/`csv`) and PDF/print export (`frontend/src/lib/export-pdf.ts`); single-day (`date`) or range (`from`/`to`) export buttons in the reports dashboard (CSV downloads all five parts). | Backend owns all logic; the frontend never computes totals. @@ -54,7 +54,7 @@ Backend owns all logic; the frontend never computes totals. - **XLSX:** sheets `Summary`, `Daily`, `Taxes`, `Items`, then `Payments`. Money cells are numeric with `moneyNumFmt(fractionDigits)`, percentage cells (`*_percent`) use `'0.00'`, and `tax_rate` cells use `'0.##'`; `null` rate, base, gross, cost, profit, and margin values are empty string `""`; no formulas. Frozen header row. - **CSV:** stable English snake_case headers; ASCII `.` decimals; formula-neutralized via shared `toCsvRow`. -- **HTML (PDF / Print):** self-contained A4 portrait HTML5 document (`serializeDailySalesExportHtml`) containing the store header, optional `tax_detail_warning` alert banner, Summary metrics, Payment Methods summary, Daily Breakdown, Tax Summary, and Item Sales tables from the same dataset (`Tax Summary` and `Item Sales` render up to `maxTableRows`, default 2,000 rows, with a truncation note when exceeded). +- **HTML (PDF / Print):** self-contained A4 landscape HTML5 document (`serializeDailySalesExportHtml`) containing the store header, optional `tax_detail_warning` alert banner, Summary metrics, Payment Methods summary, Daily Breakdown, Tax Summary, and Item Sales tables from the same dataset (returning `400` if the rendered HTML exceeds the 15 MB PDF export limit). - **Filenames:** `daily-sales-YYYY-MM-DD.xlsx` / `.html` (single day) or `daily-sales-YYYY-MM-DD_to_YYYY-MM-DD.xlsx` / `.html` (multi-day range), and `-summary.csv`, `-daily.csv`, `-taxes.csv`, `-items.csv`, `-payments.csv` (from `dailySalesExportFilename`). ### Summary metric field order diff --git a/frontend/e2e/cash-close-phase1-ui.spec.ts b/frontend/e2e/cash-close-phase1-ui.spec.ts index 71d44da10..0c39511cf 100644 --- a/frontend/e2e/cash-close-phase1-ui.spec.ts +++ b/frontend/e2e/cash-close-phase1-ui.spec.ts @@ -167,7 +167,7 @@ test.describe('Cash close phase-1 UI', () => { await expect( popup.getByRole('heading', { name: 'Summary', level: 2, exact: true }), ).toBeVisible(); - await expect.poll(() => popup.locator('style').textContent()).toContain('size: A4 portrait'); + await expect.poll(() => popup.locator('style').textContent()).toContain('size: A4 landscape'); await popup.close(); // Now test X Report and Z Report PDF export via desktop saveHtmlAsPdf IPC bridge @@ -212,5 +212,48 @@ test.describe('Cash close phase-1 UI', () => { expect(xPdfCall.defaultFileName).toMatch(/^x-report-\d{4}-\d{2}-\d{2}\.pdf$/); expect(xPdfCall.pageSize).toBe('A4'); expect(xPdfCall.html).toContain('X Report'); + + // Now close a past business date (so today's state stays untouched) and export the Z Report as PDF + const xDate = xPdfCall.defaultFileName.slice('x-report-'.length, -'.pdf'.length); + const pastDateObj = new Date(`${xDate}T00:00:00Z`); + pastDateObj.setUTCDate(pastDateObj.getUTCDate() - 30); + const pastDate = pastDateObj.toISOString().slice(0, 10); + + await dialog.locator('#business-date').fill(pastDate); + const continueBtn = dialog.getByRole('button', { name: 'Continue', exact: true }); + if (await continueBtn.isVisible()) { + await expect(continueBtn).toBeEnabled({ timeout: 10000 }); + await continueBtn.click(); + await dialog.locator('#opening-float').fill('0'); + await dialog.locator('#counted-cash').fill('0'); + await dialog.getByRole('button', { name: 'Close day', exact: true }).click(); + } + await expect(dialog.getByText(/^Z report$/i)).toBeVisible({ timeout: 15000 }); + await dialog.getByRole('button', { name: 'Export sales', exact: true }).click(); + await page.getByRole('menuitem', { name: 'PDF (.pdf)' }).click(); + + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __pdfCalls: Array<{ defaultFileName: string; pageSize: string; html: string }>; + } + ).__pdfCalls.length, + ), + ) + .toBe(2); + const zPdfCall = await page.evaluate( + () => + ( + window as unknown as { + __pdfCalls: Array<{ defaultFileName: string; pageSize: string; html: string }>; + } + ).__pdfCalls[1], + ); + expect(zPdfCall.defaultFileName).toMatch(new RegExp(`^z-report-Z\\d+-${pastDate}\\.pdf$`)); + expect(zPdfCall.pageSize).toBe('A4'); + expect(zPdfCall.html).toContain('Z Report'); }); }); diff --git a/frontend/src/app/(dashboard)/dashboard/page.tsx b/frontend/src/app/(dashboard)/dashboard/page.tsx index ec7fa26d2..7ff283599 100644 --- a/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -56,7 +56,7 @@ import { splitHoursMinutes } from '@/lib/table-timing'; import { tenantCan } from '@/lib/permissions'; import { businessDateForInstant } from '@shared/business-date'; import { downloadBlob } from '@/lib/download'; -import { exportReportAsPdf } from '@/lib/export-pdf'; +import { exportReportAsPdf, MenuPopupBlockedError } from '@/lib/export-pdf'; interface PaymentMethodBreakdown { method: string | null; @@ -256,6 +256,7 @@ export default function DashboardPage() { const tCommon = useTranslations('common'); const tPos = useTranslations('pos'); const tOrders = useTranslations('orders'); + const tProducts = useTranslations('products'); const router = useRouter(); const [stats, setStats] = useState(null); const [daySummary, setDaySummary] = useState(null); @@ -507,8 +508,12 @@ export default function DashboardPage() { }); downloadBlob(res.data as Blob, `daily-sales-${span}-${part}.csv`); } - } catch { - toast.error(tCommon('downloadFailed')); + } catch (error) { + toast.error( + error instanceof MenuPopupBlockedError + ? tProducts('menuPopupBlocked') + : tCommon('downloadFailed'), + ); } finally { setIsExporting(false); } diff --git a/frontend/src/hooks/useCashClose.ts b/frontend/src/hooks/useCashClose.ts index a6ccd6305..5f7a8494b 100644 --- a/frontend/src/hooks/useCashClose.ts +++ b/frontend/src/hooks/useCashClose.ts @@ -12,7 +12,7 @@ import { printerService } from '@/lib/printer/PrinterService'; import { displayAmountToCents } from '@/lib/money'; import { businessDateForInstant } from '@shared/business-date'; import { downloadBlob } from '@/lib/download'; -import { exportReportAsPdf } from '@/lib/export-pdf'; +import { exportReportAsPdf, MenuPopupBlockedError } from '@/lib/export-pdf'; import { shareCashCloseViaWhatsApp, type XReportExport, @@ -121,6 +121,7 @@ export function useCashClose() { const t = useTranslations('dashboard'); const tCommon = useTranslations('common'); const tPrint = useTranslations('print'); + const tProducts = useTranslations('products'); const tSettings = useTranslations('settings'); const locale = useLocale(); const fmt = useFormatCurrency(); @@ -416,8 +417,12 @@ export function useCashClose() { responseType: 'blob', }); downloadBlob(res.data as Blob, `${baseFilename}.${format}`); - } catch { - toast.error(tCommon('downloadFailed')); + } catch (error) { + toast.error( + error instanceof MenuPopupBlockedError + ? tProducts('menuPopupBlocked') + : tCommon('downloadFailed'), + ); } finally { setExportingReport(false); } diff --git a/frontend/src/lib/export-pdf.ts b/frontend/src/lib/export-pdf.ts index 68b33a9e4..3a5ad3e5b 100644 --- a/frontend/src/lib/export-pdf.ts +++ b/frontend/src/lib/export-pdf.ts @@ -1,5 +1,11 @@ import api from '@/lib/api'; -import { printMenuInBrowser, reservePrintGesture } from '@/lib/printer/menu-web-print'; +import { + MenuPopupBlockedError, + printMenuInBrowser, + reservePrintGesture, +} from '@/lib/printer/menu-web-print'; + +export { MenuPopupBlockedError }; export interface ExportReportPdfOptions { endpoint: string; diff --git a/main/services/daily-sales-export.ts b/main/services/daily-sales-export.ts index 5f2c5660a..b69d58bc9 100644 --- a/main/services/daily-sales-export.ts +++ b/main/services/daily-sales-export.ts @@ -1801,7 +1801,7 @@ export interface DailySalesHtmlOptions { maxTableRows?: number; } -const DAILY_SALES_HTML_DEFAULT_MAX_ROWS = 2000; +export const MAX_DAILY_SALES_HTML_BYTES = 15 * 1024 * 1024; const SUMMARY_METRIC_LABELS: Record = { business_date: 'Business Date', @@ -1877,31 +1877,22 @@ export function serializeDailySalesExportHtml( const maxRows = typeof options?.maxTableRows === 'number' && options.maxTableRows > 0 ? Math.floor(options.maxTableRows) - : DAILY_SALES_HTML_DEFAULT_MAX_ROWS; + : undefined; const numberingSystem = resolvedPrefs.digits === 'latin' ? 'latn' : undefined; const fmtMoney = (amount: number | null | undefined): string => { if (amount === null || amount === undefined) return '—'; - const formatted = formatMoney(amount, currency, resolvedLocale, resolvedPrefs); - const exact = String(amount); - if (formatted.includes(exact)) return escapeHtml(formatted); - return `${escapeHtml(formatted)} (${escapeHtml(exact)})`; + return escapeHtml(formatMoney(amount, currency, resolvedLocale, resolvedPrefs)); }; const fmtNum = (val: number | null | undefined): string => { if (val === null || val === undefined) return '—'; - const formatted = formatNumber(val, resolvedLocale, numberingSystem); - const exact = String(val); - if (formatted.includes(exact)) return escapeHtml(formatted); - return `${escapeHtml(formatted)} (${escapeHtml(exact)})`; + return escapeHtml(formatNumber(val, resolvedLocale, numberingSystem)); }; const fmtPercent = (val: number | null | undefined): string => { if (val === null || val === undefined) return '—'; - const exact = String(val); - const fixed = val.toFixed(2); - if (fixed.includes(exact)) return `${escapeHtml(fixed)}%`; - return `${escapeHtml(fixed)}% (${escapeHtml(exact)})`; + return `${escapeHtml(val.toFixed(2))}%`; }; const periodLabel = @@ -1980,7 +1971,7 @@ export function serializeDailySalesExportHtml( .join('\n') : `No daily rows`; - const visibleTaxes = dataset.taxes.slice(0, maxRows); + const visibleTaxes = maxRows ? dataset.taxes.slice(0, maxRows) : dataset.taxes; const taxesTruncated = dataset.taxes.length - visibleTaxes.length; const taxRowsHtml = visibleTaxes.length > 0 @@ -2004,7 +1995,7 @@ export function serializeDailySalesExportHtml( .join('\n') : `No tax rows`; - const visibleItems = dataset.items.slice(0, maxRows); + const visibleItems = maxRows ? dataset.items.slice(0, maxRows) : dataset.items; const itemsTruncated = dataset.items.length - visibleItems.length; const itemRowsHtml = visibleItems.length > 0 @@ -2032,13 +2023,13 @@ export function serializeDailySalesExportHtml( ? `` : ''; - return ` + const html = ` ${escapeHtml(businessName)} — ${escapeHtml(reportTitle)}