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/api.md b/docs/reference/api.md index d2b146386..be352b171 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,24 +868,26 @@ 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=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`); see [daily sales export](daily-sales-export.md#file-formats). -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. +Errors: `400` for an invalid `format`, a CSV without `part`, mixed `date` and `from`/`to` arguments, an incomplete range, an invalid calendar date, `to < from`, a range exceeding 93 days, or an `html` export exceeding the 15 MB PDF size limit; `401` unauthenticated; `403` non-owner; `409` for a concurrent-write conflict; `500` otherwise. ### Inline endpoints (registered outside a router) diff --git a/docs/reference/daily-sales-export.md b/docs/reference/daily-sales-export.md index f248c2d4f..417576f20 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,18 +6,18 @@ 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` (print-styled HTML document used for desktop `save-html-as-pdf` and browser print; see [File formats](#file-formats)). - Gated by `reports.daily-sales.export` (owner by default). No PII, no staff/Addons sheets. ## 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`, `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). | +| 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,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 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 @@ -93,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, HTML escaping, and large-dataset rendering without row truncation), 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 native Electron `printToPDF` rendering and 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/frontend/e2e/cash-close-phase1-ui.spec.ts b/frontend/e2e/cash-close-phase1-ui.spec.ts index 87c1c6fda..0c39511cf 100644 --- a/frontend/e2e/cash-close-phase1-ui.spec.ts +++ b/frontend/e2e/cash-close-phase1-ui.spec.ts @@ -139,4 +139,121 @@ 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 landscape'); + 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'); + + // 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 dc813f268..7ff283599 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, MenuPopupBlockedError } from '@/lib/export-pdf'; interface PaymentMethodBreakdown { method: string | null; @@ -255,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); @@ -468,7 +470,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 +483,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' }, @@ -497,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); } @@ -881,6 +896,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..5f7a8494b 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, MenuPopupBlockedError } from '@/lib/export-pdf'; import { shareCashCloseViaWhatsApp, type XReportExport, @@ -120,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(); @@ -393,21 +395,34 @@ 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); - } catch { - toast.error(tCommon('downloadFailed')); + downloadBlob(res.data as Blob, `${baseFilename}.${format}`); + } 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 new file mode 100644 index 000000000..6f5712983 --- /dev/null +++ b/frontend/src/lib/export-pdf.ts @@ -0,0 +1,89 @@ +import api from './api'; +import { + MenuPopupBlockedError, + printMenuInBrowser, + reservePrintGesture, +} from './printer/menu-web-print'; + +export { MenuPopupBlockedError }; + +export interface ExportHtmlDocumentPdfOptions { + html: string; + filename: string; + pageSize?: 'A4' | 'Letter'; + reservedWindow?: Window | null; +} + +export async function exportHtmlDocumentAsPdf({ + html, + filename, + pageSize = 'A4', + reservedWindow, +}: ExportHtmlDocumentPdfOptions): Promise { + const saveHtmlAsPdf = + typeof window !== 'undefined' ? window.electronAPI?.saveHtmlAsPdf : undefined; + 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); +} + +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 ?? ''); + await exportHtmlDocumentAsPdf({ + html, + filename, + pageSize, + reservedWindow, + }); + if (!saveHtmlAsPdf) { + 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..0564396ed 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,13 @@ router.get( orderCount: staff.orderCount, revenue: staff.revenue_cents / minorFactor, })), + 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, @@ -1512,6 +1572,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 +1640,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 +1657,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 +1704,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..76b4251d2 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,268 @@ 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 +506,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..0ecc9c3d0 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`; } @@ -47,7 +52,7 @@ function moneyNumFmt(fractionDigits: number): string { } /** - * Serializers only - no accounting math. Both formats read the same dataset. + * Serializers only - no accounting math. All formats read the same dataset. * Values stay numeric in XLSX; CSV uses ASCII '.' decimals for stable Sheets import. */ export async function serializeDailySalesExportXlsx( diff --git a/main/services/daily-sales-export.ts b/main/services/daily-sales-export.ts index 7c9419a2d..c8c14845d 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,475 @@ export const DAILY_SALES_PAYMENT_COLUMNS = [ 'amount', 'transaction_id', ] as const; + +export interface DailySalesHtmlOptions { + businessName?: string; + locale?: string; + preferences?: LocalePreferences; +} + +export const MAX_DAILY_SALES_HTML_BYTES = 15 * 1024 * 1024; + +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 numberingSystem = resolvedPrefs.digits === 'latin' ? 'latn' : undefined; + + const fmtMoney = (amount: number | null | undefined): string => { + if (amount === null || amount === undefined) return '—'; + return escapeHtml(formatMoney(amount, currency, resolvedLocale, resolvedPrefs)); + }; + + const fmtMoneyWithExact = (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 '—'; + return escapeHtml(formatNumber(val, resolvedLocale, numberingSystem)); + }; + + const fmtPercent = (val: number | null | undefined): string => { + if (val === null || val === undefined) return '—'; + return `${escapeHtml(val.toFixed(2))}%`; + }; + + 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 = fmtMoneyWithExact(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)} + ${fmtMoneyWithExact(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 taxRowsHtml = + dataset.taxes.length > 0 + ? dataset.taxes + .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 itemRowsHtml = + dataset.items.length > 0 + ? dataset.items + .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 + ? `` + : ''; + + const html = ` + + + + ${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
+
+ +
+

Item Sales

+ + + + + + + + + + + + + + + + + + + ${itemRowsHtml} + +
Product IDProduct NameSKUQtyGross SalesDiscountsNet SalesTaxCostProfitMargin %Cost Status
+
+ + +`; + + if (Buffer.byteLength(html, 'utf8') > MAX_DAILY_SALES_HTML_BYTES) { + throw new DailySalesExportValidationError( + 'Date range is too large to export as PDF; narrow the date range or export as XLSX/CSV.', + ); + } + return html; +} diff --git a/main/services/html-to-pdf.ts b/main/services/html-to-pdf.ts index 097367983..09b0e4ae1 100644 --- a/main/services/html-to-pdf.ts +++ b/main/services/html-to-pdf.ts @@ -52,7 +52,11 @@ export async function renderHtmlToPdfBuffer(input: { pdfWindow.webContents.on('will-navigate', (event) => event.preventDefault()); try { await pdfWindow.loadFile(htmlPath); - return await pdfWindow.webContents.printToPDF({ printBackground: true, pageSize }); + return await pdfWindow.webContents.printToPDF({ + printBackground: true, + pageSize, + preferCSSPageSize: true, + }); } finally { try { fs.unlinkSync(htmlPath); diff --git a/tests/daily-sales-export.test.ts b/tests/daily-sales-export.test.ts index 9fb1a6aac..b545cd449 100644 --- a/tests/daily-sales-export.test.ts +++ b/tests/daily-sales-export.test.ts @@ -1,11 +1,12 @@ /** - * Owner-only daily sales export — GET /api/reports/daily-sales/export. + * Owner-only daily sales export - GET /api/reports/daily-sales/export. * * Covers: * - Role gating: owner OK, manager/cashier 403. - * - Validation: bad format → 400; csv without part → 400. - * - XLSX: Summary + Items + Payments sheets, Content-Disposition filename. - * - CSV: separate summary/items/payments files; formula-neutralized cells. + * - Validation: bad format -> 400; csv without part -> 400; date-range bounds. + * - XLSX: Summary + Daily + Taxes + Items + Payments sheets, Content-Disposition filename. + * - CSV: separate summary/daily/taxes/items/payments files; formula-neutralized cells. + * - HTML: print-styled document parity with XLSX for PDF/browser print export. * - Reconciliation identities from the approved contract. * * Usage: node tests/run-electron-node-test.cjs tests/daily-sales-export.test.ts @@ -42,6 +43,10 @@ const { now, } = require('./helpers/test-setup'); const { reportRoutes } = require('../main/routes/reports'); +const { + buildDailySalesExportDataset, + serializeDailySalesExportHtml, +} = require('../main/services/daily-sales-export'); const ExcelJS = require('exceljs'); function dbTimestamp(date: Date): string { @@ -1403,6 +1408,177 @@ async function main() { 'XLSX Items row count matches CSV items part', ); + // 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 htmlEntities: Record = { + '<': '<', + '>': '>', + '"': '"', + ''': "'", + '&': '&', + }; + 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( + /&(?:lt|gt|quot|#39|amp);/g, + (entity) => htmlEntities[entity] ?? entity, + ), + ); + } + 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(/ ({ + business_date: day1, + tax_name: `TaxRow-${idx + 1}`, + tax_rate: 5, + taxable_base: 100, + tax_amount: 5, + gross_amount: 105, + shared_base: false, + base_additive: true, + incomplete_detail: false, + warning: '', + })); + const manyItems = Array.from({ length: 650 }, (_, idx) => ({ + product_id: `prod-large-${idx + 1}`, + product_name: `ItemRow-${idx + 1}`, + product_sku: `SKU-${idx + 1}`, + quantity: 1, + gross_item_sales: 10, + item_discounts: 0, + net_item_sales: 10, + tax_amount: 0.5, + ingredient_cost: 4, + profit: 6, + margin_percent: 60, + cost_status: 'known' as const, + })); + const largeHtml = serializeDailySalesExportHtml({ + ...baseDataset, + taxes: manyTaxes, + items: manyItems, + }); + assertEqual( + (largeHtml.match(/ { ); assert.deepEqual( pdfWindow.pdfOptions, - { printBackground: true, pageSize: 'Letter' }, + { printBackground: true, pageSize: 'Letter', preferCSSPageSize: true }, 'the requested page size reaches the print call', ); assert.equal(pdfWindow.destroyed, true, 'the offscreen window is destroyed after export'); diff --git a/tests/reports-export.test.ts b/tests/reports-export.test.ts index 128fc61e4..f2b692d04 100644 --- a/tests/reports-export.test.ts +++ b/tests/reports-export.test.ts @@ -1,61 +1,677 @@ import * as assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import * as zlib from 'node:zlib'; const Module = require('module'); const originalLoad = Module._load; +const rawElectron = (() => { + try { + return originalLoad('electron', module, false); + } catch { + return null; + } +})(); +const isRealElectronRuntime = + typeof rawElectron === 'object' && + rawElectron !== null && + typeof rawElectron.BrowserWindow === 'function'; + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-reports-export-')); const menuChannels = new Set(); +const registeredIpcHandlers = new Map any>(); let exposedApi: Record | undefined; +let lastLoadedHtmlPath = ''; +let lastLoadedHtmlContent = ''; +let saveDialogResult: { canceled: boolean; filePath?: string } = { canceled: true }; +let lastSaveDialogOptions: any = null; +const trackedBrowserWindows: any[] = []; + +let preloadLoading = !isRealElectronRuntime; + +const HTML_ENTITIES: Record = { + '<': '<', + '>': '>', + '"': '"', + ''': "'", + '&': '&', +}; + +function unescapeHtml(value: string): string { + return value.replace(/&(?:lt|gt|quot|#39|amp);/g, (entity) => HTML_ENTITIES[entity] ?? entity); +} + Module._load = function (request: string, parent: unknown, isMain: boolean) { if (request === 'electron') { + if (isRealElectronRuntime) { + const NativeBrowserWindow = rawElectron.BrowserWindow; + class TrackedNativeBrowserWindow extends NativeBrowserWindow { + loadedFilePath: string | null = null; + destroyedFlag = false; + constructor(options?: any) { + super(options); + trackedBrowserWindows.push(this); + } + async loadFile(filePath: string, options?: any) { + this.loadedFilePath = filePath; + lastLoadedHtmlPath = filePath; + assert.ok(fs.existsSync(filePath), 'staging HTML file exists during real loadFile'); + lastLoadedHtmlContent = fs.readFileSync(filePath, 'utf8'); + return super.loadFile(filePath, options); + } + destroy() { + this.destroyedFlag = true; + super.destroy(); + } + static fromWebContents(sender: any) { + if (sender?.ownerWindow) return sender.ownerWindow; + return super.fromWebContents(sender); + } + } + return { + ...rawElectron, + BrowserWindow: TrackedNativeBrowserWindow, + ipcMain: { + on: () => undefined, + handle: (channel: string, listener: (...args: any[]) => any) => { + registeredIpcHandlers.set(channel, listener); + }, + }, + dialog: { + showSaveDialog: async (options: any) => { + lastSaveDialogOptions = options; + return saveDialogResult; + }, + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + showMessageBox: async () => ({ response: 1 }), + }, + app: { + isPackaged: true, + getPath: () => testDir, + getVersion: () => 'test', + getName: () => 'FloCafe', + whenReady: () => rawElectron.app.whenReady(), + isReady: () => rawElectron.app.isReady(), + quit: () => rawElectron.app.quit(), + exit: (code?: number) => rawElectron.app.exit(code), + }, + }; + } + + class FakePrintWindow { + destroyed = false; + webContents = { + on: () => undefined, + setWindowOpenHandler: () => undefined, + session: { + webRequest: { onBeforeRequest: () => undefined }, + }, + printToPDF: async (opts?: Record) => + Buffer.from( + `%PDF-1.4\n%FloCafe test PDF pageSize=${String(opts?.pageSize ?? '')} preferCSSPageSize=${String(opts?.preferCSSPageSize ?? '')}\n${lastLoadedHtmlContent}\n%%EOF\n`, + ), + }; + constructor() { + trackedBrowserWindows.push(this); + } + async loadFile(filePath: string) { + lastLoadedHtmlPath = filePath; + assert.ok(fs.existsSync(filePath), 'staging HTML file exists during loadFile'); + lastLoadedHtmlContent = fs.readFileSync(filePath, 'utf8'); + } + isDestroyed() { + return this.destroyed; + } + destroy() { + this.destroyed = true; + } + static fromWebContents(sender: any) { + return sender?.ownerWindow ?? null; + } + } return { - app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' }, - 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); }, + app: { + isPackaged: true, + getPath: () => testDir, + getVersion: () => 'test', + getName: () => 'FloCafe', }, + BrowserWindow: FakePrintWindow, + ipcMain: { + on: () => undefined, + handle: (channel: string, listener: (...args: any[]) => any) => { + registeredIpcHandlers.set(channel, listener); + }, + }, + dialog: { + showSaveDialog: async (options: any) => { + lastSaveDialogOptions = options; + return saveDialogResult; + }, + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + showMessageBox: async () => ({ response: 1 }), + }, + ...(preloadLoading + ? { + 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); + }, + }, + } + : {}), }; } return originalLoad.apply(this, arguments as any); }; -require('../main/preload'); +if (!isRealElectronRuntime) { + require('../main/preload'); + preloadLoading = false; +} process.env.JWT_SECRET = 'test-secret-reports-export'; -const { initTestDb, seedOwnerUser, createApp, startServer, closeDatabase } = require('./helpers/test-setup'); +const { + initTestDb, + seedOwnerUser, + seedCategory, + seedProduct, + 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 { MAX_PDF_HTML_BYTES, renderHtmlToPdfBuffer } = require('../main/services/html-to-pdf'); +const { registerIpcHandlers } = require('../main/ipc'); const request = require('supertest'); const ExcelJS = require('exceljs'); +function parseHtmlSummaryMetrics(html: string): Map { + const map = new Map(); + const re = / void): void { const chunks: Buffer[] = []; response.on('data', (chunk: Buffer) => chunks.push(chunk)); response.on('end', () => callback(null, Buffer.concat(chunks))); } +interface ExtractedPdfInfo { + pageCount: number; + pages: string[]; + fullText: string; + normalizedText: string; + title: string; + mediaBoxes: Array<[number, number, number, number]>; +} + +function pdfTextIncludesToken(pdfInfo: ExtractedPdfInfo, token: string): boolean { + const compactText = pdfInfo.normalizedText.replace(/\s+/g, ''); + const compactToken = token.replace(/\s+/g, ''); + if (pdfInfo.normalizedText.includes(token) || compactText.includes(compactToken)) { + return true; + } + // On headless Linux runners where optional CJK/Devanagari system fonts are not + // pre-installed, Skia's PDF backend maps the first .notdef glyph/cluster in a + // font subset to glyph ID 0 (<0000> Tj, omitted from /ToUnicode) and emits all + // subsequent .notdef characters via /Span<> BDC. + if (/[\u0900-\u097F\u3040-\u30FF\u4E00-\u9FFF]/.test(token)) { + const afterFirstChar = Array.from(token).slice(1).join('').trim(); + if ( + afterFirstChar.length >= 2 && + (pdfInfo.normalizedText.includes(afterFirstChar) || + compactText.includes(afterFirstChar.replace(/\s+/g, ''))) + ) { + return true; + } + const firstSpace = token.indexOf(' '); + if (firstSpace !== -1) { + const afterFirstCluster = token.slice(firstSpace + 1).trim(); + if ( + afterFirstCluster.length >= 2 && + (pdfInfo.normalizedText.includes(afterFirstCluster) || + compactText.includes(afterFirstCluster.replace(/\s+/g, ''))) + ) { + return true; + } + } + } + return false; +} + +function extractPdfInfo(buf: Buffer): ExtractedPdfInfo { + const raw = buf.toString('latin1'); + const objects = new Map(); + const objRe = /(\d+)\s+0\s+obj\b([\s\S]*?)endobj/g; + let m: RegExpExecArray | null; + while ((m = objRe.exec(raw)) !== null) { + const id = Number(m[1]); + const body = m[2]; + let streamText: string | null = null; + const sIdx = body.indexOf('stream'); + if (sIdx !== -1) { + const eIdx = body.lastIndexOf('endstream'); + if (eIdx > sIdx) { + let slice = body.slice(sIdx + 6, eIdx); + if (slice.startsWith('\r\n')) slice = slice.slice(2); + else if (slice.startsWith('\n')) slice = slice.slice(1); + if (slice.endsWith('\r\n')) slice = slice.slice(0, -2); + else if (slice.endsWith('\n')) slice = slice.slice(0, -1); + try { + streamText = zlib.inflateSync(Buffer.from(slice, 'latin1')).toString('latin1'); + } catch {} + } + } + objects.set(id, { body, streamText }); + } + + function parseCMap(cmapStr: string): { map: Map; keyBytes: number } { + const map = new Map(); + let keyBytes = 1; + const csMatch = cmapStr.match(/begincodespacerange\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/); + if (csMatch) keyBytes = Math.max(1, csMatch[1].length / 2); + const decodeHexUcs = (hex: string): string => { + const codeUnits: number[] = []; + for (let i = 0; i < hex.length; i += 4) { + const cu = parseInt(hex.slice(i, i + 4), 16); + if (cu !== 0) codeUnits.push(cu); + } + return String.fromCharCode(...codeUnits); + }; + const bfcharRe = /beginbfchar([\s\S]*?)endbfchar/g; + let bm: RegExpExecArray | null; + while ((bm = bfcharRe.exec(cmapStr)) !== null) { + const pairRe = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g; + let pm: RegExpExecArray | null; + while ((pm = pairRe.exec(bm[1])) !== null) { + map.set(parseInt(pm[1], 16), decodeHexUcs(pm[2])); + } + } + const bfrangeRe = /beginbfrange([\s\S]*?)endbfrange/g; + while ((bm = bfrangeRe.exec(cmapStr)) !== null) { + const rangeRe = /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g; + let rm: RegExpExecArray | null; + while ((rm = rangeRe.exec(bm[1])) !== null) { + const start = parseInt(rm[1], 16); + const end = parseInt(rm[2], 16); + const baseUcs = parseInt(rm[3], 16); + for (let k = start; k <= end; k++) { + map.set(k, String.fromCharCode(baseUcs + (k - start))); + } + } + } + return { map, keyBytes }; + } + + const fontCMapByObj = new Map; keyBytes: number }>(); + for (const [id, obj] of objects.entries()) { + const tu = obj.body.match(/\/ToUnicode\s+(\d+)\s+0\s+R/); + if (tu) { + const cmapObj = objects.get(Number(tu[1])); + if (cmapObj && cmapObj.streamText) { + fontCMapByObj.set(id, parseCMap(cmapObj.streamText)); + } + } + } + + const pages: string[] = []; + const mediaBoxes: Array<[number, number, number, number]> = []; + for (const [, obj] of objects.entries()) { + if (!/\/Type\s*\/Page\b/.test(obj.body)) continue; + const mbMatch = obj.body.match( + /\/MediaBox\s*\[\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*\]/, + ); + if (mbMatch) { + mediaBoxes.push([ + Number(mbMatch[1]), + Number(mbMatch[2]), + Number(mbMatch[3]), + Number(mbMatch[4]), + ]); + } + const fontMap = new Map; keyBytes: number }>(); + const fontBlock = obj.body.match(/\/Font\s*<<([\s\S]*?)>>/); + if (fontBlock) { + const fRe = /\/(F\d+)\s+(\d+)\s+0\s+R/g; + let fm: RegExpExecArray | null; + while ((fm = fRe.exec(fontBlock[1])) !== null) { + const cmap = fontCMapByObj.get(Number(fm[2])); + if (cmap) fontMap.set(fm[1], cmap); + } + } + const contentsMatch = obj.body.match(/\/Contents\s+(\d+)\s+0\s+R/); + const contentStream = contentsMatch + ? objects.get(Number(contentsMatch[1]))?.streamText || '' + : ''; + let currentFont: { map: Map; keyBytes: number } | null = null; + let pageText = ''; + const tokenRe = + /\/Span\s*<<\s*\/ActualText\s*\s*>>\s*BDC([\s\S]*?)EMC|\/(F\d+)\s+[\d.]+\s+Tf|<([0-9A-Fa-f]+)>\s*Tj|\[([\s\S]*?)\]\s*TJ/g; + let tm: RegExpExecArray | null; + const decodeTokenHex = (hex: string): string => { + if (!currentFont) return ''; + const step = currentFont.keyBytes * 2; + let out = ''; + for (let i = 0; i < hex.length; i += step) { + const code = parseInt(hex.slice(i, i + step), 16); + out += currentFont.map.get(code) ?? ''; + } + return out; + }; + while ((tm = tokenRe.exec(contentStream)) !== null) { + if (tm[1] !== undefined) { + const tfRe = /\/(F\d+)\s+[\d.]+\s+Tf/g; + let tfm: RegExpExecArray | null; + while ((tfm = tfRe.exec(tm[2])) !== null) { + currentFont = fontMap.get(tfm[1]) || null; + } + const units: number[] = []; + for (let i = 0; i < tm[1].length; i += 4) { + const u = parseInt(tm[1].slice(i, i + 4), 16); + if (u !== 0) units.push(u); + } + pageText += String.fromCharCode(...units); + } else if (tm[3]) { + currentFont = fontMap.get(tm[3]) || null; + } else if (tm[4]) { + pageText += decodeTokenHex(tm[4]); + } else if (tm[5]) { + const subRe = /<([0-9A-Fa-f]+)>/g; + let sm: RegExpExecArray | null; + while ((sm = subRe.exec(tm[5])) !== null) { + pageText += decodeTokenHex(sm[1]); + } + } + } + pages.push(pageText.replace(/\s+/g, ' ').trim()); + } + + let title = ''; + for (const [, obj] of objects.entries()) { + const titleMatch = obj.body.match(/\/Title\s*/); + if (titleMatch) { + const units: number[] = []; + for (let i = 0; i < titleMatch[1].length; i += 4) { + const u = parseInt(titleMatch[1].slice(i, i + 4), 16); + if (u !== 0) units.push(u); + } + title = String.fromCharCode(...units).normalize('NFKC'); + break; + } + } + + const fullText = pages.join('\n'); + return { + pageCount: pages.length, + pages, + fullText, + normalizedText: fullText.normalize('NFKC'), + title, + mediaBoxes, + }; +} + +async function verifyHtmlLayout( + html: string, + orientation: 'portrait' | 'landscape', + prefix: string, +): Promise<{ + scrollWidth: number; + clientWidth: number; + scrollHeight: number; + clientHeight: number; + overflowingCells: number; +}> { + const width = orientation === 'landscape' ? 1123 : 794; + const height = orientation === 'landscape' ? 794 : 1123; + const htmlFile = path.join(testDir, `layout-${prefix}.html`); + fs.writeFileSync(htmlFile, html, 'utf8'); + const win = new rawElectron.BrowserWindow({ + show: false, + width, + height, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + try { + await win.loadFile(htmlFile); + return (await win.webContents.executeJavaScript(`(() => { + const docEl = document.documentElement; + const vw = docEl.clientWidth; + let overflowingCells = 0; + for (const el of document.querySelectorAll('table, th, td')) { + const rect = el.getBoundingClientRect(); + if (rect.right > vw + 2 || el.scrollWidth > el.clientWidth + 2) { + overflowingCells += 1; + } + } + return { + scrollWidth: docEl.scrollWidth, + clientWidth: docEl.clientWidth, + scrollHeight: docEl.scrollHeight, + clientHeight: docEl.clientHeight, + overflowingCells, + }; + })()`)) as { + scrollWidth: number; + clientWidth: number; + scrollHeight: number; + clientHeight: number; + overflowingCells: number; + }; + } finally { + try { + fs.unlinkSync(htmlFile); + } catch {} + if (!win.isDestroyed()) win.destroy(); + } +} + +async function verifyBrowserPrintFallback(html: string): Promise { + const { exportHtmlDocumentAsPdf } = require('../frontend/src/lib/export-pdf'); + const originalWindow = (globalThis as any).window; + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + + const createdUrls: string[] = []; + const revokedUrls: string[] = []; + URL.createObjectURL = () => { + const url = `blob:report-pdf-${createdUrls.length + 1}`; + createdUrls.push(url); + return url; + }; + URL.revokeObjectURL = (url: string) => { + revokedUrls.push(url); + }; + + const actions: string[] = []; + let listeners = new Map void>>(); + const popup: any = { + closed: false, + document: { readyState: 'complete' }, + location: { + href: 'about:blank', + replace(url: string) { + actions.push(`replace:${url}`); + popup.location.href = url; + listeners = new Map void>>(); + popup.document = { readyState: 'loading' }; + }, + }, + addEventListener(type: string, callback: () => void) { + const list = listeners.get(type) || []; + list.push(callback); + listeners.set(type, list); + }, + focus() { + actions.push('focus'); + }, + print() { + actions.push('print'); + }, + close() { + actions.push('close'); + popup.closed = true; + }, + dispatchLoad() { + popup.document.readyState = 'complete'; + for (const cb of [...(listeners.get('load') || [])]) cb(); + }, + dispatchAfterPrint() { + for (const cb of [...(listeners.get('afterprint') || [])]) cb(); + }, + }; + + try { + (globalThis as any).window = { + open: () => popup, + }; + await exportHtmlDocumentAsPdf({ + html, + filename: 'x-report-2026-09-25.pdf', + pageSize: 'A4', + }); + assert.deepEqual( + actions, + ['replace:blob:report-pdf-1'], + 'browser fallback navigates opened popup to HTML blob URL', + ); + popup.dispatchLoad(); + assert.deepEqual( + actions, + ['replace:blob:report-pdf-1', 'focus', 'print'], + 'browser fallback focuses popup and triggers window.print() once loaded', + ); + popup.dispatchAfterPrint(); + assert.equal(popup.closed, true, 'browser fallback closes popup after print completes'); + assert.deepEqual( + revokedUrls, + ['blob:report-pdf-1'], + 'browser fallback revokes blob URL after print completes', + ); + } finally { + if (originalWindow === undefined) delete (globalThis as any).window; + else (globalThis as any).window = originalWindow; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + } +} + async function run(): Promise { let server: any; try { - 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', - ]) { - assert.ok(menuChannels.has(channel), `preload allows ${channel}`); + if (isRealElectronRuntime) { + rawElectron.app.on('window-all-closed', () => {}); + await rawElectron.app.whenReady(); + } else { + 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', + ]) { + assert.ok(menuChannels.has(channel), `preload allows ${channel}`); + } + unsubscribe(); } - unsubscribe(); 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', 'كافيه النور · 東京カフェ · फ्लो कैफे · Café Élégant ', CURRENT_TIMESTAMP)", + ).run(); + + seedCategory(db, 'cat-rep-exp', 'Beverages & Pâtisserie'); + const specialProducts = [ + { id: 'prod-rep-1', name: '抹茶ラテ Matcha Latte', sku: 'SKU-MATCHA-01', price: 120 }, + { id: 'prod-rep-2', name: 'قهوة عربية Arabic Coffee', sku: 'SKU-ARABIC-02', price: 110 }, + { id: 'prod-rep-3', name: 'मसाला चाय Masala Chai', sku: 'SKU-CHAI-03', price: 100 }, + { id: 'prod-rep-4', name: 'Crème Brûlée Éclair', sku: 'SKU-ECLAIR-04', price: 120 }, + ]; + for (let i = 5; i <= 35; i++) { + const padded = String(i).padStart(2, '0'); + specialProducts.push({ + id: `prod-rep-${padded}`, + name: `Specialty Item ${padded}`, + sku: `SKU-SPEC-${padded}`, + price: 20 + i, + }); + } + for (const prod of specialProducts) { + seedProduct(db, prod.id, 'cat-rep-exp', prod.name, prod.price); + } + + const mainFrame = { frameToken: 'frame-main', detached: false }; + const mainWin = { isDestroyed: () => false }; + const trustedSender = { + sender: { + ownerWindow: mainWin, + mainFrame, + getURL: () => 'http://localhost:3001/', + }, + senderFrame: mainFrame, + }; + registerIpcHandlers(undefined, () => mainWin as any); + const saveHtmlAsPdfIpc = registeredIpcHandlers.get('save-html-as-pdf'); + assert.ok(saveHtmlAsPdfIpc, 'save-html-as-pdf IPC handler is registered'); const app = createApp({ '/api/reports': reportRoutes, @@ -64,31 +680,254 @@ 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, + ); + const day1Items = [ + { prod: specialProducts[0], qty: 1, subtotal: 120, tax: 12, cost: 40 }, + { prod: specialProducts[1], qty: 1, subtotal: 110, tax: 11, cost: 35 }, + { prod: specialProducts[2], qty: 1, subtotal: 100, tax: 10, cost: 30 }, + { prod: specialProducts[3], qty: 1, subtotal: 120, tax: 17, cost: 45 }, + ]; + for (const entry of day1Items) { + db.prepare(` + INSERT INTO order_items ( + order_id, product_id, product_name, product_sku, quantity, unit_price, subtotal, tax_amount, total, + tax_snapshot, ingredient_cost, ingredient_cost_known, status, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'ready', ?, ?) + `).run( + orderId, + entry.prod.id, + entry.prod.name, + entry.prod.sku, + entry.qty, + entry.subtotal, + entry.subtotal, + entry.tax, + entry.subtotal + entry.tax, + JSON.stringify({ + taxable_base: entry.subtotal, + components: [{ name: 'VAT ', rate: 10, amount: entry.tax }], + }), + entry.cost, + paidAt, + paidAt, + ); + } + db.prepare(` + 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, + ); + + // Seed days 2 and 3 (2026-09-26 and 2026-09-27) for the multi-day daily-sales range export + const day2 = '2026-09-26'; + const day2At = '2026-09-26 13:30:00'; + const day3 = '2026-09-27'; + const day3At = '2026-09-27 15:45:00'; + + const day2Prods = specialProducts.slice(4, 20); + const day2Subtotal = day2Prods.reduce((sum, p) => sum + p.price, 0); + const day2Tax = Number((day2Subtotal * 0.05).toFixed(2)); + const day2Total = day2Subtotal + day2Tax + 15; + db.prepare(` + INSERT INTO orders (order_number, user_id, type, status, subtotal, tax_amount, service_charge, total, created_at, updated_at, completed_at) + VALUES ('ORD-REP-DAY2', ?, 'dine_in', 'completed', ?, ?, 15, ?, ?, ?, ?) + `).run(owner.userId, day2Subtotal, day2Tax, day2Total, day2At, day2At, day2At); + const orderDay2Id = Number( + (db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-REP-DAY2'").get() as any).id, + ); + for (const prod of day2Prods) { + const cgst = Number((prod.price * 0.025).toFixed(2)); + const sgst = Number((prod.price * 0.025).toFixed(2)); + const itemTax = Number((cgst + sgst).toFixed(2)); + db.prepare(` + INSERT INTO order_items ( + order_id, product_id, product_name, product_sku, quantity, unit_price, subtotal, tax_amount, total, + tax_snapshot, ingredient_cost, ingredient_cost_known, status, created_at, updated_at + ) + VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, 1, 'ready', ?, ?) + `).run( + orderDay2Id, + prod.id, + prod.name, + prod.sku, + prod.price, + prod.price, + itemTax, + prod.price + itemTax, + JSON.stringify({ + taxable_base: prod.price, + components: [ + { name: 'CGST', rate: 2.5, amount: cgst }, + { name: 'SGST', rate: 2.5, amount: sgst }, + ], + }), + Number((prod.price * 0.35).toFixed(2)), + day2At, + day2At, + ); + } + const billDay2Id = Number( + db + .prepare(` + INSERT INTO bills ( + bill_number, order_id, subtotal, tax_amount, service_charge, total, paid_amount, balance, + payment_status, payment_details, paid_at, created_at, updated_at + ) + VALUES ('B-REP-DAY2', ?, ?, ?, 15, ?, ?, 0, 'paid', ?, ?, ?, ?) + `) + .run( + orderDay2Id, + day2Subtotal, + day2Tax, + day2Total, + day2Total, + JSON.stringify([ + { method: 'card', amount: day2Total, timestamp: day2At, transaction_id: 'TXN-DAY2-01' }, + ]), + day2At, + day2At, + day2At, + ).lastInsertRowid, + ); + db.prepare(` + INSERT INTO refunds (bill_id, order_item_id, amount_cents, method, reason, shift_id, approved_by, created_by, created_at) + VALUES (?, NULL, 2500, 'cash', 'customer courtesy refund', NULL, ?, ?, ?) + `).run(billDay2Id, owner.userId, owner.userId, day2At); + + const day3Prods = specialProducts.slice(20); + const day3Subtotal = day3Prods.reduce((sum, p) => sum + p.price, 0); + const day3Tax = Number((day3Subtotal * 0.1).toFixed(2)); + const day3Total = day3Subtotal - 10 + day3Tax + 5 + 8; + db.prepare(` + INSERT INTO orders ( + order_number, user_id, type, status, subtotal, discount_amount, tax_amount, + packaging_charge, delivery_charge, total, created_at, updated_at, completed_at + ) + VALUES ('ORD-REP-DAY3', ?, 'delivery', 'completed', ?, 10, ?, 5, 8, ?, ?, ?, ?) + `).run(owner.userId, day3Subtotal, day3Tax, day3Total, day3At, day3At, day3At); + const orderDay3Id = Number( + (db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-REP-DAY3'").get() as any).id, + ); + for (const prod of day3Prods) { + const itemTax = Number((prod.price * 0.1).toFixed(2)); + db.prepare(` + INSERT INTO order_items ( + order_id, product_id, product_name, product_sku, quantity, unit_price, subtotal, tax_amount, total, + tax_snapshot, ingredient_cost, ingredient_cost_known, status, created_at, updated_at + ) + VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, 1, 'ready', ?, ?) + `).run( + orderDay3Id, + prod.id, + prod.name, + prod.sku, + prod.price, + prod.price, + itemTax, + prod.price + itemTax, + JSON.stringify({ + taxable_base: prod.price, + components: [{ name: 'VAT ', rate: 10, amount: itemTax }], + }), + Number((prod.price * 0.4).toFixed(2)), + day3At, + day3At, + ); + } 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, discount_amount, tax_amount, packaging_charge, delivery_charge, + tax_snapshot, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at + ) + VALUES ('B-REP-DAY3', ?, ?, 10, ?, 5, 8, ?, ?, ?, 0, 'paid', ?, ?, ?, ?) + `).run( + orderDay3Id, + day3Subtotal, + day3Tax, + JSON.stringify({ + lines: [ + { + taxableBase: day3Subtotal, + components: [{ label: 'VAT ', rate: 10, amount: day3Tax }], + }, + ], + }), + day3Total, + day3Total, + JSON.stringify([{ method: 'cash', amount: day3Total, timestamp: day3At }]), + day3At, + day3At, + day3At, + ); 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 +935,87 @@ 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( + 'كافيه النور · 東京カフェ · फ्लो कैफे · Café Élégant <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 +1029,444 @@ 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`, + ); + } + + // Fetch Date-range Daily Sales XLSX, CSV, and HTML + const dsRangeXlsx = await http + .get(`/api/reports/daily-sales/export?from=${businessDate}&to=${day3}&format=xlsx`) + .set(auth) + .buffer(true) + .parse(parseBuffer); + assert.equal(dsRangeXlsx.status, 200, 'daily-sales range XLSX returns 200'); + const dsWorkbook = new ExcelJS.Workbook(); + await dsWorkbook.xlsx.load(dsRangeXlsx.body); + const dsSummaryMetrics = new Map(); + dsWorkbook.getWorksheet('Summary').eachRow((row: any, rowNumber: number) => { + if (rowNumber > 1) { + dsSummaryMetrics.set(String(row.getCell(1).value), String(row.getCell(2).value ?? '')); + } + }); + + const dsRangeHtml = await http + .get(`/api/reports/daily-sales/export?from=${businessDate}&to=${day3}&format=html`) + .set(auth); + assert.equal(dsRangeHtml.status, 200, 'daily-sales range HTML returns 200'); + const dsHtmlMetrics = parseHtmlSummaryMetrics(dsRangeHtml.text); + for (const [metric, xlsxVal] of dsSummaryMetrics.entries()) { + assert.equal( + dsHtmlMetrics.get(metric), + xlsxVal, + `daily-sales range HTML metric ${metric} matches XLSX`, + ); + } + + // Verify browser print fallback when window.electronAPI.saveHtmlAsPdf is absent + await verifyBrowserPrintFallback(xHtml.text); + + // Verify save-html-as-pdf IPC handler: + // (1) User save-dialog cancellation returns { success: false, canceled: true } without opening a BrowserWindow or writing a file + trackedBrowserWindows.length = 0; + const cancelTargetPath = path.join(testDir, 'cancelled-report.pdf'); + saveDialogResult = { canceled: true }; + const cancelRes = await saveHtmlAsPdfIpc!(trustedSender, { + html: xHtml.text, + defaultFileName: 'x-report-cancelled.pdf', + pageSize: 'A4', + }); + assert.deepEqual( + cancelRes, + { success: false, canceled: true }, + 'cancelled save-html-as-pdf returns { success: false, canceled: true }', + ); + assert.equal( + trackedBrowserWindows.length, + 0, + 'cancelled save-html-as-pdf opens no BrowserWindow', + ); + assert.equal( + fs.existsSync(cancelTargetPath), + false, + 'cancelled save-html-as-pdf writes no file', + ); + + // (2) Oversize HTML (> MAX_PDF_HTML_BYTES) fails before opening a BrowserWindow with Document too large to export + trackedBrowserWindows.length = 0; + const oversizeHtml = `${'x'.repeat(MAX_PDF_HTML_BYTES + 1024)}`; + const oversizeRes = await saveHtmlAsPdfIpc!(trustedSender, { + html: oversizeHtml, + defaultFileName: 'oversize.pdf', + pageSize: 'A4', + }); + assert.deepEqual( + oversizeRes, + { success: false, error: 'Document too large to export' }, + 'oversize HTML (> MAX_PDF_HTML_BYTES) fails with Document too large to export', + ); + assert.equal( + trackedBrowserWindows.length, + 0, + 'oversize HTML fails before opening any BrowserWindow', + ); + await assert.rejects( + () => renderHtmlToPdfBuffer({ html: oversizeHtml, pageSize: 'A4' }), + /Document too large to export/, + 'renderHtmlToPdfBuffer also rejects oversize HTML before opening a BrowserWindow', + ); + assert.equal( + trackedBrowserWindows.length, + 0, + 'renderHtmlToPdfBuffer oversize check opens no BrowserWindow', + ); + + // (3) Execute save-html-as-pdf on X report, Z report, and date-range daily-sales report + const xPdfPath = path.join(testDir, `x-report-${businessDate}.pdf`); + saveDialogResult = { canceled: false, filePath: xPdfPath }; + const xSaveRes = await saveHtmlAsPdfIpc!(trustedSender, { + html: xHtml.text, + defaultFileName: `x-report-${businessDate}.pdf`, + pageSize: 'A4', + }); + assert.deepEqual( + xSaveRes, + { success: true, path: xPdfPath }, + 'save-html-as-pdf writes X report PDF', + ); + const xPdfBuffer = fs.readFileSync(xPdfPath); + assert.equal( + xPdfBuffer.subarray(0, 5).toString('ascii'), + '%PDF-', + 'X report PDF begins with %PDF-', + ); + assert.ok(xPdfBuffer.length > 0, 'X report PDF is non-empty'); + assert.ok( + lastLoadedHtmlPath.length > 0 && !fs.existsSync(lastLoadedHtmlPath), + 'X report staging HTML file is cleaned up after PDF render', + ); + + const zPdfPath = path.join( + testDir, + `z-report-Z${close.body.zReport.z_number}-${businessDate}.pdf`, + ); + saveDialogResult = { canceled: false, filePath: zPdfPath }; + const zSaveRes = await saveHtmlAsPdfIpc!(trustedSender, { + html: zHtml.text, + defaultFileName: `z-report-Z${close.body.zReport.z_number}-${businessDate}.pdf`, + pageSize: 'A4', + }); + assert.deepEqual( + zSaveRes, + { success: true, path: zPdfPath }, + 'save-html-as-pdf writes Z report PDF', + ); + const zPdfBuffer = fs.readFileSync(zPdfPath); + assert.equal( + zPdfBuffer.subarray(0, 5).toString('ascii'), + '%PDF-', + 'Z report PDF begins with %PDF-', + ); + assert.ok(zPdfBuffer.length > 0, 'Z report PDF is non-empty'); + assert.ok( + lastLoadedHtmlPath.length > 0 && !fs.existsSync(lastLoadedHtmlPath), + 'Z report staging HTML file is cleaned up after PDF render', + ); + + const dsPdfPath = path.join(testDir, `daily-sales-${businessDate}_to_${day3}.pdf`); + saveDialogResult = { canceled: false, filePath: dsPdfPath }; + const dsSaveRes = await saveHtmlAsPdfIpc!(trustedSender, { + html: dsRangeHtml.text, + defaultFileName: `daily-sales-${businessDate}_to_${day3}.pdf`, + pageSize: 'A4', + }); + assert.deepEqual( + dsSaveRes, + { success: true, path: dsPdfPath }, + 'save-html-as-pdf writes date-range daily-sales PDF', + ); + const dsPdfBuffer = fs.readFileSync(dsPdfPath); + assert.equal( + dsPdfBuffer.subarray(0, 5).toString('ascii'), + '%PDF-', + 'daily-sales PDF begins with %PDF-', + ); + assert.ok(dsPdfBuffer.length > 0, 'daily-sales PDF is non-empty'); + assert.ok( + lastLoadedHtmlPath.length > 0 && !fs.existsSync(lastLoadedHtmlPath), + 'daily-sales staging HTML file is cleaned up after PDF render', + ); + + if (!isRealElectronRuntime) { + const xPdfText = xPdfBuffer.toString('utf8'); + assert.ok( + xPdfText.includes('pageSize=A4 preferCSSPageSize=true'), + 'X report PDF render passes pageSize and preferCSSPageSize=true', + ); + const xPdfMetrics = parseHtmlSummaryMetrics(xPdfText); + for (const [metric, xlsxVal] of xMetrics.entries()) { + assert.equal( + xPdfMetrics.get(metric), + xlsxVal === null || xlsxVal === undefined ? '' : String(xlsxVal), + `X report PDF metric ${metric} matches XLSX`, + ); + } + + const zPdfText = zPdfBuffer.toString('utf8'); + assert.ok( + zPdfText.includes('pageSize=A4 preferCSSPageSize=true'), + 'Z report PDF render passes pageSize and preferCSSPageSize=true', + ); + assert.ok( + zPdfText.includes(`Z Report #${close.body.zReport.z_number} — ${businessDate}`), + 'Z report PDF contains Z report header', + ); + const zPdfMetrics = parseHtmlSummaryMetrics(zPdfText); + for (const [metric, xlsxVal] of zMetrics.entries()) { + assert.equal( + zPdfMetrics.get(metric), + xlsxVal === null || xlsxVal === undefined ? '' : String(xlsxVal), + `Z report PDF metric ${metric} matches XLSX`, + ); + } + } else { + // Real native Electron Chromium webContents.printToPDF verification + assert.equal( + trackedBrowserWindows.length, + 3, + 'exactly 3 real BrowserWindow instances were used for X, Z, and daily-sales PDFs', + ); + for (const win of trackedBrowserWindows) { + assert.equal( + win.destroyedFlag, + true, + 'every real offscreen PDF BrowserWindow is destroyed after printToPDF', + ); + } + + const xPdfInfo = extractPdfInfo(xPdfBuffer); + assert.ok(xPdfInfo.pageCount >= 1, 'real X report PDF has at least 1 page'); + assert.ok( + xPdfInfo.mediaBoxes.length === xPdfInfo.pageCount && + xPdfInfo.mediaBoxes[0][2] < xPdfInfo.mediaBoxes[0][3], + 'real X report PDF uses A4 portrait page dimensions', + ); + for (const [metric, val] of xMetrics.entries()) { + const sVal = val === null || val === undefined ? '' : String(val); + if (sVal === '') continue; + assert.ok( + xPdfInfo.normalizedText.includes(metric) && xPdfInfo.normalizedText.includes(sVal), + `real X report PDF text reconciles metric ${metric}=${sVal} with XLSX`, + ); + } + assert.ok( + xPdfInfo.title.includes( + 'كافيه النور · 東京カフェ · फ्लो कैफे · Café Élégant ', + ), + 'real X report PDF metadata title preserves full multi-script business name', + ); + for (const expectedToken of [ + '東京カフェ', + 'फ्लो कैफे', + 'Café Élégant', + `X Report — ${businessDate}`, + '₹500.00', + 'cash', + 'VAT ', + '=Formula Staff', + ]) { + assert.ok( + pdfTextIncludesToken(xPdfInfo, expectedToken), + `real X report PDF text includes "${expectedToken}"`, + ); + } + assert.ok( + xPdfInfo.normalizedText.includes('النور') || xPdfInfo.normalizedText.includes('رونلا'), + 'real X report PDF text includes Arabic store name glyphs', + ); + + const zPdfInfo = extractPdfInfo(zPdfBuffer); + assert.ok(zPdfInfo.pageCount >= 1, 'real Z report PDF has at least 1 page'); + assert.ok( + zPdfInfo.mediaBoxes.length === zPdfInfo.pageCount && + zPdfInfo.mediaBoxes[0][2] < zPdfInfo.mediaBoxes[0][3], + 'real Z report PDF uses A4 portrait page dimensions', + ); + for (const [metric, val] of zMetrics.entries()) { + const sVal = val === null || val === undefined ? '' : String(val); + if (sVal === '') continue; + assert.ok( + zPdfInfo.normalizedText.includes(metric) && zPdfInfo.normalizedText.includes(sVal), + `real Z report PDF text reconciles metric ${metric}=${sVal} with XLSX`, + ); + } + assert.ok( + zPdfInfo.title.includes( + 'كافيه النور · 東京カフェ · फ्लो कैफे · Café Élégant ', + ), + 'real Z report PDF metadata title preserves full multi-script business name', + ); + for (const expectedToken of [ + '東京カフェ', + 'फ्लो कैफे', + 'Café Élégant', + `Z Report #${close.body.zReport.z_number} — ${businessDate}`, + `Z#${close.body.zReport.z_number}`, + '=Formula Staff', + 'End of day check', + '₹600.00', + '₹650.00', + '₹50.00', + 'VAT ', + ]) { + assert.ok( + pdfTextIncludesToken(zPdfInfo, expectedToken), + `real Z report PDF text includes "${expectedToken}"`, + ); + } + + const dsPdfInfo = extractPdfInfo(dsPdfBuffer); + assert.ok( + dsPdfInfo.pageCount >= 2, + `real multi-row daily-sales PDF paginates across multiple pages (got ${dsPdfInfo.pageCount})`, + ); + assert.ok( + dsPdfInfo.mediaBoxes.length === dsPdfInfo.pageCount && + dsPdfInfo.mediaBoxes[0][2] > dsPdfInfo.mediaBoxes[0][3], + 'real daily-sales PDF uses A4 landscape page dimensions', + ); + const dsCompactText = dsPdfInfo.normalizedText.replace(/\s+/g, ''); + for (const [metric, val] of dsSummaryMetrics.entries()) { + if (val === '') continue; + assert.ok( + dsCompactText.includes(metric.replace(/\s+/g, '')) && + dsCompactText.includes(val.replace(/\s+/g, '')), + `real daily-sales PDF text reconciles summary metric ${metric}=${val} with XLSX`, + ); + } + assert.ok( + dsPdfInfo.title.includes( + 'كافيه النور · 東京カフェ · फ्लो कैफे · Café Élégant ', + ), + 'real daily-sales PDF metadata title preserves full multi-script business name', + ); + for (const expectedToken of [ + '東京カフェ', + 'फ्लो कैफे', + 'Café Élégant', + businessDate, + day2, + day3, + 'VAT ', + 'CGST', + 'SGST', + '抹茶ラテ Matcha Latte', + 'मसाला चाय Masala Chai', + 'Crème Brûlée Éclair', + 'SKU-MATCHA-01', + 'SKU-ARABIC-02', + 'SKU-CHAI-03', + 'SKU-ECLAIR-04', + 'Specialty Item 05', + 'Specialty Item 35', + 'SKU-SPEC-35', + ]) { + assert.ok( + pdfTextIncludesToken(dsPdfInfo, expectedToken), + `real daily-sales PDF text includes "${expectedToken}"`, + ); + } + assert.ok( + dsPdfInfo.pages.filter((p) => p.includes('Product ID') && p.includes('Product Name')) + .length >= 2, + 'real daily-sales PDF repeats Item Sales table header on subsequent page', + ); + + // Verify layout (no horizontal clipping/overflow) + const xLayout = await verifyHtmlLayout(xHtml.text, 'portrait', 'x-report'); + assert.ok( + xLayout.scrollWidth <= xLayout.clientWidth && xLayout.overflowingCells === 0, + `X report layout has no horizontal clipping or overflow (${JSON.stringify(xLayout)})`, + ); + + const zLayout = await verifyHtmlLayout(zHtml.text, 'portrait', 'z-report'); + assert.ok( + zLayout.scrollWidth <= zLayout.clientWidth && zLayout.overflowingCells === 0, + `Z report layout has no horizontal clipping or overflow (${JSON.stringify(zLayout)})`, + ); + + const dsLayout = await verifyHtmlLayout(dsRangeHtml.text, 'landscape', 'daily-sales'); + assert.ok( + dsLayout.scrollWidth <= dsLayout.clientWidth && dsLayout.overflowingCells === 0, + `daily-sales layout has no horizontal clipping or overflow (${JSON.stringify(dsLayout)})`, + ); + + console.log( + `real Electron printToPDF verification passed (X=${xPdfBuffer.length}B/${xPdfInfo.pageCount}p, Z=${zPdfBuffer.length}B/${zPdfInfo.pageCount}p, DailySales=${dsPdfBuffer.length}B/${dsPdfInfo.pageCount}p)`, + ); + } + + if ( + !isRealElectronRuntime && + typeof rawElectron === 'string' && + process.env.SKIP_REAL_ELECTRON_PDF !== '1' + ) { + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + server = null; + } + closeDatabase(); + + const repoRoot = path.resolve(__dirname, '..'); + const bootstrapFile = path.join(testDir, 'run-real-electron-reports-export.cjs'); + fs.writeFileSync( + bootstrapFile, + `const path = require('node:path'); +const repoRoot = ${JSON.stringify(repoRoot)}; +require(path.join(repoRoot, 'node_modules/ts-node')).register({ + transpileOnly: true, + project: path.join(repoRoot, 'tests/tsconfig.json'), +}); +require(path.join(repoRoot, 'tests/reports-export.test.ts')); +`, + 'utf8', + ); + const childEnv: NodeJS.ProcessEnv = { ...process.env }; + delete childEnv.ELECTRON_RUN_AS_NODE; + const electronRun = spawnSync(rawElectron, ['--no-sandbox', bootstrapFile], { + env: childEnv, + stdio: 'inherit', + timeout: 120_000, + }); + assert.equal( + electronRun.status, + 0, + `real Electron printToPDF test run exited with status ${String(electronRun.status)}`, + ); + } + console.log('reports-export tests passed'); } finally { if (server) await new Promise((resolve) => server.close(() => resolve())); @@ -138,7 +1475,16 @@ async function run(): Promise { } } -run().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; -}); +run() + .then(() => { + if (isRealElectronRuntime) { + rawElectron.app.exit(0); + } + }) + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + if (isRealElectronRuntime) { + rawElectron.app.exit(1); + } + }); 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,