From 76ffe5bf92301880a144c35c686e9e24826a5427 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 14:44:45 +0200 Subject: [PATCH 01/67] Start new v0.12.0 development Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/CHANGELOG.md | 6 ++++++ packages/ksef-client-ts/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ksef-client-ts/CHANGELOG.md b/packages/ksef-client-ts/CHANGELOG.md index d4dc72b5..f80c1f43 100644 --- a/packages/ksef-client-ts/CHANGELOG.md +++ b/packages/ksef-client-ts/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [0.12.0] - Unreleased + +### Added + +### Fixed + ## [0.11.0] - 2026-08-27 ### Changed (breaking) diff --git a/packages/ksef-client-ts/package.json b/packages/ksef-client-ts/package.json index 0d3e6890..ed934c75 100644 --- a/packages/ksef-client-ts/package.json +++ b/packages/ksef-client-ts/package.json @@ -1,6 +1,6 @@ { "name": "ksef-client-ts", - "version": "0.11.0", + "version": "0.12.0", "description": "TypeScript client for the Polish National e-Invoice System (KSeF) API", "type": "module", "sideEffects": false, From b49d9b7258e6b3df1590ed893884ffc105c4a752 Mon Sep 17 00:00:00 2001 From: Flop Butylkin Date: Mon, 6 Jul 2026 00:07:15 +0200 Subject: [PATCH 02/67] feat(pdf): render invoices & UPO to PDF via optional ksef-client-ts/pdf subpath Add a node-only `ksef-client-ts/pdf` subpath that renders FA(2)/FA(3) invoices and UPO(4.2)/(4.3) receipts to PDF from a template-driven block DSL, with pl/en/pl+en labels and automatic KSeF Code I QR whose hash is taken over the original input bytes. pdfmake is an optional peer (^0.2.20), lazily loaded, so the core install stays clean and `./pdf` imports never pull it in. Also adds the `ksef invoice pdf` CLI command, built-in templates for each supported version, i18n bundles, a verification harness (attw / publint / pdf-types / cold-subpath across the Node 18/20/22 matrix), a how-to guide, and the OpenSpec spec for the new invoice-pdf-render capability. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EaMDwzgFf8zzWn7mM1Tjq5 (cherry picked from commit 1e46f55991ff39b4597ff0361498fc903c82e0fe) --- .github/workflows/ci.yml | 18 + README.md | 1 + openspec/specs/cli-invoice/spec.md | 66 +- openspec/specs/invoice-pdf-render/spec.md | 212 +++++ packages/ksef-client-ts/CHANGELOG.md | 3 + .../ksef-client-ts/docs/.vitepress/config.ts | 1 + packages/ksef-client-ts/docs/index.md | 2 + packages/ksef-client-ts/docs/pdf-export.md | 302 ++++++ packages/ksef-client-ts/package.json | 22 +- .../ksef-client-ts/scripts/check-pdf-cold.mjs | 69 ++ .../src/cli/commands/invoice.ts | 76 +- packages/ksef-client-ts/src/pdf/accessor.ts | 97 ++ packages/ksef-client-ts/src/pdf/errors.ts | 13 + packages/ksef-client-ts/src/pdf/fonts.ts | 110 +++ packages/ksef-client-ts/src/pdf/format.ts | 62 ++ packages/ksef-client-ts/src/pdf/i18n/en.ts | 37 + packages/ksef-client-ts/src/pdf/i18n/index.ts | 50 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 43 + packages/ksef-client-ts/src/pdf/i18n/types.ts | 11 + packages/ksef-client-ts/src/pdf/index.ts | 195 ++++ packages/ksef-client-ts/src/pdf/parse.ts | 66 ++ .../src/pdf/pdfmake-modules.d.ts | 11 + packages/ksef-client-ts/src/pdf/qr.ts | 72 ++ .../src/pdf/template/blocks/annotations.ts | 23 + .../src/pdf/template/blocks/footer.ts | 16 + .../src/pdf/template/blocks/header.ts | 33 + .../src/pdf/template/blocks/image.ts | 14 + .../src/pdf/template/blocks/index.ts | 31 + .../src/pdf/template/blocks/lines.ts | 30 + .../src/pdf/template/blocks/parties.ts | 22 + .../src/pdf/template/blocks/payment.ts | 24 + .../src/pdf/template/blocks/qr.ts | 14 + .../src/pdf/template/blocks/table.ts | 45 + .../src/pdf/template/blocks/totals.ts | 29 + .../src/pdf/template/builtin/fa2-default.json | 81 ++ .../src/pdf/template/builtin/fa3-default.json | 81 ++ .../src/pdf/template/builtin/index.ts | 26 + .../src/pdf/template/builtin/upo-4_2.json | 43 + .../src/pdf/template/builtin/upo-4_3.json | 43 + .../ksef-client-ts/src/pdf/template/dsl.ts | 311 ++++++ .../src/pdf/template/interpret.ts | 175 ++++ .../tests/fixtures/pdf-types-check.ts | 41 + .../ksef-client-ts/tests/fixtures/pdf/fa2.xml | 75 ++ .../ksef-client-ts/tests/fixtures/pdf/fa3.xml | 75 ++ .../tests/fixtures/pdf/upo-4_2.xml | 24 + .../tests/fixtures/pdf/upo-4_3.xml | 24 + .../unit/cli/commands/invoice-pdf.test.ts | 186 ++++ .../tests/unit/pdf/accessor.test.ts | 242 +++++ .../tests/unit/pdf/blocks-primitive.test.ts | 150 +++ .../tests/unit/pdf/blocks-semantic.test.ts | 383 ++++++++ .../ksef-client-ts/tests/unit/pdf/dsl.test.ts | 138 +++ .../tests/unit/pdf/errors.test.ts | 20 + .../tests/unit/pdf/fonts-loader.test.ts | 37 + .../tests/unit/pdf/fonts.test.ts | 65 ++ .../tests/unit/pdf/format.test.ts | 117 +++ .../tests/unit/pdf/i18n.test.ts | 64 ++ .../tests/unit/pdf/interpret.test.ts | 336 +++++++ .../tests/unit/pdf/parse.test.ts | 154 +++ .../ksef-client-ts/tests/unit/pdf/qr.test.ts | 146 +++ .../tests/unit/pdf/render-builtins.test.ts | 59 ++ .../tests/unit/pdf/render-smoke.test.ts | 69 ++ .../ksef-client-ts/tsconfig.pdf-check.json | 12 + packages/ksef-client-ts/tsup.config.ts | 7 +- yarn.lock | 887 +++++++++++++++++- 64 files changed, 5877 insertions(+), 14 deletions(-) create mode 100644 openspec/specs/invoice-pdf-render/spec.md create mode 100644 packages/ksef-client-ts/docs/pdf-export.md create mode 100644 packages/ksef-client-ts/scripts/check-pdf-cold.mjs create mode 100644 packages/ksef-client-ts/src/pdf/accessor.ts create mode 100644 packages/ksef-client-ts/src/pdf/errors.ts create mode 100644 packages/ksef-client-ts/src/pdf/fonts.ts create mode 100644 packages/ksef-client-ts/src/pdf/format.ts create mode 100644 packages/ksef-client-ts/src/pdf/i18n/en.ts create mode 100644 packages/ksef-client-ts/src/pdf/i18n/index.ts create mode 100644 packages/ksef-client-ts/src/pdf/i18n/pl.ts create mode 100644 packages/ksef-client-ts/src/pdf/i18n/types.ts create mode 100644 packages/ksef-client-ts/src/pdf/index.ts create mode 100644 packages/ksef-client-ts/src/pdf/parse.ts create mode 100644 packages/ksef-client-ts/src/pdf/pdfmake-modules.d.ts create mode 100644 packages/ksef-client-ts/src/pdf/qr.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/footer.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/header.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/image.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/index.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/lines.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/parties.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/payment.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/qr.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/table.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/totals.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json create mode 100644 packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json create mode 100644 packages/ksef-client-ts/src/pdf/template/builtin/index.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json create mode 100644 packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json create mode 100644 packages/ksef-client-ts/src/pdf/template/dsl.ts create mode 100644 packages/ksef-client-ts/src/pdf/template/interpret.ts create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf-types-check.ts create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/upo-4_2.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/upo-4_3.xml create mode 100644 packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/accessor.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/errors.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/fonts-loader.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/format.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/parse.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/qr.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/render-smoke.test.ts create mode 100644 packages/ksef-client-ts/tsconfig.pdf-check.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5a3ce55..ed50ada5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,24 @@ jobs: - name: Check /node entry types run: yarn check:node-types + # Proves the ./pdf public types resolve WITHOUT @types/pdfmake in scope — + # a consumer that has not installed pdfmake still type-checks ./pdf. + - name: Check /pdf entry types + run: yarn check:pdf-types + + # Runs on every matrix Node (18/20/22): the ./pdf subpath imports/requires + # without eagerly loading pdfmake and never throws at module-load time. + - name: Check /pdf cold subpath + run: node packages/ksef-client-ts/scripts/check-pdf-cold.mjs + + # Publish-correctness of the package exports (incl. the new ./pdf subpath). + # Runs once — the built dist is identical across the matrix. + - name: Check package exports (attw + publint) + if: matrix.node-version == 22 + run: | + yarn check:attw + yarn check:publint + - name: Check fs-free core bundles run: node packages/ksef-client-ts/scripts/check-fs-free-core.mjs diff --git a/README.md b/README.md index 7114ac52..5b8cdc87 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ TypeScript client for the Polish National e-Invoice System (KSeF) API v2. - **Multiple document structures** — FA, PEF, PEF_KOR, FA_RR with typed FormCode constants and UPO parsing - **Invoice XML serialization (FA2/FA3/PEF/PEF_KOR)** — build XSD-compliant invoice XML from typed TypeScript objects with correct element ordering (including the FA3 per-VAT-rate interleave) and namespace injection; `ksef invoice build` exposes the same pipeline to shell workflows with JSON/YAML input and optional XSD validation - **Invoice XML validation** — three-level client-side validation (well-formedness, XSD schema via Zod, NIP/PESEL checksums, future date rejection) with auto-detection for all 6 invoice types +- **PDF visualization** — render FA(2)/FA(3) invoices and UPO(4.2)/(4.3) receipts to print-ready PDF offline from version-specific, declarative templates, with Polish/English/bilingual labels and the embedded KSeF Code I verification QR; `ksef invoice pdf` brings the same rendering to shell workflows. Requires the optional `pdfmake` peer (`npm i "pdfmake@^0.2.20"`), so the core install stays dependency-free - **Typed errors with RFC 7807 Problem Details** — `KSeFError` hierarchy with dedicated classes for 400/401/403/410/429 carrying structured diagnostic context; exhaustive dispatch via the `KSeFApiProblem` union and `assertNever`; fluent request builders - **Comprehensive test coverage** — unit + E2E tests across HTTP, crypto, services, workflows; CI on every change - **Interactive setup wizard** — `ksef setup` guides through environment selection, authentication, and token generation in one flow diff --git a/openspec/specs/cli-invoice/spec.md b/openspec/specs/cli-invoice/spec.md index 231921de..68ff9e74 100644 --- a/openspec/specs/cli-invoice/spec.md +++ b/openspec/specs/cli-invoice/spec.md @@ -1,5 +1,7 @@ -## MODIFIED Requirements +## Purpose +The `cli-invoice` capability defines the `ksef invoice` command group, which submits invoices to KSeF from the command line. It covers sending a single invoice XML file — reading the file, computing its hash and size, encrypting the content via the client crypto layer, and dispatching it through an active online session. It also handles form-code selection and optional pre-send schema validation. +## Requirements ### Requirement: Send single invoice The CLI SHALL provide `ksef invoice send ` to send a single invoice. The CLI MUST read the XML file, compute its hash and size, encrypt the content via `client.crypto`, and call `OnlineSessionService.sendInvoice()`. Crypto MUST be initialized automatically (`client.crypto.init()`). It MUST accept an optional `--form-code ` flag where `` is one of `FA2`, `FA3`, `PEF3`, `PEFKOR3`, `FARR1`. It MUST accept an optional `--validate` flag that runs schema validation before sending. @@ -34,3 +36,65 @@ The CLI SHALL provide `ksef invoice send ` to send a single invoice. T #### Scenario: Send with validation passing - **WHEN** user runs `ksef invoice send invoice.xml --validate` and the XML is valid - **THEN** CLI proceeds with encryption and sending normally + +### Requirement: Render an invoice or UPO to PDF + +The CLI SHALL provide `ksef invoice pdf ` to render a KSeF invoice or UPO XML file to a PDF. The command MUST lazily bridge into the internal PDF module and, when `pdfmake` is not installed or is an incompatible version, MUST surface the module's friendly installation error (`npm i "pdfmake@^0.2.20"`) rather than a raw crash. By default the output PDF MUST be written next to the source file with a `.pdf` extension. + +The command MUST accept: +- `--template ` — select a built-in template by name; mutually exclusive with `--template-file`. +- `--template-file ` — load a custom `.json` template from a path; mutually exclusive with `--template`. +- `--locale ` — label language, defaulting to `pl`. +- `--out ` — output path override. +- `--qr` — embed the KSeF Code I QR derived from the invoice XML. +- `--ksef-number ` — the KSeF number to print; when absent the visualization is marked OFFLINE. +- `--upo` — treat the input as a UPO document (otherwise the version is auto-detected). +- `--env ` — environment used to derive the QR base URL. + +When neither `--template` nor `--template-file` is given, the command MUST select the default built-in template matching the detected XML version. + +#### Scenario: Render an invoice with the default built-in template + +- **WHEN** user runs `ksef invoice pdf invoice.xml` with no template flag +- **THEN** CLI detects the invoice version, renders it with the matching built-in template, and writes `invoice.pdf` next to the source file + +#### Scenario: Render with a built-in template by name + +- **WHEN** user runs `ksef invoice pdf invoice.xml --template fa3-default` +- **THEN** CLI renders the invoice using the named built-in template + +#### Scenario: Render with a custom template file + +- **WHEN** user runs `ksef invoice pdf invoice.xml --template-file ./my-template.json` +- **THEN** CLI loads the custom template from the path and renders the invoice + +#### Scenario: Mutually exclusive template flags + +- **WHEN** user runs `ksef invoice pdf invoice.xml --template fa3-default --template-file ./my-template.json` +- **THEN** CLI SHALL display an error stating that `--template` and `--template-file` cannot be combined + +#### Scenario: Output path override + +- **WHEN** user runs `ksef invoice pdf invoice.xml --out /tmp/result.pdf` +- **THEN** CLI writes the PDF to `/tmp/result.pdf` instead of next to the source file + +#### Scenario: Embed QR code + +- **WHEN** user runs `ksef invoice pdf invoice.xml --qr` +- **THEN** CLI renders the PDF with the KSeF Code I QR derived from the invoice XML + +#### Scenario: Render a UPO document + +- **WHEN** user runs `ksef invoice pdf upo.xml --upo` +- **THEN** CLI renders the UPO receipt using the matching built-in UPO template + +#### Scenario: pdfmake not installed + +- **WHEN** user runs `ksef invoice pdf invoice.xml` without `pdfmake` installed +- **THEN** CLI SHALL display the friendly installation hint `npm i "pdfmake@^0.2.20"` rather than a raw module-not-found error + +#### Scenario: Localized labels + +- **WHEN** user runs `ksef invoice pdf invoice.xml --locale pl+en` +- **THEN** CLI renders the PDF with bilingual Polish/English labels + diff --git a/openspec/specs/invoice-pdf-render/spec.md b/openspec/specs/invoice-pdf-render/spec.md new file mode 100644 index 00000000..05e09ee1 --- /dev/null +++ b/openspec/specs/invoice-pdf-render/spec.md @@ -0,0 +1,212 @@ +# invoice-pdf-render Specification + +## Purpose +Render a KSeF invoice or UPO receipt XML into a PDF via the node-only `ksef-client-ts/pdf` subpath. The capability covers a template-driven block DSL (bindings, repeaters, conditions, formatters) over an accessor layer that smooths compact XML parsing, built-in version-specific templates for FA(2)/FA(3)/UPO(4.2)/UPO(4.3), `pl`/`en`/`pl+en` label localization, automatic KSeF Code I QR derivation whose hash is taken over the original input bytes, custom-template loading with validation and version matching, and an optional lazily-loaded `pdfmake` peer so the core install stays clean. +## Requirements +### Requirement: Render invoice XML to PDF via the `ksef-client-ts/pdf` subpath + +The library SHALL expose a node-only subpath entry point `ksef-client-ts/pdf` that renders a KSeF invoice XML document into a PDF and returns it as a `Uint8Array`. The entry point MUST accept the XML as either a `string` or a `Uint8Array`. The subpath MUST be isolated from the primary `.` entry point so that importing `.` never loads the PDF stack and never violates the fs-free invariant of `.`. + +#### Scenario: Render an FA(3) invoice to PDF bytes + +- **WHEN** a caller imports `renderInvoicePdf` from `ksef-client-ts/pdf` and calls it with a valid FA(3) XML and a built-in template name +- **THEN** the call resolves to a non-empty `Uint8Array` whose bytes begin with `%PDF-` and end with `%%EOF` + +#### Scenario: Primary entry point stays free of the PDF stack + +- **WHEN** a consumer imports only the primary `.` entry point +- **THEN** the PDF stack (and `pdfmake`) MUST NOT be loaded and the fs-free invariant of `.` MUST hold + +### Requirement: Three template-source entry points + +The module SHALL provide three separate render functions distinguished by template source, so the caller never has to disambiguate "is this a name or a path": a built-in template selected by name, a custom template loaded from a file path, and a custom template passed as a DSL object. The module SHALL additionally provide a dedicated UPO renderer. + +#### Scenario: Render with a built-in template by name + +- **WHEN** the caller invokes the by-name render function with `'fa3-default'` +- **THEN** the module resolves the corresponding built-in template and renders the invoice + +#### Scenario: Render with a custom template from a file + +- **WHEN** the caller invokes the from-file render function with a path to a `.json` template +- **THEN** the module reads the file, validates it, and renders the invoice + +#### Scenario: Render with a custom template object + +- **WHEN** the caller invokes the from-template render function with a DSL template object +- **THEN** the module validates the object and renders the invoice without reading the filesystem + +### Requirement: Invoice and UPO version detection + +The module SHALL detect the document version from the XML. It MUST distinguish `FA(2)` and `FA(3)` invoices and `UPO(4.2)` and `UPO(4.3)` receipts, and MUST return a null-like result for unrecognized documents. FA(1) MUST NOT be supported. + +#### Scenario: Detect FA(3) + +- **WHEN** `detectInvoiceVersion` is called with an FA(3) invoice XML +- **THEN** it returns `FA(3)` + +#### Scenario: Detect UPO version + +- **WHEN** `detectUpoVersion` is called with a UPO(4.3) receipt XML +- **THEN** it returns `UPO(4.3)` + +#### Scenario: Unrecognized document + +- **WHEN** a version-detection function is called with XML that is neither a supported invoice nor a supported UPO +- **THEN** it returns a null-like result rather than throwing + +### Requirement: Built-in templates for supported versions + +The module SHALL ship built-in templates for at least `fa2-default`, `fa3-default`, `upo-4_2`, and `upo-4_3`. Built-in templates MUST be bundled into the distributed output (not read from the filesystem at runtime). + +#### Scenario: Built-in template renders without runtime filesystem access + +- **WHEN** a built-in template is selected by name in an environment where the package's own template files are not readable from disk +- **THEN** rendering still succeeds because the template is bundled into the distributed module + +#### Scenario: Built-in templates are self-consistent + +- **WHEN** each built-in template is rendered in strict mode against a fixture that populates every optional field +- **THEN** rendering succeeds with no unresolved binding, so a typo in a built-in template's dot-path surfaces as an error rather than an empty string + +### Requirement: Template DSL interpretation + +The module SHALL interpret a declarative, version-specific block DSL into a PDF layout. The DSL MUST support semantic blocks and layout-primitive blocks, dot-path bindings into the parsed XML, repeaters that iterate a collection, conditions that show a block only when a field is present or truthy, and value formatters (money / date / number / nip). The DSL MUST NOT provide general scripting. Container blocks MUST nest recursively up to a bounded depth. + +#### Scenario: Repeater over invoice lines + +- **WHEN** a template contains a repeater block bound to the invoice-lines collection +- **THEN** the interpreter emits one rendered row per line + +#### Scenario: Conditional block + +- **WHEN** a block declares a condition on a field that is absent +- **THEN** the interpreter omits that block from the output + +#### Scenario: Value formatter + +- **WHEN** a binding declares a `money` formatter over a numeric field +- **THEN** the rendered value is formatted as a monetary amount rather than the raw string + +#### Scenario: Nesting depth limit exceeded + +- **WHEN** a template nests container blocks beyond the allowed depth +- **THEN** interpretation fails with a clear error + +### Requirement: Accessor layer over compact XML parsing + +Field access from templates SHALL go through an accessor layer that smooths compact-parse artifacts, not directly against the parsed object. A repeater's collection MUST always be read as an array, so a single-element collection that the parser collapsed into an object is handled identically to a multi-element collection. Field access MUST descend safely (a missing intermediate segment yields an absent value, not a throw), MUST unwrap mixed-content text nodes, and MUST support reading an attribute segment. + +#### Scenario: Single-line invoice does not break the repeater + +- **WHEN** an invoice has exactly one line and the parser collapsed the lines collection into a single object +- **THEN** the repeater still iterates exactly once, as if the collection were an array + +#### Scenario: Safe descent through a missing field + +- **WHEN** a binding path descends through a segment that is absent in the document +- **THEN** the accessor yields an empty/absent value without throwing (subject to strict mode) + +### Requirement: Missing-binding behavior is configurable via strict mode + +By default a missing binding SHALL resolve to an empty string, because KSeF invoices contain many optional fields. When strict mode is enabled, a missing binding MUST instead throw a clear error identifying the failing path. + +#### Scenario: Lenient default + +- **WHEN** a template binds a field that is absent and strict mode is not enabled +- **THEN** the field renders as an empty string and rendering succeeds + +#### Scenario: Strict mode surfaces the missing path + +- **WHEN** a template binds a field that is absent and strict mode is enabled +- **THEN** rendering fails with an error naming the missing binding path + +### Requirement: Multi-language labels + +The module SHALL support label localization in `pl`, `en`, and `pl+en`, selected per render call and defaulting to `pl`. Only `pl` and `en` label bundles are maintained; `pl+en` MUST be produced by concatenating the two with a configurable separator (defaulting to `' / '`). A custom template MAY override individual labels; a missing label key MUST fall back to `pl`. + +#### Scenario: English labels + +- **WHEN** a render is requested with locale `en` +- **THEN** block labels are printed from the English bundle + +#### Scenario: Bilingual labels with a custom separator + +- **WHEN** a render is requested with locale `pl+en` and a newline separator +- **THEN** each label prints the Polish and English text joined by the newline + +### Requirement: Automatic QR (Code I) derivation + +When QR output is requested, the module SHALL build the KSeF Code I verification URL from the invoice XML using the core verification-link service, and render it as a QR code in the PDF. The seller NIP and issue date used for the URL MAY be read from the parsed XML. The base verification URL MUST derive from an explicit override or from the selected environment, defaulting to production. + +#### Scenario: QR requested builds the same URL as the core service + +- **WHEN** QR output is requested for an invoice +- **THEN** the URL encoded in the QR equals the URL the core verification-link service produces for the same NIP, issue date, and hash + +#### Scenario: QR disabled + +- **WHEN** QR output is not requested +- **THEN** no QR code is rendered and no verification URL is built + +### Requirement: QR hash is computed over the original input bytes + +The invoice hash used for the QR verification URL SHALL be computed over the original input bytes, bypassing the XML parser and any normalization. Field extraction and hashing MUST be two independent consumptions of the input. A `Uint8Array` input MUST be hashed directly. A `string` input MUST be hashed as its UTF-8 bytes with no BOM stripping, re-encoding, or line-ending normalization. A caller-supplied canonical hash MUST be used verbatim without recomputation. + +#### Scenario: Byte-identical inputs produce the same hash + +- **WHEN** the same document is rendered once as a `Uint8Array` and once as the equivalent UTF-8 `string` with no BOM and no reformatting +- **THEN** both produce the same QR hash + +#### Scenario: Byte-level changes change the hash + +- **WHEN** the same logical document is hashed with an added BOM, CRLF line endings, or pretty-printing +- **THEN** the resulting hash differs, proving the hash is taken over raw bytes rather than reparsed XML + +#### Scenario: Caller-supplied hash overrides recomputation + +- **WHEN** a caller passes a known canonical hash +- **THEN** the module uses it verbatim and does not recompute a hash from the input + +### Requirement: Custom template validation and version matching + +The module SHALL validate custom templates before rendering. An unknown block type or structurally invalid template MUST produce a clear error. A template whose declared schema does not match the detected XML version MUST be rejected with a version-mismatch error. + +#### Scenario: Unknown block type + +- **WHEN** a custom template contains an unknown block type +- **THEN** validation fails with a clear error identifying the problem + +#### Scenario: Template/version mismatch + +- **WHEN** an FA(2) template is used to render an FA(3) invoice +- **THEN** the module rejects the render with a version-mismatch error + +### Requirement: Optional `pdfmake` peer dependency loaded lazily + +The module SHALL treat `pdfmake` as an optional peer dependency pinned to `^0.2.20` and load it lazily only when a render function is called. Importing or requiring the subpath without `pdfmake` installed MUST NOT throw; the absence MUST surface only on a render call, as a friendly error advising `npm i "pdfmake@^0.2.20"`. After the lazy load, the module MUST verify the resolved `pdfmake` version satisfies `^0.2.20` and MUST reject an incompatible version (including 0.3.x) with a clear error. The subpath's public types MUST NOT require consumers to have `pdfmake` types installed. + +#### Scenario: Import without pdfmake does not throw + +- **WHEN** the subpath is imported (ESM) or required (CJS) while `pdfmake` is not installed +- **THEN** the module loads successfully and does not throw at import/require time + +#### Scenario: Render without pdfmake gives a friendly error + +- **WHEN** a render function is called while `pdfmake` is not installed +- **THEN** the call fails with a friendly error advising installation of `pdfmake@^0.2.20`, not a raw module-not-found or transitive-dependency crash + +#### Scenario: Incompatible pdfmake version is rejected + +- **WHEN** a render function is called with an installed `pdfmake` version outside `^0.2.20` (for example 0.3.x) +- **THEN** the call fails with a clear error stating the required range and the version found + +### Requirement: Render UPO receipts to PDF + +The module SHALL render UPO(4.2) and UPO(4.3) receipt XML to a PDF using the same engine and the built-in UPO templates. + +#### Scenario: Render a UPO receipt + +- **WHEN** the UPO render function is called with a valid UPO receipt XML +- **THEN** it resolves to a non-empty `Uint8Array` beginning with `%PDF-` + diff --git a/packages/ksef-client-ts/CHANGELOG.md b/packages/ksef-client-ts/CHANGELOG.md index f80c1f43..a56f09b3 100644 --- a/packages/ksef-client-ts/CHANGELOG.md +++ b/packages/ksef-client-ts/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file. ### Added +- **Invoice and receipt PDF export** — render KSeF invoices and their official UPO receipts to print-ready PDF documents offline, with Polish, English, or bilingual labels, an embedded KSeF verification code, and a choice of ready-made layouts or your own custom templates, also available from the command line. +- **Optional PDF engine, kept out of the core install** — PDF rendering relies on an optional add-on that is not pulled in automatically, so installing the library adds no extra dependencies until you opt into PDF output. + ### Fixed ## [0.11.0] - 2026-08-27 diff --git a/packages/ksef-client-ts/docs/.vitepress/config.ts b/packages/ksef-client-ts/docs/.vitepress/config.ts index b6e84551..214ccd34 100644 --- a/packages/ksef-client-ts/docs/.vitepress/config.ts +++ b/packages/ksef-client-ts/docs/.vitepress/config.ts @@ -73,6 +73,7 @@ export default defineConfig({ { text: 'Polish Holidays', link: '/polish-holidays' }, { text: 'Validation', link: '/validation' }, { text: 'XML Serialization', link: '/xml-serialization' }, + { text: 'PDF Export', link: '/pdf-export' }, { text: 'Error Handling', link: '/error-handling' }, { text: 'External Signing', link: '/external-signing' }, { text: 'Models & Types', link: '/models' }, diff --git a/packages/ksef-client-ts/docs/index.md b/packages/ksef-client-ts/docs/index.md index bdf22509..f3139af1 100644 --- a/packages/ksef-client-ts/docs/index.md +++ b/packages/ksef-client-ts/docs/index.md @@ -48,6 +48,8 @@ features: details: Build XSD-compliant FA2, FA3, PEF, and PEF_KOR XML from typed TypeScript objects. Correct element ordering (including the FA3 per-VAT-rate P_13/P_14/P_14W interleave), natural P_* sort, automatic namespace injection, and pass-through for pre-built XML strings and buffers. The `ksef invoice build` CLI wraps the same pipeline for JSON or YAML input with optional Zod and XSD validation. - title: Invoice XML Validation details: Three-level client-side validation against official KSeF XSD schemas — well-formedness, schema structure (via generated Zod validators), and business rules (NIP/PESEL checksums, future date rejection). Supports all 6 invoice types with auto-detection. CLI batch validation, programmatic API, and opt-in pre-send validation in workflows. + - title: PDF Visualization + details: Render FA (2)/(3) invoices and UPO (4.2)/(4.3) receipts to print-ready PDF offline from version-specific, declarative templates — Polish, English, or bilingual labels, an embedded KSeF Code I verification QR, and built-in or custom layouts. The `ksef invoice pdf` CLI brings the same rendering to shell workflows. PDF output uses the optional `pdfmake` peer (`npm i "pdfmake@^0.2.20"`), so the core install stays dependency-free. - title: Typed Errors with RFC 7807 Problem Details details: KSeFError hierarchy with dedicated classes for 400, 401, 403, 410, and 429 carrying structured diagnostic context (trace IDs, required-vs-present permissions, validation error lists). Exhaustive dispatch via the KSeFApiProblem union and assertNever helper. Fluent request builders catch mistakes at compile time before they hit the network. - title: Comprehensive Test Coverage diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md new file mode 100644 index 00000000..1d8493ca --- /dev/null +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -0,0 +1,302 @@ +# PDF Export + +Render KSeF invoice and UPO receipt XML to a print-ready PDF, entirely offline, from a declarative template. Covers installing the optional `pdfmake` peer, the render functions, choosing and authoring a template, labels and locales, the verification QR code, and the CLI command. + +--- + +## Overview + +The `ksef-client-ts/pdf` subpath turns a KSeF document (invoice or UPO receipt) into a PDF visualization. Rendering is driven by a **template** — a declarative JSON block layout that maps XML fields onto page elements — so the visual output is fully customizable without touching library code. + +The PDF layer handles two concerns: + +1. **Layout** — a version-specific template describes the page as a tree of semantic blocks (header, parties, lines, totals, …) and primitives (text, columns, table, …). +2. **Rendering** — the template is interpreted into a PDF document and returned as raw bytes (`Uint8Array`) that you write to disk or stream to a client. + +Supported documents: + +| Document | Versions | Default built-in template | +|----------|----------|---------------------------| +| Standard invoice | `FA(2)`, `FA(3)` | `fa2-default`, `fa3-default` | +| UPO receipt | `UPO(4.2)`, `UPO(4.3)` | `upo-4_2`, `upo-4_3` | + +`FA(1)` is not supported. + +::: tip Node-only subpath +`ksef-client-ts/pdf` is a Node.js subpath — it reads bytes and returns bytes, and is not part of the fs-free core entry point. Importing it never pulls `pdfmake` into your bundle; the dependency is loaded lazily only when a render function actually runs. +::: + +--- + +## Install the pdfmake peer + +PDF rendering uses [`pdfmake`](https://www.npmjs.com/package/pdfmake) as an **optional peer dependency**. It is never installed automatically, so the core `ksef-client-ts` install stays dependency-free. To enable PDF output, install it explicitly: + +```bash +npm i "pdfmake@^0.2.20" +``` + +Pin the `^0.2.20` range. **pdfmake 0.3.x is not supported** (its import and font-storage shape differ). Importing `ksef-client-ts/pdf` without `pdfmake` present still succeeds — only calling a render function throws a friendly, actionable install error: + +```text +PDF rendering requires the optional peer dependency "pdfmake" (^0.2.20), +which is not installed. Install it with: npm i "pdfmake@^0.2.20" +``` + +--- + +## Render functions + +All four render functions accept the XML as a `string` or a `Uint8Array`, take an optional `RenderOptions` object, and resolve to a `Uint8Array` of PDF bytes. + +> Passing the **raw file bytes** (`Uint8Array`) rather than a decoded string is recommended when embedding the QR code: the verification hash is computed over the original bytes, so it matches the value registered by KSeF exactly. + +### `renderInvoicePdf(xml, name, opts?)` — built-in template + +Render an invoice with one of the built-in templates, selected by name. + +```ts +import { readFile, writeFile } from 'node:fs/promises'; +import { renderInvoicePdf } from 'ksef-client-ts/pdf'; + +const xml = await readFile('invoice.xml'); // Buffer is a Uint8Array +const pdf = await renderInvoicePdf(xml, 'fa3-default', { locale: 'pl+en', qr: true }); +await writeFile('invoice.pdf', pdf); +``` + +### `renderInvoicePdfFromFile(xml, path, opts?)` — custom template file + +Render with a custom template loaded from a `.json` file. The template is validated before rendering; a structural problem throws with a path-tagged message. + +```ts +import { renderInvoicePdfFromFile } from 'ksef-client-ts/pdf'; + +const pdf = await renderInvoicePdfFromFile(xml, './templates/my-invoice.json', { + locale: 'pl', +}); +``` + +### `renderInvoicePdfFromTemplate(xml, template, opts?)` — custom template object + +Render with a template you build in code (for example, generated at runtime). + +```ts +import { renderInvoicePdfFromTemplate, type InvoiceTemplate } from 'ksef-client-ts/pdf'; + +const template: InvoiceTemplate = { + schema: 'FA(3)', + blocks: [ + { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, + { type: 'totals', rows: [{ label: 'totalDue', path: 'Fa.P_15', format: 'money' }] }, + ], +}; + +const pdf = await renderInvoicePdfFromTemplate(xml, template); +``` + +### `renderUpoPdf(xml, opts?)` — UPO receipt + +Render a UPO receipt. The version (`UPO(4.2)` / `UPO(4.3)`) is auto-detected and the matching built-in template is used. + +```ts +import { renderUpoPdf } from 'ksef-client-ts/pdf'; + +const pdf = await renderUpoPdf(await readFile('upo.xml')); +``` + +### Version detection helpers + +`detectInvoiceVersion` and `detectUpoVersion` inspect the XML and return the detected version or `null`. + +```ts +import { detectInvoiceVersion, detectUpoVersion } from 'ksef-client-ts/pdf'; + +detectInvoiceVersion(xml); // 'FA(2)' | 'FA(3)' | null +detectUpoVersion(xml); // 'UPO(4.2)' | 'UPO(4.3)' | null +``` + +--- + +## Render options + +`RenderOptions` (all optional) is the last argument of every render function: + +| Option | Type | Purpose | +|--------|------|---------| +| `locale` | `'pl' \| 'en' \| 'pl+en'` | Label language. Default `'pl'`. | +| `qr` | `boolean` | Embed the KSeF Code I verification QR derived from the invoice XML. | +| `ksefNumber` | `string` | KSeF number printed on the visualization; when absent the document is marked **OFFLINE**. | +| `env` | `'prod' \| 'test' \| 'demo'` | Environment used to derive the QR base URL. Default `'prod'`. | +| `baseQrUrl` | `string` | Override the QR base URL (offline / non-standard). | +| `logo` | `string` | Logo image as a `data:` URI. | +| `theme` | `{ accent?: string }` | Accent colour. | +| `bilingualSeparator` | `string` | Separator for the `pl+en` locale. Default `' / '`. | +| `strict` | `boolean` | Throw on a missing binding instead of rendering an empty string. | +| `invoiceHash` | `string` | Precomputed canonical invoice hash (base64), used verbatim for the QR. | + +--- + +## Templates + +A template is a declarative JSON document. It is deliberately **not** a scripting language — there are blocks, field bindings, conditions, repeaters, and formatters, and nothing else. Custom layouts compose the built-in blocks rather than register code. + +### Structure + +```text +{ + "schema": "FA(3)", // targets one XML kind (required) + "page": { … }, // size, orientation, margins + "defaultStyle": { … }, // pdfmake style props applied everywhere + "styles": { "title": … }, // named, reusable style bags + "labels": { "seller": … }, // per-template label overrides + "blocks": [ … ] // the page content (required) +} +``` + +The `schema` field binds a template to a single document kind. If you render an `FA(2)` document with an `FA(3)` template (or vice versa), the engine rejects the mismatch rather than producing a broken PDF. + +### Blocks + +**Semantic blocks** carry meaning and lay themselves out: + +| Block | Renders | +|-------|---------| +| `header` | Title, invoice number, date, optional logo | +| `parties` | Seller / buyer two-column panel | +| `lines` | Invoice line-item table | +| `totals` | Net / VAT / gross summary rows | +| `payment` | Payment details (amount paid, date, method) | +| `annotations` | Miscellaneous labelled fields | +| `qr` | The verification QR image | +| `footer` | Footer note | + +**Primitive blocks** are layout building blocks: `text`, `columns`, `stack`, `table`, `image`, `divider`, `spacer`. + +### Bindings, labels, conditions, and formats + +- **Binding paths** are dot-paths into the document body — e.g. `Fa.P_2` (invoice number), `Podmiot1.DaneIdentyfikacyjne.Nazwa` (seller name). Paths are relative to the body element, not the document wrapper. +- **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. +- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`. +- **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. + +### Minimal example + +A trimmed `FA(3)` template with a header, a seller/buyer panel, a line table, a total, and a conditional QR: + +```json +{ + "schema": "FA(3)", + "page": { "size": "A4", "margins": [40, 40, 40, 50] }, + "styles": { + "title": { "fontSize": 20, "bold": true }, + "muted": { "color": "#666666", "fontSize": 8 } + }, + "blocks": [ + { "type": "header", "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1" }, + { "type": "divider" }, + { + "type": "parties", + "left": { "label": "seller", "fields": ["Podmiot1.DaneIdentyfikacyjne.Nazwa", "Podmiot1.DaneIdentyfikacyjne.NIP"] }, + "right": { "label": "buyer", "fields": ["Podmiot2.DaneIdentyfikacyjne.Nazwa", "Podmiot2.DaneIdentyfikacyjne.NIP"] } + }, + { + "type": "lines", + "from": "Fa.FaWiersz", + "columns": [ + { "label": "name", "path": "P_7" }, + { "label": "qty", "path": "P_8B", "format": "number" }, + { "label": "net", "path": "P_11", "format": "money" } + ] + }, + { + "type": "totals", + "rows": [{ "label": "totalDue", "path": "Fa.P_15", "format": "money" }] + }, + { "type": "qr", "when": "qr", "fit": 90 }, + { "type": "footer", "text": "ksef-client-ts", "style": "muted" } + ] +} +``` + +The bundled `fa3-default` template is a good, complete starting point to copy and adapt. + +--- + +## Locales + +Labels are localizable, driven by the `locale` option: + +| Locale | Output | +|--------|--------| +| `pl` (default) | Polish labels | +| `en` | English labels | +| `pl+en` | Both, concatenated per label | + +For `pl+en`, each label is the Polish and English text joined by `bilingualSeparator` (default `' / '`). A template can also override individual labels via its `labels` map — useful for company-specific wording. + +```ts +const pdf = await renderInvoicePdf(xml, 'fa3-default', { + locale: 'pl+en', + bilingualSeparator: ' | ', +}); +``` + +--- + +## Verification QR (Code I) + +Set `qr: true` to embed the KSeF **Code I** verification QR. It is derived automatically from the invoice XML — you do not supply a URL. The verification hash is computed over the original invoice bytes so it matches the value KSeF registered. Use `env` to pick the correct portal (`prod` by default), or `baseQrUrl` to override it for offline / non-standard cases. + +```ts +const pdf = await renderInvoicePdf(xml, 'fa3-default', { + qr: true, + env: 'test', + ksefNumber: '1234567890-20260705-ABCDEF012345-01', +}); +``` + +When no `ksefNumber` is provided the visualization is marked **OFFLINE**. UPO receipts do not carry a QR code. + +--- + +## CLI + +The same rendering is available from the command line: + +```bash +ksef invoice pdf invoice.xml +``` + +By default the invoice version is auto-detected and the matching built-in template (`fa2-default` / `fa3-default`) is used; UPO documents are auto-detected too. The PDF is written next to the source file with a `.pdf` extension. + +```bash +# Built-in template, bilingual labels, with QR +ksef invoice pdf invoice.xml --locale pl+en --qr --ksef-number 1234567890-20260705-ABCDEF012345-01 + +# Custom template, explicit output path +ksef invoice pdf invoice.xml --template-file ./templates/my-invoice.json --out ./out/invoice.pdf + +# UPO receipt (auto-detected, or force with --upo) +ksef invoice pdf upo.xml --upo +``` + +| Flag | Description | +|------|-------------| +| `--template ` | Built-in template name (mutually exclusive with `--template-file`) | +| `--template-file ` | Custom JSON template path (mutually exclusive with `--template`) | +| `--locale ` | Label language (default `pl`) | +| `--qr` | Embed the KSeF Code I QR derived from the XML | +| `--ksef-number ` | KSeF number to print (absent → marked OFFLINE) | +| `--upo` | Treat the input as a UPO document (otherwise auto-detected) | +| `--env ` | Environment for the QR base URL | +| `--out ` | Output PDF path (default: alongside the source) | + +`--template` and `--template-file` are mutually exclusive — pass at most one. If `pdfmake` is not installed, the command exits with the same friendly install hint shown above. + +--- + +## See also + +- [QR Codes & Verification Links](./qr-codes.md) — the Code I / Code II verification URLs behind the embedded QR. +- [XML Serialization](./xml-serialization.md) — build the invoice XML that feeds the PDF renderer. +- [CLI](./cli.md) — the full command reference. diff --git a/packages/ksef-client-ts/package.json b/packages/ksef-client-ts/package.json index ed934c75..74afdf4d 100644 --- a/packages/ksef-client-ts/package.json +++ b/packages/ksef-client-ts/package.json @@ -27,6 +27,16 @@ "types": "./dist/node.d.cts", "default": "./dist/node.cjs" } + }, + "./pdf": { + "import": { + "types": "./dist/pdf/index.d.ts", + "default": "./dist/pdf/index.js" + }, + "require": { + "types": "./dist/pdf/index.d.cts", + "default": "./dist/pdf/index.cjs" + } } }, "bin": { @@ -46,6 +56,9 @@ "test:watch": "vitest", "lint": "tsc --noEmit", "check:node-types": "tsc --project tsconfig.node-check.json", + "check:pdf-types": "tsc --project tsconfig.pdf-check.json", + "check:attw": "attw --pack . --profile node16", + "check:publint": "publint", "lint:md": "markdownlint-cli2", "lint:md:fix": "markdownlint-cli2 --fix", "link": "npm link", @@ -103,14 +116,19 @@ "zod": "^4.3.6" }, "peerDependencies": { - "libxmljs2": "^0.37.0" + "libxmljs2": "^0.37.0", + "pdfmake": "^0.2.20" }, "peerDependenciesMeta": { "libxmljs2": { "optional": true + }, + "pdfmake": { + "optional": true } }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.4", "@scalar/api-reference": "^1.49.3", "@types/node": "^22.13.10", "@types/node-forge": "^1.3.11", @@ -123,6 +141,8 @@ "jszip": "^3.10.1", "libxmljs2": "^0.37.0", "markdownlint-cli2": "^0.22.1", + "pdfmake": "^0.2.20", + "publint": "^0.3.12", "tsup": "^8.4.0", "typescript": "^5.8.2", "vitepress": "^1.6.4", diff --git a/packages/ksef-client-ts/scripts/check-pdf-cold.mjs b/packages/ksef-client-ts/scripts/check-pdf-cold.mjs new file mode 100644 index 00000000..351397aa --- /dev/null +++ b/packages/ksef-client-ts/scripts/check-pdf-cold.mjs @@ -0,0 +1,69 @@ +/** + * Verifies the `ksef-client-ts/pdf` subpath is a "cold" module against the built + * dist: + * 1. No top-level `require("pdfmake")` in the CJS build (must stay a lazy `import()`). + * 2. Requiring/importing the subpath does NOT eagerly load pdfmake. + * 3. Import/require never throws at module-load time (pdfmake absence surfaces + * only when a `render*` function is called). + * + * Run after `yarn build`. Exits non-zero on any violation. + */ +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; + +const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cjsPath = path.join(pkgDir, 'dist/pdf/index.cjs'); +const esmPath = path.join(pkgDir, 'dist/pdf/index.js'); + +let failures = 0; +const fail = (m) => { + console.error('✗', m); + failures++; +}; +const ok = (m) => console.log('✓', m); + +// 1) The CJS build must not statically require pdfmake — it must stay dynamic. +const cjs = readFileSync(cjsPath, 'utf8'); +if (/(^|[^.\w])require\(\s*["']pdfmake/m.test(cjs)) { + fail('dist/pdf/index.cjs has a top-level require("pdfmake") — it must be a lazy import()'); +} else { + ok('no top-level require("pdfmake") in dist/pdf/index.cjs'); +} + +const require = createRequire(import.meta.url); +const pdfmakeLoaded = () => + Object.keys(require.cache).some((k) => k.includes(`${path.sep}pdfmake${path.sep}`)); + +// 2 & 3) CJS require succeeds, exports render functions, and does not load pdfmake. +const before = pdfmakeLoaded(); +const cjsMod = require(cjsPath); +if (typeof cjsMod.renderInvoicePdf !== 'function') { + fail('CJS build did not export renderInvoicePdf'); +} else { + ok('CJS require of ./pdf succeeds and exports render functions'); +} +if (!before && pdfmakeLoaded()) { + fail('requiring ./pdf eagerly loaded pdfmake (not a cold module)'); +} else { + ok('requiring ./pdf did not load pdfmake (cold module)'); +} + +// ESM import must also succeed without throwing. +try { + const esmMod = await import(pathToFileURL(esmPath).href); + if (typeof esmMod.renderInvoicePdf !== 'function') { + fail('ESM build did not export renderInvoicePdf'); + } else { + ok('ESM import of ./pdf succeeds'); + } +} catch (err) { + fail(`ESM import of ./pdf threw at load time: ${err.message}`); +} + +if (failures > 0) { + console.error(`\n${failures} cold-subpath check(s) failed.`); + process.exit(1); +} +console.log('\nAll cold-subpath checks passed.'); diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 2b1a30de..9aff05d8 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -570,7 +570,81 @@ const validateCmd = defineCommand({ }, }); +const VALID_PDF_LOCALES = ['pl', 'en', 'pl+en'] as const; +type PdfLocale = (typeof VALID_PDF_LOCALES)[number]; + +const pdf = defineCommand({ + meta: { name: 'pdf', description: 'Render an invoice or UPO XML to a PDF (offline; requires the optional pdfmake peer)' }, + args: { + file: { type: 'positional', description: 'Path to the invoice or UPO XML file', required: true }, + template: { type: 'string', description: 'Built-in template name (e.g. fa3-default). Mutually exclusive with --template-file.' }, + templateFile: { type: 'string', description: 'Path to a custom JSON template. Mutually exclusive with --template.' }, + locale: { type: 'string', description: 'Label language: pl | en | pl+en (default: pl)' }, + out: { type: 'string', description: 'Output PDF path (default: alongside the source, .pdf)' }, + qr: { type: 'boolean', description: 'Embed the KSeF Code I QR derived from the XML' }, + ksefNumber: { type: 'string', description: 'KSeF number to print (absent → marked OFFLINE)' }, + upo: { type: 'boolean', description: 'Treat the input as a UPO document (otherwise auto-detected)' }, + env: { type: 'string', description: 'Environment for the QR base URL (test/demo/prod)' }, + json: { type: 'boolean', description: 'Output as JSON' }, + }, + run({ args }) { + return withErrorHandler(async () => { + const file = args.file; + if (!fs.existsSync(file)) { + throw new Error(`File not found: ${file}`); + } + if (args.template && args.templateFile) { + throw new Error('--template and --template-file are mutually exclusive; pass only one.'); + } + + const locale = (args.locale as string | undefined) ?? 'pl'; + if (!VALID_PDF_LOCALES.includes(locale as PdfLocale)) { + throw new Error(`Invalid --locale "${locale}". Valid: ${VALID_PDF_LOCALES.join(', ')}`); + } + + const env = args.env as 'prod' | 'test' | 'demo' | undefined; + const renderOpts = { + locale: locale as PdfLocale, + qr: Boolean(args.qr), + ...(args.ksefNumber ? { ksefNumber: args.ksefNumber as string } : {}), + ...(env ? { env } : {}), + }; + + // Exact bytes preserve the QR hash; pass the raw file as a Uint8Array. + const xmlBytes = new Uint8Array(fs.readFileSync(file)); + const xmlStr = Buffer.from(xmlBytes).toString('utf-8'); + + // Lazy bridge into the internal PDF module; it lazily requires pdfmake and, + // when absent/incompatible, throws a friendly install hint we surface here. + const pdfModule = await import('../../pdf/index.js'); + + let bytes: Uint8Array; + const isUpo = Boolean(args.upo) || (pdfModule.detectInvoiceVersion(xmlStr) === null && pdfModule.detectUpoVersion(xmlStr) !== null); + if (isUpo) { + bytes = await pdfModule.renderUpoPdf(xmlBytes, renderOpts); + } else if (args.templateFile) { + bytes = await pdfModule.renderInvoicePdfFromFile(xmlBytes, args.templateFile as string, renderOpts); + } else if (args.template) { + bytes = await pdfModule.renderInvoicePdf(xmlBytes, args.template as string, renderOpts); + } else { + const version = pdfModule.detectInvoiceVersion(xmlStr); + const builtin = version === 'FA(2)' ? 'fa2-default' : 'fa3-default'; + bytes = await pdfModule.renderInvoicePdf(xmlBytes, builtin, renderOpts); + } + + const outPath = (args.out as string | undefined) ?? file.replace(/\.xml$/i, '') + '.pdf'; + fs.writeFileSync(outPath, bytes); + + if (args.json) { + outputResult({ file, out: outPath, bytes: bytes.length }, { json: true }); + } else { + outputSuccess(`PDF written to ${outPath} (${bytes.length} bytes)`); + } + }, { json: Boolean(args.json) }); + }, +}); + export const invoiceCommand = defineCommand({ meta: { name: 'invoice', description: 'Invoice commands' }, - subCommands: { send, build: invoiceBuild, get, query, validate: validateCmd, export: exportCmd, 'export-status': exportStatus, 'export-incremental': exportIncremental }, + subCommands: { send, build: invoiceBuild, get, query, pdf, validate: validateCmd, export: exportCmd, 'export-status': exportStatus, 'export-incremental': exportIncremental }, }); diff --git a/packages/ksef-client-ts/src/pdf/accessor.ts b/packages/ksef-client-ts/src/pdf/accessor.ts new file mode 100644 index 00000000..b4d5641d --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/accessor.ts @@ -0,0 +1,97 @@ +/** + * Accessor layer over `fast-xml-parser` compact output. + * + * Compact parsing (`preserveOrder: false`) is convenient for layout but leaves + * artifacts: single-element collections collapse from an array into an object, + * elements with mixed content expose their text under `#text`, and attributes + * are prefixed (we configure `@`). Templates never touch the parsed object + * directly — every binding goes through these helpers so a one-line invoice is + * handled exactly like a many-line one and a missing optional field never throws + * (unless strict mode asks it to). + * + * A path is a dot-separated walk over the parsed object, e.g. + * `Podmiot1.DaneIdentyfikacyjne.NIP`. A segment beginning with `@` reads an + * attribute, e.g. `Naglowek.KodFormularza.@kodSystemowy`. + */ + +const TEXT_KEY = '#text'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Walk `path` and return the raw node found there (object, array, or scalar), + * or `undefined` if any intermediate segment is absent. When descent hits an + * array mid-path (a collapsed/expanded repeater), the first element is followed + * — use {@link list} when you need every element. + */ +export function getNode(root: unknown, path: string): unknown { + const segments = path.split('.').filter((s) => s.length > 0); + let cur: unknown = root; + for (const seg of segments) { + if (Array.isArray(cur)) cur = cur[0]; + if (!isRecord(cur)) return undefined; + cur = cur[seg]; + if (cur === undefined) return undefined; + } + return cur; +} + +function coerceScalar(node: unknown): string | undefined { + if (node === undefined || node === null) return undefined; + if (typeof node === 'string') return node; + if (typeof node === 'number' || typeof node === 'boolean') return String(node); + if (Array.isArray(node)) return coerceScalar(node[0]); + if (isRecord(node)) { + if (TEXT_KEY in node) return coerceScalar(node[TEXT_KEY]); + return undefined; // element with only children/attributes has no scalar value + } + return undefined; +} + +/** + * Read a scalar binding as a string. Missing/childless nodes yield `''` by + * default; when `strict` is set, a missing binding throws instead — useful for + * catching dot-path typos in built-in templates. + * + * Handles `#text` unwrapping (mixed content) and `@attr` segments transparently. + */ +export function get(root: unknown, path: string, strict = false): string { + const scalar = coerceScalar(getNode(root, path)); + if (scalar === undefined) { + if (strict) throw new Error(`Missing binding: "${path}"`); + return ''; + } + return scalar; +} + +/** + * Always-array read for repeaters. A single-element collection that the parser + * collapsed into an object is returned as a one-element array, so a repeater + * over `Fa.FaWiersz` iterates identically for one line or many. + */ +export function list(root: unknown, path: string): unknown[] { + const node = getNode(root, path); + if (node === undefined || node === null) return []; + return Array.isArray(node) ? node : [node]; +} + +/** + * Presence test for `when` conditions. An empty string, empty array, or missing + * node is falsy; a present object/number/non-empty string is truthy. + */ +export function has(root: unknown, path: string): boolean { + const node = getNode(root, path); + if (node === undefined || node === null) return false; + if (typeof node === 'string') return node.length > 0; + if (Array.isArray(node)) return node.length > 0; + if (isRecord(node)) { + // An element that carries only an empty `#text` is not meaningfully present. + if (TEXT_KEY in node && Object.keys(node).length === 1) { + return coerceScalar(node) !== undefined && coerceScalar(node) !== ''; + } + return true; + } + return true; // number / boolean +} diff --git a/packages/ksef-client-ts/src/pdf/errors.ts b/packages/ksef-client-ts/src/pdf/errors.ts new file mode 100644 index 00000000..b8b9ebac --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/errors.ts @@ -0,0 +1,13 @@ +import { KSeFError } from '../errors/ksef-error.js'; + +/** + * PDF-module runtime error: missing/incompatible `pdfmake`, template/version + * mismatch, or nesting-depth overflow. Structural DSL validation failures use + * {@link KSeFValidationError} from core instead. + */ +export class KSeFPdfError extends KSeFError { + constructor(message: string) { + super(message); + this.name = 'KSeFPdfError'; + } +} diff --git a/packages/ksef-client-ts/src/pdf/fonts.ts b/packages/ksef-client-ts/src/pdf/fonts.ts new file mode 100644 index 00000000..c00f5a6b --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/fonts.ts @@ -0,0 +1,110 @@ +/** + * Lazy `pdfmake` acquisition. `pdfmake` is an optional peer, so this is called + * only from a render path — never at module load — which keeps `./pdf` a "cold" + * import (see the subpath contract). On absence or an incompatible version we + * throw a friendly, actionable error instead of a raw resolver crash. + * + * The version guard checks `^0.2.20` (`>=0.2.20 <0.3.0`) by semver, not by + * feature-detection: the spike showed 0.3.x can pass shallow shape checks yet + * hang in `getBuffer`, so only the version number is trustworthy. + */ +import { createRequire } from 'node:module'; +import { KSeFPdfError } from './errors.js'; + +const REQUIRED_RANGE = '^0.2.20'; +const INSTALL_HINT = `npm i "pdfmake@${REQUIRED_RANGE}"`; + +/** Minimal structural view of the pdfmake browser build we depend on. */ +export interface PdfMakeLike { + createPdf(docDefinition: unknown): { getBuffer(cb: (buffer: Uint8Array) => void): void }; + vfs?: unknown; +} + +function missingError(): KSeFPdfError { + return new KSeFPdfError( + `PDF rendering requires the optional peer dependency "pdfmake" (${REQUIRED_RANGE}), ` + + `which is not installed. Install it with: ${INSTALL_HINT}`, + ); +} + +/** True iff `version` satisfies `^0.2.20` — i.e. `0.2.x` with `x >= 20`. */ +export function satisfiesRequiredRange(version: string): boolean { + const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim()); + if (!m) return false; + const major = Number(m[1]); + const minor = Number(m[2]); + const patch = Number(m[3]); + return major === 0 && minor === 2 && patch >= 20; +} + +function readPdfmakeVersion(): string | null { + try { + const require = createRequire(import.meta.url); + const pkg = require('pdfmake/package.json') as { version?: string }; + return typeof pkg.version === 'string' ? pkg.version : null; + } catch { + return null; + } +} + +/** + * Assert the resolved pdfmake version is supported. `null` (not installed) → + * friendly install error; a version outside `^0.2.20` (e.g. 0.3.x) → a clear + * incompatible-version error. Extracted so it is unit-testable without a + * live install. + */ +export function assertPdfmakeVersion(version: string | null): void { + if (version === null) throw missingError(); + if (!satisfiesRequiredRange(version)) { + throw new KSeFPdfError( + `PDF rendering requires pdfmake ${REQUIRED_RANGE}, but found ${version}. ` + + `pdfmake 0.3.x is not supported yet (incompatible import/VFS shape). Install: ${INSTALL_HINT}`, + ); + } +} + +/** Normalize the `vfs_fonts` module across shapes into the font map to assign. */ +export function normalizeVfs(mod: unknown): unknown { + const m = mod as { default?: unknown; vfs?: unknown; pdfMake?: { vfs?: unknown } } | undefined; + const cand = (m && 'default' in m ? m.default : m) as + | { vfs?: unknown; pdfMake?: { vfs?: unknown } } + | undefined; + if (cand && typeof cand === 'object') { + if ('vfs' in cand && cand.vfs) return cand.vfs; + if (cand.pdfMake && cand.pdfMake.vfs) return cand.pdfMake.vfs; + } + return cand; +} + +/** + * Resolve, version-check, and initialize pdfmake with the bundled Roboto VFS. + * Throws {@link KSeFPdfError} if pdfmake is absent or outside `^0.2.20`. + */ +export async function loadPdfMake(): Promise { + assertPdfmakeVersion(readPdfmakeVersion()); + + let pdfmakeMod: unknown; + let vfsMod: unknown; + try { + pdfmakeMod = await import('pdfmake/build/pdfmake.js'); + vfsMod = await import('pdfmake/build/vfs_fonts.js'); + } catch { + throw missingError(); + } + + const namespace = pdfmakeMod as { default?: PdfMakeLike }; + const pdfMake = (namespace.default ?? (pdfmakeMod as PdfMakeLike)); + pdfMake.vfs = normalizeVfs(vfsMod); + return pdfMake; +} + +/** Render a pdfmake document definition to PDF bytes. */ +export function createPdfBuffer(pdfMake: PdfMakeLike, docDefinition: unknown): Promise { + return new Promise((resolve, reject) => { + try { + pdfMake.createPdf(docDefinition).getBuffer((buffer) => resolve(Uint8Array.from(buffer))); + } catch (err) { + reject(err instanceof Error ? err : new KSeFPdfError(String(err))); + } + }); +} diff --git a/packages/ksef-client-ts/src/pdf/format.ts b/packages/ksef-client-ts/src/pdf/format.ts new file mode 100644 index 00000000..f27a0955 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/format.ts @@ -0,0 +1,62 @@ +/** + * Value formatters referenced by DSL bindings (`format: 'money' | 'date' | + * 'number' | 'nip'`). Each is total: on unparseable input it returns the raw + * string unchanged, so a formatter never throws mid-render. + */ +export type FormatterName = 'money' | 'date' | 'number' | 'nip'; + +const NBSP = ' '; + +function groupThousands(intPart: string): string { + return intPart.replace(/\B(?=(\d{3})+(?!\d))/g, NBSP); +} + +/** `"1234.5"` → `"1 234,50"` (Polish monetary style, 2 decimals). */ +export function formatMoney(raw: string): string { + const n = Number(raw); + if (raw.trim() === '' || Number.isNaN(n)) return raw; + const fixed = Math.abs(n).toFixed(2); + const dot = fixed.indexOf('.'); + const intPart = fixed.slice(0, dot); + const frac = fixed.slice(dot + 1); + const sign = n < 0 ? '-' : ''; + return `${sign}${groupThousands(intPart)},${frac}`; +} + +/** `"1234.5"` → `"1 234,5"` (grouped, no forced decimals). */ +export function formatNumber(raw: string): string { + const n = Number(raw); + if (raw.trim() === '' || Number.isNaN(n)) return raw; + const [intPart, frac] = Math.abs(n).toString().split('.'); + const sign = n < 0 ? '-' : ''; + const grouped = groupThousands(intPart ?? '0'); + return frac ? `${sign}${grouped},${frac}` : `${sign}${grouped}`; +} + +/** ISO `"2025-01-15"` (or a datetime) → `"15.01.2025"`; other inputs pass through. */ +export function formatDate(raw: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw.trim()); + if (!m) return raw; + return `${m[3]}.${m[2]}.${m[1]}`; +} + +/** `"5213003700"` → `"521-300-37-00"`; non-10-digit inputs pass through. */ +export function formatNip(raw: string): string { + const digits = raw.replace(/\D/g, ''); + if (digits.length !== 10) return raw; + return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6, 8)}-${digits.slice(8, 10)}`; +} + +const FORMATTERS: Record string> = { + money: formatMoney, + date: formatDate, + number: formatNumber, + nip: formatNip, +}; + +/** Apply a named formatter; an unknown name returns the value unchanged. */ +export function applyFormat(value: string, format: FormatterName | undefined): string { + if (!format) return value; + const fn = FORMATTERS[format]; + return fn ? fn(value) : value; +} diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts new file mode 100644 index 00000000..15cf318a --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -0,0 +1,37 @@ +import type { LabelBundle } from './types.js'; + +/** English label bundle (mirrors the Polish key set). */ +export const en: LabelBundle = { + invoice: 'Invoice', + duplicate: 'Duplicate', + seller: 'Seller', + buyer: 'Buyer', + issueDate: 'Issue date', + invoiceNumber: 'Invoice number', + ksefNumber: 'KSeF number', + offline: 'OFFLINE', + lp: 'No.', + name: 'Name', + unit: 'Unit', + qty: 'Qty', + unitPrice: 'Net price', + vatRate: 'VAT rate', + net: 'Net amount', + vat: 'VAT amount', + gross: 'Gross amount', + totalNet: 'Total net', + totalVat: 'Total VAT', + totalDue: 'Amount due', + payment: 'Payment', + paid: 'Paid', + paymentDate: 'Payment due', + paymentMethod: 'Payment method', + annotations: 'Annotations', + upoTitle: 'Official Receipt Confirmation (UPO)', + ksefDocNumber: 'KSeF document number', + sessionRef: 'Session reference number', + receiptDate: 'KSeF number assignment date', + documentHash: 'Document hash', + page: 'Page', + of: 'of', +}; diff --git a/packages/ksef-client-ts/src/pdf/i18n/index.ts b/packages/ksef-client-ts/src/pdf/i18n/index.ts new file mode 100644 index 00000000..50811e7d --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/index.ts @@ -0,0 +1,50 @@ +/** + * Label localization. Only `pl` and `en` bundles are maintained; `pl+en` is + * produced on the fly by concatenation with a configurable separator, so there + * is no third bundle to keep in sync. A missing key falls back to Polish, then + * to the key itself. + */ +import type { Locale, LabelBundle } from './types.js'; +import { pl } from './pl.js'; +import { en } from './en.js'; + +export type { Locale, LabelBundle } from './types.js'; +export { pl } from './pl.js'; +export { en } from './en.js'; + +const BUNDLES: Record<'pl' | 'en', LabelBundle> = { pl, en }; + +export interface LabelOptions { + /** Separator for the `pl+en` bilingual locale. Default `' / '`. */ + bilingualSeparator?: string; + /** Per-template label overrides (highest precedence). */ + overrides?: LabelBundle; +} + +function resolveOne(key: string, locale: 'pl' | 'en', overrides?: LabelBundle): string { + const override = overrides?.[key]; + if (override !== undefined) return override; + const fromBundle = BUNDLES[locale][key]; + if (fromBundle !== undefined) return fromBundle; + // Fall back to Polish, then to the raw key. + return pl[key] ?? key; +} + +/** + * Resolve a label key for the given locale. For `pl+en`, resolves both and + * joins them with the separator (default `' / '`). + */ +export function resolveLabel(key: string, locale: Locale, opts: LabelOptions = {}): string { + if (locale === 'pl+en') { + const sep = opts.bilingualSeparator ?? ' / '; + return `${resolveOne(key, 'pl', opts.overrides)}${sep}${resolveOne(key, 'en', opts.overrides)}`; + } + return resolveOne(key, locale, opts.overrides); +} + +/** A bound resolver capturing locale + options, handed to block renderers. */ +export type LabelResolver = (key: string) => string; + +export function makeLabelResolver(locale: Locale, opts: LabelOptions = {}): LabelResolver { + return (key: string) => resolveLabel(key, locale, opts); +} diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts new file mode 100644 index 00000000..197246f2 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -0,0 +1,43 @@ +import type { LabelBundle } from './types.js'; + +/** Polish label bundle (canonical key set). */ +export const pl: LabelBundle = { + invoice: 'Faktura', + duplicate: 'Duplikat', + seller: 'Sprzedawca', + buyer: 'Nabywca', + issueDate: 'Data wystawienia', + invoiceNumber: 'Numer faktury', + ksefNumber: 'Numer KSeF', + offline: 'OFFLINE', + // line-item columns + lp: 'Lp.', + name: 'Nazwa', + unit: 'j.m.', + qty: 'Ilość', + unitPrice: 'Cena netto', + vatRate: 'Stawka VAT', + net: 'Wartość netto', + vat: 'Kwota VAT', + gross: 'Wartość brutto', + // totals + totalNet: 'Razem netto', + totalVat: 'Razem VAT', + totalDue: 'Do zapłaty', + // payment + payment: 'Płatność', + paid: 'Zapłacono', + paymentDate: 'Termin płatności', + paymentMethod: 'Forma płatności', + // annotations + annotations: 'Adnotacje', + // upo + upoTitle: 'Urzędowe Poświadczenie Odbioru (UPO)', + ksefDocNumber: 'Numer KSeF dokumentu', + sessionRef: 'Numer referencyjny sesji', + receiptDate: 'Data nadania numeru KSeF', + documentHash: 'Skrót dokumentu', + // footer + page: 'Strona', + of: 'z', +}; diff --git a/packages/ksef-client-ts/src/pdf/i18n/types.ts b/packages/ksef-client-ts/src/pdf/i18n/types.ts new file mode 100644 index 00000000..aa9a0a19 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/types.ts @@ -0,0 +1,11 @@ +/** Label language for the rendered PDF. `pl+en` is built by concatenation. */ +export type Locale = 'pl' | 'en' | 'pl+en'; + +/** + * Known label keys referenced by built-in templates. Custom templates may use + * additional keys via per-template overrides; unknown keys fall back to their + * own string. Keeping this a string-keyed record (not a closed union) lets + * custom templates introduce labels without a type change, while the built-in + * bundles below document the canonical set. + */ +export type LabelBundle = Record; diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts new file mode 100644 index 00000000..b7a54c26 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -0,0 +1,195 @@ +/** + * `ksef-client-ts/pdf` — node-only subpath that renders KSeF invoice/UPO XML to + * PDF via a template-driven block DSL. `pdfmake` is an optional peer loaded + * lazily; importing this module without it must not throw — only a `render*` + * call surfaces a friendly install error. + * + * This is the public surface: its types operate on our own DSL types and + * `Uint8Array`, never on `@types/pdfmake`, so consumers without pdfmake still + * type-check `./pdf`. + */ +import { readFile } from 'node:fs/promises'; +import { + detectInvoiceVersion, + detectUpoVersion, + parseXmlForPdf, + type InvoiceVersion, + type UpoVersion, +} from './parse.js'; +import { KSeFPdfError } from './errors.js'; +import { makeLabelResolver } from './i18n/index.js'; +import type { Locale } from './i18n/types.js'; +import { validateTemplate, type InvoiceTemplate, type TemplateSchemaId } from './template/dsl.js'; +import { interpretTemplate, type RenderContext } from './template/interpret.js'; +import { blockRegistry } from './template/blocks/index.js'; +import { getBuiltinTemplate, builtinTemplateNames } from './template/builtin/index.js'; +import { loadPdfMake, createPdfBuffer } from './fonts.js'; +import { deriveInvoiceQrUrl } from './qr.js'; + +export type { Locale } from './i18n/types.js'; +export type { InvoiceTemplate } from './template/dsl.js'; +export { detectInvoiceVersion, detectUpoVersion } from './parse.js'; + +export interface RenderOptions { + /** Label language. Default `'pl'`. */ + locale?: Locale; + /** KSeF number printed on the visualization; absent → marked OFFLINE. */ + ksefNumber?: string; + /** Embed the KSeF Code I QR derived from the invoice XML. */ + qr?: boolean; + /** Environment used to derive the QR base URL. Default `'prod'`. */ + env?: 'prod' | 'test' | 'demo'; + /** Override the QR base URL (offline / non-standard). */ + baseQrUrl?: string; + /** Logo as a `data:` URI. */ + logo?: string; + /** Theming (accent colour only; the font is the bundled Roboto). */ + theme?: { accent?: string }; + /** Separator for the `pl+en` locale. Default `' / '`. */ + bilingualSeparator?: string; + /** Throw on a missing binding instead of rendering an empty string. */ + strict?: boolean; + /** Precomputed canonical invoice hash (base64) — used verbatim for the QR. */ + invoiceHash?: string; +} + +type RawXml = string | Uint8Array; + +function toXmlString(input: RawXml): string { + return typeof input === 'string' ? input : new TextDecoder('utf-8').decode(input); +} + +/** + * Bindings are written relative to the document body (`Fa.P_2`, `Podmiot1.…`, + * or UPO `Dokument.…`), so the context root is the body element, not the parsed + * document wrapper. + */ +function extractBody(parsed: Record, schema: TemplateSchemaId): unknown { + const key = schema.startsWith('UPO') ? 'Potwierdzenie' : 'Faktura'; + const body = parsed[key]; + return body && typeof body === 'object' ? body : parsed; +} + +function buildContext( + root: unknown, + template: InvoiceTemplate, + opts: RenderOptions, + qrUrl: string, +): RenderContext { + const label = makeLabelResolver(opts.locale ?? 'pl', { + bilingualSeparator: opts.bilingualSeparator, + overrides: template.labels, + }); + + const bindings: Record = { + 'opts.logo': opts.logo ?? '', + 'opts.ksefNumber': opts.ksefNumber ?? '', + 'opts.accent': opts.theme?.accent ?? '', + qrUrl, + }; + + const flags: Record = { + hasKsefNumber: Boolean(opts.ksefNumber), + offline: !opts.ksefNumber, + qr: Boolean(opts.qr) && qrUrl !== '', + }; + + return { root, strict: opts.strict ?? false, label, bindings, flags }; +} + +function assertVersionMatch(xml: string, schema: TemplateSchemaId): void { + const detected: InvoiceVersion | UpoVersion | null = schema.startsWith('UPO') + ? detectUpoVersion(xml) + : detectInvoiceVersion(xml); + if (detected !== null && detected !== schema) { + throw new KSeFPdfError( + `Template targets ${schema}, but the document was detected as ${detected}. ` + + `Use a ${detected} template (or the matching built-in).`, + ); + } +} + +async function renderWithTemplate( + rawInput: RawXml, + template: InvoiceTemplate, + opts: RenderOptions, +): Promise { + const xml = toXmlString(rawInput); + assertVersionMatch(xml, template.schema); + const parsed = parseXmlForPdf(xml); + const body = extractBody(parsed, template.schema); + + // QR (Code I) is derived only for invoices — the hash is computed over the + // ORIGINAL input bytes (bypassing the parser) so it matches the KSeF registry. + let qrUrl = ''; + if (opts.qr && !template.schema.startsWith('UPO')) { + qrUrl = deriveInvoiceQrUrl({ + rawInput, + body, + env: opts.env, + baseQrUrl: opts.baseQrUrl, + invoiceHash: opts.invoiceHash, + strict: opts.strict, + }); + } + + const ctx = buildContext(body, template, opts, qrUrl); + const doc = interpretTemplate(template, ctx, blockRegistry); + const pdfMake = await loadPdfMake(); + return createPdfBuffer(pdfMake, doc); +} + +/** Render an invoice using a built-in template selected by name. */ +export async function renderInvoicePdf( + xml: RawXml, + name: string, + opts: RenderOptions = {}, +): Promise { + const template = getBuiltinTemplate(name); + if (!template) { + throw new KSeFPdfError( + `Unknown built-in template "${name}". Available: ${builtinTemplateNames().join(', ')}`, + ); + } + return renderWithTemplate(xml, template, opts); +} + +/** Render an invoice using a custom template loaded from a JSON file. */ +export async function renderInvoicePdfFromFile( + xml: RawXml, + path: string, + opts: RenderOptions = {}, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, 'utf-8')); + } catch (err) { + throw new KSeFPdfError(`Failed to read template file "${path}": ${(err as Error).message}`); + } + const template = validateTemplate(parsed); + return renderWithTemplate(xml, template, opts); +} + +/** Render an invoice using a custom template object. */ +export async function renderInvoicePdfFromTemplate( + xml: RawXml, + template: InvoiceTemplate, + opts: RenderOptions = {}, +): Promise { + const validated = validateTemplate(template); + return renderWithTemplate(xml, validated, opts); +} + +const UPO_TEMPLATE_BY_VERSION: Record = { + 'UPO(4.2)': 'upo-4_2', + 'UPO(4.3)': 'upo-4_3', +}; + +/** Render a UPO receipt using the matching built-in UPO template. */ +export async function renderUpoPdf(xml: RawXml, opts: RenderOptions = {}): Promise { + const version = detectUpoVersion(toXmlString(xml)); + if (!version) { + throw new KSeFPdfError('Input is not a recognized UPO(4.2)/UPO(4.3) document.'); + } + return renderInvoicePdf(xml, UPO_TEMPLATE_BY_VERSION[version], opts); +} diff --git a/packages/ksef-client-ts/src/pdf/parse.ts b/packages/ksef-client-ts/src/pdf/parse.ts new file mode 100644 index 00000000..75031f44 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/parse.ts @@ -0,0 +1,66 @@ +/** + * Compact XML parsing + version detection for the PDF renderer. + * + * Uses `fast-xml-parser` in compact mode (`preserveOrder: false`) — unlike the + * core `parseXml` (which preserves order and is awkward for layout). Numbers are + * kept as strings (`parseTagValue: false`) so monetary precision and leading + * zeros survive; formatters own number presentation. Namespace prefixes are + * stripped so paths stay clean (`Fa.P_2`, not `tns:Fa.tns:P_2`). Attributes are + * exposed under the `@` prefix and read through the accessor layer. + */ +import { XMLParser } from 'fast-xml-parser'; +import { get } from './accessor.js'; + +export type InvoiceVersion = 'FA(2)' | 'FA(3)'; +export type UpoVersion = 'UPO(4.2)' | 'UPO(4.3)'; + +export const pdfXmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@', + parseTagValue: false, + parseAttributeValue: false, + removeNSPrefix: true, + trimValues: true, +}); + +/** + * Parse KSeF invoice/UPO XML into a compact object for template binding. + * `fast-xml-parser` always returns an object (empty/non-XML input yields `{}`). + */ +export function parseXmlForPdf(xml: string): Record { + return pdfXmlParser.parse(xml) as Record; +} + +/** + * Detect the invoice schema version. Reads the `kodSystemowy` attribute + * (`"FA (2)"` / `"FA (3)"`) and the `WariantFormularza` element; returns `null` + * for anything that is not a recognized FA(2)/FA(3) invoice. FA(1) is not + * supported. + */ +export function detectInvoiceVersion(xml: string): InvoiceVersion | null { + const parsed = parseXmlForPdf(xml); + if (!('Faktura' in parsed)) return null; + + const kod = get(parsed, 'Faktura.Naglowek.KodFormularza.@kodSystemowy').replace(/\s+/g, ''); + const variant = get(parsed, 'Faktura.Naglowek.WariantFormularza').trim(); + + if (kod === 'FA(3)' || variant === '3') return 'FA(3)'; + if (kod === 'FA(2)' || variant === '2') return 'FA(2)'; + return null; +} + +/** + * Detect the UPO version. Requires a `Potwierdzenie` root, then reads the + * version from the namespace marker in the raw XML (`.../KSeF/v4-3` → + * `UPO(4.3)`, `v4-2` → `UPO(4.2)`). The default `xmlns` declaration is not + * surfaced as an attribute by the compact parser, so we scan the source + * directly. Returns `null` for non-UPO documents. + */ +export function detectUpoVersion(xml: string): UpoVersion | null { + const parsed = parseXmlForPdf(xml); + if (!('Potwierdzenie' in parsed)) return null; + + if (/KSeF\/v4-3\b/.test(xml)) return 'UPO(4.3)'; + if (/KSeF\/v4-2\b/.test(xml)) return 'UPO(4.2)'; + return null; +} diff --git a/packages/ksef-client-ts/src/pdf/pdfmake-modules.d.ts b/packages/ksef-client-ts/src/pdf/pdfmake-modules.d.ts new file mode 100644 index 00000000..a8dd5b58 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/pdfmake-modules.d.ts @@ -0,0 +1,11 @@ +/** + * Minimal ambient declarations for the two pdfmake browser-build submodules the + * renderer lazily imports. We deliberately do NOT depend on `@types/pdfmake`: + * the published DefinitelyTyped package targets the 0.3.x API (promise-based + * `getBuffer`), which does not match the pinned 0.2.x runtime, and it does not + * type these `/build/*` submodules usefully anyway. `fonts.ts` narrows the + * `any` import to the local `PdfMakeLike` shape, so this keeps the `./pdf` + * public types free of any pdfmake dependency (the "cold module" invariant). + */ +declare module 'pdfmake/build/pdfmake.js'; +declare module 'pdfmake/build/vfs_fonts.js'; diff --git a/packages/ksef-client-ts/src/pdf/qr.ts b/packages/ksef-client-ts/src/pdf/qr.ts new file mode 100644 index 00000000..5d79c29d --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/qr.ts @@ -0,0 +1,72 @@ +/** + * QR ("Code I") derivation for the PDF module. + * + * The whole point of this module is a byte-exact invoice hash: KSeF registers an + * invoice under the SHA-256 of the *exact bytes* it received. If we hashed a + * re-serialized DOM, a stray BOM, a CRLF↔LF swap, a re-encode, or a pretty-print + * would flip the digest and the QR would point at a hash absent from the + * registry. So the hash here is always taken over the ORIGINAL INPUT BYTES, + * never over parsed/reserialized XML. + */ +import crypto from 'node:crypto'; +import { VerificationLinkService } from '../qr/verification-link-service.js'; +import { Environment } from '../config/environments.js'; +import { get } from './accessor.js'; + +/** + * SHA-256 over the raw invoice bytes, returned as standard base64. + * + * A `Uint8Array` is hashed directly. A `string` is hashed as + * `Buffer.from(str, 'utf8')` AS-IS — no BOM strip, no re-encode, no newline + * normalization. The caller is responsible for handing us the same bytes KSeF + * received. + */ +export function computeInvoiceHashBase64(rawInput: string | Uint8Array): string { + const bytes = typeof rawInput === 'string' ? Buffer.from(rawInput, 'utf8') : rawInput; + return crypto.createHash('sha256').update(bytes).digest('base64'); +} + +/** + * Resolve the base QR URL. An explicit `override` always wins; otherwise the + * `env` selects the matching KSeF QR host, defaulting to production. + */ +export function resolveBaseQrUrl( + env: 'prod' | 'test' | 'demo' | undefined, + override?: string, +): string { + if (override !== undefined && override !== '') return override; + switch (env) { + case 'test': + return Environment.TEST.qrUrl; + case 'demo': + return Environment.DEMO.qrUrl; + case 'prod': + default: + return Environment.PROD.qrUrl; + } +} + +export interface DeriveInvoiceQrUrlParams { + /** Original invoice bytes (or the exact UTF-8 string) — hashed AS-IS. */ + rawInput: string | Uint8Array; + /** Parsed document body node (`Faktura`), same shape as `RenderContext.root`. */ + body: unknown; + env?: 'prod' | 'test' | 'demo'; + baseQrUrl?: string; + /** Pre-computed hash; when set it is used VERBATIM (no recompute). */ + invoiceHash?: string; + strict?: boolean; +} + +/** + * Derive the invoice verification URL (Code I) from the raw bytes + parsed body. + * An `invoiceHash` override is used verbatim; otherwise the hash is computed over + * the raw input bytes. + */ +export function deriveInvoiceQrUrl(params: DeriveInvoiceQrUrlParams): string { + const nip = get(params.body, 'Podmiot1.DaneIdentyfikacyjne.NIP', params.strict); + const issueDate = get(params.body, 'P_1', params.strict); + const hash = params.invoiceHash ?? computeInvoiceHashBase64(params.rawInput); + const base = resolveBaseQrUrl(params.env, params.baseQrUrl); + return new VerificationLinkService(base).buildInvoiceVerificationUrl(nip, issueDate, hash); +} diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts new file mode 100644 index 00000000..5a1dccf9 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts @@ -0,0 +1,23 @@ +import { applyFormat } from '../../format.js'; +import type { AnnotationsBlock } from '../dsl.js'; +import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; + +/** + * Legal annotations: an `annotations` heading followed by one `label: value` + * line per {@link AnnotationsBlock.fields} entry (localized label + formatted + * scalar binding). + */ +export const annotationsRenderer: BlockRenderer = (block, ctx) => { + const stack: PdfNode[] = [{ text: ctx.label('annotations'), style: 'h2' }]; + for (const field of block.fields) { + stack.push({ + text: `${ctx.label(field.label)}: ${applyFormat(resolveBinding(field.path, ctx), field.format)}`, + }); + } + + return { + stack, + margin: [0, 8, 0, 8], + ...(block.style ? { style: block.style } : {}), + }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/footer.ts b/packages/ksef-client-ts/src/pdf/template/blocks/footer.ts new file mode 100644 index 00000000..727f3c7d --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/footer.ts @@ -0,0 +1,16 @@ +import type { FooterBlock } from '../dsl.js'; +import { resolveText, type BlockRenderer } from '../interpret.js'; + +/** + * Document footer: a single centered text node resolved from the block's + * `label` (i18n) or literal `text`. Rendered inline in the content flow (not via + * pdfmake's `docDefinition.footer` callback); the block's optional style is + * applied when present. + */ +export const footerRenderer: BlockRenderer = (block, ctx) => { + return { + text: resolveText({ label: block.label, text: block.text }, ctx), + alignment: 'center', + ...(block.style ? { style: block.style } : {}), + }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/header.ts b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts new file mode 100644 index 00000000..1b8b3b10 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts @@ -0,0 +1,33 @@ +import { applyFormat } from '../../format.js'; +import type { HeaderBlock } from '../dsl.js'; +import { resolveBinding, resolveText, type BlockRenderer, type PdfNode } from '../interpret.js'; + +/** + * Invoice header: optional logo, a title (defaults to the localized "Invoice" + * label), and the invoice number/date stacked on the right. + */ +export const headerRenderer: BlockRenderer = (block, ctx) => { + const left: PdfNode[] = []; + if (block.logo) { + const logo = resolveBinding(block.logo, ctx); + if (logo) left.push({ image: logo, width: 120, margin: [0, 0, 0, 6] }); + } + const title = resolveText(block.title, ctx) || ctx.label('invoice'); + left.push({ text: title, style: block.style ?? 'title' }); + + const right: PdfNode[] = []; + if (block.number) { + right.push({ text: `${ctx.label('invoiceNumber')}: ${resolveBinding(block.number, ctx)}` }); + } + if (block.date) { + right.push({ text: `${ctx.label('issueDate')}: ${applyFormat(resolveBinding(block.date, ctx), 'date')}` }); + } + + return { + columns: [ + { width: '*', stack: left }, + { width: 'auto', stack: right, alignment: 'right' }, + ], + margin: [0, 0, 0, 12], + }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/image.ts b/packages/ksef-client-ts/src/pdf/template/blocks/image.ts new file mode 100644 index 00000000..3a9caed0 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/image.ts @@ -0,0 +1,14 @@ +import type { ImageBlock } from '../dsl.js'; +import { resolveBinding, type BlockRenderer } from '../interpret.js'; + +/** + * Renders an image. The source is either a literal `data:` URI (`src`) or a + * binding (`path`) resolved against the document root — `src` wins when both are + * present. An empty/absent source yields an empty text node rather than throwing, + * so an optional logo simply disappears. Never touches the filesystem. + */ +export const imageRenderer: BlockRenderer = (block, ctx) => { + const src = block.src ?? (block.path !== undefined ? resolveBinding(block.path, ctx) : ''); + if (!src) return { text: '' }; + return { image: src, width: block.width ?? 120 }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/index.ts b/packages/ksef-client-ts/src/pdf/template/blocks/index.ts new file mode 100644 index 00000000..e89782a5 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/index.ts @@ -0,0 +1,31 @@ +/** + * Block-renderer registry. Each renderer lives in its own module and is + * aggregated here; the interpreter merges this over its core primitives + * (`text`/`stack`/`columns`/`divider`/`spacer`). Renderers are authored against + * their narrow block type, so the narrowing cast at registration is sound (the + * interpreter keys them by discriminant). + */ +import type { BlockRegistry, BlockRenderer } from '../interpret.js'; +import { headerRenderer } from './header.js'; +import { partiesRenderer } from './parties.js'; +import { linesRenderer } from './lines.js'; +import { totalsRenderer } from './totals.js'; +import { paymentRenderer } from './payment.js'; +import { annotationsRenderer } from './annotations.js'; +import { footerRenderer } from './footer.js'; +import { tableRenderer } from './table.js'; +import { imageRenderer } from './image.js'; +import { qrRenderer } from './qr.js'; + +export const blockRegistry: BlockRegistry = { + header: headerRenderer as BlockRenderer, + parties: partiesRenderer as BlockRenderer, + lines: linesRenderer as BlockRenderer, + totals: totalsRenderer as BlockRenderer, + payment: paymentRenderer as BlockRenderer, + annotations: annotationsRenderer as BlockRenderer, + footer: footerRenderer as BlockRenderer, + table: tableRenderer as BlockRenderer, + image: imageRenderer as BlockRenderer, + qr: qrRenderer as BlockRenderer, +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts b/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts new file mode 100644 index 00000000..2e30ea82 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts @@ -0,0 +1,30 @@ +import { applyFormat } from '../../format.js'; +import { get, list } from '../../accessor.js'; +import type { LinesBlock } from '../dsl.js'; +import { type BlockRenderer, type PdfNode } from '../interpret.js'; + +/** + * Invoice line items as a pdfmake `table`. The header row echoes each column's + * localized label (bold); the body has one row per repeater entry read from + * {@link LinesBlock.from} via {@link list} (a single collapsed line is + * normalized to a one-element array, so one line renders like many). Each cell + * reads its column path row-relative with {@link get} and applies the column's + * formatter. With no entries only the header row is emitted. + */ +export const linesRenderer: BlockRenderer = (block, ctx) => { + const headerRow: PdfNode[] = block.columns.map((c) => ({ text: ctx.label(c.label), bold: true })); + const bodyRows: PdfNode[][] = list(ctx.root, block.from).map((row) => + block.columns.map((c) => ({ text: applyFormat(get(row, c.path, ctx.strict), c.format) })), + ); + + return { + table: { + headerRows: 1, + widths: block.columns.map(() => '*'), + body: [headerRow, ...bodyRows], + }, + layout: 'lightHorizontalLines', + margin: [0, 0, 0, 12], + ...(block.style ? { style: block.style } : {}), + }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts new file mode 100644 index 00000000..d19a6d6d --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts @@ -0,0 +1,22 @@ +import type { PartiesBlock, PartyColumn } from '../dsl.js'; +import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; + +/** + * Seller/buyer parties: a two-column layout. Each side is a stack led by a bold + * label line (`ctx.label(side.label)`, styled `h2`) followed by one text line + * per binding path in `side.fields`. Left = {@link PartiesBlock.left}, right = + * {@link PartiesBlock.right}. + */ +export const partiesRenderer: BlockRenderer = (block, ctx) => { + const side = (col: PartyColumn): PdfNode => { + const stack: PdfNode[] = [{ text: ctx.label(col.label), style: 'h2' }]; + for (const path of col.fields) stack.push({ text: resolveBinding(path, ctx) }); + return { width: '*', stack }; + }; + + return { + columns: [side(block.left), side(block.right)], + margin: [0, 0, 0, 12], + ...(block.style ? { style: block.style } : {}), + }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts new file mode 100644 index 00000000..e5bf8590 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -0,0 +1,24 @@ +import { applyFormat } from '../../format.js'; +import type { PaymentBlock } from '../dsl.js'; +import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; + +/** + * Payment details: a `payment` heading followed by one `label: value` line per + * {@link PaymentBlock.rows} entry (localized label + formatted scalar binding). + * Visibility (`when`) is resolved centrally by the interpreter, so this renderer + * always emits its content. + */ +export const paymentRenderer: BlockRenderer = (block, ctx) => { + const stack: PdfNode[] = [{ text: ctx.label('payment'), style: 'h2' }]; + for (const row of block.rows) { + stack.push({ + text: `${ctx.label(row.label)}: ${applyFormat(resolveBinding(row.path, ctx), row.format)}`, + }); + } + + return { + stack, + margin: [0, 8, 0, 8], + ...(block.style ? { style: block.style } : {}), + }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts b/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts new file mode 100644 index 00000000..d437d4c8 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts @@ -0,0 +1,14 @@ +import type { QrBlock } from '../dsl.js'; +import type { BlockRenderer } from '../interpret.js'; + +/** + * Renders the invoice verification QR ("Code I"). The URL is derived by the + * orchestrator and injected as the `qrUrl` binding — an empty binding (no + * derivable hash/NIP/date) collapses to an empty text node rather than emitting + * a broken code. `when` is handled centrally by the interpreter. + */ +export const qrRenderer: BlockRenderer = (block, ctx) => { + const url = ctx.bindings['qrUrl'] ?? ''; + if (!url) return { text: '' }; + return { qr: url, fit: block.fit ?? 100 }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/table.ts b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts new file mode 100644 index 00000000..a4b239b9 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts @@ -0,0 +1,45 @@ +import { get, list } from '../../accessor.js'; +import { applyFormat } from '../../format.js'; +import type { TableBlock } from '../dsl.js'; +import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; + +/** + * Generic pdfmake `table`. Two modes: + * + * - **Repeater** (`from` set): body rows come from `list(root, from)` — always an + * array, so a single collapsed row iterates like many — and each cell reads a + * row-relative binding via `get(row, col.path)`. + * - **Single row** (`from` absent): one body row read against the document root + * via `resolveBinding(col.path)`. + * + * A header row of localized `col.label`s is prepended unless `headers` is + * explicitly `false`. Columns share the width evenly (`'*'`) under a light + * horizontal-line layout. + */ +export const tableRenderer: BlockRenderer = (block, ctx) => { + const { columns } = block; + const showHeaders = block.headers !== false; + const body: PdfNode[][] = []; + + if (showHeaders) { + body.push(columns.map((col) => ({ text: ctx.label(col.label), bold: true }))); + } + + if (block.from !== undefined) { + for (const row of list(ctx.root, block.from)) { + body.push(columns.map((col) => ({ text: applyFormat(get(row, col.path, ctx.strict), col.format) }))); + } + } else { + body.push(columns.map((col) => ({ text: applyFormat(resolveBinding(col.path, ctx), col.format) }))); + } + + const node: Record = { + table: { + headerRows: showHeaders ? 1 : 0, + widths: columns.map(() => '*'), + body, + }, + layout: 'lightHorizontalLines', + }; + return block.style ? { ...node, style: block.style } : node; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts new file mode 100644 index 00000000..8cc14eb9 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts @@ -0,0 +1,29 @@ +import { applyFormat } from '../../format.js'; +import type { TotalsBlock } from '../dsl.js'; +import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; + +/** + * Totals summary: a compact, right-aligned label/value table. Each row of + * {@link TotalsBlock.rows} contributes a bold label (`ctx.label(row.label)`) + * and its formatted scalar binding. The borderless table is pushed to the right + * edge by an elastic spacer column. + */ +export const totalsRenderer: BlockRenderer = (block, ctx) => { + const body: PdfNode[][] = block.rows.map((row) => [ + { text: ctx.label(row.label), bold: true }, + { text: applyFormat(resolveBinding(row.path, ctx), row.format), alignment: 'right' }, + ]); + + return { + columns: [ + { width: '*', text: '' }, + { + width: 'auto', + table: { widths: ['auto', 'auto'], body }, + layout: 'noBorders', + }, + ], + margin: [0, 4, 0, 8], + ...(block.style ? { style: block.style } : {}), + }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json new file mode 100644 index 00000000..d58373d7 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -0,0 +1,81 @@ +{ + "schema": "FA(2)", + "page": { "size": "A4", "margins": [40, 40, 40, 50] }, + "styles": { + "title": { "fontSize": 20, "bold": true }, + "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, + "muted": { "color": "#666666", "fontSize": 8 }, + "offline": { "color": "#b00020", "bold": true }, + "footerNote": { "fontSize": 7, "color": "#999999" } + }, + "blocks": [ + { "type": "header", "logo": "opts.logo", "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1" }, + { + "type": "columns", + "when": "hasKsefNumber", + "columns": [ + { "type": "text", "label": "ksefNumber", "style": "muted" }, + { "type": "text", "path": "opts.ksefNumber", "style": "muted" } + ] + }, + { "type": "text", "label": "offline", "when": "offline", "style": "offline" }, + { "type": "divider" }, + { "type": "spacer", "height": 6 }, + { + "type": "parties", + "left": { + "label": "seller", + "fields": [ + "Podmiot1.DaneIdentyfikacyjne.Nazwa", + "Podmiot1.DaneIdentyfikacyjne.NIP", + "Podmiot1.Adres.AdresL1", + "Podmiot1.Adres.AdresL2" + ] + }, + "right": { + "label": "buyer", + "fields": [ + "Podmiot2.DaneIdentyfikacyjne.Nazwa", + "Podmiot2.DaneIdentyfikacyjne.NIP", + "Podmiot2.Adres.AdresL1", + "Podmiot2.Adres.AdresL2" + ] + } + }, + { "type": "spacer", "height": 12 }, + { + "type": "lines", + "from": "Fa.FaWiersz", + "columns": [ + { "label": "lp", "path": "NrWierszaFa" }, + { "label": "name", "path": "P_7" }, + { "label": "unit", "path": "P_8A" }, + { "label": "qty", "path": "P_8B", "format": "number" }, + { "label": "unitPrice", "path": "P_9A", "format": "money" }, + { "label": "vatRate", "path": "P_12" }, + { "label": "net", "path": "P_11", "format": "money" } + ] + }, + { "type": "spacer", "height": 10 }, + { + "type": "totals", + "rows": [ + { "label": "totalNet", "path": "Fa.P_13_1", "format": "money" }, + { "label": "totalVat", "path": "Fa.P_14_1", "format": "money" }, + { "label": "totalDue", "path": "Fa.P_15", "format": "money" } + ] + }, + { "type": "spacer", "height": 10 }, + { + "type": "payment", + "when": "Fa.Platnosc", + "rows": [ + { "label": "paid", "path": "Fa.Platnosc.Zaplacono" }, + { "label": "paymentDate", "path": "Fa.Platnosc.DataZaplaty", "format": "date" }, + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci" } + ] + }, + { "type": "qr", "when": "qr", "fit": 90 }, + { "type": "footer", "text": "ksef-client-ts", "style": "footerNote" } + ] +} diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json new file mode 100644 index 00000000..99a93c0c --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -0,0 +1,81 @@ +{ + "schema": "FA(3)", + "page": { "size": "A4", "margins": [40, 40, 40, 50] }, + "styles": { + "title": { "fontSize": 20, "bold": true }, + "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, + "muted": { "color": "#666666", "fontSize": 8 }, + "offline": { "color": "#b00020", "bold": true }, + "footerNote": { "fontSize": 7, "color": "#999999" } + }, + "blocks": [ + { "type": "header", "logo": "opts.logo", "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1" }, + { + "type": "columns", + "when": "hasKsefNumber", + "columns": [ + { "type": "text", "label": "ksefNumber", "style": "muted" }, + { "type": "text", "path": "opts.ksefNumber", "style": "muted" } + ] + }, + { "type": "text", "label": "offline", "when": "offline", "style": "offline" }, + { "type": "divider" }, + { "type": "spacer", "height": 6 }, + { + "type": "parties", + "left": { + "label": "seller", + "fields": [ + "Podmiot1.DaneIdentyfikacyjne.Nazwa", + "Podmiot1.DaneIdentyfikacyjne.NIP", + "Podmiot1.Adres.AdresL1", + "Podmiot1.Adres.AdresL2" + ] + }, + "right": { + "label": "buyer", + "fields": [ + "Podmiot2.DaneIdentyfikacyjne.Nazwa", + "Podmiot2.DaneIdentyfikacyjne.NIP", + "Podmiot2.Adres.AdresL1", + "Podmiot2.Adres.AdresL2" + ] + } + }, + { "type": "spacer", "height": 12 }, + { + "type": "lines", + "from": "Fa.FaWiersz", + "columns": [ + { "label": "lp", "path": "NrWierszaFa" }, + { "label": "name", "path": "P_7" }, + { "label": "unit", "path": "P_8A" }, + { "label": "qty", "path": "P_8B", "format": "number" }, + { "label": "unitPrice", "path": "P_9A", "format": "money" }, + { "label": "vatRate", "path": "P_12" }, + { "label": "net", "path": "P_11", "format": "money" } + ] + }, + { "type": "spacer", "height": 10 }, + { + "type": "totals", + "rows": [ + { "label": "totalNet", "path": "Fa.P_13_1", "format": "money" }, + { "label": "totalVat", "path": "Fa.P_14_1", "format": "money" }, + { "label": "totalDue", "path": "Fa.P_15", "format": "money" } + ] + }, + { "type": "spacer", "height": 10 }, + { + "type": "payment", + "when": "Fa.Platnosc", + "rows": [ + { "label": "paid", "path": "Fa.Platnosc.Zaplacono" }, + { "label": "paymentDate", "path": "Fa.Platnosc.DataZaplaty", "format": "date" }, + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci" } + ] + }, + { "type": "qr", "when": "qr", "fit": 90 }, + { "type": "footer", "text": "ksef-client-ts", "style": "footerNote" } + ] +} diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/index.ts b/packages/ksef-client-ts/src/pdf/template/builtin/index.ts new file mode 100644 index 00000000..8806ce9a --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/index.ts @@ -0,0 +1,26 @@ +/** + * Built-in templates. JSON is imported (not read from disk) so tsup inlines it + * into the bundle — no runtime filesystem access is needed for built-ins. + */ +import { validateTemplate, type InvoiceTemplate } from '../dsl.js'; +import fa2Default from './fa2-default.json'; +import fa3Default from './fa3-default.json'; +import upo42 from './upo-4_2.json'; +import upo43 from './upo-4_3.json'; + +// Built-ins are validated at load — a drift in one of our own presets fails fast +// on import rather than silently producing a broken PDF. +export const BUILTIN_TEMPLATES: Record = { + 'fa2-default': validateTemplate(fa2Default), + 'fa3-default': validateTemplate(fa3Default), + 'upo-4_2': validateTemplate(upo42), + 'upo-4_3': validateTemplate(upo43), +}; + +export function getBuiltinTemplate(name: string): InvoiceTemplate | undefined { + return BUILTIN_TEMPLATES[name]; +} + +export function builtinTemplateNames(): string[] { + return Object.keys(BUILTIN_TEMPLATES); +} diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json new file mode 100644 index 00000000..a559468f --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json @@ -0,0 +1,43 @@ +{ + "schema": "UPO(4.2)", + "page": { "size": "A4", "margins": [40, 40, 40, 40] }, + "styles": { + "title": { "fontSize": 16, "bold": true }, + "fieldLabel": { "bold": true, "color": "#444444" }, + "muted": { "color": "#666666" } + }, + "blocks": [ + { "type": "header", "title": { "label": "upoTitle" } }, + { "type": "divider" }, + { "type": "spacer", "height": 10 }, + { + "type": "stack", + "stack": [ + { "type": "columns", "columns": [ + { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, + { "type": "text", "path": "NumerReferencyjnySesji" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "ksefDocNumber", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.NumerKSeFDokumentu" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "invoiceNumber", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.NumerFaktury" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "issueDate", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.DataWystawieniaFaktury", "format": "date" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "receiptDate", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.DataNadaniaNumeruKSeF", "format": "date" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "documentHash", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.SkrotDokumentu", "style": "muted" } + ] } + ] + } + ] +} diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json new file mode 100644 index 00000000..f62a1972 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json @@ -0,0 +1,43 @@ +{ + "schema": "UPO(4.3)", + "page": { "size": "A4", "margins": [40, 40, 40, 40] }, + "styles": { + "title": { "fontSize": 16, "bold": true }, + "fieldLabel": { "bold": true, "color": "#444444" }, + "muted": { "color": "#666666" } + }, + "blocks": [ + { "type": "header", "title": { "label": "upoTitle" } }, + { "type": "divider" }, + { "type": "spacer", "height": 10 }, + { + "type": "stack", + "stack": [ + { "type": "columns", "columns": [ + { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, + { "type": "text", "path": "NumerReferencyjnySesji" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "ksefDocNumber", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.NumerKSeFDokumentu" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "invoiceNumber", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.NumerFaktury" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "issueDate", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.DataWystawieniaFaktury", "format": "date" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "receiptDate", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.DataNadaniaNumeruKSeF", "format": "date" } + ] }, + { "type": "columns", "columns": [ + { "type": "text", "label": "documentHash", "style": "fieldLabel" }, + { "type": "text", "path": "Dokument.SkrotDokumentu", "style": "muted" } + ] } + ] + } + ] +} diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts new file mode 100644 index 00000000..bbf7a87a --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -0,0 +1,311 @@ +/** + * Template DSL — the declarative block language interpreted into a pdfmake + * document. Deliberately not Turing-complete: blocks + bindings + repeaters + + * conditions + formatters, nothing else. The block-type set is frozen; custom + * layouts compose primitives rather than register code. + * + * A binding is a dot-path string (see {@link file://../accessor.ts}); a `when` + * is a presence test; a `format` names a value formatter. + */ +import { z } from 'zod'; +import { KSeFValidationError } from '../../errors/ksef-validation-error.js'; +import type { FormatterName } from '../format.js'; + +export type TemplateSchemaId = 'FA(2)' | 'FA(3)' | 'UPO(4.2)' | 'UPO(4.3)'; + +/** Loose style bag mapping to pdfmake style props (fontSize, bold, color, …). */ +export type Style = Record>; + +export interface PageConfig { + size?: string; + orientation?: 'portrait' | 'landscape'; + margins?: [number, number, number, number]; +} + +/** A labeled reference: `{ label }` resolves via i18n, `{ text }` is literal. */ +export interface LabelRef { + label?: string; + text?: string; +} + +/** A column/field: label (i18n key) + binding path, optional formatter. */ +export interface FieldDef { + label: string; + path: string; + format?: FormatterName; + style?: string; +} + +// ── Semantic blocks ──────────────────────────────────────────────────────── + +export interface HeaderBlock { + type: 'header'; + logo?: string; + title?: LabelRef; + number?: string; + date?: string; + style?: string; +} + +export interface PartyColumn { + label: string; + fields: string[]; +} + +export interface PartiesBlock { + type: 'parties'; + left: PartyColumn; + right: PartyColumn; + style?: string; +} + +export interface LinesBlock { + type: 'lines'; + from: string; + columns: FieldDef[]; + style?: string; +} + +export interface TotalsBlock { + type: 'totals'; + rows: FieldDef[]; + style?: string; +} + +export interface PaymentBlock { + type: 'payment'; + when?: string; + rows: FieldDef[]; + style?: string; +} + +export interface AnnotationsBlock { + type: 'annotations'; + fields: FieldDef[]; + style?: string; +} + +export interface QrBlock { + type: 'qr'; + when?: string; + fit?: number; +} + +export interface FooterBlock { + type: 'footer'; + label?: string; + text?: string; + style?: string; +} + +// ── Primitive blocks ─────────────────────────────────────────────────────── + +export interface TextBlock { + type: 'text'; + text?: string; + path?: string; + label?: string; + format?: FormatterName; + when?: string; + style?: string; +} + +export interface ColumnsBlock { + type: 'columns'; + columns: Block[]; + when?: string; + style?: string; +} + +export interface StackBlock { + type: 'stack'; + stack: Block[]; + when?: string; + style?: string; +} + +export interface TableBlock { + type: 'table'; + from?: string; + columns: FieldDef[]; + headers?: boolean; + when?: string; + style?: string; +} + +export interface ImageBlock { + type: 'image'; + src?: string; + path?: string; + width?: number; + when?: string; +} + +export interface DividerBlock { + type: 'divider'; + style?: string; +} + +export interface SpacerBlock { + type: 'spacer'; + height?: number; +} + +export type Block = + | HeaderBlock + | PartiesBlock + | LinesBlock + | TotalsBlock + | PaymentBlock + | AnnotationsBlock + | QrBlock + | FooterBlock + | TextBlock + | ColumnsBlock + | StackBlock + | TableBlock + | ImageBlock + | DividerBlock + | SpacerBlock; + +export type BlockType = Block['type']; + +export interface InvoiceTemplate { + /** Binds this template to one XML kind; the engine rejects a version mismatch. */ + schema: TemplateSchemaId; + page?: PageConfig; + defaultStyle?: Style; + styles?: Record; + /** Per-template label overrides (merged over the i18n bundle). */ + labels?: Record; + blocks: Block[]; +} + +// ── zod validation ───────────────────────────────────────────────────────── + +const formatEnum = z.enum(['money', 'date', 'number', 'nip']); +const styleValue = z.union([z.string(), z.number(), z.boolean(), z.array(z.number())]); +const styleSchema = z.record(z.string(), styleValue); +const labelRef = z.object({ label: z.string().optional(), text: z.string().optional() }).strict(); +const fieldDef = z + .object({ + label: z.string(), + path: z.string(), + format: formatEnum.optional(), + style: z.string().optional(), + }) + .strict(); + +// Recursive block schema (containers embed blocks). z.lazy breaks the cycle. +const blockSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion('type', [ + z.object({ + type: z.literal('header'), + logo: z.string().optional(), + title: labelRef.optional(), + number: z.string().optional(), + date: z.string().optional(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('parties'), + left: z.object({ label: z.string(), fields: z.array(z.string()) }).strict(), + right: z.object({ label: z.string(), fields: z.array(z.string()) }).strict(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('lines'), + from: z.string(), + columns: z.array(fieldDef), + style: z.string().optional(), + }).strict(), + z.object({ type: z.literal('totals'), rows: z.array(fieldDef), style: z.string().optional() }).strict(), + z.object({ + type: z.literal('payment'), + when: z.string().optional(), + rows: z.array(fieldDef), + style: z.string().optional(), + }).strict(), + z.object({ type: z.literal('annotations'), fields: z.array(fieldDef), style: z.string().optional() }).strict(), + z.object({ type: z.literal('qr'), when: z.string().optional(), fit: z.number().optional() }).strict(), + z.object({ + type: z.literal('footer'), + label: z.string().optional(), + text: z.string().optional(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('text'), + text: z.string().optional(), + path: z.string().optional(), + label: z.string().optional(), + format: formatEnum.optional(), + when: z.string().optional(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('columns'), + columns: z.array(blockSchema), + when: z.string().optional(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('stack'), + stack: z.array(blockSchema), + when: z.string().optional(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('table'), + from: z.string().optional(), + columns: z.array(fieldDef), + headers: z.boolean().optional(), + when: z.string().optional(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('image'), + src: z.string().optional(), + path: z.string().optional(), + width: z.number().optional(), + when: z.string().optional(), + }).strict(), + z.object({ type: z.literal('divider'), style: z.string().optional() }).strict(), + z.object({ type: z.literal('spacer'), height: z.number().optional() }).strict(), + ]), +) as z.ZodType; + +export const invoiceTemplateSchema: z.ZodType = z + .object({ + schema: z.enum(['FA(2)', 'FA(3)', 'UPO(4.2)', 'UPO(4.3)']), + page: z + .object({ + size: z.string().optional(), + orientation: z.enum(['portrait', 'landscape']).optional(), + margins: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + }) + .strict() + .optional(), + defaultStyle: styleSchema.optional(), + styles: z.record(z.string(), styleSchema).optional(), + labels: z.record(z.string(), z.string()).optional(), + blocks: z.array(blockSchema), + }) + .strict() as z.ZodType; + +/** + * Validate an untrusted template object. Throws {@link KSeFValidationError} + * (with a flattened, path-tagged message per issue) on any structural problem — + * unknown block type, missing required field, or extra keys. + */ +export function validateTemplate(input: unknown): InvoiceTemplate { + const result = invoiceTemplateSchema.safeParse(input); + if (!result.success) { + const messages = result.error.issues.map((issue) => { + const path = issue.path.length > 0 ? issue.path.join('.') : '(root)'; + return `${path}: ${issue.message}`; + }); + throw KSeFValidationError.fromMessages(messages); + } + return result.data; +} diff --git a/packages/ksef-client-ts/src/pdf/template/interpret.ts b/packages/ksef-client-ts/src/pdf/template/interpret.ts new file mode 100644 index 00000000..43315427 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/interpret.ts @@ -0,0 +1,175 @@ +/** + * DSL interpreter: walks a validated {@link InvoiceTemplate} and emits a pdfmake + * document-definition content tree. Blocks are dispatched through a registry so + * renderers can be authored independently; containers recurse through a + * depth-guarded `render` callback rather than importing the interpreter, which + * keeps the module graph acyclic. + * + * Nodes are typed loosely (`PdfNode`) on purpose — the public `./pdf` types must + * not depend on `@types/pdfmake` (the optional peer). We assert node *structure* + * in tests, never bytes. + */ +import { get, has } from '../accessor.js'; +import { applyFormat, type FormatterName } from '../format.js'; +import { KSeFPdfError } from '../errors.js'; +import type { LabelResolver } from '../i18n/index.js'; +import type { Block, BlockType, InvoiceTemplate, Style } from './dsl.js'; + +/** A pdfmake content node — a string or a property bag. Intentionally loose. */ +export type PdfNode = string | Record; +export type PdfContent = PdfNode | PdfNode[]; + +/** Maximum block-nesting depth before the interpreter bails out. */ +export const MAX_DEPTH = 24; + +/** Derived, non-XML bindings (computed aliases + resolved options). */ +export interface RenderContext { + /** Parsed XML root (compact object). */ + root: unknown; + /** Throw on a missing binding instead of yielding `''`. */ + strict: boolean; + /** i18n label resolver bound to the chosen locale. */ + label: LabelResolver; + /** + * Non-XML bindings by exact key: `opts.logo`, `opts.ksefNumber`, `hash`, + * `qrUrl`, `hasKsefNumber`, … . Looked up before falling through to XML. + */ + bindings: Record; + /** Boolean aliases for `when` (e.g. `hasKsefNumber`, `qr`). */ + flags: Record; +} + +/** Recurse into a child block (depth-guarded); `null` when the child is hidden. */ +export type RenderChild = (child: Block) => PdfContent | null; + +/** A block renderer: pure (block, ctx) → pdfmake node(s), or `null` if hidden. */ +export type BlockRenderer = ( + block: B, + ctx: RenderContext, + render: RenderChild, +) => PdfContent | null; + +export type BlockRegistry = Partial>; + +/** Resolve a binding path across the three namespaces (non-XML, then XML). */ +export function resolveBinding(path: string, ctx: RenderContext): string { + if (path in ctx.bindings) return ctx.bindings[path] ?? ''; + return get(ctx.root, path, ctx.strict); +} + +/** Evaluate a `when` condition: a boolean alias, else an XML presence test. */ +export function evalWhen(when: string | undefined, ctx: RenderContext): boolean { + if (when === undefined) return true; + if (when in ctx.flags) return ctx.flags[when] === true; + if (when in ctx.bindings) return (ctx.bindings[when] ?? '') !== ''; + return has(ctx.root, when); +} + +/** Read a `{ label }`/`{ text }` ref or a literal binding into a string. */ +export function resolveText( + spec: { text?: string; path?: string; label?: string; format?: FormatterName } | undefined, + ctx: RenderContext, +): string { + if (!spec) return ''; + if (spec.label !== undefined) return ctx.label(spec.label); + if (spec.text !== undefined) return spec.text; + if (spec.path !== undefined) return applyFormat(resolveBinding(spec.path, ctx), spec.format); + return ''; +} + +function withStyle(node: Record, style: string | undefined): PdfNode { + return style ? { ...node, style } : node; +} + +// ── Core (interpreter-native) primitive renderers ────────────────────────── + +const coreRegistry: BlockRegistry = { + text: (block, ctx) => { + const b = block as import('./dsl.js').TextBlock; + return withStyle({ text: resolveText(b, ctx) }, b.style); + }, + + stack: (block, _ctx, render) => { + const b = block as import('./dsl.js').StackBlock; + const stack = b.stack.map((c) => render(c)).filter((n): n is PdfContent => n !== null).flat(); + return withStyle({ stack }, b.style); + }, + + columns: (block, _ctx, render) => { + const b = block as import('./dsl.js').ColumnsBlock; + const columns = b.columns.map((c) => render(c)).filter((n): n is PdfContent => n !== null).flat(); + return withStyle({ columns }, b.style); + }, + + divider: (block) => { + const b = block as import('./dsl.js').DividerBlock; + return withStyle( + { canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: '#cccccc' }] }, + b.style, + ); + }, + + spacer: (block) => { + const b = block as import('./dsl.js').SpacerBlock; + return { text: '', margin: [0, (b.height ?? 8) / 2, 0, (b.height ?? 8) / 2] }; + }, +}; + +/** + * Interpret one block. Enforces the depth limit, resolves the renderer from the + * registry (unknown type → error), and hands the renderer a depth-incremented + * `render` callback for its children. + */ +export function interpretBlock( + block: Block, + ctx: RenderContext, + registry: BlockRegistry, + depth: number, +): PdfContent | null { + if (depth > MAX_DEPTH) { + throw new KSeFPdfError(`Template nesting exceeds the maximum depth of ${MAX_DEPTH}`); + } + // `when` is handled centrally: a hidden block never reaches its renderer, so + // renderers stay free of visibility logic. + const when = (block as { when?: string }).when; + if (when !== undefined && !evalWhen(when, ctx)) return null; + + const renderer = registry[block.type]; + if (!renderer) { + throw new KSeFPdfError(`No renderer registered for block type "${block.type}"`); + } + const render: RenderChild = (child) => interpretBlock(child, ctx, registry, depth + 1); + return renderer(block, ctx, render); +} + +/** + * Interpret a whole template into a pdfmake document definition. Merges the + * caller-provided renderers over the core primitives, wires template styles, + * and forces the bundled Roboto font (the only font shipped in the VFS). + */ +export function interpretTemplate( + template: InvoiceTemplate, + ctx: RenderContext, + registry: BlockRegistry = {}, +): Record { + const merged: BlockRegistry = { ...coreRegistry, ...registry }; + const content: PdfNode[] = []; + for (const block of template.blocks) { + const node = interpretBlock(block, ctx, merged, 0); + if (node === null) continue; + if (Array.isArray(node)) content.push(...node); + else content.push(node); + } + + const defaultStyle: Style = { font: 'Roboto', fontSize: 9, ...(template.defaultStyle ?? {}) }; + const doc: Record = { content, defaultStyle }; + if (template.styles) doc.styles = template.styles; + if (template.page) { + if (template.page.size) doc.pageSize = template.page.size; + if (template.page.orientation) doc.pageOrientation = template.page.orientation; + if (template.page.margins) doc.pageMargins = template.page.margins; + } + return doc; +} + +export { coreRegistry }; diff --git a/packages/ksef-client-ts/tests/fixtures/pdf-types-check.ts b/packages/ksef-client-ts/tests/fixtures/pdf-types-check.ts new file mode 100644 index 00000000..42a5e531 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf-types-check.ts @@ -0,0 +1,41 @@ +/** + * Type-only fixture for the `ksef-client-ts/pdf` subpath. + * + * Compiled by `tsc --project tsconfig.pdf-check.json --noEmit` with `types` + * restricted to `node` (NO `@types/pdfmake` in scope). It proves that the + * public `./pdf` types resolve for a consumer who has NOT installed pdfmake — + * the optional peer must never leak into the published type surface. + * + * This file is NOT executed at runtime. + */ +import { + renderInvoicePdf, + renderInvoicePdfFromFile, + renderInvoicePdfFromTemplate, + renderUpoPdf, + detectInvoiceVersion, + detectUpoVersion, + type Locale, + type InvoiceTemplate, + type RenderOptions, +} from 'ksef-client-ts/pdf'; + +const xml = ''; +const opts: RenderOptions = { locale: 'pl+en', qr: true, strict: false }; + +const _a: Promise = renderInvoicePdf(xml, 'fa3-default', opts); +const _b: Promise = renderInvoicePdfFromFile(xml, './tpl.json', opts); +void _a; void _b; + +const template: InvoiceTemplate = { + schema: 'FA(3)', + blocks: [{ type: 'text', path: 'Fa.P_2' }], +}; +const _c: Promise = renderInvoicePdfFromTemplate(new Uint8Array(), template); +const _d: Promise = renderUpoPdf(xml); +void _c; void _d; + +const _loc: Locale = 'en'; +const _iv: 'FA(2)' | 'FA(3)' | null = detectInvoiceVersion(xml); +const _uv: 'UPO(4.2)' | 'UPO(4.3)' | null = detectUpoVersion(xml); +void _loc; void _iv; void _uv; diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml new file mode 100644 index 00000000..2e03abd2 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml @@ -0,0 +1,75 @@ + + + + + FA + 2 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + 1 + 2025-01-15 + 6 + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml new file mode 100644 index 00000000..b19d3cd1 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml @@ -0,0 +1,75 @@ + + + + + FA + 3 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + 1 + 2025-01-15 + 6 + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/upo-4_2.xml b/packages/ksef-client-ts/tests/fixtures/pdf/upo-4_2.xml new file mode 100644 index 00000000..dba4b459 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/upo-4_2.xml @@ -0,0 +1,24 @@ + + + + Ministerstwo Finansów (środowisko testowe) + 22222222-22-2222222222-2222222222-22 + + + 1111111111-00000 + + 20250115-EX-0000000000-0000000000-00 + + Schemat_FA(2)_v1-0E.xsd + FA (2) + + 1111111111 + 1111111111-20250115-020000000000-00 + FA/2025/01/001 + 2025-01-15 + 2025-01-15T10:00:00.000+01:00 + 2025-01-15T10:00:01.000+01:00 + BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= + Online + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/upo-4_3.xml b/packages/ksef-client-ts/tests/fixtures/pdf/upo-4_3.xml new file mode 100644 index 00000000..f12e4f42 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/upo-4_3.xml @@ -0,0 +1,24 @@ + + + + Ministerstwo Finansów (środowisko testowe) + 11111111-11-1111111111-1111111111-11 + + + 1111111111-00000 + + 20250115-EX-0000000000-0000000000-00 + + Schemat_FA(3)_v1-0E.xsd + FA (3) + + 1111111111 + 1111111111-20250115-010000000000-00 + FA/2025/01/001 + 2025-01-15 + 2025-01-15T10:00:00.000+01:00 + 2025-01-15T10:00:01.000+01:00 + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + Online + + diff --git a/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts b/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts new file mode 100644 index 00000000..719d9fe6 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as fs from 'node:fs'; +import { invoiceCommand } from '../../../../src/cli/commands/invoice.js'; +import * as output from '../../../../src/cli/output.js'; +import * as pdfModule from '../../../../src/pdf/index.js'; + +// Surface thrown errors instead of process.exit, so we can assert on them. +vi.mock('../../../../src/cli/error-handler.js', () => ({ + withErrorHandler: vi.fn((fn) => fn()), +})); + +vi.mock('consola', () => ({ + consola: { level: 0, start: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), log: vi.fn(), warn: vi.fn() }, +})); + +vi.mock('../../../../src/cli/client-factory.js', () => ({ + requireSession: vi.fn(), + createClient: vi.fn(), +})); + +vi.mock('../../../../src/cli/config-store.js', () => ({ loadConfig: vi.fn() })); + +vi.mock('../../../../src/cli/session-store.js', () => ({ + saveOnlineSessionRef: vi.fn(), + clearOnlineSessionRef: vi.fn(), + loadEncryptionData: vi.fn(), +})); + +vi.mock('../../../../src/cli/output.js', () => ({ + outputResult: vi.fn(), + outputTable: vi.fn(), + outputSuccess: vi.fn(), + outputKeyValue: vi.fn(), + outputWarning: vi.fn(), +})); + +vi.mock('../../../../src/validation/invoice-validator.js', () => ({ + validate: vi.fn(), + validateBatch: vi.fn(), + batchValidationDetails: vi.fn(() => []), +})); + +// The CLI lazily `import('../../pdf/index.js')`; vitest applies this mock to +// that dynamic import too (same resolved module). +vi.mock('../../../../src/pdf/index.js', () => ({ + renderInvoicePdf: vi.fn(), + renderInvoicePdfFromFile: vi.fn(), + renderInvoicePdfFromTemplate: vi.fn(), + renderUpoPdf: vi.fn(), + detectInvoiceVersion: vi.fn(), + detectUpoVersion: vi.fn(), +})); + +vi.mock('node:fs', () => { + const m = { + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + statSync: vi.fn(), + readdirSync: vi.fn(), + mkdirSync: vi.fn(), + }; + return { ...m, default: m }; +}); + +const mockedFs = vi.mocked(fs); +const mockedPdf = vi.mocked(pdfModule); +const mockedOutput = vi.mocked(output); + +const FAKE_PDF = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // "%PDF" + +function runPdf(args: Record) { + return (invoiceCommand.subCommands!.pdf as any).run!({ args }); +} + +beforeEach(() => { + vi.clearAllMocks(); + // Sensible defaults: file exists, reads to an FA(3) buffer, renders to bytes. + mockedFs.existsSync.mockReturnValue(true); + mockedFs.readFileSync.mockReturnValue(Buffer.from('')); + mockedPdf.detectInvoiceVersion.mockReturnValue('FA(3)'); + mockedPdf.detectUpoVersion.mockReturnValue(null); + mockedPdf.renderInvoicePdf.mockResolvedValue(FAKE_PDF); + mockedPdf.renderInvoicePdfFromFile.mockResolvedValue(FAKE_PDF); + mockedPdf.renderUpoPdf.mockResolvedValue(FAKE_PDF); +}); + +describe('invoice pdf — CLI wiring', () => { + it('errors when the input file does not exist', async () => { + mockedFs.existsSync.mockReturnValue(false); + await expect(runPdf({ file: 'missing.xml' })).rejects.toThrow(/File not found/); + }); + + it('rejects when both --template and --template-file are given', async () => { + await expect( + runPdf({ file: 'invoice.xml', template: 'fa3-default', templateFile: './t.json' }), + ).rejects.toThrow(/mutually exclusive/); + }); + + it('rejects an invalid --locale', async () => { + await expect(runPdf({ file: 'invoice.xml', locale: 'de' })).rejects.toThrow(/Invalid --locale/); + }); + + it('defaults to fa3-default for an FA(3) document and writes next to the source', async () => { + await runPdf({ file: 'dir/invoice.xml' }); + expect(mockedPdf.renderInvoicePdf).toHaveBeenCalledWith( + expect.any(Uint8Array), + 'fa3-default', + expect.objectContaining({ locale: 'pl', qr: false }), + ); + expect(mockedFs.writeFileSync).toHaveBeenCalledWith('dir/invoice.pdf', FAKE_PDF); + expect(mockedOutput.outputSuccess).toHaveBeenCalled(); + }); + + it('defaults to fa2-default for an FA(2) document', async () => { + mockedPdf.detectInvoiceVersion.mockReturnValue('FA(2)'); + await runPdf({ file: 'invoice.xml' }); + expect(mockedPdf.renderInvoicePdf).toHaveBeenCalledWith(expect.any(Uint8Array), 'fa2-default', expect.anything()); + }); + + it('uses a named built-in template with --template', async () => { + await runPdf({ file: 'invoice.xml', template: 'fa3-default' }); + expect(mockedPdf.renderInvoicePdf).toHaveBeenCalledWith(expect.any(Uint8Array), 'fa3-default', expect.anything()); + }); + + it('uses a custom template file with --template-file', async () => { + await runPdf({ file: 'invoice.xml', templateFile: './custom.json' }); + expect(mockedPdf.renderInvoicePdfFromFile).toHaveBeenCalledWith( + expect.any(Uint8Array), + './custom.json', + expect.anything(), + ); + expect(mockedPdf.renderInvoicePdf).not.toHaveBeenCalled(); + }); + + it('honors an explicit --out path', async () => { + await runPdf({ file: 'invoice.xml', out: '/tmp/result.pdf' }); + expect(mockedFs.writeFileSync).toHaveBeenCalledWith('/tmp/result.pdf', FAKE_PDF); + }); + + it('renders a UPO document when --upo is set', async () => { + await runPdf({ file: 'upo.xml', upo: true }); + expect(mockedPdf.renderUpoPdf).toHaveBeenCalled(); + expect(mockedPdf.renderInvoicePdf).not.toHaveBeenCalled(); + }); + + it('auto-detects a UPO document (no invoice version, UPO version present)', async () => { + mockedPdf.detectInvoiceVersion.mockReturnValue(null); + mockedPdf.detectUpoVersion.mockReturnValue('UPO(4.3)'); + await runPdf({ file: 'upo.xml' }); + expect(mockedPdf.renderUpoPdf).toHaveBeenCalled(); + }); + + it('passes qr / ksefNumber / env through to the renderer', async () => { + await runPdf({ file: 'invoice.xml', qr: true, ksefNumber: 'NR-1', env: 'test' }); + expect(mockedPdf.renderInvoicePdf).toHaveBeenCalledWith( + expect.any(Uint8Array), + 'fa3-default', + expect.objectContaining({ qr: true, ksefNumber: 'NR-1', env: 'test' }), + ); + }); + + it('renders bilingual labels with --locale pl+en', async () => { + await runPdf({ file: 'invoice.xml', locale: 'pl+en' }); + expect(mockedPdf.renderInvoicePdf).toHaveBeenCalledWith( + expect.any(Uint8Array), + 'fa3-default', + expect.objectContaining({ locale: 'pl+en' }), + ); + }); + + it('surfaces the friendly pdfmake-missing error from the module', async () => { + mockedPdf.renderInvoicePdf.mockRejectedValue( + new Error('PDF rendering requires the optional peer dependency "pdfmake". Install it with: npm i "pdfmake@^0.2.20"'), + ); + await expect(runPdf({ file: 'invoice.xml' })).rejects.toThrow(/npm i "pdfmake\^?0?\.?2?\.?20?"|pdfmake@\^0\.2\.20/); + }); + + it('emits JSON output when --json is set', async () => { + await runPdf({ file: 'invoice.xml', json: true }); + expect(mockedOutput.outputResult).toHaveBeenCalledWith( + expect.objectContaining({ out: 'invoice.pdf', bytes: FAKE_PDF.length }), + { json: true }, + ); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/accessor.test.ts b/packages/ksef-client-ts/tests/unit/pdf/accessor.test.ts new file mode 100644 index 00000000..0fe07b2b --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/accessor.test.ts @@ -0,0 +1,242 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { get, list, has, getNode } from '../../../src/pdf/accessor.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.resolve(__dirname, '../../fixtures'); + +function loadFixture(rel: string): string { + return fs.readFileSync(path.join(fixturesDir, rel), 'utf8'); +} + +// A synthetic, anonymous FA(3) invoice parsed through the compact PDF parser. +// Used for the fixture-backed cases (nested scalar, attribute, #text unwrap, +// collapsed list). No real taxpayer data. +const fa3 = parseXmlForPdf(loadFixture('pdf/fa3.xml')); + +// A small, hand-built compact object exercising every parser artifact the +// accessor must smooth over, without depending on a real file. +const inline = { + Podmiot1: { + DaneIdentyfikacyjne: { NIP: '1111111111', Nazwa: 'Sprzedawca Przykładowy' }, + }, + // Mixed-content element: text under `#text`, plus attributes under `@`. + KodFormularza: { '#text': 'FA', '@kodSystemowy': 'FA (3)', '@wersjaSchemy': '1-0E' }, + // Element with attributes only — no scalar value at all. + AttrsOnly: { '@code': 'X' }, + // Element whose only child is an empty `#text`. + EmptyText: { '#text': '' }, + // Element with `#text` alongside another key — not the "single #text" case. + TextPlus: { '#text': '', '@a': 'x' }, + // Repeater the parser collapsed to an object (single element). + Collapsed: { Row: { n: '1' } }, + // A genuine array (multi-element repeater) and an empty one. + Rows: [{ n: '1' }, { n: '2' }, { n: '3' }], + Empty: [] as unknown[], + // Array descent: reading through an array should follow the first element. + ArrDescent: [{ x: 'first' }, { x: 'second' }], + // Array whose first element carries mixed content — coerced via #text. + ArrText: [{ '#text': 'hi' }], + // Scalars of each primitive kind. + Str: 'hello', + EmptyStr: '', + Num: 5, + Zero: 0, + BoolTrue: true, + BoolFalse: false, + NullLeaf: null, +}; + +describe('getNode', () => { + it('returns the raw object at a nested path', () => { + const node = getNode(fa3, 'Faktura.Podmiot1.DaneIdentyfikacyjne'); + expect(node).toEqual({ NIP: '1111111111', Nazwa: 'Sprzedawca Przykładowy Sp. z o.o.' }); + }); + + it('returns the raw array node untouched', () => { + expect(getNode(inline, 'Rows')).toBe(inline.Rows); + }); + + it('returns the root when the path is empty (all segments filtered)', () => { + expect(getNode(inline, '')).toBe(inline); + // Leading/trailing/duplicate dots are filtered out too. + expect(getNode(inline, '.Str.')).toBe('hello'); + }); + + it('follows the first element when descent hits an array mid-path', () => { + expect(getNode(inline, 'ArrDescent.x')).toBe('first'); + }); + + it('returns undefined when an intermediate segment is missing', () => { + expect(getNode(inline, 'Podmiot1.Nope.NIP')).toBeUndefined(); + }); + + it('returns undefined when descent runs into a scalar', () => { + expect(getNode(inline, 'Str.somethingDeeper')).toBeUndefined(); + }); + + it('returns undefined for a null or undefined root', () => { + expect(getNode(null, 'a')).toBeUndefined(); + expect(getNode(undefined, 'a')).toBeUndefined(); + }); + + it('returns undefined when a leaf value is explicitly undefined', () => { + expect(getNode({ a: undefined }, 'a')).toBeUndefined(); + }); +}); + +describe('get', () => { + it('reads a nested scalar from a fixture', () => { + expect(get(fa3, 'Faktura.Podmiot1.DaneIdentyfikacyjne.NIP')).toBe('1111111111'); + }); + + it('unwraps #text on a mixed-content element (fixture)', () => { + expect(get(fa3, 'Faktura.Naglowek.KodFormularza')).toBe('FA'); + }); + + it('unwraps #text on a mixed-content element (inline)', () => { + expect(get(inline, 'KodFormularza')).toBe('FA'); + }); + + it('reads an @attribute segment (fixture)', () => { + expect(get(fa3, 'Faktura.Naglowek.KodFormularza.@kodSystemowy')).toBe('FA (3)'); + }); + + it('reads an @attribute segment (inline)', () => { + expect(get(inline, 'KodFormularza.@wersjaSchemy')).toBe('1-0E'); + }); + + it('coerces a number leaf to a string', () => { + expect(get(inline, 'Num')).toBe('5'); + expect(get(inline, 'Zero')).toBe('0'); + }); + + it('coerces a boolean leaf to a string', () => { + expect(get(inline, 'BoolTrue')).toBe('true'); + expect(get(inline, 'BoolFalse')).toBe('false'); + }); + + it('returns "" for a leaf of an uncoercible type (e.g. bigint)', () => { + // Not a string/number/boolean/array/record — falls through to no scalar. + expect(get({ big: 10n }, 'big')).toBe(''); + }); + + it('follows the first element when descending through an array', () => { + expect(get(inline, 'ArrDescent.x')).toBe('first'); + }); + + it('coerces via the first array element (unwrapping its #text)', () => { + expect(get(inline, 'ArrText')).toBe('hi'); + }); + + it('returns "" for an element that has only attributes (no scalar)', () => { + expect(get(inline, 'AttrsOnly')).toBe(''); + }); + + it('returns "" for a missing path', () => { + expect(get(inline, 'Nope.Missing')).toBe(''); + }); + + it('returns "" for a null leaf', () => { + expect(get(inline, 'NullLeaf')).toBe(''); + }); + + it('returns "" for a null/undefined root', () => { + expect(get(null, 'a')).toBe(''); + expect(get(undefined, 'a')).toBe(''); + }); + + it('throws with the path in the message when strict and missing', () => { + expect(() => get(inline, 'Nope.Missing', true)).toThrow(/Missing binding: "Nope\.Missing"/); + }); + + it('does not throw in strict mode when the binding exists', () => { + expect(get(inline, 'Str', true)).toBe('hello'); + }); +}); + +describe('list', () => { + it('wraps a parser-collapsed single element into a 1-element array (fixture)', () => { + const rows = list(fa3, 'Faktura.Fa.FaWiersz'); + expect(Array.isArray(rows)).toBe(true); + expect(rows).toHaveLength(1); + expect((rows[0] as Record).NrWierszaFa).toBe('1'); + }); + + it('wraps a collapsed object (inline) into a 1-element array', () => { + expect(list(inline, 'Collapsed.Row')).toEqual([{ n: '1' }]); + }); + + it('returns a real array unchanged (same reference)', () => { + const rows = list(inline, 'Rows'); + expect(rows).toBe(inline.Rows); + expect(rows).toHaveLength(3); + }); + + it('returns [] for a missing path', () => { + expect(list(inline, 'Nope')).toEqual([]); + }); + + it('returns [] for a null leaf', () => { + expect(list(inline, 'NullLeaf')).toEqual([]); + }); + + it('returns [] for a null root', () => { + expect(list(null, 'a')).toEqual([]); + }); +}); + +describe('has', () => { + it('is true for a present nested object (fixture)', () => { + expect(has(fa3, 'Faktura.Podmiot1')).toBe(true); + }); + + it('is true for a present non-empty scalar (fixture)', () => { + expect(has(fa3, 'Faktura.Fa.P_2')).toBe(true); + }); + + it('is true for a number leaf, including 0', () => { + expect(has(inline, 'Num')).toBe(true); + expect(has(inline, 'Zero')).toBe(true); + }); + + it('is true for a boolean leaf, including false', () => { + expect(has(inline, 'BoolTrue')).toBe(true); + expect(has(inline, 'BoolFalse')).toBe(true); + }); + + it('is true for a non-empty array', () => { + expect(has(inline, 'Rows')).toBe(true); + }); + + it('is true for an element carrying a non-empty #text only', () => { + expect(has({ K: { '#text': 'FA' } }, 'K')).toBe(true); + }); + + it('is true for an element with #text plus other keys', () => { + expect(has(inline, 'TextPlus')).toBe(true); + }); + + it('is false for a missing path', () => { + expect(has(inline, 'Nope')).toBe(false); + }); + + it('is false for a null leaf', () => { + expect(has(inline, 'NullLeaf')).toBe(false); + }); + + it('is false for an empty string', () => { + expect(has(inline, 'EmptyStr')).toBe(false); + }); + + it('is false for an empty array', () => { + expect(has(inline, 'Empty')).toBe(false); + }); + + it('is false for an element carrying only an empty #text', () => { + expect(has(inline, 'EmptyText')).toBe(false); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts new file mode 100644 index 00000000..744d4e38 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { tableRenderer } from '../../../src/pdf/template/blocks/table.js'; +import { imageRenderer } from '../../../src/pdf/template/blocks/image.js'; +import type { RenderContext } from '../../../src/pdf/template/interpret.js'; +import type { ImageBlock, TableBlock } from '../../../src/pdf/template/dsl.js'; + +/** Build a RenderContext by hand; label is identity so keys pass through. */ +function makeCtx(root: unknown, overrides: Partial = {}): RenderContext { + return { + root, + strict: false, + label: (k: string) => k, + bindings: {}, + flags: {}, + ...overrides, + }; +} + +/** The interpreter passes `render` to renderers; these primitives ignore it. */ +const noRender = () => null; + +/** Narrow a returned node to a property bag for structural assertions. */ +function asRecord(node: unknown): Record { + expect(node).toBeTypeOf('object'); + return node as Record; +} + +const ROOT = { + Fa: { + // Multi-line repeater. + FaWiersz: [ + { P_7: 'Item A', P_11: '100.00' }, + { P_7: 'Item B', P_11: '200.5' }, + ], + // Single element the parser would collapse into an object (not an array). + Solo: { P_7: 'Only', P_11: '5' }, + P_15: '1234.5', + }, +}; + +const COLS = [ + { label: 'name', path: 'P_7' }, + { label: 'amount', path: 'P_11', format: 'money' as const }, +]; + +describe('tableRenderer', () => { + it('renders a repeater with a 2-element array (header + 2 body rows)', () => { + const block: TableBlock = { type: 'table', from: 'Fa.FaWiersz', columns: COLS }; + const node = asRecord(tableRenderer(block, makeCtx(ROOT), noRender)); + const table = asRecord(node.table); + const body = table.body as Record[][]; + + expect(table.headerRows).toBe(1); + expect(table.widths).toEqual(['*', '*']); + expect(body).toHaveLength(3); // header + 2 rows + // Header row uses the (identity) label resolver. + expect(body[0]).toEqual([ + { text: 'name', bold: true }, + { text: 'amount', bold: true }, + ]); + // Row-relative bindings + formatter application in a cell. + expect(body[1][0]).toEqual({ text: 'Item A' }); + expect(body[1][1]).toEqual({ text: '100,00' }); + expect(body[2][1]).toEqual({ text: '200,50' }); + // Light layout, no style when unset. + expect(node.layout).toBe('lightHorizontalLines'); + expect(node.style).toBeUndefined(); + }); + + it('renders a repeater whose single element collapsed to an object (header + 1 row)', () => { + const block: TableBlock = { type: 'table', from: 'Fa.Solo', columns: COLS }; + const node = asRecord(tableRenderer(block, makeCtx(ROOT), noRender)); + const body = asRecord(node.table).body as Record[][]; + + expect(body).toHaveLength(2); // header + single collapsed row + expect(body[1][0]).toEqual({ text: 'Only' }); + expect(body[1][1]).toEqual({ text: '5,00' }); + }); + + it('renders a single root-relative row when `from` is absent', () => { + const block: TableBlock = { + type: 'table', + columns: [{ label: 'total', path: 'Fa.P_15', format: 'money' }], + }; + const node = asRecord(tableRenderer(block, makeCtx(ROOT), noRender)); + const body = asRecord(node.table).body as Record[][]; + + expect(body).toHaveLength(2); // header + single root row + // Thousands are grouped with a non-breaking space (formatMoney). + expect(body[1][0]).toEqual({ text: '1\u00A0234,50' }); + }); + + it('resolves a non-XML binding for a root-relative cell', () => { + const block: TableBlock = { type: 'table', columns: [{ label: 'k', path: 'opts.ksefNumber' }] }; + const ctx = makeCtx(ROOT, { bindings: { 'opts.ksefNumber': 'KSEF-1' } }); + const node = asRecord(tableRenderer(block, ctx, noRender)); + const body = asRecord(node.table).body as Record[][]; + expect(body[1][0]).toEqual({ text: 'KSEF-1' }); + }); + + it('omits the header row when `headers` is false', () => { + const block: TableBlock = { type: 'table', from: 'Fa.FaWiersz', columns: COLS, headers: false }; + const node = asRecord(tableRenderer(block, makeCtx(ROOT), noRender)); + const table = asRecord(node.table); + const body = table.body as Record[][]; + + expect(table.headerRows).toBe(0); + expect(body).toHaveLength(2); // no header, 2 rows only + expect(body[0][0]).toEqual({ text: 'Item A' }); + }); + + it('attaches a style when set', () => { + const block: TableBlock = { type: 'table', from: 'Fa.FaWiersz', columns: COLS, style: 'lines' }; + const node = asRecord(tableRenderer(block, makeCtx(ROOT), noRender)); + expect(node.style).toBe('lines'); + }); +}); + +describe('imageRenderer', () => { + it('uses a literal `src` data URI with the default width', () => { + const block: ImageBlock = { type: 'image', src: 'data:image/png;base64,AAAA' }; + const node = asRecord(imageRenderer(block, makeCtx(ROOT), noRender)); + expect(node).toEqual({ image: 'data:image/png;base64,AAAA', width: 120 }); + }); + + it('resolves a `path` binding when `src` is absent', () => { + const block: ImageBlock = { type: 'image', path: 'opts.logo', width: 80 }; + const ctx = makeCtx(ROOT, { bindings: { 'opts.logo': 'data:image/png;base64,BBBB' } }); + const node = asRecord(imageRenderer(block, ctx, noRender)); + expect(node).toEqual({ image: 'data:image/png;base64,BBBB', width: 80 }); + }); + + it('honours a custom `width`', () => { + const block: ImageBlock = { type: 'image', src: 'data:image/png;base64,CCCC', width: 42 }; + const node = asRecord(imageRenderer(block, makeCtx(ROOT), noRender)); + expect(node.width).toBe(42); + }); + + it('returns an empty text node when the `path` binding resolves empty', () => { + const block: ImageBlock = { type: 'image', path: 'opts.logo' }; + const node = asRecord(imageRenderer(block, makeCtx(ROOT), noRender)); + expect(node).toEqual({ text: '' }); + }); + + it('returns an empty text node when neither `src` nor `path` is set', () => { + const block: ImageBlock = { type: 'image' }; + const node = asRecord(imageRenderer(block, makeCtx(ROOT), noRender)); + expect(node).toEqual({ text: '' }); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts new file mode 100644 index 00000000..444e4617 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -0,0 +1,383 @@ +/** + * Semantic (node-structure) tests for the block renderers. We assert the shape + * of the emitted pdfmake nodes — never bytes. Each renderer is exercised through + * every branch: present/absent optional style, empty vs populated collections, + * collapsed vs expanded repeaters, and strict vs non-strict binding resolution. + */ +import { describe, it, expect } from 'vitest'; +import type { RenderContext, RenderChild, PdfNode } from '../../../src/pdf/template/interpret.js'; +import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; +import { applyFormat } from '../../../src/pdf/format.js'; +import { headerRenderer } from '../../../src/pdf/template/blocks/header.js'; +import { partiesRenderer } from '../../../src/pdf/template/blocks/parties.js'; +import { linesRenderer } from '../../../src/pdf/template/blocks/lines.js'; +import { totalsRenderer } from '../../../src/pdf/template/blocks/totals.js'; +import { paymentRenderer } from '../../../src/pdf/template/blocks/payment.js'; +import { annotationsRenderer } from '../../../src/pdf/template/blocks/annotations.js'; +import { footerRenderer } from '../../../src/pdf/template/blocks/footer.js'; + +// ── test harness ──────────────────────────────────────────────────────────── + +/** Build a RenderContext by hand. `label` echoes the key by default. */ +function makeCtx(root: unknown, overrides: Partial = {}): RenderContext { + return { + root, + strict: false, + label: (k: string) => k, + bindings: {}, + flags: {}, + ...overrides, + }; +} + +/** The child-render callback is unused by these blocks. */ +const noRender: RenderChild = () => null; + +/** Cast a node to an indexable bag for structural assertions. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rec = (n: PdfNode | PdfNode[] | null): any => n as any; + +// ── header (existing renderer) ─────────────────────────────────────────────── + +describe('headerRenderer', () => { + it('renders logo, title, number and date with an explicit style', () => { + const ctx = makeCtx( + { Fa: { P_2: 'FV/2025/01', P_1: '2025-01-15' } }, + { bindings: { 'opts.logo': 'data:image/png;base64,AAAA' } }, + ); + const node = rec( + headerRenderer( + { + type: 'header', + logo: 'opts.logo', + title: { label: 'invoice' }, + number: 'Fa.P_2', + date: 'Fa.P_1', + style: 'bigtitle', + }, + ctx, + noRender, + ), + ); + + expect(node.columns).toHaveLength(2); + const [left, right] = node.columns; + // logo image + title + expect(left.stack[0].image).toBe('data:image/png;base64,AAAA'); + expect(left.stack[1].text).toBe('invoice'); + expect(left.stack[1].style).toBe('bigtitle'); + // number + date, right-aligned + expect(right.alignment).toBe('right'); + expect(right.stack).toHaveLength(2); + expect(right.stack[0].text).toBe('invoiceNumber: FV/2025/01'); + expect(right.stack[1].text).toBe('issueDate: 15.01.2025'); + }); + + it('falls back to the invoice label and default title style, omitting empty right column', () => { + const node = rec(headerRenderer({ type: 'header' }, makeCtx({}), noRender)); + const [left, right] = node.columns; + expect(left.stack).toHaveLength(1); + expect(left.stack[0].text).toBe('invoice'); // label('invoice') + expect(left.stack[0].style).toBe('title'); // default + expect(right.stack).toHaveLength(0); + }); + + it('drops the logo node when its binding resolves empty', () => { + const node = rec(headerRenderer({ type: 'header', logo: 'missing.logo' }, makeCtx({}), noRender)); + const [left] = node.columns; + // no image pushed; only the title line remains + expect(left.stack).toHaveLength(1); + expect(left.stack[0].image).toBeUndefined(); + }); +}); + +// ── parties ─────────────────────────────────────────────────────────────── + +describe('partiesRenderer', () => { + it('emits a two-column layout with a bold label line and one line per field', () => { + const ctx = makeCtx({ + Podmiot1: { Nazwa: 'ACME Sp. z o.o.', NIP: '5213003700' }, + Podmiot2: { Nazwa: 'Buyer Co' }, + }); + const node = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: ['Podmiot1.Nazwa', 'Podmiot1.NIP'] }, + right: { label: 'buyer', fields: ['Podmiot2.Nazwa'] }, + style: 'pstyle', + }, + ctx, + noRender, + ), + ); + + expect(node.columns).toHaveLength(2); + expect(node.style).toBe('pstyle'); + + const [left, right] = node.columns; + expect(left.width).toBe('*'); + expect(left.stack).toHaveLength(3); // label + 2 fields + expect(left.stack[0].text).toBe('seller'); + expect(left.stack[0].style).toBe('h2'); + expect(left.stack[1].text).toBe('ACME Sp. z o.o.'); + expect(left.stack[2].text).toBe('5213003700'); + + expect(right.stack).toHaveLength(2); // label + 1 field + expect(right.stack[1].text).toBe('Buyer Co'); + }); + + it('handles empty field lists and omits style when absent', () => { + const node = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: [] }, + right: { label: 'buyer', fields: [] }, + }, + makeCtx({}), + noRender, + ), + ); + expect(node.style).toBeUndefined(); + expect('style' in node).toBe(false); + expect(node.columns[0].stack).toHaveLength(1); // label only + }); + + it('resolves real localized labels via makeLabelResolver', () => { + const ctx = makeCtx({ Podmiot1: { Nazwa: 'X' } }, { label: makeLabelResolver('pl') }); + const node = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: ['Podmiot1.Nazwa'] }, + right: { label: 'buyer', fields: [] }, + }, + ctx, + noRender, + ), + ); + expect(node.columns[0].stack[0].text).toBe('Sprzedawca'); + expect(node.columns[1].stack[0].text).toBe('Nabywca'); + }); +}); + +// ── lines ───────────────────────────────────────────────────────────────── + +const lineColumns = [ + { label: 'lp', path: 'NrWierszaFa' }, + { label: 'name', path: 'P_7' }, + { label: 'qty', path: 'P_8B', format: 'number' as const }, + { label: 'net', path: 'P_9A', format: 'money' as const }, +]; + +describe('linesRenderer', () => { + it('renders a header row plus one body row for a collapsed single line', () => { + const ctx = makeCtx({ + Fa: { FaWiersz: { NrWierszaFa: '1', P_7: 'Widget', P_8B: '2', P_9A: '10.00' } }, + }); + const node = rec( + linesRenderer({ type: 'lines', from: 'Fa.FaWiersz', columns: lineColumns }, ctx, noRender), + ); + + expect(node.table.headerRows).toBe(1); + expect(node.table.widths).toHaveLength(4); + expect(node.layout).toBe('lightHorizontalLines'); + expect(node.table.body).toHaveLength(2); // 1 header + 1 line + + // header cells echo the column labels and are bold + const header = node.table.body[0]; + expect(header.map((c: any) => c.text)).toEqual(['lp', 'name', 'qty', 'net']); + expect(header.every((c: any) => c.bold === true)).toBe(true); + + // body cell values are formatted + const row = node.table.body[1]; + expect(row[0].text).toBe('1'); + expect(row[1].text).toBe('Widget'); + expect(row[2].text).toBe(applyFormat('2', 'number')); + expect(row[3].text).toBe(applyFormat('10.00', 'money')); // '10,00' + expect(node.style).toBeUndefined(); + }); + + it('renders one body row per element for an expanded array of lines (+ style)', () => { + const ctx = makeCtx({ Fa: { FaWiersz: [{ P_7: 'A' }, { P_7: 'B' }] } }); + const node = rec( + linesRenderer( + { + type: 'lines', + from: 'Fa.FaWiersz', + columns: [ + { label: 'name', path: 'P_7' }, + { label: 'qty', path: 'P_8B' }, // missing → '' (non-strict) + ], + style: 'lstyle', + }, + ctx, + noRender, + ), + ); + expect(node.table.body).toHaveLength(3); // 1 header + 2 lines + expect(node.table.body[1][0].text).toBe('A'); + expect(node.table.body[2][0].text).toBe('B'); + expect(node.table.body[1][1].text).toBe(''); // missing binding, non-strict + expect(node.style).toBe('lstyle'); + }); + + it('emits only the header row for an empty collection', () => { + const node = rec( + linesRenderer({ type: 'lines', from: 'Fa.FaWiersz', columns: lineColumns }, makeCtx({ Fa: {} }), noRender), + ); + expect(node.table.body).toHaveLength(1); // header only + }); + + it('throws in strict mode on a missing cell binding', () => { + const ctx = makeCtx({ Fa: { FaWiersz: { P_7: 'X' } } }, { strict: true }); + expect(() => + linesRenderer( + { type: 'lines', from: 'Fa.FaWiersz', columns: [{ label: 'name', path: 'P_MISSING' }] }, + ctx, + noRender, + ), + ).toThrow(/Missing binding/); + }); +}); + +// ── totals ────────────────────────────────────────────────────────────────── + +describe('totalsRenderer', () => { + it('renders a right-aligned borderless summary table (+ style)', () => { + const ctx = makeCtx({ Fa: { P_13_1: '100', P_15: '123' } }); + const node = rec( + totalsRenderer( + { + type: 'totals', + rows: [ + { label: 'totalNet', path: 'Fa.P_13_1', format: 'money' }, + { label: 'totalDue', path: 'Fa.P_15', format: 'money' }, + ], + style: 'tstyle', + }, + ctx, + noRender, + ), + ); + + expect(node.columns).toHaveLength(2); + expect(node.columns[0].text).toBe(''); // elastic spacer + expect(node.columns[0].width).toBe('*'); + expect(node.style).toBe('tstyle'); + + const summary = node.columns[1]; + expect(summary.layout).toBe('noBorders'); + expect(summary.table.body).toHaveLength(2); + const [labelCell, valueCell] = summary.table.body[0]; + expect(labelCell.text).toBe('totalNet'); + expect(labelCell.bold).toBe(true); + expect(valueCell.text).toBe(applyFormat('100', 'money')); // '100,00' + expect(valueCell.alignment).toBe('right'); + }); + + it('omits style when absent', () => { + const node = rec( + totalsRenderer({ type: 'totals', rows: [{ label: 'totalDue', path: 'Fa.P_15' }] }, makeCtx({ Fa: { P_15: '5' } }), noRender), + ); + expect('style' in node).toBe(false); + expect(node.columns[1].table.body).toHaveLength(1); + }); +}); + +// ── payment ─────────────────────────────────────────────────────────────── + +describe('paymentRenderer', () => { + it('renders a heading and one label:value line per row (+ style)', () => { + const ctx = makeCtx({ + Fa: { FormaPlatnosci: '6', TerminPlatnosci: { Termin: '2025-02-01' } }, + }); + const node = rec( + paymentRenderer( + { + type: 'payment', + rows: [ + { label: 'paymentMethod', path: 'Fa.FormaPlatnosci' }, + { label: 'paymentDate', path: 'Fa.TerminPlatnosci.Termin', format: 'date' }, + ], + style: 'paystyle', + }, + ctx, + noRender, + ), + ); + + expect(node.stack).toHaveLength(3); // heading + 2 rows + expect(node.stack[0].text).toBe('payment'); + expect(node.stack[0].style).toBe('h2'); + expect(node.stack[1].text).toBe('paymentMethod: 6'); + expect(node.stack[2].text).toBe('paymentDate: 01.02.2025'); + expect(node.style).toBe('paystyle'); + }); + + it('renders only the heading for no rows and omits style', () => { + const node = rec(paymentRenderer({ type: 'payment', rows: [] }, makeCtx({}), noRender)); + expect(node.stack).toHaveLength(1); + expect(node.stack[0].text).toBe('payment'); + expect('style' in node).toBe(false); + }); +}); + +// ── annotations ───────────────────────────────────────────────────────────── + +describe('annotationsRenderer', () => { + it('renders a heading and one label:value line per field (+ style)', () => { + const ctx = makeCtx({ Fa: { Adnotacje: { P_16: '1', P_17: '2' } } }); + const node = rec( + annotationsRenderer( + { + type: 'annotations', + fields: [ + { label: 'annotations', path: 'Fa.Adnotacje.P_16' }, + { label: 'annotations', path: 'Fa.Adnotacje.P_17' }, + ], + style: 'astyle', + }, + ctx, + noRender, + ), + ); + expect(node.stack).toHaveLength(3); // heading + 2 fields + expect(node.stack[0].text).toBe('annotations'); + expect(node.stack[1].text).toBe('annotations: 1'); + expect(node.stack[2].text).toBe('annotations: 2'); + expect(node.style).toBe('astyle'); + }); + + it('renders only the heading for no fields and omits style', () => { + const node = rec(annotationsRenderer({ type: 'annotations', fields: [] }, makeCtx({}), noRender)); + expect(node.stack).toHaveLength(1); + expect('style' in node).toBe(false); + }); +}); + +// ── footer ──────────────────────────────────────────────────────────────── + +describe('footerRenderer', () => { + it('renders a centered label-resolved line', () => { + const node = rec(footerRenderer({ type: 'footer', label: 'page' }, makeCtx({}), noRender)); + expect(node.text).toBe('page'); + expect(node.alignment).toBe('center'); + expect('style' in node).toBe(false); + }); + + it('renders literal text with a style', () => { + const node = rec( + footerRenderer({ type: 'footer', text: 'Thank you', style: 'fstyle' }, makeCtx({}), noRender), + ); + expect(node.text).toBe('Thank you'); + expect(node.style).toBe('fstyle'); + }); + + it('renders an empty string when neither label nor text is set', () => { + const node = rec(footerRenderer({ type: 'footer' }, makeCtx({}), noRender)); + expect(node.text).toBe(''); + expect(node.alignment).toBe('center'); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts new file mode 100644 index 00000000..da15ad4e --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { validateTemplate } from '../../../src/pdf/template/dsl.js'; +import type { InvoiceTemplate } from '../../../src/pdf/template/dsl.js'; +import { KSeFValidationError } from '../../../src/errors/ksef-validation-error.js'; + +/** A small but structurally rich valid template (header + text + nested container). */ +const VALID: unknown = { + schema: 'FA(3)', + page: { size: 'A4', orientation: 'portrait', margins: [40, 40, 40, 40] }, + defaultStyle: { fontSize: 9 }, + styles: { h1: { fontSize: 14, bold: true } }, + labels: { seller: 'Wystawca' }, + blocks: [ + { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', style: 'h1' }, + { type: 'text', text: 'Hello', style: 'h1' }, + { + type: 'columns', + columns: [ + { type: 'text', path: 'Fa.P_1', format: 'date' }, + { + type: 'stack', + stack: [ + { type: 'text', label: 'seller' }, + { type: 'divider' }, + ], + }, + ], + }, + ], +}; + +describe('validateTemplate', () => { + it('returns the typed template for a valid input', () => { + const template: InvoiceTemplate = validateTemplate(VALID); + expect(template.schema).toBe('FA(3)'); + expect(template.blocks).toHaveLength(3); + expect(template.blocks[0].type).toBe('header'); + // The recursive container is preserved through validation. + const cols = template.blocks[2]; + expect(cols.type).toBe('columns'); + }); + + it('validates a deeply nested container (columns → stack → blocks)', () => { + const nested: unknown = { + schema: 'FA(2)', + blocks: [ + { + type: 'stack', + stack: [ + { + type: 'columns', + columns: [{ type: 'text', text: 'a' }, { type: 'spacer', height: 4 }], + }, + ], + }, + ], + }; + expect(() => validateTemplate(nested)).not.toThrow(); + }); + + it('throws KSeFValidationError for an unknown block type', () => { + expect(() => validateTemplate({ schema: 'FA(3)', blocks: [{ type: 'bogus' }] })).toThrow( + KSeFValidationError, + ); + }); + + it("includes a path segment for an unknown block type", () => { + try { + validateTemplate({ schema: 'FA(3)', blocks: [{ type: 'bogus' }] }); + expect.unreachable('expected validateTemplate to throw'); + } catch (e) { + expect(e).toBeInstanceOf(KSeFValidationError); + expect((e as Error).message).toContain('blocks.0.type'); + } + }); + + it('throws for an extra/unknown key on a block (strict schema)', () => { + try { + validateTemplate({ schema: 'FA(3)', blocks: [{ type: 'text', text: 'x', bogus: 1 }] }); + expect.unreachable('expected validateTemplate to throw'); + } catch (e) { + expect(e).toBeInstanceOf(KSeFValidationError); + expect((e as Error).message).toContain('blocks.0'); + expect((e as Error).message).toContain('bogus'); + } + }); + + it('throws for an extra/unknown key on the root (strict schema)', () => { + try { + validateTemplate({ schema: 'FA(3)', blocks: [], bogus: 1 }); + expect.unreachable('expected validateTemplate to throw'); + } catch (e) { + expect(e).toBeInstanceOf(KSeFValidationError); + expect((e as Error).message).toContain('(root)'); + } + }); + + it('throws for a missing required block field (lines without from)', () => { + try { + validateTemplate({ schema: 'FA(3)', blocks: [{ type: 'lines', columns: [] }] }); + expect.unreachable('expected validateTemplate to throw'); + } catch (e) { + expect(e).toBeInstanceOf(KSeFValidationError); + expect((e as Error).message).toContain('blocks.0.from'); + } + }); + + it('throws when the root omits blocks', () => { + try { + validateTemplate({ schema: 'FA(3)' }); + expect.unreachable('expected validateTemplate to throw'); + } catch (e) { + expect(e).toBeInstanceOf(KSeFValidationError); + expect((e as Error).message).toContain('blocks'); + } + }); + + it('throws for an invalid schema id', () => { + try { + validateTemplate({ schema: 'ZZ', blocks: [] }); + expect.unreachable('expected validateTemplate to throw'); + } catch (e) { + expect(e).toBeInstanceOf(KSeFValidationError); + expect((e as Error).message).toContain('schema'); + } + }); + + it('exposes each issue as a details entry', () => { + try { + validateTemplate({ schema: 'FA(3)', blocks: [{ type: 'lines', columns: [] }] }); + expect.unreachable('expected validateTemplate to throw'); + } catch (e) { + const err = e as KSeFValidationError; + expect(err.details.length).toBeGreaterThan(0); + expect(err.details[0].message).toContain('blocks.0.from'); + } + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/errors.test.ts b/packages/ksef-client-ts/tests/unit/pdf/errors.test.ts new file mode 100644 index 00000000..60b8feea --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/errors.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { KSeFPdfError } from '../../../src/pdf/errors.js'; +import { KSeFError } from '../../../src/errors/ksef-error.js'; + +describe('KSeFPdfError', () => { + it('is an instance of KSeFError and Error', () => { + const err = new KSeFPdfError('boom'); + expect(err).toBeInstanceOf(KSeFPdfError); + expect(err).toBeInstanceOf(KSeFError); + expect(err).toBeInstanceOf(Error); + }); + + it('sets its name to KSeFPdfError', () => { + expect(new KSeFPdfError('x').name).toBe('KSeFPdfError'); + }); + + it('preserves the message', () => { + expect(new KSeFPdfError('missing pdfmake').message).toBe('missing pdfmake'); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/fonts-loader.test.ts b/packages/ksef-client-ts/tests/unit/pdf/fonts-loader.test.ts new file mode 100644 index 00000000..ad01063b --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/fonts-loader.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest'; +import { assertPdfmakeVersion, loadPdfMake } from '../../../src/pdf/fonts.js'; +import { KSeFPdfError } from '../../../src/pdf/errors.js'; + +// Simulate "pdfmake not installed": the dynamic import inside loadPdfMake fails. +// The version reader (createRequire on the real package.json) is unaffected, so +// this exercises the import-catch → friendly-error branch specifically. +vi.mock('pdfmake/build/pdfmake.js', () => { + throw new Error('mock: module not found'); +}); + +describe('assertPdfmakeVersion', () => { + it('throws a friendly install error when pdfmake is absent (null version)', () => { + expect(() => assertPdfmakeVersion(null)).toThrow(KSeFPdfError); + expect(() => assertPdfmakeVersion(null)).toThrow(/npm i "pdfmake\^?0?\.?2?\.?20?"|pdfmake@\^0\.2\.20/); + }); + + it('rejects an incompatible 0.3.x version with a clear message', () => { + expect(() => assertPdfmakeVersion('0.3.11')).toThrow(/0\.3\.x is not supported|found 0\.3\.11/); + }); + + it('rejects a too-old 0.2.x version', () => { + expect(() => assertPdfmakeVersion('0.2.19')).toThrow(KSeFPdfError); + }); + + it('accepts a supported version', () => { + expect(() => assertPdfmakeVersion('0.2.20')).not.toThrow(); + expect(() => assertPdfmakeVersion('0.2.99')).not.toThrow(); + }); +}); + +describe('loadPdfMake — friendly error when pdfmake import fails', () => { + it('throws KSeFPdfError with an install hint instead of a raw resolver crash', async () => { + await expect(loadPdfMake()).rejects.toBeInstanceOf(KSeFPdfError); + await expect(loadPdfMake()).rejects.toThrow(/pdfmake/); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts b/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts new file mode 100644 index 00000000..483ccf6b --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { satisfiesRequiredRange, normalizeVfs } from '../../../src/pdf/fonts.js'; + +describe('satisfiesRequiredRange', () => { + it('accepts the exact lower bound 0.2.20', () => { + expect(satisfiesRequiredRange('0.2.20')).toBe(true); + }); + + it('accepts a higher patch within 0.2.x', () => { + expect(satisfiesRequiredRange('0.2.99')).toBe(true); + }); + + it('rejects a patch below 20', () => { + expect(satisfiesRequiredRange('0.2.19')).toBe(false); + }); + + it('rejects the 0.3.0 minor bump', () => { + expect(satisfiesRequiredRange('0.3.0')).toBe(false); + }); + + it('rejects any 0.3.x', () => { + expect(satisfiesRequiredRange('0.3.11')).toBe(false); + }); + + it('rejects an older minor', () => { + expect(satisfiesRequiredRange('0.1.5')).toBe(false); + }); + + it('rejects a major bump', () => { + expect(satisfiesRequiredRange('1.0.0')).toBe(false); + }); + + it('rejects a non-semver string', () => { + expect(satisfiesRequiredRange('abc')).toBe(false); + }); + + it('rejects an empty string', () => { + expect(satisfiesRequiredRange('')).toBe(false); + }); +}); + +describe('normalizeVfs', () => { + // A font map never carries `vfs`/`default`/`pdfMake` keys of its own. + const fontMap = { 'Roboto-Regular.ttf': 'AAAA', 'Roboto-Bold.ttf': 'BBBB' }; + + it('unwraps { default: } (ESM default export that is the map itself)', () => { + expect(normalizeVfs({ default: fontMap })).toBe(fontMap); + }); + + it('unwraps { default: { vfs: } }', () => { + expect(normalizeVfs({ default: { vfs: fontMap } })).toBe(fontMap); + }); + + it('unwraps { vfs: }', () => { + expect(normalizeVfs({ vfs: fontMap })).toBe(fontMap); + }); + + it('unwraps { pdfMake: { vfs: } }', () => { + expect(normalizeVfs({ pdfMake: { vfs: fontMap } })).toBe(fontMap); + }); + + it('returns a bare map unchanged', () => { + expect(normalizeVfs(fontMap)).toBe(fontMap); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/format.test.ts b/packages/ksef-client-ts/tests/unit/pdf/format.test.ts new file mode 100644 index 00000000..1c8f7e3c --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/format.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { + formatMoney, + formatNumber, + formatDate, + formatNip, + applyFormat, +} from '../../../src/pdf/format.js'; + +/** + * The thousands separator is a non-breaking space (U+00A0), NOT an ASCII space. + * Spelled as an escape so the assertions are unambiguous. + */ +const NBSP = ' '; + +describe('formatMoney', () => { + it('groups thousands with a non-breaking space and forces 2 decimals', () => { + expect(formatMoney('1234.5')).toBe(`1${NBSP}234,50`); + }); + + it('formats a value below 1000 without a group separator', () => { + expect(formatMoney('12.3')).toBe('12,30'); + }); + + it('groups multiple thousands groups', () => { + expect(formatMoney('1234567.8')).toBe(`1${NBSP}234${NBSP}567,80`); + }); + + it('preserves a negative sign in front of the grouped value', () => { + expect(formatMoney('-1234.5')).toBe(`-1${NBSP}234,50`); + }); + + it('passes a non-numeric string through unchanged', () => { + expect(formatMoney('abc')).toBe('abc'); + }); + + it('returns an empty string unchanged', () => { + expect(formatMoney('')).toBe(''); + }); + + it('returns a whitespace-only string unchanged', () => { + expect(formatMoney(' ')).toBe(' '); + }); +}); + +describe('formatNumber', () => { + it('groups thousands but does not force decimals', () => { + expect(formatNumber('1234.5')).toBe(`1${NBSP}234,5`); + }); + + it('omits the decimal part for an integer', () => { + expect(formatNumber('1000')).toBe(`1${NBSP}000`); + }); + + it('preserves a negative sign', () => { + expect(formatNumber('-1234.5')).toBe(`-1${NBSP}234,5`); + }); + + it('passes a non-numeric string through unchanged', () => { + expect(formatNumber('abc')).toBe('abc'); + }); + + it('returns an empty string unchanged', () => { + expect(formatNumber('')).toBe(''); + }); +}); + +describe('formatDate', () => { + it('reformats an ISO date to DD.MM.YYYY', () => { + expect(formatDate('2025-01-15')).toBe('15.01.2025'); + }); + + it('reformats the date portion of a datetime', () => { + expect(formatDate('2025-01-15T10:00:00Z')).toBe('15.01.2025'); + }); + + it('passes a non-date string through unchanged', () => { + expect(formatDate('not a date')).toBe('not a date'); + }); +}); + +describe('formatNip', () => { + it('inserts group separators into a 10-digit NIP', () => { + expect(formatNip('5213003700')).toBe('521-300-37-00'); + }); + + it('passes a wrong-length value through unchanged', () => { + expect(formatNip('123')).toBe('123'); + }); +}); + +describe('applyFormat', () => { + it('returns the value unchanged when no formatter is named', () => { + expect(applyFormat('1234.5', undefined)).toBe('1234.5'); + }); + + it('returns the value unchanged for an unknown formatter name', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(applyFormat('1234.5', 'bogus' as any)).toBe('1234.5'); + }); + + it('routes to the money formatter', () => { + expect(applyFormat('1234.5', 'money')).toBe(`1${NBSP}234,50`); + }); + + it('routes to the number formatter', () => { + expect(applyFormat('1234.5', 'number')).toBe(`1${NBSP}234,5`); + }); + + it('routes to the date formatter', () => { + expect(applyFormat('2025-01-15', 'date')).toBe('15.01.2025'); + }); + + it('routes to the nip formatter', () => { + expect(applyFormat('5213003700', 'nip')).toBe('521-300-37-00'); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts b/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts new file mode 100644 index 00000000..a04bb46e --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { resolveLabel, makeLabelResolver } from '../../../src/pdf/i18n/index.js'; + +describe('resolveLabel', () => { + it('resolves a Polish label from the pl bundle', () => { + expect(resolveLabel('seller', 'pl')).toBe('Sprzedawca'); + }); + + it('resolves an English label from the en bundle', () => { + expect(resolveLabel('seller', 'en')).toBe('Seller'); + }); + + it('joins pl and en with the default " / " separator for pl+en', () => { + expect(resolveLabel('seller', 'pl+en')).toBe('Sprzedawca / Seller'); + }); + + it('honours a custom bilingual separator', () => { + expect(resolveLabel('seller', 'pl+en', { bilingualSeparator: '\n' })).toBe( + 'Sprzedawca\nSeller', + ); + }); + + it('lets an override win over the bundle (pl)', () => { + expect(resolveLabel('seller', 'pl', { overrides: { seller: 'Wystawca' } })).toBe('Wystawca'); + }); + + it('lets an override win over the bundle (en)', () => { + expect(resolveLabel('seller', 'en', { overrides: { seller: 'Vendor' } })).toBe('Vendor'); + }); + + it('applies overrides to both halves of a bilingual label', () => { + expect( + resolveLabel('seller', 'pl+en', { overrides: { seller: 'X' } }), + ).toBe('X / X'); + }); + + it('falls back to the raw key when the key is unknown in every bundle', () => { + expect(resolveLabel('totally-unknown-key', 'en')).toBe('totally-unknown-key'); + }); + + it('falls back to the raw key for a bilingual unknown key on both sides', () => { + expect(resolveLabel('totally-unknown-key', 'pl+en')).toBe( + 'totally-unknown-key / totally-unknown-key', + ); + }); +}); + +describe('makeLabelResolver', () => { + it('returns a bound resolver capturing the locale', () => { + const resolve = makeLabelResolver('en'); + expect(resolve('buyer')).toBe('Buyer'); + expect(resolve('seller')).toBe('Seller'); + }); + + it('captures bilingual options in the bound resolver', () => { + const resolve = makeLabelResolver('pl+en', { bilingualSeparator: ' | ' }); + expect(resolve('buyer')).toBe('Nabywca | Buyer'); + }); + + it('captures overrides in the bound resolver', () => { + const resolve = makeLabelResolver('pl', { overrides: { buyer: 'Klient' } }); + expect(resolve('buyer')).toBe('Klient'); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts b/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts new file mode 100644 index 00000000..5bb1c366 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveBinding, + evalWhen, + resolveText, + interpretBlock, + interpretTemplate, + coreRegistry, + MAX_DEPTH, + type RenderContext, + type BlockRegistry, + type PdfNode, +} from '../../../src/pdf/template/interpret.js'; +import { KSeFPdfError } from '../../../src/pdf/errors.js'; +import type { Block, InvoiceTemplate } from '../../../src/pdf/template/dsl.js'; + +/** Build a RenderContext by hand; label is identity so keys pass through. */ +function makeCtx(root: unknown, overrides: Partial = {}): RenderContext { + return { + root, + strict: false, + label: (k: string) => k, + bindings: {}, + flags: {}, + ...overrides, + }; +} + +/** Non-breaking space (U+00A0): the thousands separator emitted by formatMoney. */ +const NBSP = String.fromCharCode(0x00a0); + +const ROOT = { + Fa: { + P_1: '2025-01-15', + P_2: 'FV/1/2025', + P_15: '1234.5', + Adnotacje: 'note', + }, +}; + +/** Narrow a returned node to a property bag for structural assertions. */ +function asRecord(node: PdfNode | null): Record { + expect(node).toBeTypeOf('object'); + expect(node).not.toBeNull(); + return node as Record; +} + +// ── resolveBinding ────────────────────────────────────────────────────────── + +describe('resolveBinding', () => { + it('reads a non-XML binding by exact key before XML', () => { + const ctx = makeCtx(ROOT, { bindings: { 'opts.logo': 'data:image/png;base64,AAAA' } }); + expect(resolveBinding('opts.logo', ctx)).toBe('data:image/png;base64,AAAA'); + }); + + it('falls through to an XML dot-path when the key is not a binding', () => { + expect(resolveBinding('Fa.P_2', makeCtx(ROOT))).toBe('FV/1/2025'); + }); + + it('yields "" for a missing XML path in non-strict mode', () => { + expect(resolveBinding('Fa.DoesNotExist', makeCtx(ROOT))).toBe(''); + }); + + it('propagates a strict-mode throw for a missing XML path', () => { + const ctx = makeCtx(ROOT, { strict: true }); + expect(() => resolveBinding('Fa.DoesNotExist', ctx)).toThrow(/Missing binding/); + }); + + it('does not apply strict mode to a resolved non-XML binding', () => { + const ctx = makeCtx(ROOT, { strict: true, bindings: { hash: 'abc' } }); + expect(resolveBinding('hash', ctx)).toBe('abc'); + }); + + it('coerces an undefined binding value to ""', () => { + // The key is present but maps to undefined at runtime (defensive `?? ""`). + const ctx = makeCtx(ROOT, { bindings: { hash: undefined as unknown as string } }); + expect(resolveBinding('hash', ctx)).toBe(''); + }); +}); + +// ── evalWhen ──────────────────────────────────────────────────────────────── + +describe('evalWhen', () => { + it('treats an undefined condition as visible', () => { + expect(evalWhen(undefined, makeCtx(ROOT))).toBe(true); + }); + + it('reads a boolean flag when the key is a flag', () => { + expect(evalWhen('qr', makeCtx(ROOT, { flags: { qr: true } }))).toBe(true); + expect(evalWhen('qr', makeCtx(ROOT, { flags: { qr: false } }))).toBe(false); + }); + + it('treats a non-empty binding key as visible and an empty one as hidden', () => { + expect(evalWhen('opts.ksefNumber', makeCtx(ROOT, { bindings: { 'opts.ksefNumber': 'KSEF-1' } }))).toBe(true); + expect(evalWhen('opts.ksefNumber', makeCtx(ROOT, { bindings: { 'opts.ksefNumber': '' } }))).toBe(false); + }); + + it('falls back to an XML presence test when the key is neither flag nor binding', () => { + expect(evalWhen('Fa.P_2', makeCtx(ROOT))).toBe(true); + expect(evalWhen('Fa.Missing', makeCtx(ROOT))).toBe(false); + }); + + it('prefers a flag over a same-named binding', () => { + const ctx = makeCtx(ROOT, { flags: { dup: false }, bindings: { dup: 'present' } }); + expect(evalWhen('dup', ctx)).toBe(false); + }); + + it('treats a binding key mapping to undefined as hidden (defensive `?? ""`)', () => { + const ctx = makeCtx(ROOT, { bindings: { maybe: undefined as unknown as string } }); + expect(evalWhen('maybe', ctx)).toBe(false); + }); +}); + +// ── resolveText ───────────────────────────────────────────────────────────── + +describe('resolveText', () => { + it('resolves a { label } ref through the label resolver', () => { + expect(resolveText({ label: 'seller' }, makeCtx(ROOT))).toBe('seller'); + }); + + it('returns a { text } literal verbatim', () => { + expect(resolveText({ text: 'Literal' }, makeCtx(ROOT))).toBe('Literal'); + }); + + it('resolves a { path } binding and applies the formatter', () => { + expect(resolveText({ path: 'Fa.P_1', format: 'date' }, makeCtx(ROOT))).toBe('15.01.2025'); + }); + + it('resolves a { path } binding unformatted when no format is given', () => { + expect(resolveText({ path: 'Fa.P_2' }, makeCtx(ROOT))).toBe('FV/1/2025'); + }); + + it('returns "" for an undefined spec', () => { + expect(resolveText(undefined, makeCtx(ROOT))).toBe(''); + }); + + it('returns "" for an empty spec (no label/text/path)', () => { + expect(resolveText({}, makeCtx(ROOT))).toBe(''); + }); +}); + +// ── interpretBlock: primitives + control ──────────────────────────────────── + +describe('interpretBlock core primitives', () => { + it('renders a text block', () => { + const node = asRecord(interpretBlock({ type: 'text', text: 'Hello' }, makeCtx(ROOT), coreRegistry, 0)); + expect(node).toEqual({ text: 'Hello' }); + }); + + it('attaches a style to a text block', () => { + const node = asRecord( + interpretBlock({ type: 'text', text: 'Hello', style: 'h1' }, makeCtx(ROOT), coreRegistry, 0), + ); + expect(node).toEqual({ text: 'Hello', style: 'h1' }); + }); + + it('renders a text block from a formatted path binding', () => { + const block: Block = { type: 'text', path: 'Fa.P_15', format: 'money' }; + const node = asRecord(interpretBlock(block, makeCtx(ROOT), coreRegistry, 0)); + expect(node).toEqual({ text: `1${NBSP}234,50` }); + }); + + it('renders a stack, flattening and dropping hidden children', () => { + const block: Block = { + type: 'stack', + stack: [ + { type: 'text', text: 'a' }, + { type: 'text', text: 'hidden', when: 'Fa.Missing' }, + { type: 'text', text: 'b' }, + ], + }; + const node = asRecord(interpretBlock(block, makeCtx(ROOT), coreRegistry, 0)); + expect(node).toEqual({ stack: [{ text: 'a' }, { text: 'b' }] }); + }); + + it('renders columns with a style', () => { + const block: Block = { + type: 'columns', + style: 'row', + columns: [ + { type: 'text', text: 'left' }, + { type: 'text', text: 'right' }, + ], + }; + const node = asRecord(interpretBlock(block, makeCtx(ROOT), coreRegistry, 0)); + expect(node).toEqual({ columns: [{ text: 'left' }, { text: 'right' }], style: 'row' }); + }); + + it('renders a divider canvas line', () => { + const node = asRecord(interpretBlock({ type: 'divider' }, makeCtx(ROOT), coreRegistry, 0)); + expect(node).toEqual({ + canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: '#cccccc' }], + }); + }); + + it('attaches a style to a divider', () => { + const node = asRecord( + interpretBlock({ type: 'divider', style: 'rule' }, makeCtx(ROOT), coreRegistry, 0), + ); + expect(node.style).toBe('rule'); + }); + + it('renders a spacer with the default height', () => { + const node = asRecord(interpretBlock({ type: 'spacer' }, makeCtx(ROOT), coreRegistry, 0)); + expect(node).toEqual({ text: '', margin: [0, 4, 0, 4] }); + }); + + it('renders a spacer with a custom height', () => { + const node = asRecord(interpretBlock({ type: 'spacer', height: 20 }, makeCtx(ROOT), coreRegistry, 0)); + expect(node).toEqual({ text: '', margin: [0, 10, 0, 10] }); + }); + + it('returns null for a block whose `when` is false', () => { + const block: Block = { type: 'text', text: 'x', when: 'Fa.Missing' }; + expect(interpretBlock(block, makeCtx(ROOT), coreRegistry, 0)).toBeNull(); + }); + + it('renders a block whose `when` is true', () => { + const block: Block = { type: 'text', text: 'x', when: 'Fa.P_2' }; + expect(interpretBlock(block, makeCtx(ROOT), coreRegistry, 0)).toEqual({ text: 'x' }); + }); +}); + +describe('interpretBlock errors + depth', () => { + it('throws KSeFPdfError for an unknown block type', () => { + const block = { type: 'nope' } as unknown as Block; + expect(() => interpretBlock(block, makeCtx(ROOT), coreRegistry, 0)).toThrow(KSeFPdfError); + }); + + it('throws KSeFPdfError when no renderer is registered (empty registry)', () => { + const block: Block = { type: 'text', text: 'x' }; + expect(() => interpretBlock(block, makeCtx(ROOT), {}, 0)).toThrow( + /No renderer registered/, + ); + }); + + it('throws KSeFPdfError when nesting exceeds MAX_DEPTH', () => { + let block: Block = { type: 'text', text: 'deep' }; + for (let i = 0; i < MAX_DEPTH + 5; i++) { + block = { type: 'stack', stack: [block] }; + } + expect(() => interpretBlock(block, makeCtx(ROOT), coreRegistry, 0)).toThrow(KSeFPdfError); + expect(() => interpretBlock(block, makeCtx(ROOT), coreRegistry, 0)).toThrow( + new RegExp(`maximum depth of ${MAX_DEPTH}`), + ); + }); +}); + +// ── interpretTemplate ─────────────────────────────────────────────────────── + +/** Assemble an InvoiceTemplate literal (only the fields the interpreter reads). */ +function makeTemplate(partial: Partial & { blocks: Block[] }): InvoiceTemplate { + return { schema: 'FA(3)', ...partial } as InvoiceTemplate; +} + +describe('interpretTemplate', () => { + it('wires content and a Roboto default style', () => { + const template = makeTemplate({ blocks: [{ type: 'text', text: 'Hello' }] }); + const doc = interpretTemplate(template, makeCtx(ROOT)); + expect(doc.content).toEqual([{ text: 'Hello' }]); + expect(doc.defaultStyle).toEqual({ font: 'Roboto', fontSize: 9 }); + }); + + it('merges a template defaultStyle over the Roboto default', () => { + const template = makeTemplate({ + blocks: [{ type: 'text', text: 'x' }], + defaultStyle: { fontSize: 11, color: '#333' }, + }); + const doc = interpretTemplate(template, makeCtx(ROOT)); + // Roboto font is kept; fontSize overridden; extra prop merged in. + expect(doc.defaultStyle).toEqual({ font: 'Roboto', fontSize: 11, color: '#333' }); + }); + + it('wires named styles when present', () => { + const styles = { h1: { fontSize: 14, bold: true } }; + const template = makeTemplate({ blocks: [{ type: 'text', text: 'x' }], styles }); + const doc = interpretTemplate(template, makeCtx(ROOT)); + expect(doc.styles).toEqual(styles); + }); + + it('omits styles when the template has none', () => { + const template = makeTemplate({ blocks: [{ type: 'text', text: 'x' }] }); + const doc = interpretTemplate(template, makeCtx(ROOT)); + expect('styles' in doc).toBe(false); + }); + + it('wires page size/orientation/margins when present', () => { + const template = makeTemplate({ + blocks: [{ type: 'text', text: 'x' }], + page: { size: 'A4', orientation: 'landscape', margins: [10, 20, 30, 40] }, + }); + const doc = interpretTemplate(template, makeCtx(ROOT)); + expect(doc.pageSize).toBe('A4'); + expect(doc.pageOrientation).toBe('landscape'); + expect(doc.pageMargins).toEqual([10, 20, 30, 40]); + }); + + it('omits page props when the page config is partial or absent', () => { + const template = makeTemplate({ blocks: [{ type: 'text', text: 'x' }], page: {} }); + const doc = interpretTemplate(template, makeCtx(ROOT)); + expect('pageSize' in doc).toBe(false); + expect('pageOrientation' in doc).toBe(false); + expect('pageMargins' in doc).toBe(false); + }); + + it('omits hidden blocks from content', () => { + const template = makeTemplate({ + blocks: [ + { type: 'text', text: 'visible' }, + { type: 'text', text: 'gone', when: 'Fa.Missing' }, + ], + }); + const doc = interpretTemplate(template, makeCtx(ROOT)); + expect(doc.content).toEqual([{ text: 'visible' }]); + }); + + it('flattens an array-returning custom renderer into content', () => { + const registry: BlockRegistry = { + qr: () => [{ text: 'a' }, { text: 'b' }], + }; + const template = makeTemplate({ + blocks: [{ type: 'text', text: 'first' }, { type: 'qr' }], + }); + const doc = interpretTemplate(template, makeCtx(ROOT), registry); + expect(doc.content).toEqual([{ text: 'first' }, { text: 'a' }, { text: 'b' }]); + }); + + it('lets a custom registry override a core primitive', () => { + const registry: BlockRegistry = { + text: () => ({ text: 'OVERRIDDEN' }), + }; + const template = makeTemplate({ blocks: [{ type: 'text', text: 'orig' }] }); + const doc = interpretTemplate(template, makeCtx(ROOT), registry); + expect(doc.content).toEqual([{ text: 'OVERRIDDEN' }]); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts new file mode 100644 index 00000000..7f11f817 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts @@ -0,0 +1,154 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { + pdfXmlParser, + parseXmlForPdf, + detectInvoiceVersion, + detectUpoVersion, +} from '../../../src/pdf/parse.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.resolve(__dirname, '../../fixtures'); + +function loadFixture(rel: string): string { + return fs.readFileSync(path.join(fixturesDir, rel), 'utf8'); +} + +const fa3Xml = loadFixture('pdf/fa3.xml'); +const fa2Xml = loadFixture('pdf/fa2.xml'); +const upoV43Xml = loadFixture('pdf/upo-4_3.xml'); + +// Non-FA / non-Faktura roots for negative detection — inline so the test stays +// self-contained and free of any personal invoice data. +const pef3Xml = + '1'; +const rr1Xml = + 'FA_RR'; + +describe('pdfXmlParser', () => { + it('parses a mixed-content element into #text plus @-prefixed attributes', () => { + const parsed = pdfXmlParser.parse( + 'FA', + ) as Record>; + expect(parsed.KodFormularza).toEqual({ + '#text': 'FA', + '@kodSystemowy': 'FA (3)', + '@wersjaSchemy': '1-0E', + }); + }); + + it('strips namespace prefixes from element names', () => { + const parsed = pdfXmlParser.parse('X') as Record< + string, + Record + >; + expect(parsed.Fa).toEqual({ P_2: 'X' }); + }); + + it('keeps numeric-looking values as strings (parseTagValue: false)', () => { + const parsed = pdfXmlParser.parse('05') as Record; + expect(parsed.P_8B).toBe('05'); + }); +}); + +describe('parseXmlForPdf', () => { + it('returns an object keyed by the document root', () => { + const parsed = parseXmlForPdf(fa3Xml); + expect(parsed).toHaveProperty('Faktura'); + }); + + it('collapses a single repeated element into an object (not an array)', () => { + const parsed = parseXmlForPdf(fa3Xml) as { + Faktura: { Fa: { FaWiersz: unknown } }; + }; + expect(Array.isArray(parsed.Faktura.Fa.FaWiersz)).toBe(false); + }); + + it('returns {} for an empty string without throwing', () => { + expect(parseXmlForPdf('')).toEqual({}); + }); + + it('returns {} for non-XML text without throwing', () => { + expect(parseXmlForPdf('just some text')).toEqual({}); + }); + + it('does not throw on malformed / unterminated XML', () => { + expect(() => parseXmlForPdf('oops')).not.toThrow(); + expect(parseXmlForPdf('oops')).toHaveProperty('a'); + }); +}); + +describe('detectInvoiceVersion', () => { + it('detects FA(3) from a real fixture', () => { + expect(detectInvoiceVersion(fa3Xml)).toBe('FA(3)'); + }); + + it('detects FA(2) from a real fixture', () => { + expect(detectInvoiceVersion(fa2Xml)).toBe('FA(2)'); + }); + + it('detects FA(3) via WariantFormularza when kodSystemowy is absent', () => { + const xml = '3'; + expect(detectInvoiceVersion(xml)).toBe('FA(3)'); + }); + + it('detects FA(2) via WariantFormularza when kodSystemowy is absent', () => { + const xml = '2'; + expect(detectInvoiceVersion(xml)).toBe('FA(2)'); + }); + + it('detects FA(3) from kodSystemowy alone (no WariantFormularza)', () => { + const xml = + 'FA'; + expect(detectInvoiceVersion(xml)).toBe('FA(3)'); + }); + + it('returns null for a Faktura with neither a recognized kod nor variant', () => { + const xml = + '1'; + expect(detectInvoiceVersion(xml)).toBeNull(); + }); + + it('returns null for a UPO document (no Faktura root)', () => { + expect(detectInvoiceVersion(upoV43Xml)).toBeNull(); + }); + + it('returns null for a PEF/UBL Invoice (root is Invoice, not Faktura)', () => { + expect(detectInvoiceVersion(pef3Xml)).toBeNull(); + }); + + it('returns null for a farmer invoice (root is FakturaRR, not Faktura)', () => { + expect(detectInvoiceVersion(rr1Xml)).toBeNull(); + }); + + it('returns null for unrecognized / non-invoice XML', () => { + expect(detectInvoiceVersion('baz')).toBeNull(); + }); +}); + +describe('detectUpoVersion', () => { + it('detects UPO(4.3) from a real v4-3 fixture', () => { + expect(detectUpoVersion(upoV43Xml)).toBe('UPO(4.3)'); + }); + + it('detects UPO(4.2) from a v4-2 namespace (raw-scan fallback)', () => { + const xml = + 'X'; + expect(detectUpoVersion(xml)).toBe('UPO(4.2)'); + }); + + it('returns null for a Potwierdzenie without a recognized version marker', () => { + const xml = '1'; + expect(detectUpoVersion(xml)).toBeNull(); + }); + + it('returns null for a non-UPO document (no Potwierdzenie root)', () => { + expect(detectUpoVersion(fa3Xml)).toBeNull(); + }); + + it('returns null for unrecognized XML', () => { + expect(detectUpoVersion('')).toBeNull(); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts new file mode 100644 index 00000000..de89564b --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest'; +import crypto from 'node:crypto'; +import { + computeInvoiceHashBase64, + resolveBaseQrUrl, + deriveInvoiceQrUrl, +} from '../../../src/pdf/qr.js'; +import { qrRenderer } from '../../../src/pdf/template/blocks/qr.js'; +import { VerificationLinkService } from '../../../src/qr/verification-link-service.js'; +import { Environment } from '../../../src/config/environments.js'; +import type { RenderContext } from '../../../src/pdf/template/interpret.js'; +import type { QrBlock } from '../../../src/pdf/template/dsl.js'; + +/** Minimal parsed body: seller NIP + issue date, same shape as ctx.root. */ +const body = { Podmiot1: { DaneIdentyfikacyjne: { NIP: '5213003700' } }, P_1: '2025-01-15' }; + +// A clean UTF-8 invoice payload with LF newlines and no BOM. +const CLEAN = '\nFV/1\n'; + +function base64ToBase64Url(b64: string): string { + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +describe('computeInvoiceHashBase64 — byte-exact hash invariant', () => { + it('hashes a Uint8Array and the equivalent clean UTF-8 string to the SAME hash', () => { + const bytes = new TextEncoder().encode(CLEAN); + const fromBytes = computeInvoiceHashBase64(bytes); + const fromString = computeInvoiceHashBase64(CLEAN); + + expect(fromBytes).toBe(fromString); + // Sanity: matches a raw node:crypto digest over the same bytes. + expect(fromString).toBe(crypto.createHash('sha256').update(Buffer.from(CLEAN, 'utf8')).digest('base64')); + }); + + it('returns standard base64 (not base64url)', () => { + const hash = computeInvoiceHashBase64(CLEAN); + // 32-byte digest → 44-char base64 with a trailing '=' pad. + expect(hash).toMatch(/^[A-Za-z0-9+/]+=*$/); + expect(hash).toHaveLength(44); + }); + + it('changes the hash when a BOM is prepended', () => { + const withBom = '' + CLEAN; + expect(computeInvoiceHashBase64(withBom)).not.toBe(computeInvoiceHashBase64(CLEAN)); + }); + + it('changes the hash when newlines are CRLF instead of LF', () => { + const crlf = CLEAN.replace(/\n/g, '\r\n'); + expect(computeInvoiceHashBase64(crlf)).not.toBe(computeInvoiceHashBase64(CLEAN)); + }); + + it('changes the hash when the XML is pretty-printed', () => { + const pretty = '\n FV/1\n'; + expect(computeInvoiceHashBase64(pretty)).not.toBe(computeInvoiceHashBase64(CLEAN)); + }); +}); + +describe('resolveBaseQrUrl', () => { + it('override wins over env', () => { + expect(resolveBaseQrUrl('prod', 'https://custom.example')).toBe('https://custom.example'); + expect(resolveBaseQrUrl(undefined, 'https://custom.example')).toBe('https://custom.example'); + }); + + it('treats an empty-string override as no override (falls through to env)', () => { + expect(resolveBaseQrUrl('test', '')).toBe(Environment.TEST.qrUrl); + }); + + it('maps env → qrUrl', () => { + expect(resolveBaseQrUrl('prod')).toBe(Environment.PROD.qrUrl); + expect(resolveBaseQrUrl('test')).toBe(Environment.TEST.qrUrl); + expect(resolveBaseQrUrl('demo')).toBe(Environment.DEMO.qrUrl); + }); + + it('defaults to prod when env is undefined', () => { + expect(resolveBaseQrUrl(undefined)).toBe(Environment.PROD.qrUrl); + }); +}); + +describe('deriveInvoiceQrUrl', () => { + it('equals VerificationLinkService.buildInvoiceVerificationUrl for the same inputs', () => { + const url = deriveInvoiceQrUrl({ rawInput: CLEAN, body, env: 'test' }); + + const base = Environment.TEST.qrUrl; + const hash = computeInvoiceHashBase64(CLEAN); + const expected = new VerificationLinkService(base).buildInvoiceVerificationUrl( + '5213003700', + '2025-01-15', + hash, + ); + + expect(url).toBe(expected); + }); + + it('honors an explicit baseQrUrl override', () => { + const url = deriveInvoiceQrUrl({ rawInput: CLEAN, body, baseQrUrl: 'https://custom.example' }); + expect(url.startsWith('https://custom.example/invoice/5213003700/15-01-2025/')).toBe(true); + }); + + it('uses an invoiceHash override VERBATIM (does not recompute)', () => { + const override = 'AAAA++//zz=='; + const withOverride = deriveInvoiceQrUrl({ rawInput: CLEAN, body, env: 'test', invoiceHash: override }); + const withoutOverride = deriveInvoiceQrUrl({ rawInput: CLEAN, body, env: 'test' }); + + // URL carries the base64url form of the override, not the computed hash. + expect(withOverride.endsWith(base64ToBase64Url(override))).toBe(true); + expect(withOverride).not.toBe(withoutOverride); + }); + + it('passes strict through to the accessor (throws on a missing binding)', () => { + expect(() => deriveInvoiceQrUrl({ rawInput: CLEAN, body: {}, strict: true })).toThrow(); + }); +}); + +describe('qrRenderer', () => { + function ctxWith(bindings: Record): RenderContext { + return { + root: body, + strict: false, + label: (k: string) => k, + bindings, + flags: {}, + }; + } + const noopRender = () => null; + const block: QrBlock = { type: 'qr' }; + + it('emits a qr node with default fit 100 when qrUrl is present', () => { + const out = qrRenderer(block, ctxWith({ qrUrl: 'https://qr/invoice/x' }), noopRender); + expect(out).toEqual({ qr: 'https://qr/invoice/x', fit: 100 }); + }); + + it('honors a custom fit', () => { + const out = qrRenderer({ type: 'qr', fit: 64 }, ctxWith({ qrUrl: 'https://qr/invoice/x' }), noopRender); + expect(out).toEqual({ qr: 'https://qr/invoice/x', fit: 64 }); + }); + + it('emits an empty text node when qrUrl binding is empty', () => { + const out = qrRenderer(block, ctxWith({ qrUrl: '' }), noopRender); + expect(out).toEqual({ text: '' }); + }); + + it('emits an empty text node when qrUrl binding is absent', () => { + const out = qrRenderer(block, ctxWith({}), noopRender); + expect(out).toEqual({ text: '' }); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts new file mode 100644 index 00000000..93e8d71a --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { renderInvoicePdf, renderUpoPdf } from '../../../src/pdf/index.js'; + +const fx = (p: string) => readFileSync(new URL(`../../fixtures/${p}`, import.meta.url), 'utf8'); +const fa2 = fx('pdf/fa2.xml'); +const fa3 = fx('pdf/fa3.xml'); +const upo43 = fx('pdf/upo-4_3.xml'); +const upo42 = fx('pdf/upo-4_2.xml'); + +const head = (b: Uint8Array) => Buffer.from(b.subarray(0, 5)).toString('latin1'); +const tail = (b: Uint8Array) => Buffer.from(b.subarray(-6)).toString('latin1').trim(); +const isPdf = (b: Uint8Array) => head(b) === '%PDF-' && tail(b).endsWith('%%EOF'); + +describe('built-in templates render valid PDFs', () => { + it('fa2-default renders FA(2)', async () => { + expect(isPdf(await renderInvoicePdf(fa2, 'fa2-default'))).toBe(true); + }); + + it('fa3-default renders FA(3)', async () => { + expect(isPdf(await renderInvoicePdf(fa3, 'fa3-default'))).toBe(true); + }); + + it('upo-4_3 renders via renderUpoPdf (auto-detected version)', async () => { + expect(isPdf(await renderUpoPdf(upo43))).toBe(true); + }); + + it('upo-4_2 renders via renderUpoPdf (auto-detected version)', async () => { + expect(isPdf(await renderUpoPdf(upo42))).toBe(true); + }); + + it('rejects a non-UPO document in renderUpoPdf', async () => { + await expect(renderUpoPdf(fa3)).rejects.toThrow(/UPO/); + }); +}); + +describe('built-in templates in strict mode against full fixtures', () => { + // valid-fa2 / valid-fa3 populate every path the default templates reference, + // so a dot-path typo in our own preset would surface as a thrown error here. + it('fa2-default is strict-clean', async () => { + expect(isPdf(await renderInvoicePdf(fa2, 'fa2-default', { strict: true }))).toBe(true); + }); + + it('fa3-default is strict-clean', async () => { + expect(isPdf(await renderInvoicePdf(fa3, 'fa3-default', { strict: true }))).toBe(true); + }); +}); + +describe('QR embedding', () => { + it('renders fa3-default with qr enabled without error', async () => { + const bytes = await renderInvoicePdf(fa3, 'fa3-default', { qr: true, ksefNumber: '1234567890-20250115-ABCDEF-01' }); + expect(isPdf(bytes)).toBe(true); + }); + + it('renders bilingual pl+en labels', async () => { + const bytes = await renderInvoicePdf(fa3, 'fa3-default', { locale: 'pl+en' }); + expect(isPdf(bytes)).toBe(true); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/render-smoke.test.ts b/packages/ksef-client-ts/tests/unit/pdf/render-smoke.test.ts new file mode 100644 index 00000000..1a8c27aa --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/render-smoke.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { + renderInvoicePdf, + renderInvoicePdfFromTemplate, + detectInvoiceVersion, +} from '../../../src/pdf/index.js'; +import { KSeFPdfError } from '../../../src/pdf/errors.js'; +import type { InvoiceTemplate } from '../../../src/pdf/template/dsl.js'; + +const fa3 = readFileSync(new URL('../../fixtures/pdf/fa3.xml', import.meta.url), 'utf8'); +const fa2 = readFileSync(new URL('../../fixtures/pdf/fa2.xml', import.meta.url), 'utf8'); + +function pdfEnvelope(bytes: Uint8Array): { head: string; tail: string } { + const head = Buffer.from(bytes.subarray(0, 5)).toString('latin1'); + const tail = Buffer.from(bytes.subarray(-6)).toString('latin1').trim(); + return { head, tail }; +} + +describe('renderInvoicePdf — end-to-end', () => { + it('renders an FA(3) invoice to a valid PDF envelope', async () => { + const bytes = await renderInvoicePdf(fa3, 'fa3-default'); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.length).toBeGreaterThan(1000); + const { head, tail } = pdfEnvelope(bytes); + expect(head).toBe('%PDF-'); + expect(tail.endsWith('%%EOF')).toBe(true); + }); + + it('accepts a Uint8Array input', async () => { + const bytes = await renderInvoicePdf(new TextEncoder().encode(fa3), 'fa3-default'); + expect(pdfEnvelope(bytes).head).toBe('%PDF-'); + }); + + it('renders the built-in fa3-default in strict mode without a missing binding', async () => { + // valid-fa3.xml populates every path the built-in references. + const bytes = await renderInvoicePdf(fa3, 'fa3-default', { strict: true }); + expect(pdfEnvelope(bytes).head).toBe('%PDF-'); + }); + + it('throws a friendly error for an unknown built-in template', async () => { + await expect(renderInvoicePdf(fa3, 'does-not-exist')).rejects.toBeInstanceOf(KSeFPdfError); + }); + + it('rejects a template whose schema does not match the document version', async () => { + // fa3-default targets FA(3); feeding FA(2) must be rejected. + expect(detectInvoiceVersion(fa2)).toBe('FA(2)'); + await expect(renderInvoicePdf(fa2, 'fa3-default')).rejects.toThrow(/FA\(3\).*FA\(2\)|FA\(2\).*FA\(3\)/); + }); +}); + +describe('renderInvoicePdfFromTemplate — custom object', () => { + it('renders a minimal custom template', async () => { + const template: InvoiceTemplate = { + schema: 'FA(3)', + blocks: [ + { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, + { type: 'text', path: 'Fa.P_15', format: 'money' }, + ], + }; + const bytes = await renderInvoicePdfFromTemplate(fa3, template); + expect(pdfEnvelope(bytes).head).toBe('%PDF-'); + }); + + it('rejects a structurally invalid template', async () => { + const bad = { schema: 'FA(3)', blocks: [{ type: 'nope' }] } as unknown as InvoiceTemplate; + await expect(renderInvoicePdfFromTemplate(fa3, bad)).rejects.toThrow(); + }); +}); diff --git a/packages/ksef-client-ts/tsconfig.pdf-check.json b/packages/ksef-client-ts/tsconfig.pdf-check.json new file mode 100644 index 00000000..765c4802 --- /dev/null +++ b/packages/ksef-client-ts/tsconfig.pdf-check.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "module": "node16", + "moduleResolution": "node16", + "types": ["node"] + }, + "include": ["tests/fixtures/pdf-types-check.ts"], + "exclude": [] +} diff --git a/packages/ksef-client-ts/tsup.config.ts b/packages/ksef-client-ts/tsup.config.ts index 03ec6547..5fcd71df 100644 --- a/packages/ksef-client-ts/tsup.config.ts +++ b/packages/ksef-client-ts/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig([ { - entry: ["src/index.ts", "src/node.ts"], + entry: ["src/index.ts", "src/node.ts", "src/pdf/index.ts"], format: ["esm", "cjs"], dts: true, clean: true, @@ -11,6 +11,8 @@ export default defineConfig([ shims: true, target: "node18", removeNodeProtocol: false, + // pdfmake is an optional peer — never bundle it; it is loaded lazily at runtime. + external: ["pdfmake"], }, { entry: { cli: "src/cli/index.ts" }, @@ -22,5 +24,8 @@ export default defineConfig([ target: "node18", banner: { js: "#!/usr/bin/env node" }, removeNodeProtocol: false, + // The CLI lazily bridges into ./pdf, which lazily imports pdfmake — keep it + // external so it is never pulled into the CLI bundle (optional peer). + external: ["pdfmake"], }, ]); diff --git a/yarn.lock b/yarn.lock index 3a51c263..7ac28dd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -260,6 +260,46 @@ __metadata: languageName: node linkType: hard +"@andrewbranch/untar.js@npm:^1.0.3": + version: 1.0.3 + resolution: "@andrewbranch/untar.js@npm:1.0.3" + checksum: 10c0/16774208cd5bc2cace3c8c6ca608b2b9ab07719a44501e5553f72bffb63c5fbac0b715a4b1065a65d09e010d940ac3cd148ade44dd7d49682765fe09e2c3b2a8 + languageName: node + linkType: hard + +"@arethetypeswrong/cli@npm:^0.18.4": + version: 0.18.4 + resolution: "@arethetypeswrong/cli@npm:0.18.4" + dependencies: + "@arethetypeswrong/core": "npm:0.18.4" + chalk: "npm:^4.1.2" + cli-table3: "npm:^0.6.3" + commander: "npm:^10.0.1" + marked: "npm:^9.1.2" + marked-terminal: "npm:^7.1.0" + semver: "npm:^7.5.4" + bin: + attw: ./dist/index.js + checksum: 10c0/33fda6943521762f873fc3c508ac5544e4578de018abaed195b30d5db54b7e43a88e7725d70196da6cb67522a19cc1e636189784af53041dad8f68ee34476033 + languageName: node + linkType: hard + +"@arethetypeswrong/core@npm:0.18.4": + version: 0.18.4 + resolution: "@arethetypeswrong/core@npm:0.18.4" + dependencies: + "@andrewbranch/untar.js": "npm:^1.0.3" + "@loaderkit/resolve": "npm:^1.0.2" + cjs-module-lexer: "npm:^1.2.3" + fflate: "npm:^0.8.3" + lru-cache: "npm:^11.0.1" + semver: "npm:^7.5.4" + typescript: "npm:5.6.1-rc" + validate-npm-package-name: "npm:^5.0.0" + checksum: 10c0/988eb93c3eaa42216b19cda2cc2c3076bf36b7ba29bff7ce447931b2224eeb117cd37139ae6e3c18bbc5ebae22c9d7ca4a82af49420b64eec95c49585ba086dd + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/helper-string-parser@npm:7.29.7" @@ -302,6 +342,13 @@ __metadata: languageName: node linkType: hard +"@braidai/lang@npm:^1.0.0": + version: 1.1.2 + resolution: "@braidai/lang@npm:1.1.2" + checksum: 10c0/24bc85bf85dfe027102b19f418a591202b00cc8be12525ceff8adb65d5ebdda0dc1a0463c1e2cb6de1d85cd409012aa85850d9377bae61160162afbea278ab26 + languageName: node + linkType: hard + "@codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.18.3": version: 6.20.3 resolution: "@codemirror/autocomplete@npm:6.20.3" @@ -904,6 +951,52 @@ __metadata: languageName: node linkType: hard +"@foliojs-fork/fontkit@npm:^1.9.2": + version: 1.9.2 + resolution: "@foliojs-fork/fontkit@npm:1.9.2" + dependencies: + "@foliojs-fork/restructure": "npm:^2.0.2" + brotli: "npm:^1.2.0" + clone: "npm:^1.0.4" + deep-equal: "npm:^1.0.0" + dfa: "npm:^1.2.0" + tiny-inflate: "npm:^1.0.2" + unicode-properties: "npm:^1.2.2" + unicode-trie: "npm:^2.0.0" + checksum: 10c0/0855b621942aeaec3a20261154532b3a4653cc530e5429954b9ec2bd61805a484a450a43229d0e836e78d08674ac729d81193ac03347b0e202646d900446ce84 + languageName: node + linkType: hard + +"@foliojs-fork/linebreak@npm:^1.1.1, @foliojs-fork/linebreak@npm:^1.1.2": + version: 1.1.2 + resolution: "@foliojs-fork/linebreak@npm:1.1.2" + dependencies: + base64-js: "npm:1.3.1" + unicode-trie: "npm:^2.0.0" + checksum: 10c0/5791eab874ae120bbe7bbd5a70675d3f88869376dfba3c76c61487b42275eeaf07026de22f074c5d4b5fdde900b43cac1289c6c142aca6015ccdb0da1166c3e8 + languageName: node + linkType: hard + +"@foliojs-fork/pdfkit@npm:^0.15.3": + version: 0.15.3 + resolution: "@foliojs-fork/pdfkit@npm:0.15.3" + dependencies: + "@foliojs-fork/fontkit": "npm:^1.9.2" + "@foliojs-fork/linebreak": "npm:^1.1.1" + crypto-js: "npm:^4.2.0" + jpeg-exif: "npm:^1.1.4" + png-js: "npm:^1.0.0" + checksum: 10c0/5f3edff0182a6e29d12891e1573be6837035de8d4a4aa446ebf5ee561224b6f11b059cda05dd82a91bbe3de2cedc9676c59980506d5daf209a5d564865a297cb + languageName: node + linkType: hard + +"@foliojs-fork/restructure@npm:^2.0.2": + version: 2.0.2 + resolution: "@foliojs-fork/restructure@npm:2.0.2" + checksum: 10c0/f9e6e94f7377f467a93988ee85cf326f2db3edd3029530791aced6d530fd7862efb27b00212beb8df6f7458cbec4acb6b198b36535d1acee3fb413c1d5ac55ac + languageName: node + linkType: hard + "@headlessui/tailwindcss@npm:^0.2.2": version: 0.2.2 resolution: "@headlessui/tailwindcss@npm:0.2.2" @@ -1113,6 +1206,15 @@ __metadata: languageName: node linkType: hard +"@loaderkit/resolve@npm:^1.0.2": + version: 1.0.6 + resolution: "@loaderkit/resolve@npm:1.0.6" + dependencies: + "@braidai/lang": "npm:^1.0.0" + checksum: 10c0/e7dfcc0fa8f073c3048c9b8ef702d82dcc4390dc8d57438ac3a652e4936d66df2ca7ae8a4f2b6901157493dd42236156c1f47a5280f680a12bdef2f1b870135c + languageName: node + linkType: hard + "@marijn/find-cluster-break@npm:^1.0.0": version: 1.0.2 resolution: "@marijn/find-cluster-break@npm:1.0.2" @@ -1351,6 +1453,15 @@ __metadata: languageName: node linkType: hard +"@publint/pack@npm:^0.1.4": + version: 0.1.5 + resolution: "@publint/pack@npm:0.1.5" + dependencies: + tinyexec: "npm:^1.2.4" + checksum: 10c0/b92344164e5e0c9b7103a8451bf8b0dede475c04ce6150d8d0c2b5a37260068ad35e3869fdc283f6fbaf7e9f19644e2d3728c13e05a35e200f08dce6981fbd97 + languageName: node + linkType: hard + "@replit/codemirror-css-color-picker@npm:^6.3.0": version: 6.3.0 resolution: "@replit/codemirror-css-color-picker@npm:6.3.0" @@ -1960,6 +2071,13 @@ __metadata: languageName: node linkType: hard +"@sindresorhus/is@npm:^4.6.0": + version: 4.6.0 + resolution: "@sindresorhus/is@npm:4.6.0" + checksum: 10c0/33b6fb1d0834ec8dd7689ddc0e2781c2bfd8b9c4e4bacbcb14111e0ae00621f2c264b8a7d36541799d74888b5dccdf422a891a5cb5a709ace26325eedc81e22e + languageName: node + linkType: hard + "@sindresorhus/merge-streams@npm:^4.0.0": version: 4.0.0 resolution: "@sindresorhus/merge-streams@npm:4.0.0" @@ -2739,6 +2857,15 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:^7.0.0": + version: 7.3.0 + resolution: "ansi-escapes@npm:7.3.0" + dependencies: + environment: "npm:^1.0.0" + checksum: 10c0/068961d99f0ef28b661a4a9f84a5d645df93ccf3b9b93816cc7d46bbe1913321d4cdf156bb842a4e1e4583b7375c631fa963efb43001c4eb7ff9ab8f78fc0679 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -2746,14 +2873,14 @@ __metadata: languageName: node linkType: hard -"ansi-regex@npm:^6.2.2": +"ansi-regex@npm:^6.1.0, ansi-regex@npm:^6.2.2": version: 6.2.2 resolution: "ansi-regex@npm:6.2.2" checksum: 10c0/05d4acb1d2f59ab2cf4b794339c7b168890d44dda4bf0ce01152a8da0213aca207802f930442ce8cd22d7a92f44907664aac6508904e75e038fa944d2601b30f languageName: node linkType: hard -"ansi-styles@npm:^4.0.0": +"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" dependencies: @@ -2828,6 +2955,20 @@ __metadata: languageName: node linkType: hard +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 + languageName: node + linkType: hard + "b4a@npm:^1.6.4, b4a@npm:^1.8.1": version: 1.8.1 resolution: "b4a@npm:1.8.1" @@ -2938,7 +3079,14 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.1": +"base64-js@npm:1.3.1": + version: 1.3.1 + resolution: "base64-js@npm:1.3.1" + checksum: 10c0/f111a2c2b105eb9ee3818c30154e047fb00370699a03c2130d0944f10914697677ceb5faec64ea851e00710b80539afb03e9aa098e19c183a5e7a1cdc18abb29 + languageName: node + linkType: hard + +"base64-js@npm:^1.1.2, base64-js@npm:^1.3.0, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf @@ -2999,6 +3147,24 @@ __metadata: languageName: node linkType: hard +"brotli@npm:^1.2.0": + version: 1.3.3 + resolution: "brotli@npm:1.3.3" + dependencies: + base64-js: "npm:^1.1.2" + checksum: 10c0/9d24e24f8b7eabf44af034ed5f7d5530008b835f09a107a84ac060723e86dd43c6aa68958691fe5df524f59473b35f5ce2e0854aa1152c0a254d1010f51bcf22 + languageName: node + linkType: hard + +"browserify-zlib@npm:^0.2.0": + version: 0.2.0 + resolution: "browserify-zlib@npm:0.2.0" + dependencies: + pako: "npm:~1.0.5" + checksum: 10c0/9ab10b6dc732c6c5ec8ebcbe5cb7fe1467f97402c9b2140113f47b5f187b9438f93a8e065d8baf8b929323c18324fbf1105af479ee86d9d36cab7d7ef3424ad9 + languageName: node + linkType: hard + "buffer-crc32@npm:^1.0.0": version: 1.0.0 resolution: "buffer-crc32@npm:1.0.0" @@ -3054,6 +3220,38 @@ __metadata: languageName: node linkType: hard +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + +"call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": + version: 1.0.9 + resolution: "call-bind@npm:1.0.9" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + get-intrinsic: "npm:^1.3.0" + set-function-length: "npm:^1.2.2" + checksum: 10c0/a6621f6da1444481919ce3b4983dff725691e0754d3507ae483ce56e54985f2da7d6f1df512c56dbf28660745cf1ca52553f1fc9aef5557f3ce353ef14fab714 + languageName: node + linkType: hard + +"call-bound@npm:^1.0.2": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + get-intrinsic: "npm:^1.3.0" + checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644 + languageName: node + linkType: hard + "camelcase@npm:^5.0.0": version: 5.3.1 resolution: "camelcase@npm:5.3.1" @@ -3081,13 +3279,30 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.6.2": +"chalk@npm:^4.0.0, chalk@npm:^4.1.2": + version: 4.1.2 + resolution: "chalk@npm:4.1.2" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 + languageName: node + linkType: hard + +"chalk@npm:^5.4.1, chalk@npm:^5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 languageName: node linkType: hard +"char-regex@npm:^1.0.2": + version: 1.0.2 + resolution: "char-regex@npm:1.0.2" + checksum: 10c0/57a09a86371331e0be35d9083ba429e86c4f4648ecbe27455dbfb343037c16ee6fdc7f6b61f433a57cc5ded5561d71c56a150e018f40c2ffb7bc93a26dae341e + languageName: node + linkType: hard + "character-entities-html4@npm:^2.0.0": version: 2.1.0 resolution: "character-entities-html4@npm:2.1.0" @@ -3153,7 +3368,30 @@ __metadata: languageName: node linkType: hard -"cli-table3@npm:^0.6.5": +"cjs-module-lexer@npm:^1.2.3": + version: 1.4.3 + resolution: "cjs-module-lexer@npm:1.4.3" + checksum: 10c0/076b3af85adc4d65dbdab1b5b240fe5b45d44fcf0ef9d429044dd94d19be5589376805c44fb2d4b3e684e5fe6a9b7cf3e426476a6507c45283c5fc6ff95240be + languageName: node + linkType: hard + +"cli-highlight@npm:^2.1.11": + version: 2.1.11 + resolution: "cli-highlight@npm:2.1.11" + dependencies: + chalk: "npm:^4.0.0" + highlight.js: "npm:^10.7.1" + mz: "npm:^2.4.0" + parse5: "npm:^5.1.1" + parse5-htmlparser2-tree-adapter: "npm:^6.0.0" + yargs: "npm:^16.0.0" + bin: + highlight: bin/highlight + checksum: 10c0/b5b4af3b968aa9df77eee449a400fbb659cf47c4b03a395370bd98d5554a00afaa5819b41a9a8a1ca0d37b0b896a94e57c65289b37359a25b700b1f56eb04852 + languageName: node + linkType: hard + +"cli-table3@npm:^0.6.3, cli-table3@npm:^0.6.5": version: 0.6.5 resolution: "cli-table3@npm:0.6.5" dependencies: @@ -3177,6 +3415,24 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^7.0.2": + version: 7.0.4 + resolution: "cliui@npm:7.0.4" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.0" + wrap-ansi: "npm:^7.0.0" + checksum: 10c0/6035f5daf7383470cef82b3d3db00bec70afb3423538c50394386ffbbab135e26c3689c41791f911fa71b62d13d3863c712fdd70f0fbdffd938a1e6fd09aac00 + languageName: node + linkType: hard + +"clone@npm:^1.0.4": + version: 1.0.4 + resolution: "clone@npm:1.0.4" + checksum: 10c0/2176952b3649293473999a95d7bebfc9dc96410f6cbd3d2595cf12fd401f63a4bf41a7adbfd3ab2ff09ed60cb9870c58c6acdd18b87767366fabfc163700f13b + languageName: node + linkType: hard + "clsx@npm:^2.1.1": version: 2.1.1 resolution: "clsx@npm:2.1.1" @@ -3207,6 +3463,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^10.0.1": + version: 10.0.1 + resolution: "commander@npm:10.0.1" + checksum: 10c0/53f33d8927758a911094adadda4b2cbac111a5b377d8706700587650fd8f45b0bbe336de4b5c3fe47fd61f420a3d9bd452b6e0e6e5600a7e74d7bf0174f6efe3 + languageName: node + linkType: hard + "commander@npm:^4.0.0": version: 4.1.1 resolution: "commander@npm:4.1.1" @@ -3276,6 +3539,13 @@ __metadata: languageName: node linkType: hard +"crypto-js@npm:^4.2.0": + version: 4.2.0 + resolution: "crypto-js@npm:4.2.0" + checksum: 10c0/8fbdf9d56f47aea0794ab87b0eb9833baf80b01a7c5c1b0edc7faf25f662fb69ab18dc2199e2afcac54670ff0cd9607a9045a3f7a80336cccd18d77a55b9fdf0 + languageName: node + linkType: hard + "csstype@npm:^3.2.3": version: 3.2.3 resolution: "csstype@npm:3.2.3" @@ -3341,6 +3611,20 @@ __metadata: languageName: node linkType: hard +"deep-equal@npm:^1.0.0": + version: 1.1.2 + resolution: "deep-equal@npm:1.1.2" + dependencies: + is-arguments: "npm:^1.1.1" + is-date-object: "npm:^1.0.5" + is-regex: "npm:^1.1.4" + object-is: "npm:^1.1.5" + object-keys: "npm:^1.1.1" + regexp.prototype.flags: "npm:^1.5.1" + checksum: 10c0/cd85d822d18e9b3e1532d0f6ba412d229aa9d22881d70da161674428ae96e47925191296f7cda29306bac252889007da40ed8449363bd1c96c708acb82068a00 + languageName: node + linkType: hard + "deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" @@ -3348,6 +3632,28 @@ __metadata: languageName: node linkType: hard +"define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.4": + version: 1.1.4 + resolution: "define-data-property@npm:1.1.4" + dependencies: + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.0.1" + checksum: 10c0/dea0606d1483eb9db8d930d4eac62ca0fa16738b0b3e07046cddfacf7d8c868bbe13fa0cb263eb91c7d0d527960dc3f2f2471a69ed7816210307f6744fe62e37 + languageName: node + linkType: hard + +"define-properties@npm:^1.2.1": + version: 1.2.1 + resolution: "define-properties@npm:1.2.1" + dependencies: + define-data-property: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.0" + object-keys: "npm:^1.1.1" + checksum: 10c0/88a152319ffe1396ccc6ded510a3896e77efac7a1bfbaa174a7b00414a1747377e0bb525d303794a47cf30e805c2ec84e575758512c6e44a993076d29fd4e6c3 + languageName: node + linkType: hard + "defu@npm:^6.1.4": version: 6.1.7 resolution: "defu@npm:6.1.7" @@ -3378,6 +3684,13 @@ __metadata: languageName: node linkType: hard +"dfa@npm:^1.2.0": + version: 1.2.0 + resolution: "dfa@npm:1.2.0" + checksum: 10c0/ad12f0bc73b530876672e0a9dfbaa350eeff0c876580042734a004e462eca86d7749b9dedf6b067ba54f346137ab23d16615826bbfa424a3e01ab0e2786fad3c + languageName: node + linkType: hard + "dijkstrajs@npm:^1.0.1": version: 1.0.3 resolution: "dijkstrajs@npm:1.0.3" @@ -3385,6 +3698,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -3413,6 +3737,13 @@ __metadata: languageName: node linkType: hard +"emojilib@npm:^2.4.0": + version: 2.4.0 + resolution: "emojilib@npm:2.4.0" + checksum: 10c0/6e66ba8921175842193f974e18af448bb6adb0cf7aeea75e08b9d4ea8e9baba0e4a5347b46ed901491dcaba277485891c33a8d70b0560ca5cc9672a94c21ab8f + languageName: node + linkType: hard + "encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -3459,6 +3790,13 @@ __metadata: languageName: node linkType: hard +"environment@npm:^1.0.0": + version: 1.1.0 + resolution: "environment@npm:1.1.0" + checksum: 10c0/fb26434b0b581ab397039e51ff3c92b34924a98b2039dcb47e41b7bca577b9dbf134a8eadb364415c74464b682e2d3afe1a4c0eb9873dc44ea814c5d3103331d + languageName: node + linkType: hard + "err-code@npm:^2.0.2": version: 2.0.3 resolution: "err-code@npm:2.0.3" @@ -3466,6 +3804,20 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + "es-module-lexer@npm:^1.7.0": version: 1.7.0 resolution: "es-module-lexer@npm:1.7.0" @@ -3473,6 +3825,15 @@ __metadata: languageName: node linkType: hard +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.2 + resolution: "es-object-atoms@npm:1.1.2" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/1772861f094f739d6f41b579cfb9a18579daffeb434552a370a5fbef50a32d22227e27b63fdbb757b7ddd429d1b42fe52ccae7966d9302a2ec221b6f1b41bbc4 + languageName: node + linkType: hard + "esbuild@npm:^0.21.3": version: 0.21.5 resolution: "esbuild@npm:0.21.5" @@ -3642,6 +4003,13 @@ __metadata: languageName: node linkType: hard +"escalade@npm:^3.1.1": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + "escape-string-regexp@npm:^5.0.0": version: 5.0.0 resolution: "escape-string-regexp@npm:5.0.0" @@ -3791,6 +4159,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:^0.8.3": + version: 0.8.3 + resolution: "fflate@npm:0.8.3" + checksum: 10c0/eab181ca37f5348ae76d4b6f840e0026e30220e33153289ac942222d8b9638237d486507dbcc09878d724095bd354993a2ee48bbee99c8f2c6440d4448719aa7 + languageName: node + linkType: hard + "file-uri-to-path@npm:1.0.0": version: 1.0.0 resolution: "file-uri-to-path@npm:1.0.0" @@ -3889,6 +4264,13 @@ __metadata: languageName: node linkType: hard +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + "function-timeout@npm:^1.0.1": version: 1.0.2 resolution: "function-timeout@npm:1.0.2" @@ -3896,6 +4278,13 @@ __metadata: languageName: node linkType: hard +"functions-have-names@npm:^1.2.3": + version: 1.2.3 + resolution: "functions-have-names@npm:1.2.3" + checksum: 10c0/33e77fd29bddc2d9bb78ab3eb854c165909201f88c75faa8272e35899e2d35a8a642a15e7420ef945e1f64a9670d6aa3ec744106b2aa42be68ca5114025954ca + languageName: node + linkType: hard + "fuse.js@npm:^7.1.0": version: 7.4.2 resolution: "fuse.js@npm:7.4.2" @@ -3903,7 +4292,14 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.1": +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde @@ -3917,6 +4313,27 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.3.0": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d + languageName: node + linkType: hard + "get-own-enumerable-keys@npm:^1.0.0": version: 1.0.0 resolution: "get-own-enumerable-keys@npm:1.0.0" @@ -3924,6 +4341,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + "github-from-package@npm:0.0.0": version: 0.0.0 resolution: "github-from-package@npm:0.0.0" @@ -3970,6 +4397,13 @@ __metadata: languageName: node linkType: hard +"gopd@npm:^1.0.1, gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead + languageName: node + linkType: hard + "graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -3991,6 +4425,40 @@ __metadata: languageName: node linkType: hard +"has-property-descriptors@npm:^1.0.0, has-property-descriptors@npm:^1.0.2": + version: 1.0.2 + resolution: "has-property-descriptors@npm:1.0.2" + dependencies: + es-define-property: "npm:^1.0.0" + checksum: 10c0/253c1f59e80bb476cf0dde8ff5284505d90c3bdb762983c3514d36414290475fe3fd6f574929d84de2a8eec00d35cf07cb6776205ff32efd7c50719125f00236 + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + +"hasown@npm:^2.0.2": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506 + languageName: node + linkType: hard + "hast-util-embedded@npm:^3.0.0": version: 3.0.0 resolution: "hast-util-embedded@npm:3.0.0" @@ -4208,6 +4676,13 @@ __metadata: languageName: node linkType: hard +"highlight.js@npm:^10.7.1": + version: 10.7.3 + resolution: "highlight.js@npm:10.7.3" + checksum: 10c0/073837eaf816922427a9005c56c42ad8786473dc042332dfe7901aa065e92bc3d94ebf704975257526482066abb2c8677cc0326559bb8621e046c21c5991c434 + languageName: node + linkType: hard + "highlight.js@npm:^11.11.1, highlight.js@npm:~11.11.0": version: 11.11.1 resolution: "highlight.js@npm:11.11.1" @@ -4286,6 +4761,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:^0.7.1": + version: 0.7.3 + resolution: "iconv-lite@npm:0.7.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/be2fd2414f7e94be3a63063fa0ad5919c16bc7b6e00c77eea0765ced6db405739f38dd51016583058fba25cc1d0106ba30b83fbb7a7e32f824d4db76f23d8d33 + languageName: node + linkType: hard + "identifier-regex@npm:^1.0.0": version: 1.0.1 resolution: "identifier-regex@npm:1.0.1" @@ -4368,6 +4852,26 @@ __metadata: languageName: node linkType: hard +"is-arguments@npm:^1.1.1": + version: 1.2.0 + resolution: "is-arguments@npm:1.2.0" + dependencies: + call-bound: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/6377344b31e9fcb707c6751ee89b11f132f32338e6a782ec2eac9393b0cbd32235dad93052998cda778ee058754860738341d8114910d50ada5615912bb929fc + languageName: node + linkType: hard + +"is-date-object@npm:^1.0.5": + version: 1.1.0 + resolution: "is-date-object@npm:1.1.0" + dependencies: + call-bound: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/1a4d199c8e9e9cac5128d32e6626fa7805175af9df015620ac0d5d45854ccf348ba494679d872d37301032e35a54fc7978fba1687e8721b2139aea7870cafa2f + languageName: node + linkType: hard + "is-decimal@npm:^2.0.0": version: 2.0.1 resolution: "is-decimal@npm:2.0.1" @@ -4443,6 +4947,18 @@ __metadata: languageName: node linkType: hard +"is-regex@npm:^1.1.4": + version: 1.2.1 + resolution: "is-regex@npm:1.2.1" + dependencies: + call-bound: "npm:^1.0.2" + gopd: "npm:^1.2.0" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/1d3715d2b7889932349241680032e85d0b492cfcb045acb75ffc2c3085e8d561184f1f7e84b6f8321935b4aea39bc9c6ba74ed595b57ce4881a51dfdbc214e04 + languageName: node + linkType: hard + "is-regexp@npm:^3.1.0": version: 3.1.0 resolution: "is-regexp@npm:3.1.0" @@ -4544,6 +5060,13 @@ __metadata: languageName: node linkType: hard +"jpeg-exif@npm:^1.1.4": + version: 1.1.4 + resolution: "jpeg-exif@npm:1.1.4" + checksum: 10c0/0f9225b2423184d60c66b3d7361176801c17ede92fc9b3c044fcf00f379a5a1d424b360ecf0027dda47d405d253c7b62bf5b353fb08b2589e3650f38cc575e82 + languageName: node + linkType: hard + "js-base64@npm:^3.7.8": version: 3.7.8 resolution: "js-base64@npm:3.7.8" @@ -4624,6 +5147,7 @@ __metadata: version: 0.0.0-use.local resolution: "ksef-client-ts@workspace:packages/ksef-client-ts" dependencies: + "@arethetypeswrong/cli": "npm:^0.18.4" "@peculiar/x509": "npm:^1.12.3" "@scalar/api-reference": "npm:^1.49.3" "@types/node": "npm:^22.13.10" @@ -4643,6 +5167,8 @@ __metadata: libxmljs2: "npm:^0.37.0" markdownlint-cli2: "npm:^0.22.1" node-forge: "npm:^1.3.1" + pdfmake: "npm:^0.2.20" + publint: "npm:^0.3.12" qrcode: "npm:^1.5.4" tar-stream: "npm:^3.2.0" tsup: "npm:^8.4.0" @@ -4656,9 +5182,12 @@ __metadata: zod: "npm:^4.3.6" peerDependencies: libxmljs2: ^0.37.0 + pdfmake: ^0.2.20 peerDependenciesMeta: libxmljs2: optional: true + pdfmake: + optional: true bin: ksef: ./dist/cli.js languageName: unknown @@ -4756,6 +5285,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.0.1": + version: 11.5.1 + resolution: "lru-cache@npm:11.5.1" + checksum: 10c0/7b341cea79a8efe9c6a6f20c8757a77eca5b25d7ff983ccf4e11e547b81f6787824baa1c84705251dff84ab4ffac85717ac354b9d02e465f86a9f8b166409979 + languageName: node + linkType: hard + "magic-string@npm:^0.30.17, magic-string@npm:^0.30.21": version: 0.30.21 resolution: "magic-string@npm:0.30.21" @@ -4890,6 +5426,39 @@ __metadata: languageName: node linkType: hard +"marked-terminal@npm:^7.1.0": + version: 7.3.0 + resolution: "marked-terminal@npm:7.3.0" + dependencies: + ansi-escapes: "npm:^7.0.0" + ansi-regex: "npm:^6.1.0" + chalk: "npm:^5.4.1" + cli-highlight: "npm:^2.1.11" + cli-table3: "npm:^0.6.5" + node-emoji: "npm:^2.2.0" + supports-hyperlinks: "npm:^3.1.0" + peerDependencies: + marked: ">=1 <16" + checksum: 10c0/59d23c2ed9488c40856d828f431ae1d5d57426e791bbce8f05ec5a7d3a1f848cdb3b8d8880d76ae45570415f8b48ae459f50bbbd88ece5a31306f1e3de57f021 + languageName: node + linkType: hard + +"marked@npm:^9.1.2": + version: 9.1.6 + resolution: "marked@npm:9.1.6" + bin: + marked: bin/marked.js + checksum: 10c0/010bbd33c0f38300259c5d3bf0063deb36bab098d37ac0a3be5a35a65674a4c693427fc6704f486a89f638e9b36c36b8e220a93d47163f4e70e45a1fa8ca7b60 + languageName: node + linkType: hard + +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + "mdast-util-find-and-replace@npm:^3.0.0": version: 3.0.2 resolution: "mdast-util-find-and-replace@npm:3.0.2" @@ -5583,6 +6152,13 @@ __metadata: languageName: node linkType: hard +"mri@npm:^1.1.0": + version: 1.2.0 + resolution: "mri@npm:1.2.0" + checksum: 10c0/a3d32379c2554cf7351db6237ddc18dc9e54e4214953f3da105b97dc3babe0deb3ffe99cf409b38ea47cc29f9430561ba6b53b24ab8f9ce97a4b50409e4a50e7 + languageName: node + linkType: hard + "ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -5590,7 +6166,7 @@ __metadata: languageName: node linkType: hard -"mz@npm:^2.7.0": +"mz@npm:^2.4.0, mz@npm:^2.7.0": version: 2.7.0 resolution: "mz@npm:2.7.0" dependencies: @@ -5658,6 +6234,18 @@ __metadata: languageName: node linkType: hard +"node-emoji@npm:^2.2.0": + version: 2.2.0 + resolution: "node-emoji@npm:2.2.0" + dependencies: + "@sindresorhus/is": "npm:^4.6.0" + char-regex: "npm:^1.0.2" + emojilib: "npm:^2.4.0" + skin-tone: "npm:^2.0.0" + checksum: 10c0/9525defbd90a82a2131758c2470203fa2a2faa8edd177147a8654a26307fe03594e52847ecbe2746d06cfc5c50acd12bd500f035350a7609e8217c9894c19aad + languageName: node + linkType: hard + "node-forge@npm:^1.3.1": version: 1.4.0 resolution: "node-forge@npm:1.4.0" @@ -5734,6 +6322,23 @@ __metadata: languageName: node linkType: hard +"object-is@npm:^1.1.5": + version: 1.1.6 + resolution: "object-is@npm:1.1.6" + dependencies: + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + checksum: 10c0/506af444c4dce7f8e31f34fc549e2fb8152d6b9c4a30c6e62852badd7f520b579c679af433e7a072f9d78eb7808d230dc12e1cf58da9154dfbf8813099ea0fe0 + languageName: node + linkType: hard + +"object-keys@npm:^1.1.1": + version: 1.1.1 + resolution: "object-keys@npm:1.1.1" + checksum: 10c0/b11f7ccdbc6d406d1f186cdadb9d54738e347b2692a14439ca5ac70c225fa6db46db809711b78589866d47b25fc3e8dee0b4c722ac751e11180f9380e3d8601d + languageName: node + linkType: hard + "once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -5809,7 +6414,21 @@ __metadata: languageName: node linkType: hard -"pako@npm:~1.0.2": +"package-manager-detector@npm:^1.6.0": + version: 1.7.0 + resolution: "package-manager-detector@npm:1.7.0" + checksum: 10c0/49488e732ef854205b8c58f68f2315c5b1006169bf9b3a34ca3042c2995edf53e805ee720778f214f32bb80cf6e7cc1ac13508423e89decc691c28329ade6a7d + languageName: node + linkType: hard + +"pako@npm:^0.2.5": + version: 0.2.9 + resolution: "pako@npm:0.2.9" + checksum: 10c0/79c1806ebcf325b60ae599e4d7227c2e346d7b829dc20f5cf24cef07c934079dc3a61c5b3c8278a2f7a190c4a613e343ea11e5302dbe252efd11712df4b6b041 + languageName: node + linkType: hard + +"pako@npm:~1.0.2, pako@npm:~1.0.5": version: 1.0.11 resolution: "pako@npm:1.0.11" checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe @@ -5838,6 +6457,29 @@ __metadata: languageName: node linkType: hard +"parse5-htmlparser2-tree-adapter@npm:^6.0.0": + version: 6.0.1 + resolution: "parse5-htmlparser2-tree-adapter@npm:6.0.1" + dependencies: + parse5: "npm:^6.0.1" + checksum: 10c0/dfa5960e2aaf125707e19a4b1bc333de49232eba5a6ffffb95d313a7d6087c3b7a274b58bee8d3bd41bdf150638815d1d601a42bbf2a0345208c3c35b1279556 + languageName: node + linkType: hard + +"parse5@npm:^5.1.1": + version: 5.1.1 + resolution: "parse5@npm:5.1.1" + checksum: 10c0/b0f87a77a7fea5f242e3d76917c983bbea47703b9371801d51536b78942db6441cbda174bf84eb30e47315ddc6f8a0b57d68e562c790154430270acd76c1fa03 + languageName: node + linkType: hard + +"parse5@npm:^6.0.1": + version: 6.0.1 + resolution: "parse5@npm:6.0.1" + checksum: 10c0/595821edc094ecbcfb9ddcb46a3e1fe3a718540f8320eff08b8cf6742a5114cce2d46d45f95c26191c11b184dcaf4e2960abcd9c5ed9eb9393ac9a37efcfdecb + languageName: node + linkType: hard + "parse5@npm:^7.0.0": version: 7.3.0 resolution: "parse5@npm:7.3.0" @@ -5892,6 +6534,18 @@ __metadata: languageName: node linkType: hard +"pdfmake@npm:^0.2.20": + version: 0.2.23 + resolution: "pdfmake@npm:0.2.23" + dependencies: + "@foliojs-fork/linebreak": "npm:^1.1.2" + "@foliojs-fork/pdfkit": "npm:^0.15.3" + iconv-lite: "npm:^0.7.1" + xmldoc: "npm:^2.0.3" + checksum: 10c0/c94ea1d3efbcbbb4f683f50501f2f864e69682f3b90e5d0680fb8e631efb29f540dedae25a71565aecee5b1402c5d0024b0a4333cf218190c11a2ec6b4529a87 + languageName: node + linkType: hard + "pend@npm:~1.2.0": version: 1.2.0 resolution: "pend@npm:1.2.0" @@ -5945,6 +6599,15 @@ __metadata: languageName: node linkType: hard +"png-js@npm:^1.0.0": + version: 1.1.0 + resolution: "png-js@npm:1.1.0" + dependencies: + browserify-zlib: "npm:^0.2.0" + checksum: 10c0/61e275cb424b914fbc01c2a1b0096857f214c5fee45054c713dec1ad320760f4dd61f28bb0b3b0c139bd8c49def8891b02ca4018238e7b5f14bb671cd1ca7874 + languageName: node + linkType: hard + "pngjs@npm:^5.0.0": version: 5.0.0 resolution: "pngjs@npm:5.0.0" @@ -6062,6 +6725,20 @@ __metadata: languageName: node linkType: hard +"publint@npm:^0.3.12": + version: 0.3.21 + resolution: "publint@npm:0.3.21" + dependencies: + "@publint/pack": "npm:^0.1.4" + package-manager-detector: "npm:^1.6.0" + picocolors: "npm:^1.1.1" + sade: "npm:^1.8.1" + bin: + publint: src/cli.js + checksum: 10c0/46f54061b112f852523e8b1357c5b454633008056de86f6acbbabcb155a8ab758d3e575df9f82fa37b7ba4a25bde8e68f98866763929eaef8c6d96ff1f761cea + languageName: node + linkType: hard + "pump@npm:^3.0.0": version: 3.0.4 resolution: "pump@npm:3.0.4" @@ -6222,6 +6899,20 @@ __metadata: languageName: node linkType: hard +"regexp.prototype.flags@npm:^1.5.1": + version: 1.5.4 + resolution: "regexp.prototype.flags@npm:1.5.4" + dependencies: + call-bind: "npm:^1.0.8" + define-properties: "npm:^1.2.1" + es-errors: "npm:^1.3.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + set-function-name: "npm:^2.0.2" + checksum: 10c0/83b88e6115b4af1c537f8dabf5c3744032cb875d63bc05c288b1b8c0ef37cbe55353f95d8ca817e8843806e3e150b118bc624e4279b24b4776b4198232735a77 + languageName: node + linkType: hard + "rehype-external-links@npm:^3.0.0": version: 3.0.0 resolution: "rehype-external-links@npm:3.0.0" @@ -6493,6 +7184,15 @@ __metadata: languageName: node linkType: hard +"sade@npm:^1.8.1": + version: 1.8.1 + resolution: "sade@npm:1.8.1" + dependencies: + mri: "npm:^1.1.0" + checksum: 10c0/da8a3a5d667ad5ce3bf6d4f054bbb9f711103e5df21003c5a5c1a8a77ce12b640ed4017dd423b13c2307ea7e645adee7c2ae3afe8051b9db16a6f6d3da3f90b1 + languageName: node + linkType: hard + "safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -6514,6 +7214,13 @@ __metadata: languageName: node linkType: hard +"sax@npm:^1.4.3": + version: 1.6.0 + resolution: "sax@npm:1.6.0" + checksum: 10c0/e5593f4a91eb25761a688c4d96902e4e95a0dd6017bc65146b6f21236e3d715cf893333b76bc758923c9574c2fb5a7a76c3a81e96ea15432f2624f906c027c1e + languageName: node + linkType: hard + "semver@npm:^7.3.5, semver@npm:^7.5.3": version: 7.8.4 resolution: "semver@npm:7.8.4" @@ -6523,6 +7230,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.5.4": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + languageName: node + linkType: hard + "set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -6537,6 +7253,32 @@ __metadata: languageName: node linkType: hard +"set-function-length@npm:^1.2.2": + version: 1.2.2 + resolution: "set-function-length@npm:1.2.2" + dependencies: + define-data-property: "npm:^1.1.4" + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + get-intrinsic: "npm:^1.2.4" + gopd: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.2" + checksum: 10c0/82850e62f412a258b71e123d4ed3873fa9377c216809551192bb6769329340176f109c2eeae8c22a8d386c76739855f78e8716515c818bcaef384b51110f0f3c + languageName: node + linkType: hard + +"set-function-name@npm:^2.0.2": + version: 2.0.2 + resolution: "set-function-name@npm:2.0.2" + dependencies: + define-data-property: "npm:^1.1.4" + es-errors: "npm:^1.3.0" + functions-have-names: "npm:^1.2.3" + has-property-descriptors: "npm:^1.0.2" + checksum: 10c0/fce59f90696c450a8523e754abb305e2b8c73586452619c2bad5f7bf38c7b6b4651895c9db895679c5bef9554339cf3ef1c329b66ece3eda7255785fbe299316 + languageName: node + linkType: hard + "setimmediate@npm:^1.0.5": version: 1.0.5 resolution: "setimmediate@npm:1.0.5" @@ -6608,6 +7350,15 @@ __metadata: languageName: node linkType: hard +"skin-tone@npm:^2.0.0": + version: 2.0.0 + resolution: "skin-tone@npm:2.0.0" + dependencies: + unicode-emoji-modifier-base: "npm:^1.0.0" + checksum: 10c0/82d4c2527864f9cbd6cb7f3c4abb31e2224752234d5013b881d3e34e9ab543545b05206df5a17d14b515459fcb265ce409f9cfe443903176b0360cd20e4e4ba5 + languageName: node + linkType: hard + "slash@npm:^5.1.0": version: 5.1.0 resolution: "slash@npm:5.1.0" @@ -6886,7 +7637,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^7.1.0": +"supports-color@npm:^7.0.0, supports-color@npm:^7.1.0": version: 7.2.0 resolution: "supports-color@npm:7.2.0" dependencies: @@ -6895,6 +7646,16 @@ __metadata: languageName: node linkType: hard +"supports-hyperlinks@npm:^3.1.0": + version: 3.2.0 + resolution: "supports-hyperlinks@npm:3.2.0" + dependencies: + has-flag: "npm:^4.0.0" + supports-color: "npm:^7.0.0" + checksum: 10c0/bca527f38d4c45bc95d6a24225944675746c515ddb91e2456d00ae0b5c537658e9dd8155b996b191941b0c19036195a098251304b9082bbe00cd1781f3cd838e + languageName: node + linkType: hard + "swrv@npm:^1.0.4": version: 1.2.0 resolution: "swrv@npm:1.2.0" @@ -7031,6 +7792,13 @@ __metadata: languageName: node linkType: hard +"tiny-inflate@npm:^1.0.0, tiny-inflate@npm:^1.0.2": + version: 1.0.3 + resolution: "tiny-inflate@npm:1.0.3" + checksum: 10c0/fab687537254f6ec44c9a2e880048fe70da3542aba28f73cda3e74c95cabf342a339372f2a6c032e322324f01accc03ca26c04ba2bad9b3eb8cf3ee99bba7f9b + languageName: node + linkType: hard + "tinybench@npm:^2.9.0": version: 2.9.0 resolution: "tinybench@npm:2.9.0" @@ -7045,6 +7813,13 @@ __metadata: languageName: node linkType: hard +"tinyexec@npm:^1.2.4": + version: 1.2.4 + resolution: "tinyexec@npm:1.2.4" + checksum: 10c0/153b8db6b080194b558ff145b9cffc36b80a6e07babd644dcfbe49c807eee668c876049d28bdee90b96304476f883352f2dad91b3f86bc23832532f4363e66ff + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.11, tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": version: 0.2.17 resolution: "tinyglobby@npm:0.2.17" @@ -7216,6 +7991,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:5.6.1-rc": + version: 5.6.1-rc + resolution: "typescript@npm:5.6.1-rc" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/9d6e99b7ddbc797ebc8d09e5d7f2f81ce5f288a4e607bcded3c545ced8582796c937fba31bede5660a56f978b2f68e7c4ee85614b307425a2b4617535721509f + languageName: node + linkType: hard + "typescript@npm:^5.8.2": version: 5.9.3 resolution: "typescript@npm:5.9.3" @@ -7226,6 +8011,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A5.6.1-rc#optional!builtin": + version: 5.6.1-rc + resolution: "typescript@patch:typescript@npm%3A5.6.1-rc#optional!builtin::version=5.6.1-rc&hash=8c6c40" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/9c6f8d864bc2efc964d1bfc94bf2e14f35cd2ad3df5e92d5304c8759674ba77ae927078a5fc06a527c087953615465dd5decc2d4d28ca8e13c11f9b29e068d93 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": version: 5.9.3 resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" @@ -7287,6 +8082,33 @@ __metadata: languageName: node linkType: hard +"unicode-emoji-modifier-base@npm:^1.0.0": + version: 1.0.0 + resolution: "unicode-emoji-modifier-base@npm:1.0.0" + checksum: 10c0/b37623fcf0162186debd20f116483e035a2d5b905b932a2c472459d9143d446ebcbefb2a494e2fe4fa7434355396e2a95ec3fc1f0c29a3bc8f2c827220e79c66 + languageName: node + linkType: hard + +"unicode-properties@npm:^1.2.2": + version: 1.4.1 + resolution: "unicode-properties@npm:1.4.1" + dependencies: + base64-js: "npm:^1.3.0" + unicode-trie: "npm:^2.0.0" + checksum: 10c0/1d140b7945664fb0ef53de955170821e077b949eef377c6e4905902f07e339039271bfa2a005e4f4c6074b080d3420b486c52dc905e11f924949a04d1fb47ffd + languageName: node + linkType: hard + +"unicode-trie@npm:^2.0.0": + version: 2.0.0 + resolution: "unicode-trie@npm:2.0.0" + dependencies: + pako: "npm:^0.2.5" + tiny-inflate: "npm:^1.0.0" + checksum: 10c0/2422368645249f315640a1c9e9506046aa7738fc9c5d59e15c207cdd6ec66101c35b0b9f75dc3ac28fe7be19aaf1efc898bbea074fa1e8e295ef736aeb7904bb + languageName: node + linkType: hard + "unicorn-magic@npm:^0.4.0": version: 0.4.0 resolution: "unicorn-magic@npm:0.4.0" @@ -7392,6 +8214,13 @@ __metadata: languageName: node linkType: hard +"validate-npm-package-name@npm:^5.0.0": + version: 5.0.1 + resolution: "validate-npm-package-name@npm:5.0.1" + checksum: 10c0/903e738f7387404bb72f7ac34e45d7010c877abd2803dc2d614612527927a40a6d024420033132e667b1bade94544b8a1f65c9431a4eb30d0ce0d80093cd1f74 + languageName: node + linkType: hard + "vfile-location@npm:^5.0.0": version: 5.0.3 resolution: "vfile-location@npm:5.0.3" @@ -7748,7 +8577,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" dependencies: @@ -7806,6 +8635,15 @@ __metadata: languageName: node linkType: hard +"xmldoc@npm:^2.0.3": + version: 2.0.3 + resolution: "xmldoc@npm:2.0.3" + dependencies: + sax: "npm:^1.4.3" + checksum: 10c0/8b01fc2b49f7dfa3fc0506d7ae4bab41476e15836114a1ab3794a5dc925498e2f4c248391479f57426e166a718082e6628c7112a89e6bf015ed780c9772f4406 + languageName: node + linkType: hard + "xpath@npm:^0.0.33": version: 0.0.33 resolution: "xpath@npm:0.0.33" @@ -7820,6 +8658,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 + languageName: node + linkType: hard + "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" @@ -7853,6 +8698,13 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^20.2.2": + version: 20.2.9 + resolution: "yargs-parser@npm:20.2.9" + checksum: 10c0/0685a8e58bbfb57fab6aefe03c6da904a59769bd803a722bb098bd5b0f29d274a1357762c7258fb487512811b8063fb5d2824a3415a0a4540598335b3b086c72 + languageName: node + linkType: hard + "yargs@npm:^15.3.1": version: 15.4.1 resolution: "yargs@npm:15.4.1" @@ -7872,6 +8724,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^16.0.0": + version: 16.2.2 + resolution: "yargs@npm:16.2.2" + dependencies: + cliui: "npm:^7.0.2" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.0" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^20.2.2" + checksum: 10c0/1ca2152581ee7c9c9fb4174767ff7294b1c272d2d0a1f6eb13c39ce177fded85eb9c96711e00cd7913c0a9b88b6e763713ee11cae81b9b62fd97bdd0b03d5549 + languageName: node + linkType: hard + "yauzl@npm:^3.2.1": version: 3.4.0 resolution: "yauzl@npm:3.4.0" From f6f384917547faedd139958b1fe01a39ec300bdf Mon Sep 17 00:00:00 2001 From: Flop Butylkin Date: Mon, 6 Jul 2026 00:39:02 +0200 Subject: [PATCH 03/67] fix(pdf): render bank account details & fix NaN QR verification date The invoice PDF renderer dropped bank account data entirely and emitted a QR verification URL with a NaN date: the payment due date and issue date were bound to the wrong XML paths, the payment-form code was left raw, and bank details were never modeled at all. - Add a bank-account repeater to the payment block (account number, SWIFT, bank name), rendered as localized label:value lines; skip empty optional payment fields. - Bind the payment due date to the payment-term element and decode the payment-form code to its name. - Read the QR issue date from Fa/P_1 (not the document root) and guard the verification-link service against an unparseable date instead of emitting a NaN segment. - Rebuild the FA(2)/FA(3) fixtures from a realistic payment structure so the strict self-consistency test exercises the payment/bank paths, and add tests for the QR date, the payment-form formatter, and bank-account rendering. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EaMDwzgFf8zzWn7mM1Tjq5 (cherry picked from commit 2e7df1ec7d333e1d74203bee65e26506b602fd57) --- packages/ksef-client-ts/src/pdf/format.ts | 25 ++++- packages/ksef-client-ts/src/pdf/i18n/en.ts | 4 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 4 + packages/ksef-client-ts/src/pdf/qr.ts | 11 +- .../src/pdf/template/blocks/payment.ts | 38 +++++-- .../src/pdf/template/builtin/fa2-default.json | 15 ++- .../src/pdf/template/builtin/fa3-default.json | 15 ++- .../ksef-client-ts/src/pdf/template/dsl.ts | 23 +++- .../src/qr/verification-link-service.ts | 5 + .../ksef-client-ts/tests/fixtures/pdf/fa2.xml | 9 +- .../ksef-client-ts/tests/fixtures/pdf/fa3.xml | 9 +- .../tests/unit/pdf/blocks-semantic.test.ts | 102 ++++++++++++++++++ .../tests/unit/pdf/format.test.ts | 22 ++++ .../ksef-client-ts/tests/unit/pdf/qr.test.ts | 18 +++- .../unit/qr/verification-link-service.test.ts | 5 + 15 files changed, 283 insertions(+), 22 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/format.ts b/packages/ksef-client-ts/src/pdf/format.ts index f27a0955..a69b34df 100644 --- a/packages/ksef-client-ts/src/pdf/format.ts +++ b/packages/ksef-client-ts/src/pdf/format.ts @@ -1,9 +1,9 @@ /** * Value formatters referenced by DSL bindings (`format: 'money' | 'date' | - * 'number' | 'nip'`). Each is total: on unparseable input it returns the raw - * string unchanged, so a formatter never throws mid-render. + * 'number' | 'nip' | 'paymentForm'`). Each is total: on unparseable input it + * returns the raw string unchanged, so a formatter never throws mid-render. */ -export type FormatterName = 'money' | 'date' | 'number' | 'nip'; +export type FormatterName = 'money' | 'date' | 'number' | 'nip' | 'paymentForm'; const NBSP = ' '; @@ -47,11 +47,30 @@ export function formatNip(raw: string): string { return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6, 8)}-${digits.slice(8, 10)}`; } +/** + * KSeF `FormaPlatnosci` enum code → Polish label (the codes are a Polish fiscal + * enum, so the decoded name is Polish regardless of the render locale, matching + * how the official visualizations print it). An unknown code passes through. + */ +const PAYMENT_FORMS: Record = { + '1': 'Gotówka', + '2': 'Karta', + '3': 'Bon', + '4': 'Czek', + '5': 'Kredyt', + '6': 'Przelew', + '7': 'Mobilna', +}; +export function formatPaymentForm(raw: string): string { + return PAYMENT_FORMS[raw.trim()] ?? raw; +} + const FORMATTERS: Record string> = { money: formatMoney, date: formatDate, number: formatNumber, nip: formatNip, + paymentForm: formatPaymentForm, }; /** Apply a named formatter; an unknown name returns the value unchanged. */ diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 15cf318a..50701aad 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -26,6 +26,10 @@ export const en: LabelBundle = { paid: 'Paid', paymentDate: 'Payment due', paymentMethod: 'Payment method', + bankAccounts: 'Bank account', + bankAccount: 'Account number', + swift: 'SWIFT / BIC', + bankName: 'Bank name', annotations: 'Annotations', upoTitle: 'Official Receipt Confirmation (UPO)', ksefDocNumber: 'KSeF document number', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 197246f2..f8c3ba21 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -29,6 +29,10 @@ export const pl: LabelBundle = { paid: 'Zapłacono', paymentDate: 'Termin płatności', paymentMethod: 'Forma płatności', + bankAccounts: 'Rachunek bankowy', + bankAccount: 'Numer rachunku', + swift: 'Kod SWIFT', + bankName: 'Nazwa banku', // annotations annotations: 'Adnotacje', // upo diff --git a/packages/ksef-client-ts/src/pdf/qr.ts b/packages/ksef-client-ts/src/pdf/qr.ts index 5d79c29d..a06d2c13 100644 --- a/packages/ksef-client-ts/src/pdf/qr.ts +++ b/packages/ksef-client-ts/src/pdf/qr.ts @@ -12,6 +12,7 @@ import crypto from 'node:crypto'; import { VerificationLinkService } from '../qr/verification-link-service.js'; import { Environment } from '../config/environments.js'; import { get } from './accessor.js'; +import { KSeFPdfError } from './errors.js'; /** * SHA-256 over the raw invoice bytes, returned as standard base64. @@ -65,7 +66,15 @@ export interface DeriveInvoiceQrUrlParams { */ export function deriveInvoiceQrUrl(params: DeriveInvoiceQrUrlParams): string { const nip = get(params.body, 'Podmiot1.DaneIdentyfikacyjne.NIP', params.strict); - const issueDate = get(params.body, 'P_1', params.strict); + // Issue date lives at Faktura/Fa/P_1 — the invoice-line group `Fa`, not the + // document root (where Podmiot1 sits). Reading it at the root yields '' and a + // NaN-NaN-NaN date segment in the URL. + const issueDate = get(params.body, 'Fa.P_1', params.strict); + if (issueDate === '') { + throw new KSeFPdfError( + 'Cannot build the QR verification URL: issue date (Fa/P_1) is missing from the invoice.', + ); + } const hash = params.invoiceHash ?? computeInvoiceHashBase64(params.rawInput); const base = resolveBaseQrUrl(params.env, params.baseQrUrl); return new VerificationLinkService(base).buildInvoiceVerificationUrl(nip, issueDate, hash); diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts index e5bf8590..2f185874 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -1,19 +1,43 @@ +import { get, list } from '../../accessor.js'; import { applyFormat } from '../../format.js'; import type { PaymentBlock } from '../dsl.js'; import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; /** - * Payment details: a `payment` heading followed by one `label: value` line per - * {@link PaymentBlock.rows} entry (localized label + formatted scalar binding). - * Visibility (`when`) is resolved centrally by the interpreter, so this renderer - * always emits its content. + * Payment details: a `payment` heading, one `label: value` line per + * {@link PaymentBlock.rows} entry, then an optional repeating bank-account + * section ({@link PaymentBlock.accounts}) — one `label: value` line per field, + * for each account in the collection. + * + * A row/field whose value resolves empty is skipped, so absent optional fields + * (KSeF invoices carry many) don't print a dangling "Label:" with no value. + * Strict mode still surfaces dot-path typos: a *missing* binding throws before + * it can be skipped, so a self-consistency fixture that populates every path + * catches template mistakes. Visibility (`when`) is resolved centrally by the + * interpreter, so this renderer always emits its content. */ export const paymentRenderer: BlockRenderer = (block, ctx) => { const stack: PdfNode[] = [{ text: ctx.label('payment'), style: 'h2' }]; + for (const row of block.rows) { - stack.push({ - text: `${ctx.label(row.label)}: ${applyFormat(resolveBinding(row.path, ctx), row.format)}`, - }); + const value = applyFormat(resolveBinding(row.path, ctx), row.format); + if (value === '') continue; + stack.push({ text: `${ctx.label(row.label)}: ${value}` }); + } + + if (block.accounts) { + const lines: PdfNode[] = []; + for (const account of list(ctx.root, block.accounts.from)) { + for (const field of block.accounts.fields) { + const value = applyFormat(get(account, field.path, ctx.strict), field.format); + if (value === '') continue; + lines.push({ text: `${ctx.label(field.label)}: ${value}` }); + } + } + if (lines.length > 0) { + if (block.accounts.heading) stack.push({ text: ctx.label(block.accounts.heading), style: 'h2' }); + stack.push(...lines); + } } return { diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index d58373d7..534d98f7 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -71,9 +71,18 @@ "when": "Fa.Platnosc", "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono" }, - { "label": "paymentDate", "path": "Fa.Platnosc.DataZaplaty", "format": "date" }, - { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci" } - ] + { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date" }, + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm" } + ], + "accounts": { + "from": "Fa.Platnosc.RachunekBankowy", + "heading": "bankAccounts", + "fields": [ + { "label": "bankAccount", "path": "NrRB" }, + { "label": "swift", "path": "SWIFT" }, + { "label": "bankName", "path": "NazwaBanku" } + ] + } }, { "type": "qr", "when": "qr", "fit": 90 }, { "type": "footer", "text": "ksef-client-ts", "style": "footerNote" } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 99a93c0c..a8ee41da 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -71,9 +71,18 @@ "when": "Fa.Platnosc", "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono" }, - { "label": "paymentDate", "path": "Fa.Platnosc.DataZaplaty", "format": "date" }, - { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci" } - ] + { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date" }, + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm" } + ], + "accounts": { + "from": "Fa.Platnosc.RachunekBankowy", + "heading": "bankAccounts", + "fields": [ + { "label": "bankAccount", "path": "NrRB" }, + { "label": "swift", "path": "SWIFT" }, + { "label": "bankName", "path": "NazwaBanku" } + ] + } }, { "type": "qr", "when": "qr", "fit": 90 }, { "type": "footer", "text": "ksef-client-ts", "style": "footerNote" } diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index bbf7a87a..4ebeb4ad 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -72,10 +72,23 @@ export interface TotalsBlock { style?: string; } +/** + * A repeating group of bank-account fields under a payment block. `from` names + * the account collection (read as an always-array), `fields` are the per-account + * label:value lines, and `heading` is an optional i18n sub-heading printed once + * when at least one account resolves. + */ +export interface PaymentAccounts { + from: string; + heading?: string; + fields: FieldDef[]; +} + export interface PaymentBlock { type: 'payment'; when?: string; rows: FieldDef[]; + accounts?: PaymentAccounts; style?: string; } @@ -183,7 +196,7 @@ export interface InvoiceTemplate { // ── zod validation ───────────────────────────────────────────────────────── -const formatEnum = z.enum(['money', 'date', 'number', 'nip']); +const formatEnum = z.enum(['money', 'date', 'number', 'nip', 'paymentForm']); const styleValue = z.union([z.string(), z.number(), z.boolean(), z.array(z.number())]); const styleSchema = z.record(z.string(), styleValue); const labelRef = z.object({ label: z.string().optional(), text: z.string().optional() }).strict(); @@ -224,6 +237,14 @@ const blockSchema: z.ZodType = z.lazy(() => type: z.literal('payment'), when: z.string().optional(), rows: z.array(fieldDef), + accounts: z + .object({ + from: z.string(), + heading: z.string().optional(), + fields: z.array(fieldDef), + }) + .strict() + .optional(), style: z.string().optional(), }).strict(), z.object({ type: z.literal('annotations'), fields: z.array(fieldDef), style: z.string().optional() }).strict(), diff --git a/packages/ksef-client-ts/src/qr/verification-link-service.ts b/packages/ksef-client-ts/src/qr/verification-link-service.ts index 981da99a..eb4dfec2 100644 --- a/packages/ksef-client-ts/src/qr/verification-link-service.ts +++ b/packages/ksef-client-ts/src/qr/verification-link-service.ts @@ -13,6 +13,11 @@ export class VerificationLinkService { invoiceHashBase64: string, ): string { const date = typeof issueDate === 'string' ? new Date(issueDate) : issueDate; + if (Number.isNaN(date.getTime())) { + throw new Error( + `Invalid issueDate for verification URL: ${JSON.stringify(issueDate)} (expected a parseable date, e.g. "2026-06-08").`, + ); + } const dd = String(date.getUTCDate()).padStart(2, '0'); const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); const yyyy = date.getUTCFullYear(); diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml index 2e03abd2..e2baef69 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml @@ -68,8 +68,15 @@ 1 - 2025-01-15 + + 2025-02-01 + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml index b19d3cd1..ee99bfeb 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml @@ -68,8 +68,15 @@ 1 - 2025-01-15 + + 2025-02-01 + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index 444e4617..d37466b4 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -322,6 +322,108 @@ describe('paymentRenderer', () => { expect(node.stack[0].text).toBe('payment'); expect('style' in node).toBe(false); }); + + it('skips rows whose value resolves empty (absent optional field)', () => { + const ctx = makeCtx({ Fa: { FormaPlatnosci: '6' } }); + const node = rec( + paymentRenderer( + { + type: 'payment', + rows: [ + { label: 'paid', path: 'Fa.Zaplacono' }, // absent → skipped + { label: 'paymentMethod', path: 'Fa.FormaPlatnosci', format: 'paymentForm' }, + ], + }, + ctx, + noRender, + ), + ); + expect(node.stack).toHaveLength(2); // heading + only the present row + expect(node.stack[1].text).toBe('paymentMethod: Przelew'); + }); + + it('renders a bank-account repeater: heading + one label:value line per field, per account', () => { + const ctx = makeCtx({ + Fa: { + Platnosc: { + RachunekBankowy: [ + { NrRB: '11109000880000000100000001', SWIFT: 'WBKPPLPP', NazwaBanku: 'Bank A' }, + { NrRB: '22109000880000000100000002', NazwaBanku: 'Bank B' }, // no SWIFT → skipped + ], + }, + }, + }); + const node = rec( + paymentRenderer( + { + type: 'payment', + rows: [], + accounts: { + from: 'Fa.Platnosc.RachunekBankowy', + heading: 'bankAccounts', + fields: [ + { label: 'bankAccount', path: 'NrRB' }, + { label: 'swift', path: 'SWIFT' }, + { label: 'bankName', path: 'NazwaBanku' }, + ], + }, + }, + ctx, + noRender, + ), + ); + // heading(payment) + heading(bankAccounts) + [acc1: 3 lines] + [acc2: 2 lines] + expect(node.stack).toHaveLength(7); + expect(node.stack[1].text).toBe('bankAccounts'); + expect(node.stack[1].style).toBe('h2'); + expect(node.stack[2].text).toBe('bankAccount: 11109000880000000100000001'); + expect(node.stack[3].text).toBe('swift: WBKPPLPP'); + expect(node.stack[4].text).toBe('bankName: Bank A'); + // second account skipped its empty SWIFT + expect(node.stack[5].text).toBe('bankAccount: 22109000880000000100000002'); + expect(node.stack[6].text).toBe('bankName: Bank B'); + }); + + it('omits the accounts section entirely when the collection is absent', () => { + const node = rec( + paymentRenderer( + { + type: 'payment', + rows: [{ label: 'paymentMethod', path: 'Fa.Platnosc.FormaPlatnosci', format: 'paymentForm' }], + accounts: { + from: 'Fa.Platnosc.RachunekBankowy', + heading: 'bankAccounts', + fields: [{ label: 'bankAccount', path: 'NrRB' }], + }, + }, + makeCtx({ Fa: { Platnosc: { FormaPlatnosci: '6' } } }), + noRender, + ), + ); + expect(node.stack).toHaveLength(2); // heading + the one payment row, no bank heading + expect(node.stack[1].text).toBe('paymentMethod: Przelew'); + }); + + it('throws in strict mode on a missing bank-account field binding', () => { + const ctx = makeCtx( + { Fa: { Platnosc: { RachunekBankowy: { NrRB: '111' } } } }, + { strict: true }, + ); + expect(() => + paymentRenderer( + { + type: 'payment', + rows: [], + accounts: { + from: 'Fa.Platnosc.RachunekBankowy', + fields: [{ label: 'swift', path: 'SWIFT' }], // absent in the account → strict throws + }, + }, + ctx, + noRender, + ), + ).toThrow(/Missing binding/); + }); }); // ── annotations ───────────────────────────────────────────────────────────── diff --git a/packages/ksef-client-ts/tests/unit/pdf/format.test.ts b/packages/ksef-client-ts/tests/unit/pdf/format.test.ts index 1c8f7e3c..c83b3824 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/format.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/format.test.ts @@ -4,6 +4,7 @@ import { formatNumber, formatDate, formatNip, + formatPaymentForm, applyFormat, } from '../../../src/pdf/format.js'; @@ -89,6 +90,23 @@ describe('formatNip', () => { }); }); +describe('formatPaymentForm', () => { + it('decodes each known FormaPlatnosci code to its Polish label', () => { + expect(formatPaymentForm('1')).toBe('Gotówka'); + expect(formatPaymentForm('6')).toBe('Przelew'); + expect(formatPaymentForm('7')).toBe('Mobilna'); + }); + + it('tolerates surrounding whitespace', () => { + expect(formatPaymentForm(' 6 ')).toBe('Przelew'); + }); + + it('passes an unknown code through unchanged', () => { + expect(formatPaymentForm('99')).toBe('99'); + expect(formatPaymentForm('')).toBe(''); + }); +}); + describe('applyFormat', () => { it('returns the value unchanged when no formatter is named', () => { expect(applyFormat('1234.5', undefined)).toBe('1234.5'); @@ -114,4 +132,8 @@ describe('applyFormat', () => { it('routes to the nip formatter', () => { expect(applyFormat('5213003700', 'nip')).toBe('521-300-37-00'); }); + + it('routes to the paymentForm formatter', () => { + expect(applyFormat('6', 'paymentForm')).toBe('Przelew'); + }); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts index de89564b..8cd9753e 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts @@ -11,8 +11,11 @@ import { Environment } from '../../../src/config/environments.js'; import type { RenderContext } from '../../../src/pdf/template/interpret.js'; import type { QrBlock } from '../../../src/pdf/template/dsl.js'; -/** Minimal parsed body: seller NIP + issue date, same shape as ctx.root. */ -const body = { Podmiot1: { DaneIdentyfikacyjne: { NIP: '5213003700' } }, P_1: '2025-01-15' }; +/** + * Minimal parsed body: seller NIP at the document root, issue date under `Fa` + * (P_1 lives at Faktura/Fa/P_1, NOT the root) — the real parsed-Faktura shape. + */ +const body = { Podmiot1: { DaneIdentyfikacyjne: { NIP: '5213003700' } }, Fa: { P_1: '2025-01-15' } }; // A clean UTF-8 invoice payload with LF newlines and no BOM. const CLEAN = '\nFV/1\n'; @@ -109,6 +112,17 @@ describe('deriveInvoiceQrUrl', () => { it('passes strict through to the accessor (throws on a missing binding)', () => { expect(() => deriveInvoiceQrUrl({ rawInput: CLEAN, body: {}, strict: true })).toThrow(); }); + + it('embeds a real DD-MM-YYYY issue date read from Fa/P_1 (regression: not NaN-NaN-NaN)', () => { + const url = deriveInvoiceQrUrl({ rawInput: CLEAN, body, env: 'prod' }); + expect(url).toContain('/invoice/5213003700/15-01-2025/'); + expect(url).not.toContain('NaN'); + }); + + it('throws a clear error when the issue date is absent (never emits a NaN date)', () => { + const noDate = { Podmiot1: { DaneIdentyfikacyjne: { NIP: '5213003700' } } }; + expect(() => deriveInvoiceQrUrl({ rawInput: CLEAN, body: noDate })).toThrow(/issue date/i); + }); }); describe('qrRenderer', () => { diff --git a/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts b/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts index b1382dd1..44081d54 100644 --- a/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts +++ b/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts @@ -31,6 +31,11 @@ describe('VerificationLinkService', () => { expect(url).toMatch(/\/05-12-2024\//); }); + it('throws on an unparseable date instead of emitting a NaN-NaN-NaN segment', () => { + expect(() => service.buildInvoiceVerificationUrl(nip, '', hash)).toThrow(/Invalid issueDate/); + expect(() => service.buildInvoiceVerificationUrl(nip, 'not-a-date', hash)).toThrow(/Invalid issueDate/); + }); + it('should use Base64URL encoding without padding', () => { const url = service.buildInvoiceVerificationUrl(nip, '2024-01-01T00:00:00Z', hash); From 58d1b91f4f889fb296c70c743d1ef11fc25a858b Mon Sep 17 00:00:00 2001 From: Wayland Date: Mon, 17 Aug 2026 08:22:52 +0200 Subject: [PATCH 04/67] ci(release): run package-export guards on the release path The release workflow triggers on tag push and does not depend on ci.yml, so a tag pointing at a commit that never went through PR CI could mint a published artifact without the guards that protect the public exports map: the ./pdf type check, the cold-subpath probe that proves ./pdf does not eagerly load pdfmake, and the attw/publint export checks. Extend the "Validate build" step in both publish-npm and publish-ghpkg to run the same guard set as ci.yml, keeping build first so the checks read the built dist. Both jobs run byte-identical command lists. Co-Authored-By: Wayland Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 99d887964a3acbd5e0e5272c578d68f9e88b3eb6) --- .github/workflows/release.yml | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b84ffe9..3dd6038f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,9 +95,18 @@ jobs: run: yarn install --immutable - name: Validate build - # Build first: the fs-free guard test and the /node type check both - # read the built dist, so the bundle must exist before lint/test run. - run: yarn build && yarn lint && yarn check:node-types && yarn test + # Build first: every guard below reads the built dist. Keep this list in + # sync with the guards in ci.yml: a tag can point at a commit that never + # went through PR CI, so this is the only gate the published artifact hits. + run: | + yarn build + yarn lint + yarn check:node-types + yarn check:pdf-types + node packages/ksef-client-ts/scripts/check-pdf-cold.mjs + yarn check:attw + yarn check:publint + yarn test - name: Publish package working-directory: packages/ksef-client-ts @@ -141,9 +150,18 @@ jobs: run: yarn install --immutable - name: Validate build - # Build first: the fs-free guard test and the /node type check both - # read the built dist, so the bundle must exist before lint/test run. - run: yarn build && yarn lint && yarn check:node-types && yarn test + # Build first: every guard below reads the built dist. Keep this list in + # sync with the guards in ci.yml: a tag can point at a commit that never + # went through PR CI, so this is the only gate the published artifact hits. + run: | + yarn build + yarn lint + yarn check:node-types + yarn check:pdf-types + node packages/ksef-client-ts/scripts/check-pdf-cold.mjs + yarn check:attw + yarn check:publint + yarn test - name: Prepare scoped package name working-directory: packages/ksef-client-ts From c02eeb4385c9e66732b50fccba2f7f47fe90f76d Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 14:47:49 +0200 Subject: [PATCH 05/67] fix(cli): register `invoice pdf` in the shell completion tree The PDF subcommand was written before main gained the completion-tree test that asserts COMMAND_TREE matches the subcommands each group actually registers, so rebasing the PDF work onto current main exposed the gap: `invoice pdf` was reachable but never suggested by completions. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/src/cli/commands/completion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ksef-client-ts/src/cli/commands/completion.ts b/packages/ksef-client-ts/src/cli/commands/completion.ts index c71e6103..2b90fd82 100644 --- a/packages/ksef-client-ts/src/cli/commands/completion.ts +++ b/packages/ksef-client-ts/src/cli/commands/completion.ts @@ -7,7 +7,7 @@ export const COMMAND_TREE: Record = { config: ['set', 'show', 'reset'], auth: ['challenge', 'login', 'login-external', 'status', 'logout', 'refresh', 'whoami', 'revoke-self-token'], session: ['open', 'close', 'status', 'active', 'revoke', 'list', 'invoices', 'invoice', 'failed', 'upo'], - invoice: ['send', 'get', 'query', 'build', 'validate', 'export', 'export-status', 'export-incremental'], + invoice: ['send', 'get', 'query', 'build', 'pdf', 'validate', 'export', 'export-status', 'export-incremental'], permission: ['grant', 'revoke', 'search', 'status', 'attachment-status'], token: ['generate', 'list', 'get', 'revoke'], cert: ['generate', 'enrollment-data', 'enroll', 'status', 'retrieve', 'list', 'revoke', 'limits'], From b7f1235761fd61370536bb77a2fb817be6befd9b Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 15:12:52 +0200 Subject: [PATCH 06/67] fix(pdf): total net and VAT rows only counted the standard-rate bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `P_13_1` and `P_14_1` are the 23%/22% bucket alone, not the invoice total. Both default templates bound "Total net" and "Total VAT" to those single fields, so any invoice whose sales fall outside the standard rate printed blank or partial figures next to a correct "Amount due" — a visualization that misstates the invoice. A KSeF invoice carries no single net or VAT total, so a totals row now takes either one `path` or a `sum` of several, added in minor units so the decimals stay exact. The defaults aggregate the XSD bucket set: net over P_13_1..5 and P_13_7..11 (there is no P_13_6), VAT over P_14_1..5. The `P_14_*W` fields are excluded — they restate the same tax in PLN for foreign-currency invoices and would double-count. Sum paths are read non-strictly because a real invoice fills only the buckets that apply. Reproduced before the fix on an 8%-only FA(3): "Total net" and "Total VAT" rendered empty beside "Amount due: 540,00". After it they read 500,00 and 40,00; a mixed 23%/8%/exempt invoice adds up to 750,00 and 131,00. Full unit suite: 2477 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/docs/pdf-export.md | 8 +- packages/ksef-client-ts/src/pdf/format.ts | 35 +++++ .../src/pdf/template/blocks/totals.ts | 26 +++- .../src/pdf/template/builtin/fa2-default.json | 21 ++- .../src/pdf/template/builtin/fa3-default.json | 21 ++- .../ksef-client-ts/src/pdf/template/dsl.ts | 32 ++++- .../tests/unit/pdf/totals-sum.test.ts | 136 ++++++++++++++++++ 7 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 1d8493ca..6bbd8141 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -164,7 +164,7 @@ The `schema` field binds a template to a single document kind. If you render an | `header` | Title, invoice number, date, optional logo | | `parties` | Seller / buyer two-column panel | | `lines` | Invoice line-item table | -| `totals` | Net / VAT / gross summary rows | +| `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | | `payment` | Payment details (amount paid, date, method) | | `annotations` | Miscellaneous labelled fields | | `qr` | The verification QR image | @@ -178,6 +178,7 @@ The `schema` field binds a template to a single document kind. If you render an - **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. - **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. +- **`sum`** (totals rows only) adds several binding paths instead of reading one. A KSeF invoice has no single net or VAT total — the amounts are split across the `P_13_*` and `P_14_*` rate buckets — so the built-in templates aggregate them. Absent buckets are skipped, and the addition is decimal-exact. A totals row takes either `path` or `sum`, never both. ### Minimal example @@ -210,7 +211,10 @@ A trimmed `FA(3)` template with a header, a seller/buyer panel, a line table, a }, { "type": "totals", - "rows": [{ "label": "totalDue", "path": "Fa.P_15", "format": "money" }] + "rows": [ + { "label": "totalNet", "sum": ["Fa.P_13_1", "Fa.P_13_2", "Fa.P_13_7"], "format": "money" }, + { "label": "totalDue", "path": "Fa.P_15", "format": "money" } + ] }, { "type": "qr", "when": "qr", "fit": 90 }, { "type": "footer", "text": "ksef-client-ts", "style": "muted" } diff --git a/packages/ksef-client-ts/src/pdf/format.ts b/packages/ksef-client-ts/src/pdf/format.ts index a69b34df..38f35eb3 100644 --- a/packages/ksef-client-ts/src/pdf/format.ts +++ b/packages/ksef-client-ts/src/pdf/format.ts @@ -65,6 +65,41 @@ export function formatPaymentForm(raw: string): string { return PAYMENT_FORMS[raw.trim()] ?? raw; } +/** + * Decimal-safe sum of monetary strings, used by totals rows that aggregate + * several VAT buckets (a KSeF invoice has no single "total net" field). + * + * Values are summed in minor units so 0.1 + 0.2 stays 0.30, and the result keeps + * the widest scale seen among the inputs. Blank/whitespace entries are treated + * as an absent bucket and skipped; if nothing parseable remains the result is + * `''`, so a document that carries no totals at all renders blank rather than a + * fabricated `0,00`. A non-empty value that is not a decimal makes the whole sum + * unrepresentable and also yields `''` — a blank cell is safer than a wrong one. + */ +export function sumDecimal(values: string[]): string { + const present = values.filter((v) => v.trim() !== ''); + if (present.length === 0) return ''; + + const parsed: Array<{ sign: bigint; int: string; frac: string }> = []; + for (const value of present) { + const m = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(value.trim()); + if (!m) return ''; + parsed.push({ sign: m[1] === '-' ? -1n : 1n, int: m[2] ?? '0', frac: m[3] ?? '' }); + } + + const scale = parsed.reduce((max, p) => Math.max(max, p.frac.length), 0); + const total = parsed.reduce( + (acc, p) => acc + p.sign * BigInt(p.int + p.frac.padEnd(scale, '0')), + 0n, + ); + + const sign = total < 0n ? '-' : ''; + const digits = (total < 0n ? -total : total).toString().padStart(scale + 1, '0'); + const intPart = digits.slice(0, digits.length - scale); + const fracPart = digits.slice(digits.length - scale); + return scale === 0 ? `${sign}${intPart}` : `${sign}${intPart}.${fracPart}`; +} + const FORMATTERS: Record string> = { money: formatMoney, date: formatDate, diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts index 8cc14eb9..8658b6ab 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts @@ -1,18 +1,30 @@ -import { applyFormat } from '../../format.js'; +import { applyFormat, sumDecimal } from '../../format.js'; import type { TotalsBlock } from '../dsl.js'; import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; /** * Totals summary: a compact, right-aligned label/value table. Each row of * {@link TotalsBlock.rows} contributes a bold label (`ctx.label(row.label)`) - * and its formatted scalar binding. The borderless table is pushed to the right - * edge by an elastic spacer column. + * and its formatted value — either one scalar binding (`path`) or the decimal + * sum of several (`sum`), because net sales and tax are split across the + * `P_13_*`/`P_14_*` rate buckets and no single total field exists. The + * borderless table is pushed to the right edge by an elastic spacer column. */ export const totalsRenderer: BlockRenderer = (block, ctx) => { - const body: PdfNode[][] = block.rows.map((row) => [ - { text: ctx.label(row.label), bold: true }, - { text: applyFormat(resolveBinding(row.path, ctx), row.format), alignment: 'right' }, - ]); + // A `sum` lists every bucket the schema allows and a real invoice fills only + // the one or two that apply, so its paths are read non-strictly: an absent + // bucket is the normal case here, not the dot-path typo `strict` hunts for. + const lenient = { ...ctx, strict: false }; + + const body: PdfNode[][] = block.rows.map((row) => { + const raw = row.sum + ? sumDecimal(row.sum.map((p) => resolveBinding(p, lenient))) + : resolveBinding(row.path ?? '', ctx); + return [ + { text: ctx.label(row.label), bold: true }, + { text: applyFormat(raw, row.format), alignment: 'right' }, + ]; + }); return { columns: [ diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 534d98f7..f0ddaafa 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -60,8 +60,25 @@ { "type": "totals", "rows": [ - { "label": "totalNet", "path": "Fa.P_13_1", "format": "money" }, - { "label": "totalVat", "path": "Fa.P_14_1", "format": "money" }, + { "label": "totalNet", "sum": [ + "Fa.P_13_1", + "Fa.P_13_2", + "Fa.P_13_3", + "Fa.P_13_4", + "Fa.P_13_5", + "Fa.P_13_7", + "Fa.P_13_8", + "Fa.P_13_9", + "Fa.P_13_10", + "Fa.P_13_11" + ], "format": "money" }, + { "label": "totalVat", "sum": [ + "Fa.P_14_1", + "Fa.P_14_2", + "Fa.P_14_3", + "Fa.P_14_4", + "Fa.P_14_5" + ], "format": "money" }, { "label": "totalDue", "path": "Fa.P_15", "format": "money" } ] }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index a8ee41da..e2959b50 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -60,8 +60,25 @@ { "type": "totals", "rows": [ - { "label": "totalNet", "path": "Fa.P_13_1", "format": "money" }, - { "label": "totalVat", "path": "Fa.P_14_1", "format": "money" }, + { "label": "totalNet", "sum": [ + "Fa.P_13_1", + "Fa.P_13_2", + "Fa.P_13_3", + "Fa.P_13_4", + "Fa.P_13_5", + "Fa.P_13_7", + "Fa.P_13_8", + "Fa.P_13_9", + "Fa.P_13_10", + "Fa.P_13_11" + ], "format": "money" }, + { "label": "totalVat", "sum": [ + "Fa.P_14_1", + "Fa.P_14_2", + "Fa.P_14_3", + "Fa.P_14_4", + "Fa.P_14_5" + ], "format": "money" }, { "label": "totalDue", "path": "Fa.P_15", "format": "money" } ] }, diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 4ebeb4ad..0fa45446 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -66,9 +66,24 @@ export interface LinesBlock { style?: string; } +/** + * One totals line. A KSeF invoice has no single "total net"/"total VAT" field — + * net sales are split across `P_13_*` rate buckets and the tax across `P_14_*` + * — so a row reads either one path or the decimal sum of several. Exactly one + * of `path`/`sum` must be given. + */ +export interface TotalsRow { + label: string; + path?: string; + /** Binding paths to add up; absent buckets are skipped. */ + sum?: string[]; + format?: FormatterName; + style?: string; +} + export interface TotalsBlock { type: 'totals'; - rows: FieldDef[]; + rows: TotalsRow[]; style?: string; } @@ -209,6 +224,19 @@ const fieldDef = z }) .strict(); +const totalsRow = z + .object({ + label: z.string(), + path: z.string().optional(), + sum: z.array(z.string()).nonempty().optional(), + format: formatEnum.optional(), + style: z.string().optional(), + }) + .strict() + .refine((r) => (r.path === undefined) !== (r.sum === undefined), { + message: 'a totals row needs exactly one of "path" or "sum"', + }); + // Recursive block schema (containers embed blocks). z.lazy breaks the cycle. const blockSchema: z.ZodType = z.lazy(() => z.discriminatedUnion('type', [ @@ -232,7 +260,7 @@ const blockSchema: z.ZodType = z.lazy(() => columns: z.array(fieldDef), style: z.string().optional(), }).strict(), - z.object({ type: z.literal('totals'), rows: z.array(fieldDef), style: z.string().optional() }).strict(), + z.object({ type: z.literal('totals'), rows: z.array(totalsRow), style: z.string().optional() }).strict(), z.object({ type: z.literal('payment'), when: z.string().optional(), diff --git a/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts b/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts new file mode 100644 index 00000000..a91b49c9 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts @@ -0,0 +1,136 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { sumDecimal } from '../../../src/pdf/format.js'; +import { validateTemplate, type TotalsBlock } from '../../../src/pdf/template/dsl.js'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { interpretTemplate } from '../../../src/pdf/template/interpret.js'; +import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; +import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; + +const fa3 = readFileSync(new URL('../../fixtures/pdf/fa3.xml', import.meta.url), 'utf8'); + +/** + * The net/VAT buckets a KSeF invoice can carry, from the FA(2)/FA(3) XSD (both + * schemas declare the same set; there is no `P_13_6`). The `P_14_*W` fields are + * the same tax restated in PLN for foreign-currency invoices, so they are + * deliberately excluded — adding them would double-count. + */ +const NET_BUCKETS = [ + 'Fa.P_13_1', 'Fa.P_13_2', 'Fa.P_13_3', 'Fa.P_13_4', 'Fa.P_13_5', + 'Fa.P_13_7', 'Fa.P_13_8', 'Fa.P_13_9', 'Fa.P_13_10', 'Fa.P_13_11', +]; +const VAT_BUCKETS = ['Fa.P_14_1', 'Fa.P_14_2', 'Fa.P_14_3', 'Fa.P_14_4', 'Fa.P_14_5']; + +function totalsBody(xml: string, templateName: string) { + const template = getBuiltinTemplate(templateName)!; + const parsed = parseXmlForPdf(xml); + const ctx = { + root: (parsed as Record).Faktura, + strict: false, + label: makeLabelResolver('en', {}), + bindings: {}, + flags: {}, + }; + const doc = interpretTemplate(template, ctx, blockRegistry); + const totals = (doc.content as Array>).find( + (n) => Array.isArray(n.columns) && JSON.stringify(n).includes('table'), + )!; + const cols = totals.columns as Array>; + return (cols[1] as unknown as { table: { body: Array> } }).table.body; +} + +describe('sumDecimal', () => { + it('adds monetary strings without floating-point drift', () => { + expect(sumDecimal(['0.1', '0.2'])).toBe('0.3'); + expect(sumDecimal(['500.00', '40.00'])).toBe('540.00'); + }); + + it('keeps the widest scale among its inputs', () => { + expect(sumDecimal(['1.5', '2.25'])).toBe('3.75'); + expect(sumDecimal(['10', '5'])).toBe('15'); + }); + + it('handles negative amounts (credit notes)', () => { + expect(sumDecimal(['100.00', '-40.00'])).toBe('60.00'); + expect(sumDecimal(['-100.00', '-40.00'])).toBe('-140.00'); + }); + + it('skips absent buckets rather than counting them as zero', () => { + expect(sumDecimal(['', ' ', '12.34'])).toBe('12.34'); + }); + + it('returns blank when nothing is present, so an empty document stays blank', () => { + expect(sumDecimal([])).toBe(''); + expect(sumDecimal(['', ' '])).toBe(''); + }); + + it('returns blank rather than a wrong figure on unparseable input', () => { + expect(sumDecimal(['12.00', 'not-a-number'])).toBe(''); + }); +}); + +describe('built-in totals aggregate every VAT bucket', () => { + it.each(['fa2-default', 'fa3-default'])('%s sums the XSD bucket set', (name) => { + const template = getBuiltinTemplate(name)!; + const totals = template.blocks.find((b) => b.type === 'totals') as TotalsBlock; + const byLabel = Object.fromEntries(totals.rows.map((r) => [r.label, r])); + expect(byLabel.totalNet?.sum).toEqual(NET_BUCKETS); + expect(byLabel.totalVat?.sum).toEqual(VAT_BUCKETS); + expect(byLabel.totalDue?.path).toBe('Fa.P_15'); + }); + + it('prints real totals for a reduced-rate-only invoice', () => { + const reduced = fa3 + .replace('500.00', '500.00') + .replace('115.00', '40.00') + .replace('615.00', '540.00'); + const body = totalsBody(reduced, 'fa3-default'); + expect(body[0][1].text).toBe('500,00'); + expect(body[1][1].text).toBe('40,00'); + expect(body[2][1].text).toBe('540,00'); + }); + + it('adds the buckets of a mixed-rate invoice', () => { + const mixed = fa3 + .replace('500.00', '500.00200.0050.00') + .replace('115.00', '115.0016.00') + .replace('615.00', '881.00'); + const body = totalsBody(mixed, 'fa3-default'); + expect(body[0][1].text).toBe('750,00'); // 500 + 200 + 50 + expect(body[1][1].text).toBe('131,00'); // 115 + 16 + expect(body[2][1].text).toBe('881,00'); + }); + + it('still renders the standard-rate-only fixture unchanged', () => { + const body = totalsBody(fa3, 'fa3-default'); + expect(body[0][1].text).toBe('500,00'); + expect(body[1][1].text).toBe('115,00'); + expect(body[2][1].text).toBe('615,00'); + }); +}); + +describe('totals row validation', () => { + const wrap = (row: unknown) => ({ + schema: 'FA(3)', + blocks: [{ type: 'totals', rows: [row] }], + }); + + it('accepts a single-path row', () => { + expect(() => validateTemplate(wrap({ label: 'totalDue', path: 'Fa.P_15' }))).not.toThrow(); + }); + + it('accepts a summed row', () => { + expect(() => validateTemplate(wrap({ label: 'totalNet', sum: ['Fa.P_13_1'] }))).not.toThrow(); + }); + + it('rejects a row with both path and sum', () => { + expect(() => + validateTemplate(wrap({ label: 'totalNet', path: 'Fa.P_13_1', sum: ['Fa.P_13_2'] })), + ).toThrow(/exactly one/); + }); + + it('rejects a row with neither path nor sum', () => { + expect(() => validateTemplate(wrap({ label: 'totalNet' }))).toThrow(/exactly one/); + }); +}); From a34e86996e531ebdb85fc5bbf156bb8ccac032fb Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 15:15:16 +0200 Subject: [PATCH 07/67] fix(pdf): session UPO receipts dropped every document after the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session UPO confirms a whole batch and carries one `` per accepted invoice — our own UPO parser has modelled that as an array all along. Both UPO templates, however, bound scalar paths like `Dokument.NumerFaktury`, and `getNode` follows the first element when a path crosses an array. A receipt for twenty invoices therefore rendered as a receipt for one, with nothing to indicate the rest had been dropped. The per-document fields now render through a `lines` repeater over `Dokument`, one table row per invoice, under a "Documents" heading. The session reference stays a scalar field above the table. Reproduced before the fix on a two-document receipt: the second invoice number was absent from the rendered tree. After it, a three-document receipt renders all three and a four-document one emits five table rows (header plus four). Full unit suite: 2484 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/src/pdf/i18n/en.ts | 1 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 1 + .../src/pdf/template/builtin/upo-4_2.json | 40 ++++----- .../src/pdf/template/builtin/upo-4_3.json | 40 ++++----- .../tests/unit/pdf/upo-multi-document.test.ts | 81 +++++++++++++++++++ 5 files changed, 111 insertions(+), 52 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 50701aad..82f1b765 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -36,6 +36,7 @@ export const en: LabelBundle = { sessionRef: 'Session reference number', receiptDate: 'KSeF number assignment date', documentHash: 'Document hash', + documents: 'Documents', page: 'Page', of: 'of', }; diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index f8c3ba21..5ed5dbd5 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -41,6 +41,7 @@ export const pl: LabelBundle = { sessionRef: 'Numer referencyjny sesji', receiptDate: 'Data nadania numeru KSeF', documentHash: 'Skrót dokumentu', + documents: 'Dokumenty', // footer page: 'Strona', of: 'z', diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json index a559468f..0a5c00ae 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json @@ -10,33 +10,21 @@ { "type": "header", "title": { "label": "upoTitle" } }, { "type": "divider" }, { "type": "spacer", "height": 10 }, + { "type": "columns", "columns": [ + { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, + { "type": "text", "path": "NumerReferencyjnySesji" } + ] }, + { "type": "spacer", "height": 10 }, + { "type": "text", "label": "documents", "style": "fieldLabel" }, { - "type": "stack", - "stack": [ - { "type": "columns", "columns": [ - { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, - { "type": "text", "path": "NumerReferencyjnySesji" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "ksefDocNumber", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.NumerKSeFDokumentu" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "invoiceNumber", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.NumerFaktury" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "issueDate", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.DataWystawieniaFaktury", "format": "date" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "receiptDate", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.DataNadaniaNumeruKSeF", "format": "date" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "documentHash", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.SkrotDokumentu", "style": "muted" } - ] } + "type": "lines", + "from": "Dokument", + "columns": [ + { "label": "ksefDocNumber", "path": "NumerKSeFDokumentu" }, + { "label": "invoiceNumber", "path": "NumerFaktury" }, + { "label": "issueDate", "path": "DataWystawieniaFaktury", "format": "date" }, + { "label": "receiptDate", "path": "DataNadaniaNumeruKSeF", "format": "date" }, + { "label": "documentHash", "path": "SkrotDokumentu" } ] } ] diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json index f62a1972..89f087d0 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json @@ -10,33 +10,21 @@ { "type": "header", "title": { "label": "upoTitle" } }, { "type": "divider" }, { "type": "spacer", "height": 10 }, + { "type": "columns", "columns": [ + { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, + { "type": "text", "path": "NumerReferencyjnySesji" } + ] }, + { "type": "spacer", "height": 10 }, + { "type": "text", "label": "documents", "style": "fieldLabel" }, { - "type": "stack", - "stack": [ - { "type": "columns", "columns": [ - { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, - { "type": "text", "path": "NumerReferencyjnySesji" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "ksefDocNumber", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.NumerKSeFDokumentu" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "invoiceNumber", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.NumerFaktury" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "issueDate", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.DataWystawieniaFaktury", "format": "date" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "receiptDate", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.DataNadaniaNumeruKSeF", "format": "date" } - ] }, - { "type": "columns", "columns": [ - { "type": "text", "label": "documentHash", "style": "fieldLabel" }, - { "type": "text", "path": "Dokument.SkrotDokumentu", "style": "muted" } - ] } + "type": "lines", + "from": "Dokument", + "columns": [ + { "label": "ksefDocNumber", "path": "NumerKSeFDokumentu" }, + { "label": "invoiceNumber", "path": "NumerFaktury" }, + { "label": "issueDate", "path": "DataWystawieniaFaktury", "format": "date" }, + { "label": "receiptDate", "path": "DataNadaniaNumeruKSeF", "format": "date" }, + { "label": "documentHash", "path": "SkrotDokumentu" } ] } ] diff --git a/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts b/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts new file mode 100644 index 00000000..c97e2913 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts @@ -0,0 +1,81 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { interpretTemplate } from '../../../src/pdf/template/interpret.js'; +import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; +import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; +import { renderUpoPdf } from '../../../src/pdf/index.js'; + +const fx = (p: string) => readFileSync(new URL(`../../fixtures/${p}`, import.meta.url), 'utf8'); + +/** Clone the fixture's single `` into an n-document session UPO. */ +function withDocuments(xml: string, count: number): string { + const start = xml.indexOf(''); + const end = xml.indexOf('') + ''.length; + const first = xml.slice(start, end); + const clones = Array.from({ length: count - 1 }, (_, i) => + first + .replace('010000000000-00', `${String(i + 2).padStart(2, '0')}0000000000-00`) + .replace('FA/2025/01/001', `FA/2025/01/00${i + 2}`), + ); + return xml.slice(0, end) + '\n' + clones.join('\n') + xml.slice(end); +} + +function renderTree(xml: string, templateName: string): string { + const template = getBuiltinTemplate(templateName)!; + const parsed = parseXmlForPdf(xml); + const ctx = { + root: (parsed as Record).Potwierdzenie, + strict: false, + label: makeLabelResolver('en', {}), + bindings: {}, + flags: {}, + }; + return JSON.stringify(interpretTemplate(template, ctx, blockRegistry)); +} + +describe.each([ + ['upo-4_3', 'pdf/upo-4_3.xml'], + ['upo-4_2', 'pdf/upo-4_2.xml'], +])('%s renders every document in a session UPO', (templateName, fixture) => { + const single = fx(fixture); + + it('keeps rendering a single-document receipt', () => { + const tree = renderTree(single, templateName); + const sessionRef = /([^<]+) { + const tree = renderTree(withDocuments(single, 3), templateName); + expect(tree).toContain('FA/2025/01/001'); + expect(tree).toContain('FA/2025/01/002'); + expect(tree).toContain('FA/2025/01/003'); + }); + + it('emits one table row per document plus the header', () => { + const template = getBuiltinTemplate(templateName)!; + const parsed = parseXmlForPdf(withDocuments(single, 4)); + const ctx = { + root: (parsed as Record).Potwierdzenie, + strict: false, + label: makeLabelResolver('en', {}), + bindings: {}, + flags: {}, + }; + const doc = interpretTemplate(template, ctx, blockRegistry); + const table = (doc.content as Array>).find((n) => 'table' in n) as { + table: { body: unknown[] }; + }; + expect(table.table.body).toHaveLength(5); // 1 header + 4 documents + }); +}); + +describe('multi-document UPO end to end', () => { + it('produces a valid PDF', async () => { + const bytes = await renderUpoPdf(withDocuments(fx('pdf/upo-4_3.xml'), 3)); + expect(Buffer.from(bytes.subarray(0, 5)).toString('latin1')).toBe('%PDF-'); + }); +}); From 517940478a9b1a352eb4f2da3817b5b7bd45ce67 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 15:16:16 +0200 Subject: [PATCH 08/67] fix(pdf): an unrecognized document passed the template schema check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version guard only rejected a document it could positively identify as the wrong version. When the detector returned null it fell through and rendered anyway, binding every path against a root that does not exist — so a UPO, an FA(1), or arbitrary XML handed to an invoice template came back as a plausible, near-empty PDF instead of an error. Null is now a rejection. The detectors key off the root element plus a version marker the KSeF schemas make mandatory, so a null means "not this kind of document", not "cannot tell". The message distinguishes the two cases: an unrecognized input points at the input, a detected mismatch still names the version to use instead. Reproduced before the fix: rendering the UPO(4.3) fixture with `fa3-default` returned a 16547-byte PDF. It now throws, as do an invoice fed to a UPO template and arbitrary XML, while a detectable FA(2)/FA(3) mismatch keeps its original message. Full unit suite: 2489 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/src/pdf/index.ts | 19 ++++++++++++--- .../tests/unit/pdf/render-builtins.test.ts | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index b7a54c26..1bc83c1f 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -97,16 +97,29 @@ function buildContext( return { root, strict: opts.strict ?? false, label, bindings, flags }; } +/** + * A template renders one document kind, so the input must be recognized *as* + * that kind. `null` is a rejection, not a pass: the detectors read the root + * element plus a version marker that KSeF's schemas make mandatory, so a `null` + * means the input is not the FA/UPO version this template targets — a UPO fed + * to an invoice template, an FA(1), or arbitrary XML. Letting it through would + * bind every path against the wrong root and yield a plausible but blank PDF. + */ function assertVersionMatch(xml: string, schema: TemplateSchemaId): void { const detected: InvoiceVersion | UpoVersion | null = schema.startsWith('UPO') ? detectUpoVersion(xml) : detectInvoiceVersion(xml); - if (detected !== null && detected !== schema) { + if (detected === schema) return; + if (detected === null) { throw new KSeFPdfError( - `Template targets ${schema}, but the document was detected as ${detected}. ` + - `Use a ${detected} template (or the matching built-in).`, + `Template targets ${schema}, but the document was not recognized as a ${schema} document. ` + + `Check that the input is the right kind of XML and carries its version marker.`, ); } + throw new KSeFPdfError( + `Template targets ${schema}, but the document was detected as ${detected}. ` + + `Use a ${detected} template (or the matching built-in).`, + ); } async function renderWithTemplate( diff --git a/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts index 93e8d71a..95752852 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts @@ -57,3 +57,27 @@ describe('QR embedding', () => { expect(isPdf(bytes)).toBe(true); }); }); + +describe('a template rejects a document it does not target', () => { + it('rejects a UPO fed to an invoice template', async () => { + await expect(renderInvoicePdf(upo43, 'fa3-default')).rejects.toThrow(/not recognized as a FA\(3\)/); + }); + + it('rejects an invoice fed to a UPO template', async () => { + await expect(renderInvoicePdf(fa3, 'upo-4_3')).rejects.toThrow(/not recognized as a UPO\(4\.3\)/); + }); + + it('rejects arbitrary XML', async () => { + await expect(renderInvoicePdf('1', 'fa3-default')).rejects.toThrow( + /not recognized as a FA\(3\)/, + ); + }); + + it('still reports a concrete mismatch when the version is detectable', async () => { + await expect(renderInvoicePdf(fa2, 'fa3-default')).rejects.toThrow(/detected as FA\(2\)/); + }); + + it('rejects a UPO(4.2) document fed to the UPO(4.3) template', async () => { + await expect(renderInvoicePdf(upo42, 'upo-4_3')).rejects.toThrow(/detected as UPO\(4\.2\)/); + }); +}); From e5e6ba025f459f4a152f3a99758d3d0f8230b51a Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 15:17:57 +0200 Subject: [PATCH 09/67] fix(cli): invoice pdf ignored --template for UPO input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UPO branch was tested before the template branches, so once the input was a UPO — auto-detected or forced with --upo — both --template and --template-file were dropped on the floor. A custom UPO layout could never be selected from the command line, and a misspelled template name reported success instead of failing, because the flag was never read. An explicit template now takes precedence over auto-detection. The built-in registry holds the UPO layouts alongside the invoice ones, so the same branch serves both, and the renderer still rejects a template whose schema does not match the document. Reproduced against the built CLI: `--template totally-bogus-name` on a UPO wrote a 14978-byte PDF and exited 0. It now reports the unknown template, `--template upo-4_3` renders through the named layout, `--template fa3-default` on a UPO is rejected, and plain auto-detection still works. Full unit suite: 2492 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/docs/pdf-export.md | 7 ++++-- .../src/cli/commands/invoice.ts | 10 +++++--- .../unit/cli/commands/invoice-pdf.test.ts | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 6bbd8141..fd926116 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -282,6 +282,9 @@ ksef invoice pdf invoice.xml --template-file ./templates/my-invoice.json --out . # UPO receipt (auto-detected, or force with --upo) ksef invoice pdf upo.xml --upo + +# UPO receipt with a custom layout +ksef invoice pdf upo.xml --template-file ./templates/my-upo.json ``` | Flag | Description | @@ -291,11 +294,11 @@ ksef invoice pdf upo.xml --upo | `--locale ` | Label language (default `pl`) | | `--qr` | Embed the KSeF Code I QR derived from the XML | | `--ksef-number ` | KSeF number to print (absent → marked OFFLINE) | -| `--upo` | Treat the input as a UPO document (otherwise auto-detected) | +| `--upo` | Treat the input as a UPO document (otherwise auto-detected); ignored when a template is named explicitly | | `--env ` | Environment for the QR base URL | | `--out ` | Output PDF path (default: alongside the source) | -`--template` and `--template-file` are mutually exclusive — pass at most one. If `pdfmake` is not installed, the command exits with the same friendly install hint shown above. +`--template` and `--template-file` are mutually exclusive — pass at most one. An explicit template takes precedence over document auto-detection, so a custom UPO layout is selected the same way an invoice one is; the renderer still rejects a template whose `schema` does not match the document. If `pdfmake` is not installed, the command exits with the same friendly install hint shown above. --- diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 9aff05d8..b2b74eba 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -619,13 +619,17 @@ const pdf = defineCommand({ const pdfModule = await import('../../pdf/index.js'); let bytes: Uint8Array; + // An explicit template wins over document auto-detection: the built-in + // registry holds the UPO layouts too, so `--template`/`--template-file` + // must be honoured for UPO input as well. The renderer validates the + // template's schema against the document, so a wrong pairing still fails. const isUpo = Boolean(args.upo) || (pdfModule.detectInvoiceVersion(xmlStr) === null && pdfModule.detectUpoVersion(xmlStr) !== null); - if (isUpo) { - bytes = await pdfModule.renderUpoPdf(xmlBytes, renderOpts); - } else if (args.templateFile) { + if (args.templateFile) { bytes = await pdfModule.renderInvoicePdfFromFile(xmlBytes, args.templateFile as string, renderOpts); } else if (args.template) { bytes = await pdfModule.renderInvoicePdf(xmlBytes, args.template as string, renderOpts); + } else if (isUpo) { + bytes = await pdfModule.renderUpoPdf(xmlBytes, renderOpts); } else { const version = pdfModule.detectInvoiceVersion(xmlStr); const builtin = version === 'FA(2)' ? 'fa2-default' : 'fa3-default'; diff --git a/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts b/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts index 719d9fe6..669d95d3 100644 --- a/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts +++ b/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts @@ -151,6 +151,31 @@ describe('invoice pdf — CLI wiring', () => { expect(mockedPdf.renderUpoPdf).toHaveBeenCalled(); }); + it('honors --template for a UPO document instead of the default UPO renderer', async () => { + mockedPdf.detectInvoiceVersion.mockReturnValue(null); + mockedPdf.detectUpoVersion.mockReturnValue('UPO(4.3)'); + await runPdf({ file: 'upo.xml', template: 'upo-4_2' }); + expect(mockedPdf.renderInvoicePdf).toHaveBeenCalledWith(expect.any(Uint8Array), 'upo-4_2', expect.anything()); + expect(mockedPdf.renderUpoPdf).not.toHaveBeenCalled(); + }); + + it('honors --template-file alongside an explicit --upo', async () => { + await runPdf({ file: 'upo.xml', upo: true, templateFile: './custom-upo.json' }); + expect(mockedPdf.renderInvoicePdfFromFile).toHaveBeenCalledWith( + expect.any(Uint8Array), + './custom-upo.json', + expect.anything(), + ); + expect(mockedPdf.renderUpoPdf).not.toHaveBeenCalled(); + }); + + it('surfaces an unknown template name for UPO input instead of ignoring the flag', async () => { + mockedPdf.detectInvoiceVersion.mockReturnValue(null); + mockedPdf.detectUpoVersion.mockReturnValue('UPO(4.3)'); + mockedPdf.renderInvoicePdf.mockRejectedValue(new Error('Unknown built-in template "bogus".')); + await expect(runPdf({ file: 'upo.xml', template: 'bogus' })).rejects.toThrow(/Unknown built-in template/); + }); + it('passes qr / ksefNumber / env through to the renderer', async () => { await runPdf({ file: 'invoice.xml', qr: true, ksefNumber: 'NR-1', env: 'test' }); expect(mockedPdf.renderInvoicePdf).toHaveBeenCalledWith( From e1e992f37955bb138a42566251d58b8e0faca684 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 15:19:38 +0200 Subject: [PATCH 10/67] test(pdf): lint `when` and repeater paths in the built-in templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict mode throws on a missing scalar binding, which is what catches dot-path typos in the values a template prints. Presence tests and repeater sources are outside that net by design: `Platnosc` is minOccurs="0" in the FA schemas and `RachunekBankowy` is minOccurs="0" maxOccurs="100", so an absent node there is a cash-paid invoice, and making them throw would reject valid documents. That left a gap in our own presets — a misspelled `when` silently hides its block, a misspelled `from` silently yields a header-only table, and the strict-mode fixture test passes either way. This closes it where it can be closed without touching the public contract: every `when` and `from` path in every built-in template must resolve against that template's reference fixture. The scope of `strict` is now stated in the docs. Verified by mutation: misspelling `Fa.Platnosc` and `Fa.FaWiersz` in fa3-default each fails the lint, and both pass again once restored. Full unit suite: 2504 passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/docs/pdf-export.md | 2 + .../unit/pdf/builtin-template-lint.test.ts | 98 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index fd926116..a3d69eeb 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -132,6 +132,8 @@ detectUpoVersion(xml); // 'UPO(4.2)' | 'UPO(4.3)' | null | `theme` | `{ accent?: string }` | Accent colour. | | `bilingualSeparator` | `string` | Separator for the `pl+en` locale. Default `' / '`. | | `strict` | `boolean` | Throw on a missing binding instead of rendering an empty string. | + +`strict` covers the scalar bindings a template *prints*. It deliberately does not apply to `when` conditions or repeater `from` paths: the KSeF schemas make `Platnosc` and `RachunekBankowy` optional, so an absent node there is a cash-paid invoice rather than a template mistake, and throwing would reject valid documents. Typos in those paths are caught for the built-in templates by a lint that resolves every `when` and `from` against the reference fixtures. | `invoiceHash` | `string` | Precomputed canonical invoice hash (base64), used verbatim for the QR. | --- diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts new file mode 100644 index 00000000..e5e65ca7 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -0,0 +1,98 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { has, list } from '../../../src/pdf/accessor.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { getBuiltinTemplate, builtinTemplateNames } from '../../../src/pdf/template/builtin/index.js'; +import type { Block } from '../../../src/pdf/template/dsl.js'; + +/** + * Strict mode throws on a missing *scalar* binding, which catches dot-path + * typos in the values a template prints. It deliberately cannot do the same for + * `when` conditions and repeater `from` paths: `Platnosc` is `minOccurs="0"` in + * the FA schemas and `RachunekBankowy` is `minOccurs="0" maxOccurs="100"`, so an + * absent node there is a cash-paid invoice, not a mistake — making those throw + * would break strict rendering of perfectly valid documents. + * + * The typo risk is real all the same: a misspelled `when` silently hides its + * block and a misspelled `from` silently yields a header-only table, and the + * strict-mode fixture test would pass either way. This lint closes that gap + * where it can be closed without weakening the public contract — our own + * templates against fixtures that populate every path they reference. + */ + +const FIXTURE_BY_TEMPLATE: Record = { + 'fa2-default': 'pdf/fa2.xml', + 'fa3-default': 'pdf/fa3.xml', + 'upo-4_2': 'pdf/upo-4_2.xml', + 'upo-4_3': 'pdf/upo-4_3.xml', +}; + +/** `when` values resolved from the render context, not from the XML. */ +const CONTEXT_CONDITIONS = new Set(['qr', 'offline', 'hasKsefNumber', 'opts.logo', 'opts.ksefNumber', 'opts.accent', 'qrUrl']); + +interface CollectedPaths { + conditions: string[]; + repeaters: string[]; +} + +function collect(blocks: Block[], acc: CollectedPaths = { conditions: [], repeaters: [] }): CollectedPaths { + for (const block of blocks) { + const when = (block as { when?: string }).when; + if (when !== undefined && !CONTEXT_CONDITIONS.has(when)) acc.conditions.push(when); + + if (block.type === 'lines') acc.repeaters.push(block.from); + if (block.type === 'table') acc.repeaters.push(block.from); + if (block.type === 'payment' && block.accounts) acc.repeaters.push(block.accounts.from); + + if (block.type === 'stack') collect(block.stack, acc); + if (block.type === 'columns') collect(block.columns, acc); + } + return acc; +} + +function bodyOf(templateName: string): unknown { + const template = getBuiltinTemplate(templateName)!; + const xml = readFileSync(new URL(`../../fixtures/${FIXTURE_BY_TEMPLATE[templateName]}`, import.meta.url), 'utf8'); + const parsed = parseXmlForPdf(xml) as Record; + return parsed[template.schema.startsWith('UPO') ? 'Potwierdzenie' : 'Faktura']; +} + +describe('built-in template lint', () => { + it('covers every built-in template', () => { + expect(builtinTemplateNames().sort()).toEqual(Object.keys(FIXTURE_BY_TEMPLATE).sort()); + }); + + it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: every `when` path resolves against its fixture', (name) => { + const root = bodyOf(name); + const { conditions } = collect(getBuiltinTemplate(name)!.blocks); + const unresolved = conditions.filter((path) => !has(root, path)); + expect(unresolved).toEqual([]); + }); + + it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: every repeater `from` path resolves against its fixture', (name) => { + const root = bodyOf(name); + const { repeaters } = collect(getBuiltinTemplate(name)!.blocks); + const empty = repeaters.filter((path) => list(root, path).length === 0); + expect(empty).toEqual([]); + }); + + it('actually inspects some paths (guards against a walker that finds nothing)', () => { + const fa3 = collect(getBuiltinTemplate('fa3-default')!.blocks); + expect(fa3.conditions).toContain('Fa.Platnosc'); + expect(fa3.repeaters).toContain('Fa.FaWiersz'); + expect(fa3.repeaters).toContain('Fa.Platnosc.RachunekBankowy'); + expect(collect(getBuiltinTemplate('upo-4_3')!.blocks).repeaters).toContain('Dokument'); + }); + + it('fails a template whose `when` path is misspelled', () => { + const root = bodyOf('fa3-default'); + expect(has(root, 'Fa.Platnosc')).toBe(true); + expect(has(root, 'Fa.Platnsoc')).toBe(false); // the typo this lint exists to catch + }); + + it('fails a template whose repeater path is misspelled', () => { + const root = bodyOf('fa3-default'); + expect(list(root, 'Fa.FaWiersz').length).toBeGreaterThan(0); + expect(list(root, 'Fa.FaWierzs')).toEqual([]); + }); +}); From 2deaab29580951cc60d0f546d501a39444b898b2 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 15:39:03 +0200 Subject: [PATCH 11/67] fix(pdf): tables ran off the page edge on real invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both table renderers sized every column `'*'`. pdfmake gives star columns one shared width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token inflates all of them and the table silently extends past the page — the trailing columns are simply not on the paper. On a real FA(3) the "Wartość netto" column was cut off; the UPO receipt lost two of five columns. Columns now carry an optional `width` (points, `'auto'`, or `'*'`), and the invoice templates pin the narrow ones so the description column absorbs what is left. The UPO receipt cannot be fixed that way at all — a 35-character KSeF number beside a 44-character hash will not share a page-wide row — so a new `each` block repeats a group of blocks per collection entry with that entry as the binding root, and the UPO templates lay each confirmed document out as stacked label/value rows separated by a divider. This also repairs a regression from the multi-document UPO fix: that change traded "silently drops every document after the first" for "renders them all but clips the columns". Verified by rendering real invoices and receipts: the FA(3) line table now shows all seven columns in Polish and in the wider pl+en labels, and a five-document session UPO renders every field of every document on the page. The built-in template lint learned `each`, so a typo in its `from` is caught too. Full suites: 2506 unit, 119 E2E. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/docs/pdf-export.md | 11 ++-- .../src/pdf/template/blocks/each.ts | 39 +++++++++++++ .../src/pdf/template/blocks/index.ts | 2 + .../src/pdf/template/blocks/lines.ts | 7 ++- .../src/pdf/template/blocks/table.ts | 6 +- .../src/pdf/template/builtin/fa2-default.json | 54 ++++++++--------- .../src/pdf/template/builtin/fa3-default.json | 54 ++++++++--------- .../src/pdf/template/builtin/upo-4_2.json | 58 +++++++++++++++---- .../src/pdf/template/builtin/upo-4_3.json | 58 +++++++++++++++---- .../ksef-client-ts/src/pdf/template/dsl.ts | 56 ++++++++++++++++-- .../src/pdf/template/interpret.ts | 10 +++- .../unit/pdf/builtin-template-lint.test.ts | 7 ++- .../tests/unit/pdf/upo-multi-document.test.ts | 31 ++++++++-- 13 files changed, 299 insertions(+), 94 deletions(-) create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/each.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index a3d69eeb..61091149 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -172,7 +172,9 @@ The `schema` field binds a template to a single document kind. If you render an | `qr` | The verification QR image | | `footer` | Footer note | -**Primitive blocks** are layout building blocks: `text`, `columns`, `stack`, `table`, `image`, `divider`, `spacer`. +**Primitive blocks** are layout building blocks: `text`, `columns`, `stack`, `each`, `table`, `image`, `divider`, `spacer`. + +`each` repeats a group of blocks once per entry of a collection, with the entry as the binding root, so its children use item-relative paths. Use it where a table cannot fit a record on one row — the built-in UPO templates lay out each confirmed document this way, because a 35-character KSeF number beside a 44-character hash will not share a page-wide row. ### Bindings, labels, conditions, and formats @@ -180,6 +182,7 @@ The `schema` field binds a template to a single document kind. If you render an - **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. - **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. +- **`width`** (table columns only) sizes a column: a number of points, `'auto'` to fit the content, or `'*'` to share out what is left (the default). Sizing is worth setting explicitly — pdfmake gives every `'*'` column the *same* width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token silently widens the whole table past the page edge. - **`sum`** (totals rows only) adds several binding paths instead of reading one. A KSeF invoice has no single net or VAT total — the amounts are split across the `P_13_*` and `P_14_*` rate buckets — so the built-in templates aggregate them. Absent buckets are skipped, and the addition is decimal-exact. A totals row takes either `path` or `sum`, never both. ### Minimal example @@ -206,9 +209,9 @@ A trimmed `FA(3)` template with a header, a seller/buyer panel, a line table, a "type": "lines", "from": "Fa.FaWiersz", "columns": [ - { "label": "name", "path": "P_7" }, - { "label": "qty", "path": "P_8B", "format": "number" }, - { "label": "net", "path": "P_11", "format": "money" } + { "label": "name", "path": "P_7", "width": "*" }, + { "label": "qty", "path": "P_8B", "width": 44, "format": "number" }, + { "label": "net", "path": "P_11", "width": 70, "format": "money" } ] }, { diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/each.ts b/packages/ksef-client-ts/src/pdf/template/blocks/each.ts new file mode 100644 index 00000000..3bb46d43 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/each.ts @@ -0,0 +1,39 @@ +import { list } from '../../accessor.js'; +import type { Block, EachBlock } from '../dsl.js'; +import { type BlockRenderer, type PdfNode } from '../interpret.js'; + +const DIVIDER: Block = { type: 'divider' }; + +/** + * Repeat a group of blocks once per entry of a collection, each entry rendered + * with itself as the binding root, so children read item-relative paths. + * + * This exists because a table cannot lay out every record. pdfmake sizes all + * `'*'` columns identically and never below their widest minimum content width, + * so a record holding long unbreakable tokens — a UPO document pairs a + * 35-character KSeF number with a 44-character hash — forces a table wider than + * the page and the trailing columns fall off it. Stacking each record instead + * keeps every field on the page whatever its length. + * + * The collection is read with {@link list}, so one collapsed entry repeats like + * many, and an absent collection renders nothing rather than an empty frame. + */ +export const eachRenderer: BlockRenderer = (block, ctx, render) => { + const items = list(ctx.root, block.from); + if (items.length === 0) return null; + + const stack: PdfNode[] = []; + items.forEach((item, index) => { + if (block.separator && index > 0) { + const divider = render(DIVIDER, ctx); + if (divider !== null) stack.push(...(Array.isArray(divider) ? divider : [divider])); + } + for (const child of block.blocks) { + const node = render(child, { ...ctx, root: item }); + if (node === null) continue; + stack.push(...(Array.isArray(node) ? node : [node])); + } + }); + + return { stack, ...(block.style ? { style: block.style } : {}) }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/index.ts b/packages/ksef-client-ts/src/pdf/template/blocks/index.ts index e89782a5..60d2a630 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/index.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/index.ts @@ -14,6 +14,7 @@ import { paymentRenderer } from './payment.js'; import { annotationsRenderer } from './annotations.js'; import { footerRenderer } from './footer.js'; import { tableRenderer } from './table.js'; +import { eachRenderer } from './each.js'; import { imageRenderer } from './image.js'; import { qrRenderer } from './qr.js'; @@ -26,6 +27,7 @@ export const blockRegistry: BlockRegistry = { annotations: annotationsRenderer as BlockRenderer, footer: footerRenderer as BlockRenderer, table: tableRenderer as BlockRenderer, + each: eachRenderer as BlockRenderer, image: imageRenderer as BlockRenderer, qr: qrRenderer as BlockRenderer, }; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts b/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts index 2e30ea82..13cd1868 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts @@ -10,6 +10,11 @@ import { type BlockRenderer, type PdfNode } from '../interpret.js'; * normalized to a one-element array, so one line renders like many). Each cell * reads its column path row-relative with {@link get} and applies the column's * formatter. With no entries only the header row is emitted. + * + * Column widths come from the template (`'*'` when unset). Leaving them all + * `'*'` is rarely right: pdfmake sizes star columns identically and never below + * the widest minimum content width among them, so one long unbreakable token + * silently widens the whole table past the page edge. */ export const linesRenderer: BlockRenderer = (block, ctx) => { const headerRow: PdfNode[] = block.columns.map((c) => ({ text: ctx.label(c.label), bold: true })); @@ -20,7 +25,7 @@ export const linesRenderer: BlockRenderer = (block, ctx) => { return { table: { headerRows: 1, - widths: block.columns.map(() => '*'), + widths: block.columns.map((c) => c.width ?? '*'), body: [headerRow, ...bodyRows], }, layout: 'lightHorizontalLines', diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/table.ts b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts index a4b239b9..0d153313 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/table.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts @@ -13,8 +13,8 @@ import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.j * via `resolveBinding(col.path)`. * * A header row of localized `col.label`s is prepended unless `headers` is - * explicitly `false`. Columns share the width evenly (`'*'`) under a light - * horizontal-line layout. + * explicitly `false`. Each column takes its own `width` (default `'*'`, an even + * share) under a light horizontal-line layout. */ export const tableRenderer: BlockRenderer = (block, ctx) => { const { columns } = block; @@ -36,7 +36,7 @@ export const tableRenderer: BlockRenderer = (block, ctx) => { const node: Record = { table: { headerRows: showHeaders ? 1 : 0, - widths: columns.map(() => '*'), + widths: columns.map((col) => col.width ?? '*'), body, }, layout: 'lightHorizontalLines', diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index f0ddaafa..3ef4abb3 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -47,38 +47,40 @@ "type": "lines", "from": "Fa.FaWiersz", "columns": [ - { "label": "lp", "path": "NrWierszaFa" }, - { "label": "name", "path": "P_7" }, - { "label": "unit", "path": "P_8A" }, - { "label": "qty", "path": "P_8B", "format": "number" }, - { "label": "unitPrice", "path": "P_9A", "format": "money" }, - { "label": "vatRate", "path": "P_12" }, - { "label": "net", "path": "P_11", "format": "money" } + { "label": "lp", "path": "NrWierszaFa", "width": 24 }, + { "label": "name", "path": "P_7", "width": "*" }, + { "label": "unit", "path": "P_8A", "width": 36 }, + { "label": "qty", "path": "P_8B", "format": "number", "width": 44 }, + { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 64 }, + { "label": "vatRate", "path": "P_12", "width": 50 }, + { "label": "net", "path": "P_11", "format": "money", "width": 70 } ] }, { "type": "spacer", "height": 10 }, { "type": "totals", "rows": [ - { "label": "totalNet", "sum": [ - "Fa.P_13_1", - "Fa.P_13_2", - "Fa.P_13_3", - "Fa.P_13_4", - "Fa.P_13_5", - "Fa.P_13_7", - "Fa.P_13_8", - "Fa.P_13_9", - "Fa.P_13_10", - "Fa.P_13_11" - ], "format": "money" }, - { "label": "totalVat", "sum": [ - "Fa.P_14_1", - "Fa.P_14_2", - "Fa.P_14_3", - "Fa.P_14_4", - "Fa.P_14_5" - ], "format": "money" }, + { + "label": "totalNet", + "sum": [ + "Fa.P_13_1", + "Fa.P_13_2", + "Fa.P_13_3", + "Fa.P_13_4", + "Fa.P_13_5", + "Fa.P_13_7", + "Fa.P_13_8", + "Fa.P_13_9", + "Fa.P_13_10", + "Fa.P_13_11" + ], + "format": "money" + }, + { + "label": "totalVat", + "sum": ["Fa.P_14_1", "Fa.P_14_2", "Fa.P_14_3", "Fa.P_14_4", "Fa.P_14_5"], + "format": "money" + }, { "label": "totalDue", "path": "Fa.P_15", "format": "money" } ] }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index e2959b50..589c8990 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -47,38 +47,40 @@ "type": "lines", "from": "Fa.FaWiersz", "columns": [ - { "label": "lp", "path": "NrWierszaFa" }, - { "label": "name", "path": "P_7" }, - { "label": "unit", "path": "P_8A" }, - { "label": "qty", "path": "P_8B", "format": "number" }, - { "label": "unitPrice", "path": "P_9A", "format": "money" }, - { "label": "vatRate", "path": "P_12" }, - { "label": "net", "path": "P_11", "format": "money" } + { "label": "lp", "path": "NrWierszaFa", "width": 24 }, + { "label": "name", "path": "P_7", "width": "*" }, + { "label": "unit", "path": "P_8A", "width": 36 }, + { "label": "qty", "path": "P_8B", "format": "number", "width": 44 }, + { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 64 }, + { "label": "vatRate", "path": "P_12", "width": 50 }, + { "label": "net", "path": "P_11", "format": "money", "width": 70 } ] }, { "type": "spacer", "height": 10 }, { "type": "totals", "rows": [ - { "label": "totalNet", "sum": [ - "Fa.P_13_1", - "Fa.P_13_2", - "Fa.P_13_3", - "Fa.P_13_4", - "Fa.P_13_5", - "Fa.P_13_7", - "Fa.P_13_8", - "Fa.P_13_9", - "Fa.P_13_10", - "Fa.P_13_11" - ], "format": "money" }, - { "label": "totalVat", "sum": [ - "Fa.P_14_1", - "Fa.P_14_2", - "Fa.P_14_3", - "Fa.P_14_4", - "Fa.P_14_5" - ], "format": "money" }, + { + "label": "totalNet", + "sum": [ + "Fa.P_13_1", + "Fa.P_13_2", + "Fa.P_13_3", + "Fa.P_13_4", + "Fa.P_13_5", + "Fa.P_13_7", + "Fa.P_13_8", + "Fa.P_13_9", + "Fa.P_13_10", + "Fa.P_13_11" + ], + "format": "money" + }, + { + "label": "totalVat", + "sum": ["Fa.P_14_1", "Fa.P_14_2", "Fa.P_14_3", "Fa.P_14_4", "Fa.P_14_5"], + "format": "money" + }, { "label": "totalDue", "path": "Fa.P_15", "format": "money" } ] }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json index 0a5c00ae..235deb6b 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json @@ -10,21 +10,57 @@ { "type": "header", "title": { "label": "upoTitle" } }, { "type": "divider" }, { "type": "spacer", "height": 10 }, - { "type": "columns", "columns": [ - { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, - { "type": "text", "path": "NumerReferencyjnySesji" } - ] }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, + { "type": "text", "path": "NumerReferencyjnySesji" } + ] + }, { "type": "spacer", "height": 10 }, { "type": "text", "label": "documents", "style": "fieldLabel" }, + { "type": "spacer", "height": 4 }, { - "type": "lines", + "type": "each", "from": "Dokument", - "columns": [ - { "label": "ksefDocNumber", "path": "NumerKSeFDokumentu" }, - { "label": "invoiceNumber", "path": "NumerFaktury" }, - { "label": "issueDate", "path": "DataWystawieniaFaktury", "format": "date" }, - { "label": "receiptDate", "path": "DataNadaniaNumeruKSeF", "format": "date" }, - { "label": "documentHash", "path": "SkrotDokumentu" } + "separator": true, + "blocks": [ + { + "type": "columns", + "columns": [ + { "type": "text", "label": "ksefDocNumber", "style": "fieldLabel" }, + { "type": "text", "path": "NumerKSeFDokumentu" } + ] + }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "invoiceNumber", "style": "fieldLabel" }, + { "type": "text", "path": "NumerFaktury" } + ] + }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "issueDate", "style": "fieldLabel" }, + { "type": "text", "path": "DataWystawieniaFaktury", "format": "date" } + ] + }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "receiptDate", "style": "fieldLabel" }, + { "type": "text", "path": "DataNadaniaNumeruKSeF", "format": "date" } + ] + }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "documentHash", "style": "fieldLabel" }, + { "type": "text", "path": "SkrotDokumentu", "style": "muted" } + ] + }, + { "type": "spacer", "height": 4 } ] } ] diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json index 89f087d0..4f9539ff 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json @@ -10,21 +10,57 @@ { "type": "header", "title": { "label": "upoTitle" } }, { "type": "divider" }, { "type": "spacer", "height": 10 }, - { "type": "columns", "columns": [ - { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, - { "type": "text", "path": "NumerReferencyjnySesji" } - ] }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, + { "type": "text", "path": "NumerReferencyjnySesji" } + ] + }, { "type": "spacer", "height": 10 }, { "type": "text", "label": "documents", "style": "fieldLabel" }, + { "type": "spacer", "height": 4 }, { - "type": "lines", + "type": "each", "from": "Dokument", - "columns": [ - { "label": "ksefDocNumber", "path": "NumerKSeFDokumentu" }, - { "label": "invoiceNumber", "path": "NumerFaktury" }, - { "label": "issueDate", "path": "DataWystawieniaFaktury", "format": "date" }, - { "label": "receiptDate", "path": "DataNadaniaNumeruKSeF", "format": "date" }, - { "label": "documentHash", "path": "SkrotDokumentu" } + "separator": true, + "blocks": [ + { + "type": "columns", + "columns": [ + { "type": "text", "label": "ksefDocNumber", "style": "fieldLabel" }, + { "type": "text", "path": "NumerKSeFDokumentu" } + ] + }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "invoiceNumber", "style": "fieldLabel" }, + { "type": "text", "path": "NumerFaktury" } + ] + }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "issueDate", "style": "fieldLabel" }, + { "type": "text", "path": "DataWystawieniaFaktury", "format": "date" } + ] + }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "receiptDate", "style": "fieldLabel" }, + { "type": "text", "path": "DataNadaniaNumeruKSeF", "format": "date" } + ] + }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "documentHash", "style": "fieldLabel" }, + { "type": "text", "path": "SkrotDokumentu", "style": "muted" } + ] + }, + { "type": "spacer", "height": 4 } ] } ] diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 0fa45446..1ff7dded 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -36,6 +36,18 @@ export interface FieldDef { style?: string; } +/** + * A table column. `width` maps to pdfmake's column sizing: a number is an + * explicit point width, `'auto'` fits the content, and `'*'` shares out what is + * left. Sizing matters more than it looks — pdfmake gives every `'*'` column the + * *same* width, and that shared width cannot go below the widest minimum content + * width among them, so one long unbreakable token (a KSeF number, a base64 hash) + * inflates every column and pushes the table off the page. Default `'*'`. + */ +export interface ColumnDef extends FieldDef { + width?: number | 'auto' | '*'; +} + // ── Semantic blocks ──────────────────────────────────────────────────────── export interface HeaderBlock { @@ -62,7 +74,7 @@ export interface PartiesBlock { export interface LinesBlock { type: 'lines'; from: string; - columns: FieldDef[]; + columns: ColumnDef[]; style?: string; } @@ -152,10 +164,27 @@ export interface StackBlock { style?: string; } +/** + * Repeat a group of blocks once per entry of a collection, with each entry as + * the binding root — so children use paths relative to the item, exactly as + * `lines` columns do. Where a table forces every record onto one row, this lays + * a record out however its fields need, which is what wide records (a UPO + * document: a 35-character KSeF number beside a 44-character hash) require to + * stay on the page. `separator` draws a divider between entries. + */ +export interface EachBlock { + type: 'each'; + from: string; + blocks: Block[]; + separator?: boolean; + when?: string; + style?: string; +} + export interface TableBlock { type: 'table'; from?: string; - columns: FieldDef[]; + columns: ColumnDef[]; headers?: boolean; when?: string; style?: string; @@ -191,6 +220,7 @@ export type Block = | TextBlock | ColumnsBlock | StackBlock + | EachBlock | TableBlock | ImageBlock | DividerBlock @@ -224,6 +254,16 @@ const fieldDef = z }) .strict(); +const columnDef = z + .object({ + label: z.string(), + path: z.string(), + format: formatEnum.optional(), + style: z.string().optional(), + width: z.union([z.number().positive(), z.literal('auto'), z.literal('*')]).optional(), + }) + .strict(); + const totalsRow = z .object({ label: z.string(), @@ -257,7 +297,7 @@ const blockSchema: z.ZodType = z.lazy(() => z.object({ type: z.literal('lines'), from: z.string(), - columns: z.array(fieldDef), + columns: z.array(columnDef), style: z.string().optional(), }).strict(), z.object({ type: z.literal('totals'), rows: z.array(totalsRow), style: z.string().optional() }).strict(), @@ -304,10 +344,18 @@ const blockSchema: z.ZodType = z.lazy(() => when: z.string().optional(), style: z.string().optional(), }).strict(), + z.object({ + type: z.literal('each'), + from: z.string(), + blocks: z.array(blockSchema), + separator: z.boolean().optional(), + when: z.string().optional(), + style: z.string().optional(), + }).strict(), z.object({ type: z.literal('table'), from: z.string().optional(), - columns: z.array(fieldDef), + columns: z.array(columnDef), headers: z.boolean().optional(), when: z.string().optional(), style: z.string().optional(), diff --git a/packages/ksef-client-ts/src/pdf/template/interpret.ts b/packages/ksef-client-ts/src/pdf/template/interpret.ts index 43315427..baf8563b 100644 --- a/packages/ksef-client-ts/src/pdf/template/interpret.ts +++ b/packages/ksef-client-ts/src/pdf/template/interpret.ts @@ -39,8 +39,12 @@ export interface RenderContext { flags: Record; } -/** Recurse into a child block (depth-guarded); `null` when the child is hidden. */ -export type RenderChild = (child: Block) => PdfContent | null; +/** + * Recurse into a child block (depth-guarded); `null` when the child is hidden. + * A container may pass its own context to rebind the binding root — that is how + * `each` renders a child against one entry of a collection. + */ +export type RenderChild = (child: Block, ctxOverride?: RenderContext) => PdfContent | null; /** A block renderer: pure (block, ctx) → pdfmake node(s), or `null` if hidden. */ export type BlockRenderer = ( @@ -138,7 +142,7 @@ export function interpretBlock( if (!renderer) { throw new KSeFPdfError(`No renderer registered for block type "${block.type}"`); } - const render: RenderChild = (child) => interpretBlock(child, ctx, registry, depth + 1); + const render: RenderChild = (child, over) => interpretBlock(child, over ?? ctx, registry, depth + 1); return renderer(block, ctx, render); } diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index e5e65ca7..360fad11 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -41,11 +41,15 @@ function collect(blocks: Block[], acc: CollectedPaths = { conditions: [], repeat if (when !== undefined && !CONTEXT_CONDITIONS.has(when)) acc.conditions.push(when); if (block.type === 'lines') acc.repeaters.push(block.from); - if (block.type === 'table') acc.repeaters.push(block.from); + if (block.type === 'table' && block.from !== undefined) acc.repeaters.push(block.from); + if (block.type === 'each') acc.repeaters.push(block.from); if (block.type === 'payment' && block.accounts) acc.repeaters.push(block.accounts.from); if (block.type === 'stack') collect(block.stack, acc); if (block.type === 'columns') collect(block.columns, acc); + // `each` rebinds the root to one entry, so its children's paths are + // item-relative and cannot be resolved against the document root here. + // Its own `from` is checked above. } return acc; } @@ -82,6 +86,7 @@ describe('built-in template lint', () => { expect(fa3.repeaters).toContain('Fa.FaWiersz'); expect(fa3.repeaters).toContain('Fa.Platnosc.RachunekBankowy'); expect(collect(getBuiltinTemplate('upo-4_3')!.blocks).repeaters).toContain('Dokument'); + expect(collect(getBuiltinTemplate('upo-4_2')!.blocks).repeaters).toContain('Dokument'); }); it('fails a template whose `when` path is misspelled', () => { diff --git a/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts b/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts index c97e2913..a2932035 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts @@ -55,7 +55,7 @@ describe.each([ expect(tree).toContain('FA/2025/01/003'); }); - it('emits one table row per document plus the header', () => { + it('emits one field group per document, separated by dividers', () => { const template = getBuiltinTemplate(templateName)!; const parsed = parseXmlForPdf(withDocuments(single, 4)); const ctx = { @@ -66,10 +66,33 @@ describe.each([ flags: {}, }; const doc = interpretTemplate(template, ctx, blockRegistry); - const table = (doc.content as Array>).find((n) => 'table' in n) as { - table: { body: unknown[] }; + const tree = JSON.stringify(doc); + + // Each document contributes its own KSeF-number row, and consecutive + // documents are separated by a divider (3 for 4 documents). + expect(tree.split('"KSeF document number"')).toHaveLength(5); + const group = (doc.content as Array>).find( + (n) => Array.isArray(n.stack) && JSON.stringify(n).includes('KSeF document number'), + ) as { stack: unknown[] }; + expect(JSON.stringify(group).split('"canvas"')).toHaveLength(4); + }); + + it('keeps every field on the page instead of clipping wide records', () => { + const template = getBuiltinTemplate(templateName)!; + const parsed = parseXmlForPdf(single); + const ctx = { + root: (parsed as Record).Potwierdzenie, + strict: false, + label: makeLabelResolver('en', {}), + bindings: {}, + flags: {}, }; - expect(table.table.body).toHaveLength(5); // 1 header + 4 documents + const tree = JSON.stringify(interpretTemplate(template, ctx, blockRegistry)); + // A table would have forced the hash and both dates onto one row with the + // 35-character KSeF number; stacked rows carry all of them. + for (const label of ['KSeF document number', 'Invoice number', 'Issue date', 'KSeF number assignment date', 'Document hash']) { + expect(tree).toContain(label); + } }); }); From c2a3676ad98b6efc4bd7f2a66479e8c873be17d6 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 16:30:33 +0200 Subject: [PATCH 12/67] feat(pdf): print the KSeF number in the invoice header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KSeF number sat on its own full-width row below the header, in the small muted style, so the one identifier a reader looks up first was the least prominent thing on the page and visually detached from the invoice number and issue date it belongs with. The header block now takes a `ksefNumber` binding and stacks it under those two, right-aligned in the body font — three `label: value` lines that read as one group. The row it replaces is gone from both invoice templates. An absent number drops the line entirely rather than printing a dangling label; the OFFLINE marker already covers that case. Verified by rendering a real FA(3) in Polish, English and pl+en, and the same invoice with no KSeF number. Full suites: 2508 unit, 119 E2E. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/docs/pdf-export.md | 4 +-- .../src/pdf/template/blocks/header.ts | 9 +++++- .../src/pdf/template/builtin/fa2-default.json | 13 ++++---- .../src/pdf/template/builtin/fa3-default.json | 13 ++++---- .../ksef-client-ts/src/pdf/template/dsl.ts | 7 ++++ .../tests/unit/pdf/blocks-semantic.test.ts | 32 +++++++++++++++++++ 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 61091149..ddf0e0fa 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -163,7 +163,7 @@ The `schema` field binds a template to a single document kind. If you render an | Block | Renders | |-------|---------| -| `header` | Title, invoice number, date, optional logo | +| `header` | Title and optional logo on the left; invoice number, issue date and KSeF number stacked on the right | | `parties` | Seller / buyer two-column panel | | `lines` | Invoice line-item table | | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | @@ -198,7 +198,7 @@ A trimmed `FA(3)` template with a header, a seller/buyer panel, a line table, a "muted": { "color": "#666666", "fontSize": 8 } }, "blocks": [ - { "type": "header", "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1" }, + { "type": "header", "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1", "ksefNumber": "opts.ksefNumber" }, { "type": "divider" }, { "type": "parties", diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/header.ts b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts index 1b8b3b10..1d4e6638 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/header.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts @@ -4,7 +4,10 @@ import { resolveBinding, resolveText, type BlockRenderer, type PdfNode } from '. /** * Invoice header: optional logo, a title (defaults to the localized "Invoice" - * label), and the invoice number/date stacked on the right. + * label), and the invoice number, issue date and KSeF number stacked on the + * right — one `label: value` line each, in the body font. The KSeF number is + * dropped when it resolves empty, so an offline visualization shows no dangling + * label (the separate OFFLINE marker covers that case). */ export const headerRenderer: BlockRenderer = (block, ctx) => { const left: PdfNode[] = []; @@ -22,6 +25,10 @@ export const headerRenderer: BlockRenderer = (block, ctx) => { if (block.date) { right.push({ text: `${ctx.label('issueDate')}: ${applyFormat(resolveBinding(block.date, ctx), 'date')}` }); } + if (block.ksefNumber) { + const value = resolveBinding(block.ksefNumber, ctx); + if (value) right.push({ text: `${ctx.label('ksefNumber')}: ${value}` }); + } return { columns: [ diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 3ef4abb3..7da56f63 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -9,14 +9,13 @@ "footerNote": { "fontSize": 7, "color": "#999999" } }, "blocks": [ - { "type": "header", "logo": "opts.logo", "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1" }, { - "type": "columns", - "when": "hasKsefNumber", - "columns": [ - { "type": "text", "label": "ksefNumber", "style": "muted" }, - { "type": "text", "path": "opts.ksefNumber", "style": "muted" } - ] + "type": "header", + "logo": "opts.logo", + "title": { "label": "invoice" }, + "number": "Fa.P_2", + "date": "Fa.P_1", + "ksefNumber": "opts.ksefNumber" }, { "type": "text", "label": "offline", "when": "offline", "style": "offline" }, { "type": "divider" }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 589c8990..f0c1607b 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -9,14 +9,13 @@ "footerNote": { "fontSize": 7, "color": "#999999" } }, "blocks": [ - { "type": "header", "logo": "opts.logo", "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1" }, { - "type": "columns", - "when": "hasKsefNumber", - "columns": [ - { "type": "text", "label": "ksefNumber", "style": "muted" }, - { "type": "text", "path": "opts.ksefNumber", "style": "muted" } - ] + "type": "header", + "logo": "opts.logo", + "title": { "label": "invoice" }, + "number": "Fa.P_2", + "date": "Fa.P_1", + "ksefNumber": "opts.ksefNumber" }, { "type": "text", "label": "offline", "when": "offline", "style": "offline" }, { "type": "divider" }, diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 1ff7dded..82c4e3e8 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -56,6 +56,12 @@ export interface HeaderBlock { title?: LabelRef; number?: string; date?: string; + /** + * Binding for the KSeF number, printed in the same right-hand stack as + * `number` and `date`. Skipped when it resolves empty, so an offline + * visualization does not print a dangling label. + */ + ksefNumber?: string; style?: string; } @@ -286,6 +292,7 @@ const blockSchema: z.ZodType = z.lazy(() => title: labelRef.optional(), number: z.string().optional(), date: z.string().optional(), + ksefNumber: z.string().optional(), style: z.string().optional(), }).strict(), z.object({ diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index d37466b4..13422d68 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -82,6 +82,38 @@ describe('headerRenderer', () => { expect(right.stack).toHaveLength(0); }); + it('stacks the KSeF number under the date, in the same body font', () => { + const ctx = makeCtx( + { Fa: { P_2: 'FV/2025/01', P_1: '2025-01-15' } }, + { bindings: { 'opts.ksefNumber': '1111111111-20250115-010000000000-00' } }, + ); + const node = rec( + headerRenderer( + { type: 'header', number: 'Fa.P_2', date: 'Fa.P_1', ksefNumber: 'opts.ksefNumber' }, + ctx, + noRender, + ), + ); + const [, right] = node.columns; + expect(right.stack).toHaveLength(3); + expect(right.stack[2].text).toBe('ksefNumber: 1111111111-20250115-010000000000-00'); + // no style of its own — it inherits the document font like the two above it + expect(right.stack[2].style).toBeUndefined(); + }); + + it('omits the KSeF line entirely when the number is absent (offline)', () => { + const node = rec( + headerRenderer( + { type: 'header', number: 'Fa.P_2', date: 'Fa.P_1', ksefNumber: 'opts.ksefNumber' }, + makeCtx({ Fa: { P_2: 'FV/2025/01', P_1: '2025-01-15' } }), + noRender, + ), + ); + const [, right] = node.columns; + expect(right.stack).toHaveLength(2); + expect(JSON.stringify(right.stack)).not.toContain('ksefNumber'); + }); + it('drops the logo node when its binding resolves empty', () => { const node = rec(headerRenderer({ type: 'header', logo: 'missing.logo' }, makeCtx({}), noRender)); const [left] = node.columns; From d905b2e8f41ae69994da4ecd3f95796688c08c57 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 17:37:00 +0200 Subject: [PATCH 13/67] feat(pdf): rework the invoice layout and cover it with a rendering E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendering real invoices exposed several ways the default templates lost or misplaced information, and the DSL had no vocabulary to fix them: - A counterparty established outside Poland carries NrVatUE or NrID rather than a NIP, and the buyer panel was bound to NIP alone — so a foreign buyer's identifier was replaced by a blank line. Party fields can now list alternatives and print the first that resolves, and a line that resolves empty is dropped instead of leaving a gap. - The address and contact details had no headings, and the contact block — which KSeF allows up to three times per party — could only ever show its first entry. Party fields can now be a labelled group, repeating over a collection when it names one. The country code joins the address. - `height: 6` on a spacer cost about 17pt: the block emitted an empty text node, which still occupies a full line. It is now an empty canvas and adds exactly what it says. Existing spacers were rescaled so only the gap above the parties panel changes; that one is halved. - The KSeF number sat below the header in the small muted style; it now stacks with the invoice number and issue date it belongs with. Alongside: a logo can be passed from the command line (the library still takes only a data URI, so the renderer never touches the filesystem), the header sizes it, and `en+pl` joins `pl+en` as a bilingual locale — the order is read from the locale name instead of being hardcoded. A new E2E spec renders twelve variants through the built CLI and keeps them for review, mirroring the throwaway script this work was iterated with. It asserts only that each PDF is written and structurally complete; layout is judged by eye, and asserting on positions would break on every deliberate design change. Its fixtures are new and fully anonymous — a cross-border np I invoice, a mixed 23/8/exempt one, a buyer with no identifier, and a generated logo. Full suites: 2531 unit, 134 E2E, plus the ./pdf type and cold-subpath guards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- .gitignore | 3 + packages/ksef-client-ts/docs/pdf-export.md | 19 +- .../src/cli/commands/invoice.ts | 36 ++- packages/ksef-client-ts/src/pdf/i18n/en.ts | 2 + packages/ksef-client-ts/src/pdf/i18n/index.ts | 36 ++- packages/ksef-client-ts/src/pdf/i18n/pl.ts | 2 + packages/ksef-client-ts/src/pdf/i18n/types.ts | 11 +- packages/ksef-client-ts/src/pdf/index.ts | 2 +- .../src/pdf/template/blocks/header.ts | 11 +- .../src/pdf/template/blocks/parties.ts | 69 ++++- .../src/pdf/template/builtin/fa2-default.json | 48 +++- .../src/pdf/template/builtin/fa3-default.json | 54 +++- .../src/pdf/template/builtin/upo-4_2.json | 8 +- .../src/pdf/template/builtin/upo-4_3.json | 8 +- .../ksef-client-ts/src/pdf/template/dsl.ts | 56 +++- .../src/pdf/template/interpret.ts | 5 +- .../tests/e2e/35-invoice-pdf-cli.test.ts | 142 ++++++++++ .../tests/fixtures/pdf/e2e-buyer-no-id.xml | 85 ++++++ .../tests/fixtures/pdf/e2e-logo.png | Bin 0 -> 155 bytes .../tests/fixtures/pdf/e2e-services-np.xml | 99 +++++++ .../tests/fixtures/pdf/e2e-vat-multi.xml | 113 ++++++++ .../ksef-client-ts/tests/fixtures/pdf/fa2.xml | 9 + .../ksef-client-ts/tests/fixtures/pdf/fa3.xml | 9 + .../tests/unit/pdf/blocks-semantic.test.ts | 256 +++++++++++++++++- .../unit/pdf/builtin-template-lint.test.ts | 38 ++- .../tests/unit/pdf/i18n.test.ts | 17 ++ .../tests/unit/pdf/interpret.test.ts | 11 +- .../tests/unit/pdf/render-builtins.test.ts | 4 +- .../tests/unit/pdf/upo-multi-document.test.ts | 3 +- 29 files changed, 1069 insertions(+), 87 deletions(-) create mode 100644 packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/e2e-buyer-no-id.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/e2e-logo.png create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/e2e-services-np.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/e2e-vat-multi.xml diff --git a/.gitignore b/.gitignore index 482587a6..8cb7ef53 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ packages/ksef-client-ts/docs/public/open-api.json # markdown-vault MCP cache packages/ksef-client-ts/docs/.markdown_vault_mcp/ +# Rendered PDF previews from the `invoice pdf` E2E spec — reviewed by eye, never committed +packages/ksef-client-ts/.pdf-preview/ + # macOS .DS_Store diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index ddf0e0fa..0fb36145 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -123,14 +123,14 @@ detectUpoVersion(xml); // 'UPO(4.2)' | 'UPO(4.3)' | null | Option | Type | Purpose | |--------|------|---------| -| `locale` | `'pl' \| 'en' \| 'pl+en'` | Label language. Default `'pl'`. | +| `locale` | `'pl' \| 'en' \| 'pl+en' \| 'en+pl'` | Label language. Default `'pl'`. | | `qr` | `boolean` | Embed the KSeF Code I verification QR derived from the invoice XML. | | `ksefNumber` | `string` | KSeF number printed on the visualization; when absent the document is marked **OFFLINE**. | | `env` | `'prod' \| 'test' \| 'demo'` | Environment used to derive the QR base URL. Default `'prod'`. | | `baseQrUrl` | `string` | Override the QR base URL (offline / non-standard). | | `logo` | `string` | Logo image as a `data:` URI. | | `theme` | `{ accent?: string }` | Accent colour. | -| `bilingualSeparator` | `string` | Separator for the `pl+en` locale. Default `' / '`. | +| `bilingualSeparator` | `string` | Separator for the bilingual locales. Default `' / '`. | | `strict` | `boolean` | Throw on a missing binding instead of rendering an empty string. | `strict` covers the scalar bindings a template *prints*. It deliberately does not apply to `when` conditions or repeater `from` paths: the KSeF schemas make `Platnosc` and `RachunekBankowy` optional, so an absent node there is a cash-paid invoice rather than a template mistake, and throwing would reject valid documents. Typos in those paths are caught for the built-in templates by a lint that resolves every `when` and `from` against the reference fixtures. @@ -164,7 +164,7 @@ The `schema` field binds a template to a single document kind. If you render an | Block | Renders | |-------|---------| | `header` | Title and optional logo on the left; invoice number, issue date and KSeF number stacked on the right | -| `parties` | Seller / buyer two-column panel | +| `parties` | Seller / buyer two-column panel; a line that resolves empty is skipped | | `lines` | Invoice line-item table | | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | | `payment` | Payment details (amount paid, date, method) | @@ -182,6 +182,7 @@ The `schema` field binds a template to a single document kind. If you render an - **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. - **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. +- **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. - **`width`** (table columns only) sizes a column: a number of points, `'auto'` to fit the content, or `'*'` to share out what is left (the default). Sizing is worth setting explicitly — pdfmake gives every `'*'` column the *same* width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token silently widens the whole table past the page edge. - **`sum`** (totals rows only) adds several binding paths instead of reading one. A KSeF invoice has no single net or VAT total — the amounts are split across the `P_13_*` and `P_14_*` rate buckets — so the built-in templates aggregate them. Absent buckets are skipped, and the addition is decimal-exact. A totals row takes either `path` or `sum`, never both. @@ -203,7 +204,10 @@ A trimmed `FA(3)` template with a header, a seller/buyer panel, a line table, a { "type": "parties", "left": { "label": "seller", "fields": ["Podmiot1.DaneIdentyfikacyjne.Nazwa", "Podmiot1.DaneIdentyfikacyjne.NIP"] }, - "right": { "label": "buyer", "fields": ["Podmiot2.DaneIdentyfikacyjne.Nazwa", "Podmiot2.DaneIdentyfikacyjne.NIP"] } + "right": { "label": "buyer", "fields": [ + "Podmiot2.DaneIdentyfikacyjne.Nazwa", + { "firstOf": ["Podmiot2.DaneIdentyfikacyjne.NIP", "Podmiot2.DaneIdentyfikacyjne.NrVatUE", "Podmiot2.DaneIdentyfikacyjne.NrID"] } + ] } }, { "type": "lines", @@ -239,9 +243,10 @@ Labels are localizable, driven by the `locale` option: |--------|--------| | `pl` (default) | Polish labels | | `en` | English labels | -| `pl+en` | Both, concatenated per label | +| `pl+en` | Both, Polish first | +| `en+pl` | Both, English first | -For `pl+en`, each label is the Polish and English text joined by `bilingualSeparator` (default `' / '`). A template can also override individual labels via its `labels` map — useful for company-specific wording. +For a bilingual locale, each label is the Polish and English text joined by `bilingualSeparator` (default `' / '`), in the order the locale name spells out. A template can also override individual labels via its `labels` map — useful for company-specific wording. ```ts const pdf = await renderInvoicePdf(xml, 'fa3-default', { @@ -296,7 +301,7 @@ ksef invoice pdf upo.xml --template-file ./templates/my-upo.json |------|-------------| | `--template ` | Built-in template name (mutually exclusive with `--template-file`) | | `--template-file ` | Custom JSON template path (mutually exclusive with `--template`) | -| `--locale ` | Label language (default `pl`) | +| `--locale ` | Label language (default `pl`) | | `--qr` | Embed the KSeF Code I QR derived from the XML | | `--ksef-number ` | KSeF number to print (absent → marked OFFLINE) | | `--upo` | Treat the input as a UPO document (otherwise auto-detected); ignored when a template is named explicitly | diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index b2b74eba..16156f40 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -570,20 +570,50 @@ const validateCmd = defineCommand({ }, }); -const VALID_PDF_LOCALES = ['pl', 'en', 'pl+en'] as const; +const VALID_PDF_LOCALES = ['pl', 'en', 'pl+en', 'en+pl'] as const; type PdfLocale = (typeof VALID_PDF_LOCALES)[number]; +const LOGO_MIME_BY_EXT: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', +}; + +/** + * `RenderOptions.logo` takes a `data:` URI so the renderer never touches the + * filesystem; the CLI is where a path becomes one. The extension picks the MIME + * type — an unknown one is rejected rather than guessed, since a wrong type + * silently yields a blank space where the logo should be. + */ +function readImageAsDataUri(file: string): string { + if (!fs.existsSync(file)) { + throw new Error(`Logo file not found: ${file}`); + } + const ext = file.slice(file.lastIndexOf('.')).toLowerCase(); + const mime = LOGO_MIME_BY_EXT[ext]; + if (!mime) { + throw new Error( + `Unsupported logo format "${ext}". Supported: ${Object.keys(LOGO_MIME_BY_EXT).join(', ')}`, + ); + } + return `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`; +} + const pdf = defineCommand({ meta: { name: 'pdf', description: 'Render an invoice or UPO XML to a PDF (offline; requires the optional pdfmake peer)' }, args: { file: { type: 'positional', description: 'Path to the invoice or UPO XML file', required: true }, template: { type: 'string', description: 'Built-in template name (e.g. fa3-default). Mutually exclusive with --template-file.' }, templateFile: { type: 'string', description: 'Path to a custom JSON template. Mutually exclusive with --template.' }, - locale: { type: 'string', description: 'Label language: pl | en | pl+en (default: pl)' }, + locale: { type: 'string', description: 'Label language: pl | en | pl+en | en+pl (default: pl)' }, out: { type: 'string', description: 'Output PDF path (default: alongside the source, .pdf)' }, qr: { type: 'boolean', description: 'Embed the KSeF Code I QR derived from the XML' }, ksefNumber: { type: 'string', description: 'KSeF number to print (absent → marked OFFLINE)' }, upo: { type: 'boolean', description: 'Treat the input as a UPO document (otherwise auto-detected)' }, + logo: { type: 'string', description: 'Path to a logo image (PNG/JPEG/GIF/WebP/SVG) to print in the header' }, env: { type: 'string', description: 'Environment for the QR base URL (test/demo/prod)' }, json: { type: 'boolean', description: 'Output as JSON' }, }, @@ -603,11 +633,13 @@ const pdf = defineCommand({ } const env = args.env as 'prod' | 'test' | 'demo' | undefined; + const logo = args.logo ? readImageAsDataUri(args.logo as string) : undefined; const renderOpts = { locale: locale as PdfLocale, qr: Boolean(args.qr), ...(args.ksefNumber ? { ksefNumber: args.ksefNumber as string } : {}), ...(env ? { env } : {}), + ...(logo ? { logo } : {}), }; // Exact bytes preserve the QR hash; pass the raw file as a Uint8Array. diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 82f1b765..86486580 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -6,6 +6,8 @@ export const en: LabelBundle = { duplicate: 'Duplicate', seller: 'Seller', buyer: 'Buyer', + address: 'Address', + contact: 'Contact details', issueDate: 'Issue date', invoiceNumber: 'Invoice number', ksefNumber: 'KSeF number', diff --git a/packages/ksef-client-ts/src/pdf/i18n/index.ts b/packages/ksef-client-ts/src/pdf/i18n/index.ts index 50811e7d..04aa4f0e 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/index.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/index.ts @@ -1,27 +1,33 @@ /** - * Label localization. Only `pl` and `en` bundles are maintained; `pl+en` is - * produced on the fly by concatenation with a configurable separator, so there - * is no third bundle to keep in sync. A missing key falls back to Polish, then - * to the key itself. + * Label localization. Only `pl` and `en` bundles are maintained; the bilingual + * locales are produced on the fly by concatenation with a configurable + * separator, so there is no third bundle to keep in sync. A missing key falls + * back to Polish, then to the key itself. */ -import type { Locale, LabelBundle } from './types.js'; +import type { Locale, BaseLocale, LabelBundle } from './types.js'; import { pl } from './pl.js'; import { en } from './en.js'; -export type { Locale, LabelBundle } from './types.js'; +export type { Locale, BaseLocale, LabelBundle } from './types.js'; export { pl } from './pl.js'; export { en } from './en.js'; -const BUNDLES: Record<'pl' | 'en', LabelBundle> = { pl, en }; +const BUNDLES: Record = { pl, en }; + +/** Bilingual locales, in the order their name spells out. */ +const BILINGUAL: Record = { + 'pl+en': ['pl', 'en'], + 'en+pl': ['en', 'pl'], +}; export interface LabelOptions { - /** Separator for the `pl+en` bilingual locale. Default `' / '`. */ + /** Separator for the bilingual locales. Default `' / '`. */ bilingualSeparator?: string; /** Per-template label overrides (highest precedence). */ overrides?: LabelBundle; } -function resolveOne(key: string, locale: 'pl' | 'en', overrides?: LabelBundle): string { +function resolveOne(key: string, locale: BaseLocale, overrides?: LabelBundle): string { const override = overrides?.[key]; if (override !== undefined) return override; const fromBundle = BUNDLES[locale][key]; @@ -31,15 +37,17 @@ function resolveOne(key: string, locale: 'pl' | 'en', overrides?: LabelBundle): } /** - * Resolve a label key for the given locale. For `pl+en`, resolves both and - * joins them with the separator (default `' / '`). + * Resolve a label key for the given locale. A bilingual locale resolves both + * halves in the order its name spells out and joins them with the separator + * (default `' / '`). */ export function resolveLabel(key: string, locale: Locale, opts: LabelOptions = {}): string { - if (locale === 'pl+en') { + const pair = BILINGUAL[locale]; + if (pair) { const sep = opts.bilingualSeparator ?? ' / '; - return `${resolveOne(key, 'pl', opts.overrides)}${sep}${resolveOne(key, 'en', opts.overrides)}`; + return `${resolveOne(key, pair[0], opts.overrides)}${sep}${resolveOne(key, pair[1], opts.overrides)}`; } - return resolveOne(key, locale, opts.overrides); + return resolveOne(key, locale as BaseLocale, opts.overrides); } /** A bound resolver capturing locale + options, handed to block renderers. */ diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 5ed5dbd5..124fa195 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -6,6 +6,8 @@ export const pl: LabelBundle = { duplicate: 'Duplikat', seller: 'Sprzedawca', buyer: 'Nabywca', + address: 'Adres', + contact: 'Dane kontaktowe', issueDate: 'Data wystawienia', invoiceNumber: 'Numer faktury', ksefNumber: 'Numer KSeF', diff --git a/packages/ksef-client-ts/src/pdf/i18n/types.ts b/packages/ksef-client-ts/src/pdf/i18n/types.ts index aa9a0a19..4e2e0f52 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/types.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/types.ts @@ -1,5 +1,12 @@ -/** Label language for the rendered PDF. `pl+en` is built by concatenation. */ -export type Locale = 'pl' | 'en' | 'pl+en'; +/** + * Label language for the rendered PDF. The bilingual locales are built by + * concatenation and named for their order: `pl+en` puts Polish first, `en+pl` + * English first. + */ +export type Locale = 'pl' | 'en' | 'pl+en' | 'en+pl'; + +/** The two single-language bundles a bilingual locale is composed from. */ +export type BaseLocale = 'pl' | 'en'; /** * Known label keys referenced by built-in templates. Custom templates may use diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index 1bc83c1f..a1319d16 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -45,7 +45,7 @@ export interface RenderOptions { logo?: string; /** Theming (accent colour only; the font is the bundled Roboto). */ theme?: { accent?: string }; - /** Separator for the `pl+en` locale. Default `' / '`. */ + /** Separator for the bilingual locales (`pl+en`, `en+pl`). Default `' / '`. */ bilingualSeparator?: string; /** Throw on a missing binding instead of rendering an empty string. */ strict?: boolean; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/header.ts b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts index 1d4e6638..721b28c8 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/header.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts @@ -3,20 +3,19 @@ import type { HeaderBlock } from '../dsl.js'; import { resolveBinding, resolveText, type BlockRenderer, type PdfNode } from '../interpret.js'; /** - * Invoice header: optional logo, a title (defaults to the localized "Invoice" - * label), and the invoice number, issue date and KSeF number stacked on the + * Invoice header: a title (defaults to the localized "Invoice" label) with the + * optional logo beneath it, and the invoice number, issue date and KSeF number stacked on the * right — one `label: value` line each, in the body font. The KSeF number is * dropped when it resolves empty, so an offline visualization shows no dangling * label (the separate OFFLINE marker covers that case). */ export const headerRenderer: BlockRenderer = (block, ctx) => { - const left: PdfNode[] = []; + const title = resolveText(block.title, ctx) || ctx.label('invoice'); + const left: PdfNode[] = [{ text: title, style: block.style ?? 'title' }]; if (block.logo) { const logo = resolveBinding(block.logo, ctx); - if (logo) left.push({ image: logo, width: 120, margin: [0, 0, 0, 6] }); + if (logo) left.push({ image: logo, width: block.logoWidth ?? 120, margin: [0, 6, 0, 0] }); } - const title = resolveText(block.title, ctx) || ctx.label('invoice'); - left.push({ text: title, style: block.style ?? 'title' }); const right: PdfNode[] = []; if (block.number) { diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts index d19a6d6d..13d7fbc6 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts @@ -1,19 +1,74 @@ -import type { PartiesBlock, PartyColumn } from '../dsl.js'; -import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; +import { list } from '../../accessor.js'; +import type { PartiesBlock, PartyColumn, PartyField, PartyGroup } from '../dsl.js'; +import { resolveBinding, type BlockRenderer, type PdfNode, type RenderContext } from '../interpret.js'; + +/** Heading style shared by the panel label and its sub-group labels. */ +const HEADING_STYLE = 'h2'; + +function isGroup(field: PartyField): field is PartyGroup { + return typeof field !== 'string' && 'fields' in field; +} /** * Seller/buyer parties: a two-column layout. Each side is a stack led by a bold * label line (`ctx.label(side.label)`, styled `h2`) followed by one text line - * per binding path in `side.fields`. Left = {@link PartiesBlock.left}, right = + * per entry in `side.fields`. Left = {@link PartiesBlock.left}, right = * {@link PartiesBlock.right}. + * + * A line whose value resolves empty is skipped, so an optional field a + * counterparty does not carry leaves no gap in the panel. Strict mode still + * surfaces dot-path typos: a missing binding throws before it can be skipped. + * + * An entry may instead list alternatives (`firstOf`) and print the first that + * resolves — which is how the counterparty identifier is bound, since KSeF + * supplies exactly one of NIP / NrVatUE / NrID. Those are read leniently: the + * alternatives that do not apply are absent by design. + * + * An entry may also be a labelled group — the address, the contact details — + * rendered as a sub-heading over its own lines, and repeated per entry when it + * carries `from`. An entirely unresolved group is dropped with its heading, so + * no counterparty gets a label with nothing under it. */ export const partiesRenderer: BlockRenderer = (block, ctx) => { - const side = (col: PartyColumn): PdfNode => { - const stack: PdfNode[] = [{ text: ctx.label(col.label), style: 'h2' }]; - for (const path of col.fields) stack.push({ text: resolveBinding(path, ctx) }); - return { width: '*', stack }; + const at = (root: unknown, strict = ctx.strict): RenderContext => ({ ...ctx, root, strict }); + + const resolveValue = (field: string | { firstOf: string[] }, root: unknown, strict: boolean): string => { + if (typeof field === 'string') return resolveBinding(field, at(root, strict)); + for (const path of field.firstOf) { + const value = resolveBinding(path, at(root, false)); + if (value) return value; + } + return ''; }; + const renderFields = (fields: PartyField[], root: unknown, strict: boolean): PdfNode[] => { + const out: PdfNode[] = []; + for (const field of fields) { + if (isGroup(field)) { + // A repeater's entries carry optional fields, so they are read leniently. + const inner = field.from + ? list(root, field.from).flatMap((item) => renderFields(field.fields, item, false)) + : renderFields(field.fields, root, strict); + if (inner.length === 0) continue; // no heading without content + out.push({ text: ctx.label(field.label), style: HEADING_STYLE }); + out.push(...inner.map((n) => (field.style ? { ...(n as object), style: field.style } : n))); + continue; + } + const value = resolveValue(field, root, strict); + if (value === '') continue; + out.push({ text: value }); + } + return out; + }; + + const side = (col: PartyColumn): PdfNode => ({ + width: '*', + stack: [ + { text: ctx.label(col.label), style: HEADING_STYLE }, + ...renderFields(col.fields, ctx.root, ctx.strict), + ], + }); + return { columns: [side(block.left), side(block.right)], margin: [0, 0, 0, 12], diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 7da56f63..133ba25e 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -6,12 +6,14 @@ "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, - "footerNote": { "fontSize": 7, "color": "#999999" } + "footerNote": { "fontSize": 7, "color": "#999999" }, + "partyAddress": { "fontSize": 8 } }, "blocks": [ { "type": "header", "logo": "opts.logo", + "logoWidth": 48, "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1", @@ -19,7 +21,7 @@ }, { "type": "text", "label": "offline", "when": "offline", "style": "offline" }, { "type": "divider" }, - { "type": "spacer", "height": 6 }, + { "type": "spacer", "height": 4 }, { "type": "parties", "left": { @@ -27,21 +29,45 @@ "fields": [ "Podmiot1.DaneIdentyfikacyjne.Nazwa", "Podmiot1.DaneIdentyfikacyjne.NIP", - "Podmiot1.Adres.AdresL1", - "Podmiot1.Adres.AdresL2" + { + "label": "address", + "style": "partyAddress", + "fields": ["Podmiot1.Adres.AdresL1", "Podmiot1.Adres.AdresL2", "Podmiot1.Adres.KodKraju"] + }, + { + "label": "contact", + "from": "Podmiot1.DaneKontaktowe", + "style": "partyAddress", + "fields": ["Email", "Telefon"] + } ] }, "right": { "label": "buyer", "fields": [ "Podmiot2.DaneIdentyfikacyjne.Nazwa", - "Podmiot2.DaneIdentyfikacyjne.NIP", - "Podmiot2.Adres.AdresL1", - "Podmiot2.Adres.AdresL2" + { + "firstOf": [ + "Podmiot2.DaneIdentyfikacyjne.NIP", + "Podmiot2.DaneIdentyfikacyjne.NrVatUE", + "Podmiot2.DaneIdentyfikacyjne.NrID" + ] + }, + { + "label": "address", + "style": "partyAddress", + "fields": ["Podmiot2.Adres.AdresL1", "Podmiot2.Adres.AdresL2", "Podmiot2.Adres.KodKraju"] + }, + { + "label": "contact", + "from": "Podmiot2.DaneKontaktowe", + "style": "partyAddress", + "fields": ["Email", "Telefon", "NrKlienta"] + } ] } }, - { "type": "spacer", "height": 12 }, + { "type": "spacer", "height": 23 }, { "type": "lines", "from": "Fa.FaWiersz", @@ -49,13 +75,13 @@ { "label": "lp", "path": "NrWierszaFa", "width": 24 }, { "label": "name", "path": "P_7", "width": "*" }, { "label": "unit", "path": "P_8A", "width": 36 }, - { "label": "qty", "path": "P_8B", "format": "number", "width": 44 }, + { "label": "qty", "path": "P_8B", "format": "number", "width": 24 }, { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 64 }, { "label": "vatRate", "path": "P_12", "width": 50 }, { "label": "net", "path": "P_11", "format": "money", "width": 70 } ] }, - { "type": "spacer", "height": 10 }, + { "type": "spacer", "height": 21 }, { "type": "totals", "rows": [ @@ -83,7 +109,7 @@ { "label": "totalDue", "path": "Fa.P_15", "format": "money" } ] }, - { "type": "spacer", "height": 10 }, + { "type": "spacer", "height": 21 }, { "type": "payment", "when": "Fa.Platnosc", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index f0c1607b..f9315b13 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -6,12 +6,14 @@ "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, - "footerNote": { "fontSize": 7, "color": "#999999" } + "footerNote": { "fontSize": 7, "color": "#999999" }, + "partyAddress": { "fontSize": 8 } }, "blocks": [ { "type": "header", "logo": "opts.logo", + "logoWidth": 48, "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1", @@ -19,7 +21,7 @@ }, { "type": "text", "label": "offline", "when": "offline", "style": "offline" }, { "type": "divider" }, - { "type": "spacer", "height": 6 }, + { "type": "spacer", "height": 4 }, { "type": "parties", "left": { @@ -27,35 +29,59 @@ "fields": [ "Podmiot1.DaneIdentyfikacyjne.Nazwa", "Podmiot1.DaneIdentyfikacyjne.NIP", - "Podmiot1.Adres.AdresL1", - "Podmiot1.Adres.AdresL2" + { + "label": "address", + "style": "partyAddress", + "fields": ["Podmiot1.Adres.AdresL1", "Podmiot1.Adres.AdresL2", "Podmiot1.Adres.KodKraju"] + }, + { + "label": "contact", + "from": "Podmiot1.DaneKontaktowe", + "style": "partyAddress", + "fields": ["Email", "Telefon"] + } ] }, "right": { "label": "buyer", "fields": [ "Podmiot2.DaneIdentyfikacyjne.Nazwa", - "Podmiot2.DaneIdentyfikacyjne.NIP", - "Podmiot2.Adres.AdresL1", - "Podmiot2.Adres.AdresL2" + { + "firstOf": [ + "Podmiot2.DaneIdentyfikacyjne.NIP", + "Podmiot2.DaneIdentyfikacyjne.NrVatUE", + "Podmiot2.DaneIdentyfikacyjne.NrID" + ] + }, + { + "label": "address", + "style": "partyAddress", + "fields": ["Podmiot2.Adres.AdresL1", "Podmiot2.Adres.AdresL2", "Podmiot2.Adres.KodKraju"] + }, + { + "label": "contact", + "from": "Podmiot2.DaneKontaktowe", + "style": "partyAddress", + "fields": ["Email", "Telefon", "NrKlienta"] + } ] } }, - { "type": "spacer", "height": 12 }, + { "type": "spacer", "height": 23 }, { "type": "lines", "from": "Fa.FaWiersz", "columns": [ { "label": "lp", "path": "NrWierszaFa", "width": 24 }, { "label": "name", "path": "P_7", "width": "*" }, - { "label": "unit", "path": "P_8A", "width": 36 }, - { "label": "qty", "path": "P_8B", "format": "number", "width": 44 }, - { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 64 }, + { "label": "unit", "path": "P_8A", "width": 24 }, + { "label": "qty", "path": "P_8B", "format": "number", "width": 24 }, + { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 50 }, { "label": "vatRate", "path": "P_12", "width": 50 }, - { "label": "net", "path": "P_11", "format": "money", "width": 70 } + { "label": "net", "path": "P_11", "format": "money", "width": 60 } ] }, - { "type": "spacer", "height": 10 }, + { "type": "spacer", "height": 21 }, { "type": "totals", "rows": [ @@ -83,7 +109,7 @@ { "label": "totalDue", "path": "Fa.P_15", "format": "money" } ] }, - { "type": "spacer", "height": 10 }, + { "type": "spacer", "height": 21 }, { "type": "payment", "when": "Fa.Platnosc", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json index 235deb6b..e9e5c44d 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json @@ -9,7 +9,7 @@ "blocks": [ { "type": "header", "title": { "label": "upoTitle" } }, { "type": "divider" }, - { "type": "spacer", "height": 10 }, + { "type": "spacer", "height": 21 }, { "type": "columns", "columns": [ @@ -17,9 +17,9 @@ { "type": "text", "path": "NumerReferencyjnySesji" } ] }, - { "type": "spacer", "height": 10 }, + { "type": "spacer", "height": 21 }, { "type": "text", "label": "documents", "style": "fieldLabel" }, - { "type": "spacer", "height": 4 }, + { "type": "spacer", "height": 15 }, { "type": "each", "from": "Dokument", @@ -60,7 +60,7 @@ { "type": "text", "path": "SkrotDokumentu", "style": "muted" } ] }, - { "type": "spacer", "height": 4 } + { "type": "spacer", "height": 15 } ] } ] diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json index 4f9539ff..412150a5 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json @@ -9,7 +9,7 @@ "blocks": [ { "type": "header", "title": { "label": "upoTitle" } }, { "type": "divider" }, - { "type": "spacer", "height": 10 }, + { "type": "spacer", "height": 21 }, { "type": "columns", "columns": [ @@ -17,9 +17,9 @@ { "type": "text", "path": "NumerReferencyjnySesji" } ] }, - { "type": "spacer", "height": 10 }, + { "type": "spacer", "height": 21 }, { "type": "text", "label": "documents", "style": "fieldLabel" }, - { "type": "spacer", "height": 4 }, + { "type": "spacer", "height": 15 }, { "type": "each", "from": "Dokument", @@ -60,7 +60,7 @@ { "type": "text", "path": "SkrotDokumentu", "style": "muted" } ] }, - { "type": "spacer", "height": 4 } + { "type": "spacer", "height": 15 } ] } ] diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 82c4e3e8..1795064d 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -53,6 +53,11 @@ export interface ColumnDef extends FieldDef { export interface HeaderBlock { type: 'header'; logo?: string; + /** + * Logo width in points; the height follows the image's aspect ratio. Default + * 120, which suits a wide wordmark — a square mark needs far less. + */ + logoWidth?: number; title?: LabelRef; number?: string; date?: string; @@ -65,9 +70,39 @@ export interface HeaderBlock { style?: string; } +/** + * One line of a party panel: a binding path, or a set of paths of which the + * first non-empty one is printed. KSeF identifies a counterparty by exactly one + * of `NIP` / `NrVatUE` / `NrID` / `BrakID`, depending on where they are + * established, so a panel bound to `NIP` alone has nothing to print for a + * foreign buyer. Alternatives are read leniently — the ones that do not apply + * are absent by design, not by mistake. + */ +export type PartyField = string | { firstOf: string[] } | PartyGroup; + +/** + * A labelled sub-group inside a party panel — the address, say. The label is a + * sub-heading in the panel's own heading style; `style` applies to the group's + * value lines. The whole group, heading included, is dropped when none of its + * fields resolve, so a counterparty without an address leaves no orphan label. + */ +export interface PartyGroup { + label: string; + /** + * Repeat the group's fields once per entry of this collection, with each entry + * as the binding root (so `fields` hold item-relative paths). KSeF allows up + * to three `DaneKontaktowe` blocks per party, and a scalar path would silently + * print only the first. Entries are read leniently: every field of a contact + * block is optional, so an absent one is by design, not a typo. + */ + from?: string; + fields: PartyField[]; + style?: string; +} + export interface PartyColumn { label: string; - fields: string[]; + fields: PartyField[]; } export interface PartiesBlock { @@ -251,6 +286,20 @@ const formatEnum = z.enum(['money', 'date', 'number', 'nip', 'paymentForm']); const styleValue = z.union([z.string(), z.number(), z.boolean(), z.array(z.number())]); const styleSchema = z.record(z.string(), styleValue); const labelRef = z.object({ label: z.string().optional(), text: z.string().optional() }).strict(); +const partyField: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.object({ firstOf: z.array(z.string()).nonempty() }).strict(), + z + .object({ + label: z.string(), + from: z.string().optional(), + fields: z.array(partyField), + style: z.string().optional(), + }) + .strict(), + ]), +); const fieldDef = z .object({ label: z.string(), @@ -289,6 +338,7 @@ const blockSchema: z.ZodType = z.lazy(() => z.object({ type: z.literal('header'), logo: z.string().optional(), + logoWidth: z.number().positive().optional(), title: labelRef.optional(), number: z.string().optional(), date: z.string().optional(), @@ -297,8 +347,8 @@ const blockSchema: z.ZodType = z.lazy(() => }).strict(), z.object({ type: z.literal('parties'), - left: z.object({ label: z.string(), fields: z.array(z.string()) }).strict(), - right: z.object({ label: z.string(), fields: z.array(z.string()) }).strict(), + left: z.object({ label: z.string(), fields: z.array(partyField) }).strict(), + right: z.object({ label: z.string(), fields: z.array(partyField) }).strict(), style: z.string().optional(), }).strict(), z.object({ diff --git a/packages/ksef-client-ts/src/pdf/template/interpret.ts b/packages/ksef-client-ts/src/pdf/template/interpret.ts index baf8563b..1db3ff2b 100644 --- a/packages/ksef-client-ts/src/pdf/template/interpret.ts +++ b/packages/ksef-client-ts/src/pdf/template/interpret.ts @@ -113,9 +113,12 @@ const coreRegistry: BlockRegistry = { ); }, + // An empty *text* node still occupies a full line, so `{ text: '' }` with + // margins made a `height: 6` spacer cost 6pt plus ~11pt of phantom line. An + // empty canvas has no line height, so the block now adds exactly its height. spacer: (block) => { const b = block as import('./dsl.js').SpacerBlock; - return { text: '', margin: [0, (b.height ?? 8) / 2, 0, (b.height ?? 8) / 2] }; + return { canvas: [], margin: [0, 0, 0, b.height ?? 8] }; }, }; diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts new file mode 100644 index 00000000..942b22d6 --- /dev/null +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Spawn-based coverage for `ksef invoice pdf` — renders the whole preview set +// through the built CLI (dist/cli.js), no network and no authentication. It +// mirrors invoices/temp/regen.sh so the two cannot drift: the same variants, +// the same flags, only against anonymous fixtures instead of real invoices. +// +// The assertions are deliberately shallow — a file appears, and it is a +// structurally complete PDF. Layout is judged by eye, not here; asserting on +// glyph positions would break on every deliberate design change and tell us +// nothing about whether the page actually reads well. What this does catch is +// the class of failure that is invisible in unit tests: a template that no +// longer validates at import, a bundling regression that drops the fonts, a +// flag that stops being wired, an optional peer that fails to load. +// +// Output goes to a stable directory so the rendered PDFs can be opened and +// reviewed after a run; override it with KSEF_PDF_OUT. + +const repoRoot = resolve(fileURLToPath(import.meta.url), '..', '..', '..'); +const cliEntry = join(repoRoot, 'dist', 'cli.js'); +const fixtures = join(repoRoot, 'tests', 'fixtures', 'pdf'); +const outDir = process.env.KSEF_PDF_OUT ?? join(repoRoot, '.pdf-preview'); + +const fx = (name: string) => join(fixtures, name); + +/** A KSeF number shaped like the real thing; this one identifies nobody. */ +const KSEF_NUMBER = '1111111111-20260115-010000000000-00'; + +function run(args: string[]) { + const result = spawnSync('node', [cliEntry, ...args], { encoding: 'utf-8', cwd: repoRoot }); + return { status: result.status ?? -1, stdout: result.stdout, stderr: result.stderr }; +} + +/** `%PDF-` header and an `%%EOF` trailer: a complete file, not a truncated one. */ +function isCompletePdf(file: string): boolean { + const bytes = readFileSync(file); + const head = bytes.subarray(0, 5).toString('latin1'); + const tail = bytes.subarray(-8).toString('latin1').trim(); + return head === '%PDF-' && tail.endsWith('%%EOF'); +} + +/** Derived inputs that no fixture can hold on its own. */ +let oldTotalsTemplate: string; +let multiDocumentUpo: string; + +function writeDerivedInputs(): void { + // A copy of fa3-default whose totals read a single rate bucket — the shape the + // template had before net/VAT started summing every P_13_*/P_14_*. Built from + // the current template so it tracks unrelated layout edits. + const template = JSON.parse( + readFileSync(join(repoRoot, 'src', 'pdf', 'template', 'builtin', 'fa3-default.json'), 'utf-8'), + ) as { blocks: Array<{ type: string; rows?: Array> }> }; + for (const block of template.blocks) { + if (block.type !== 'totals') continue; + for (const row of block.rows ?? []) { + if (row.label === 'totalNet') { delete row.sum; row.path = 'Fa.P_13_1'; } + if (row.label === 'totalVat') { delete row.sum; row.path = 'Fa.P_14_1'; } + } + } + oldTotalsTemplate = join(outDir, 'fa3-single-bucket-totals.json'); + writeFileSync(oldTotalsTemplate, JSON.stringify(template, null, 2)); + + // A five-document session UPO, cloned from the single-document fixture. + const upo = readFileSync(fx('upo-4_3.xml'), 'utf-8'); + const start = upo.indexOf(''); + const end = upo.indexOf('') + ''.length; + const first = upo.slice(start, end); + const clones = [2, 3, 4, 5].map((i) => + first + .replace('010000000000-00', `${String(i).padStart(2, '0')}0000000000-00`) + .replace('FA/2025/01/001', `FA/2025/01/00${i}`), + ); + multiDocumentUpo = join(outDir, 'upo-4_3-five-documents.xml'); + writeFileSync(multiDocumentUpo, upo.slice(0, end) + '\n' + clones.join('\n') + upo.slice(end)); +} + +describe('35 - `ksef invoice pdf` renders the preview set', () => { + beforeAll(() => { + if (!existsSync(cliEntry)) { + throw new Error(`Missing ${cliEntry}. Run \`yarn build\` before \`yarn test:e2e\`.`); + } + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + writeDerivedInputs(); + }); + + afterAll(() => { + // eslint-disable-next-line no-console + console.log(`\n rendered PDFs kept for review in ${outDir}\n`); + }); + + const variants: Array<[name: string, args: () => string[]]> = [ + ['01-invoice-pl-qr', () => [fx('e2e-services-np.xml'), '--qr', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + ['02-invoice-en', () => [fx('e2e-services-np.xml'), '--locale', 'en', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + ['03-invoice-bilingual', () => [fx('e2e-services-np.xml'), '--locale', 'en+pl', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + ['04-invoice-offline', () => [fx('e2e-services-np.xml'), '--logo', fx('e2e-logo.png')]], + ['05-upo-pl', () => [fx('upo-4_3.xml')]], + ['06-upo-bilingual', () => [fx('upo-4_3.xml'), '--locale', 'en+pl']], + ['07-invoice-standard-rate', () => [fx('fa3.xml')]], + ['08-invoice-buyer-without-id', () => [fx('e2e-buyer-no-id.xml'), '--logo', fx('e2e-logo.png')]], + ['09-invoice-single-bucket-totals', () => [fx('e2e-vat-multi.xml'), '--template-file', oldTotalsTemplate, '--ksef-number', KSEF_NUMBER]], + ['10-upo-five-documents', () => [multiDocumentUpo]], + ['11-invoice-mixed-vat-pl', () => [fx('e2e-vat-multi.xml'), '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + ['12-invoice-mixed-vat-bilingual', () => [fx('e2e-vat-multi.xml'), '--locale', 'en+pl', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + ]; + + it.each(variants)('renders %s', (name, args) => { + const out = join(outDir, `${name}.pdf`); + const res = run(['invoice', 'pdf', ...args(), '--out', out]); + + expect(res.status, `exit ${res.status}\n${res.stderr}`).toBe(0); + expect(existsSync(out), `${out} was not written`).toBe(true); + expect(isCompletePdf(out), `${out} is not a complete PDF`).toBe(true); + }); + + it('renders every variant of the set', () => { + // Guards against a variant being silently dropped from the table above: + // regen.sh and this spec are meant to cover the same ground. + expect(variants).toHaveLength(12); + for (const [name] of variants) { + expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); + } + }); + + it('fails loudly on an unknown template instead of writing a file', () => { + const out = join(outDir, 'should-not-exist.pdf'); + const res = run(['invoice', 'pdf', fx('fa3.xml'), '--template', 'no-such-template', '--out', out]); + expect(res.status).not.toBe(0); + expect(existsSync(out)).toBe(false); + }); + + it('rejects a UPO handed to an invoice template', () => { + const out = join(outDir, 'should-not-exist-2.pdf'); + const res = run(['invoice', 'pdf', fx('upo-4_3.xml'), '--template', 'fa3-default', '--out', out]); + expect(res.status).not.toBe(0); + expect(existsSync(out)).toBe(false); + }); +}); diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/e2e-buyer-no-id.xml b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-buyer-no-id.xml new file mode 100644 index 00000000..884a122a --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-buyer-no-id.xml @@ -0,0 +1,85 @@ + + + + + FA + 3 + 2026-01-15T10:00:00Z + ksef-client-ts fixture + + + PL + + 1111111111 + Przykladowy Sprzedawca Sp. z o.o. + + + PL + ul. Przykladowa 1/2 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 1 + Example Consulting LLC + + + US + 100 Example Avenue + Springfield, CA 90001 + + 2 + 2 + + + EUR + 2026-01-15 + Warszawa + FIX/NOID/2026/001 + 2026-01-15 + 450.00 + 450.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + R&D services - software engineering + h + 5 + 90.00 + 450.00 + np I + + + + 2026-02-15 + + 6 + + PL61109010140000071219812874 + BREXPLPW + Przykladowy Bank S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/e2e-logo.png b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f4589d3faecd4bfb3631924c9bfad01a67f1b7ea GIT binary patch literal 155 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGJMBu^K|kcwMxZyw}iP~c%c;P)WB zz~p<8$oFC{?WxUsn7{LM?hJp)0#peF4pIwx>rBBMQ^xgGFkv9gBL(6#$TYOG!9^cP ZA7ISX67v3H&VC9c=;`X`vd$@?2>{+9BLe^c literal 0 HcmV?d00001 diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/e2e-services-np.xml b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-services-np.xml new file mode 100644 index 00000000..b7bacec2 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-services-np.xml @@ -0,0 +1,99 @@ + + + + + FA + 3 + 2026-01-15T10:00:00Z + ksef-client-ts fixture + + + PL + + 1111111111 + Przykladowy Sprzedawca Sp. z o.o. + + + PL + ul. Przykladowa 1/2 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + REG-000123 + Example Overseas Ltd + + + KY + 1 Example Bay Road, 3rd Floor - Suite 100, P.O. Box 10000 + Grand Cayman, Cayman Islands + + + ap@overseas.example + + 2 + 2 + + + EUR + 2026-01-15 + Warszawa + FIX/NP/2026/001 + 2026-01-15 + 6966.00 + 6966.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Software development services according to Master Services Agreement + PKD 62.01.Z + 62.01.11.0 + h + 174 + 39.00 + 6786.00 + np I + + + 2 + Tooling compensation / Kompensacja narzedzi + szt + 1 + 180.00 + 180.00 + np I + + + + 2026-02-15 + + 6 + + PL61109010140000071219812874 + BREXPLPW + Przykladowy Bank S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/e2e-vat-multi.xml b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-vat-multi.xml new file mode 100644 index 00000000..ab99ac9f --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-vat-multi.xml @@ -0,0 +1,113 @@ + + + + + FA + 3 + 2026-01-15T10:00:00Z + ksef-client-ts fixture + + + PL + + 1111111111 + Przykladowy Sprzedawca Sp. z o.o. + + + PL + ul. Przykladowa 1/2 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 5270001236 + Przykladowa Spolka z o.o. + + + PL + ul. Testowa 100/12 + 00-026 Warszawa + + + ksiegowosc@spolka.example + +48000000002 + + 2 + 2 + + + PLN + 2026-01-15 + Warszawa + FIX/VAT/2026/001 + 2026-01-15 + 5000.00 + 1150.00 + 600.00 + 48.00 + 1200.00 + 7998.00 + + 2 + 2 + 2 + 2 + + 1 + Art. 43 ust. 1 pkt 29 lit. a ustawy o VAT + + + 1 + + 2 + + 1 + + + VAT + + 1 + Uslugi programistyczne - rozwoj aplikacji webowej + 62.01.11.0 + h + 100 + 50.00 + 5000.00 + 23 + + + 2 + Uslugi konserwacji sprzetu komputerowego + h + 10 + 60.00 + 600.00 + 8 + + + 3 + Szkolenie zawodowe - kurs programowania + szt + 1 + 1200.00 + 1200.00 + zw + + + + 2026-02-15 + + 6 + + PL61109010140000071219812874 + BREXPLPW + Przykladowy Bank S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml index e2baef69..a93ff551 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml @@ -19,6 +19,10 @@ ul. Przykładowa 1 00-001 Warszawa + + kontakt@sprzedawca.example + +48000000001 + @@ -30,6 +34,11 @@ ul. Testowa 2 00-002 Kraków + + kontakt@nabywca.example + +48000000002 + KL-0001 + PLN diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml index ee99bfeb..b3169cfd 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml @@ -19,6 +19,10 @@ ul. Przykładowa 1 00-001 Warszawa + + kontakt@sprzedawca.example + +48000000001 + @@ -30,6 +34,11 @@ ul. Testowa 2 00-002 Kraków + + kontakt@nabywca.example + +48000000002 + KL-0001 + PLN diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index 13422d68..173accdb 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -10,6 +10,7 @@ import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; import { applyFormat } from '../../../src/pdf/format.js'; import { headerRenderer } from '../../../src/pdf/template/blocks/header.js'; import { partiesRenderer } from '../../../src/pdf/template/blocks/parties.js'; +import { partiesRenderer } from '../../../src/pdf/template/blocks/parties.js'; import { linesRenderer } from '../../../src/pdf/template/blocks/lines.js'; import { totalsRenderer } from '../../../src/pdf/template/blocks/totals.js'; import { paymentRenderer } from '../../../src/pdf/template/blocks/payment.js'; @@ -37,6 +38,253 @@ const noRender: RenderChild = () => null; // eslint-disable-next-line @typescript-eslint/no-explicit-any const rec = (n: PdfNode | PdfNode[] | null): any => n as any; +// ── parties ───────────────────────────────────────────────────────────────── + +describe('partiesRenderer', () => { + const panel = (root: unknown) => + rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: ['Podmiot1.DaneIdentyfikacyjne.Nazwa'] }, + right: { + label: 'buyer', + fields: [ + 'Podmiot2.DaneIdentyfikacyjne.Nazwa', + { + firstOf: [ + 'Podmiot2.DaneIdentyfikacyjne.NIP', + 'Podmiot2.DaneIdentyfikacyjne.NrVatUE', + 'Podmiot2.DaneIdentyfikacyjne.NrID', + ], + }, + 'Podmiot2.Adres.AdresL1', + ], + }, + }, + makeCtx(root), + noRender, + ), + ).columns[1].stack; + + const buyer = (ident: Record) => ({ + Podmiot1: { DaneIdentyfikacyjne: { Nazwa: 'Sprzedawca' } }, + Podmiot2: { DaneIdentyfikacyjne: { Nazwa: 'Nabywca', ...ident }, Adres: { AdresL1: 'ul. Testowa 1' } }, + }); + + it('prints a Polish NIP', () => { + const stack = panel(buyer({ NIP: '1111111111' })); + expect(stack.map((n: { text: string }) => n.text)).toEqual(['buyer', 'Nabywca', '1111111111', 'ul. Testowa 1']); + }); + + it('falls back to an EU VAT number', () => { + const stack = panel(buyer({ NrVatUE: 'DE123456789' })); + expect(stack.map((n: { text: string }) => n.text)).toContain('DE123456789'); + }); + + it('falls back to a third-country identifier', () => { + const stack = panel(buyer({ NrID: 'CR-421169' })); + expect(stack.map((n: { text: string }) => n.text)).toContain('CR-421169'); + }); + + it('leaves no blank line when the counterparty carries no identifier at all', () => { + const stack = panel(buyer({ BrakID: '1' })); + expect(stack.map((n: { text: string }) => n.text)).toEqual(['buyer', 'Nabywca', 'ul. Testowa 1']); + expect(stack.every((n: { text: string }) => n.text !== '')).toBe(true); + }); + + it('skips a plain field that resolves empty instead of printing a gap', () => { + const root = buyer({ NIP: '1111111111' }); + delete (root.Podmiot2 as { Adres?: unknown }).Adres; + const stack = panel(root); + expect(stack.map((n: { text: string }) => n.text)).toEqual(['buyer', 'Nabywca', '1111111111']); + }); + + it('prints the address under its own sub-heading, in the group style', () => { + const node = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: ['Podmiot1.DaneIdentyfikacyjne.Nazwa'] }, + right: { + label: 'buyer', + fields: [ + 'Podmiot2.DaneIdentyfikacyjne.Nazwa', + { label: 'address', style: 'partyAddress', fields: ['Podmiot2.Adres.AdresL1', 'Podmiot2.Adres.AdresL2'] }, + ], + }, + }, + makeCtx({ + Podmiot1: { DaneIdentyfikacyjne: { Nazwa: 'Sprzedawca' } }, + Podmiot2: { + DaneIdentyfikacyjne: { Nazwa: 'Nabywca' }, + Adres: { AdresL1: 'ul. Testowa 1', AdresL2: '00-001 Warszawa' }, + }, + }), + noRender, + ), + ); + const stack = node.columns[1].stack; + expect(stack.map((n: { text: string }) => n.text)).toEqual([ + 'buyer', 'Nabywca', 'address', 'ul. Testowa 1', '00-001 Warszawa', + ]); + // the sub-heading matches the panel heading; the lines carry the group style + expect(stack[2].style).toBe(stack[0].style); + expect(stack[3].style).toBe('partyAddress'); + expect(stack[4].style).toBe('partyAddress'); + }); + + it('drops an address group whose lines are all absent, heading included', () => { + const node = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: ['Podmiot1.DaneIdentyfikacyjne.Nazwa'] }, + right: { + label: 'buyer', + fields: [ + 'Podmiot2.DaneIdentyfikacyjne.Nazwa', + { label: 'address', style: 'partyAddress', fields: ['Podmiot2.Adres.AdresL1', 'Podmiot2.Adres.AdresL2'] }, + ], + }, + }, + makeCtx({ + Podmiot1: { DaneIdentyfikacyjne: { Nazwa: 'Sprzedawca' } }, + Podmiot2: { DaneIdentyfikacyjne: { Nazwa: 'Nabywca' } }, + }), + noRender, + ), + ); + expect(node.columns[1].stack.map((n: { text: string }) => n.text)).toEqual(['buyer', 'Nabywca']); + }); + + it('keeps the address group when only one of its lines resolves', () => { + const node = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: ['Podmiot1.DaneIdentyfikacyjne.Nazwa'] }, + right: { + label: 'buyer', + fields: [ + { label: 'address', style: 'partyAddress', fields: ['Podmiot2.Adres.AdresL1', 'Podmiot2.Adres.AdresL2'] }, + ], + }, + }, + makeCtx({ + Podmiot1: { DaneIdentyfikacyjne: { Nazwa: 'Sprzedawca' } }, + Podmiot2: { Adres: { AdresL1: 'ul. Testowa 1' } }, + }), + noRender, + ), + ); + expect(node.columns[1].stack.map((n: { text: string }) => n.text)).toEqual([ + 'buyer', 'address', 'ul. Testowa 1', + ]); + }); + + it('repeats a group over its collection, so every contact block is printed', () => { + const contact = { + label: 'contact', + from: 'Podmiot2.DaneKontaktowe', + style: 'partyAddress', + fields: ['Email', 'Telefon'], + }; + const node = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: [] }, + right: { label: 'buyer', fields: [contact] }, + }, + makeCtx({ + Podmiot2: { + DaneKontaktowe: [ + { Email: 'a@example.test', Telefon: '+48000000001' }, + { Email: 'b@example.test' }, + { Telefon: '+48000000003' }, + ], + }, + }), + noRender, + ), + ); + // one heading, then every line of all three blocks — not just the first + expect(node.columns[1].stack.map((n: { text: string }) => n.text)).toEqual([ + 'buyer', 'contact', 'a@example.test', '+48000000001', 'b@example.test', '+48000000003', + ]); + }); + + it('normalizes a single collapsed contact block to one entry', () => { + const node = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: [] }, + right: { + label: 'buyer', + fields: [{ label: 'contact', from: 'Podmiot2.DaneKontaktowe', fields: ['Email', 'Telefon'] }], + }, + }, + makeCtx({ Podmiot2: { DaneKontaktowe: { Email: 'only@example.test' } } }), + noRender, + ), + ); + expect(node.columns[1].stack.map((n: { text: string }) => n.text)).toEqual([ + 'buyer', 'contact', 'only@example.test', + ]); + }); + + it('drops the contact heading when the party carries no contact block', () => { + const node = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: [] }, + right: { + label: 'buyer', + fields: [ + 'Podmiot2.DaneIdentyfikacyjne.Nazwa', + { label: 'contact', from: 'Podmiot2.DaneKontaktowe', fields: ['Email', 'Telefon'] }, + ], + }, + }, + makeCtx({ Podmiot2: { DaneIdentyfikacyjne: { Nazwa: 'Nabywca' } } }), + noRender, + ), + ); + expect(node.columns[1].stack.map((n: { text: string }) => n.text)).toEqual(['buyer', 'Nabywca']); + }); + + it('reads repeater entries leniently even in strict mode', () => { + // Every field of a contact block is optional; a block without a phone number + // must not make a strict render throw. + const render = () => + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: [] }, + right: { + label: 'buyer', + fields: [{ label: 'contact', from: 'Podmiot2.DaneKontaktowe', fields: ['Email', 'Telefon'] }], + }, + }, + makeCtx({ Podmiot2: { DaneKontaktowe: { Email: 'only@example.test' } } }, { strict: true }), + noRender, + ); + expect(render).not.toThrow(); + expect(rec(render()).columns[1].stack.map((n: { text: string }) => n.text)).toEqual([ + 'buyer', 'contact', 'only@example.test', + ]); + }); + + it('prefers NIP when several identifiers are somehow present', () => { + const stack = panel(buyer({ NIP: '1111111111', NrID: 'CR-421169' })); + expect(stack.map((n: { text: string }) => n.text)).toContain('1111111111'); + expect(stack.map((n: { text: string }) => n.text)).not.toContain('CR-421169'); + }); +}); + // ── header (existing renderer) ─────────────────────────────────────────────── describe('headerRenderer', () => { @@ -62,10 +310,10 @@ describe('headerRenderer', () => { expect(node.columns).toHaveLength(2); const [left, right] = node.columns; - // logo image + title - expect(left.stack[0].image).toBe('data:image/png;base64,AAAA'); - expect(left.stack[1].text).toBe('invoice'); - expect(left.stack[1].style).toBe('bigtitle'); + // title, then the logo beneath it + expect(left.stack[0].text).toBe('invoice'); + expect(left.stack[0].style).toBe('bigtitle'); + expect(left.stack[1].image).toBe('data:image/png;base64,AAAA'); // number + date, right-aligned expect(right.alignment).toBe('right'); expect(right.stack).toHaveLength(2); diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index 360fad11..aea58ab6 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { has, list } from '../../../src/pdf/accessor.js'; import { parseXmlForPdf } from '../../../src/pdf/parse.js'; import { getBuiltinTemplate, builtinTemplateNames } from '../../../src/pdf/template/builtin/index.js'; -import type { Block } from '../../../src/pdf/template/dsl.js'; +import type { Block, PartyField } from '../../../src/pdf/template/dsl.js'; /** * Strict mode throws on a missing *scalar* binding, which catches dot-path @@ -33,9 +33,14 @@ const CONTEXT_CONDITIONS = new Set(['qr', 'offline', 'hasKsefNumber', 'opts.logo interface CollectedPaths { conditions: string[]; repeaters: string[]; + /** `firstOf` alternative sets — at least one member must resolve. */ + alternatives: string[][]; } -function collect(blocks: Block[], acc: CollectedPaths = { conditions: [], repeaters: [] }): CollectedPaths { +function collect( + blocks: Block[], + acc: CollectedPaths = { conditions: [], repeaters: [], alternatives: [] }, +): CollectedPaths { for (const block of blocks) { const when = (block as { when?: string }).when; if (when !== undefined && !CONTEXT_CONDITIONS.has(when)) acc.conditions.push(when); @@ -44,6 +49,20 @@ function collect(blocks: Block[], acc: CollectedPaths = { conditions: [], repeat if (block.type === 'table' && block.from !== undefined) acc.repeaters.push(block.from); if (block.type === 'each') acc.repeaters.push(block.from); if (block.type === 'payment' && block.accounts) acc.repeaters.push(block.accounts.from); + if (block.type === 'parties') { + const walkFields = (fields: PartyField[]): void => { + for (const field of fields) { + if (typeof field === 'string') continue; + if ('fields' in field) { + if (field.from !== undefined) acc.repeaters.push(field.from); + walkFields(field.fields); + } + else acc.alternatives.push(field.firstOf); + } + }; + walkFields(block.left.fields); + walkFields(block.right.fields); + } if (block.type === 'stack') collect(block.stack, acc); if (block.type === 'columns') collect(block.columns, acc); @@ -80,13 +99,28 @@ describe('built-in template lint', () => { expect(empty).toEqual([]); }); + it.each(Object.keys(FIXTURE_BY_TEMPLATE))( + '%s: every `firstOf` set has at least one path that resolves', + (name) => { + const root = bodyOf(name); + const { alternatives } = collect(getBuiltinTemplate(name)!.blocks); + // Individual alternatives are absent by design (a buyer carries one + // identifier), but a set where *none* resolves means every path is wrong. + const dead = alternatives.filter((paths) => !paths.some((p) => has(root, p))); + expect(dead).toEqual([]); + }, + ); + it('actually inspects some paths (guards against a walker that finds nothing)', () => { const fa3 = collect(getBuiltinTemplate('fa3-default')!.blocks); expect(fa3.conditions).toContain('Fa.Platnosc'); expect(fa3.repeaters).toContain('Fa.FaWiersz'); expect(fa3.repeaters).toContain('Fa.Platnosc.RachunekBankowy'); + expect(fa3.repeaters).toContain('Podmiot2.DaneKontaktowe'); expect(collect(getBuiltinTemplate('upo-4_3')!.blocks).repeaters).toContain('Dokument'); expect(collect(getBuiltinTemplate('upo-4_2')!.blocks).repeaters).toContain('Dokument'); + expect(fa3.alternatives).toHaveLength(1); + expect(fa3.alternatives[0]).toContain('Podmiot2.DaneIdentyfikacyjne.NrID'); }); it('fails a template whose `when` path is misspelled', () => { diff --git a/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts b/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts index a04bb46e..1355f6cf 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts @@ -34,6 +34,23 @@ describe('resolveLabel', () => { ).toBe('X / X'); }); + it('joins en and pl in that order for en+pl', () => { + expect(resolveLabel('seller', 'en+pl')).toBe('Seller / Sprzedawca'); + }); + + it('en+pl is exactly pl+en reversed', () => { + const [a, b] = resolveLabel('seller', 'pl+en').split(' / '); + expect(resolveLabel('seller', 'en+pl')).toBe(`${b} / ${a}`); + }); + + it('honours the separator for en+pl', () => { + expect(resolveLabel('seller', 'en+pl', { bilingualSeparator: ' | ' })).toBe('Seller | Sprzedawca'); + }); + + it('applies overrides to both halves of en+pl too', () => { + expect(resolveLabel('seller', 'en+pl', { overrides: { seller: 'X' } })).toBe('X / X'); + }); + it('falls back to the raw key when the key is unknown in every bundle', () => { expect(resolveLabel('totally-unknown-key', 'en')).toBe('totally-unknown-key'); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts b/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts index 5bb1c366..95e8687b 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts @@ -200,14 +200,21 @@ describe('interpretBlock core primitives', () => { expect(node.style).toBe('rule'); }); + // An empty text node still occupies a line, so the spacer is an empty canvas: + // the block must add exactly its height, not its height plus a phantom line. it('renders a spacer with the default height', () => { const node = asRecord(interpretBlock({ type: 'spacer' }, makeCtx(ROOT), coreRegistry, 0)); - expect(node).toEqual({ text: '', margin: [0, 4, 0, 4] }); + expect(node).toEqual({ canvas: [], margin: [0, 0, 0, 8] }); }); it('renders a spacer with a custom height', () => { const node = asRecord(interpretBlock({ type: 'spacer', height: 20 }, makeCtx(ROOT), coreRegistry, 0)); - expect(node).toEqual({ text: '', margin: [0, 10, 0, 10] }); + expect(node).toEqual({ canvas: [], margin: [0, 0, 0, 20] }); + }); + + it('spacer carries no text node, so it adds no line of its own', () => { + const node = asRecord(interpretBlock({ type: 'spacer', height: 20 }, makeCtx(ROOT), coreRegistry, 0)); + expect(node.text).toBeUndefined(); }); it('returns null for a block whose `when` is false', () => { diff --git a/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts index 95752852..6657d41e 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts @@ -52,8 +52,8 @@ describe('QR embedding', () => { expect(isPdf(bytes)).toBe(true); }); - it('renders bilingual pl+en labels', async () => { - const bytes = await renderInvoicePdf(fa3, 'fa3-default', { locale: 'pl+en' }); + it.each(['pl+en', 'en+pl'] as const)('renders bilingual %s labels', async (locale) => { + const bytes = await renderInvoicePdf(fa3, 'fa3-default', { locale }); expect(isPdf(bytes)).toBe(true); }); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts b/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts index a2932035..5643c248 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts @@ -74,7 +74,8 @@ describe.each([ const group = (doc.content as Array>).find( (n) => Array.isArray(n.stack) && JSON.stringify(n).includes('KSeF document number'), ) as { stack: unknown[] }; - expect(JSON.stringify(group).split('"canvas"')).toHaveLength(4); + // Count dividers by their drawn line — a spacer is an *empty* canvas. + expect(JSON.stringify(group).split('"type":"line"')).toHaveLength(4); }); it('keeps every field on the page instead of clipping wide records', () => { From 82fa766853dbae4945718ade5e48ecd77649224d Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 17:43:17 +0200 Subject: [PATCH 14/67] test(pdf): cover the library surface the CLI cannot reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 35 drives the renderer through the CLI, but the CLI is a strict subset of the library: it wires five of the ten RenderOptions and has no way to pass a template as an object. Everything else — baseQrUrl, theme, bilingualSeparator, strict, invoiceHash, renderInvoicePdfFromTemplate — had no end-to-end coverage at all, so a regression there would have been invisible simply because no flag exposes it. The spec imports by package specifier, which resolves through the exports map to dist/, so it exercises the published artifact rather than src; a guard asserts that. Assertions stay as shallow as in spec 35 — a complete PDF is written and kept for review — with the rejection paths checked for an error instead of a blank page. One option turned out to be inert: no built-in template consumes `theme.accent`, and DSL styles are static values, so an accent cannot colour anything today — it reaches a template only as the `opts.accent` string binding. The test renders through a template that actually reads that binding, so the option is exercised rather than silently ignored. E2E suite: 150 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- .../tests/e2e/36-invoice-pdf-library.test.ts | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts diff --git a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts new file mode 100644 index 00000000..f426c45d --- /dev/null +++ b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRequire } from 'node:module'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + renderInvoicePdf, + renderInvoicePdfFromFile, + renderInvoicePdfFromTemplate, + renderUpoPdf, + detectInvoiceVersion, + detectUpoVersion, + type InvoiceTemplate, +} from 'ksef-client-ts/pdf'; + +// Companion to spec 35, which drives the same renderer through the CLI. The CLI +// is a strict subset of the library: it wires five of the ten RenderOptions +// (locale, qr, ksefNumber, env, logo) and cannot pass a template as an object at +// all. This spec covers what the command line cannot reach — baseQrUrl, theme, +// bilingualSeparator, strict, invoiceHash, and renderInvoicePdfFromTemplate — +// so a regression there is not invisible just because no flag exposes it. +// +// It imports by package specifier on purpose: that resolves through the exports +// map to dist/, so the published artifact is what gets exercised, not src. +// +// Assertions stay shallow for the same reason as spec 35 — a complete PDF is +// written, and the rendered files are kept for review. Layout is judged by eye. + +const repoRoot = resolve(fileURLToPath(import.meta.url), '..', '..', '..'); +const fixtures = join(repoRoot, 'tests', 'fixtures', 'pdf'); +const outDir = join(process.env.KSEF_PDF_OUT ?? join(repoRoot, '.pdf-preview'), 'library'); + +const fx = (name: string) => join(fixtures, name); +const bytes = (name: string) => new Uint8Array(readFileSync(fx(name))); +const text = (name: string) => readFileSync(fx(name), 'utf-8'); + +const KSEF_NUMBER = '1111111111-20260115-010000000000-00'; +const LOGO = `data:image/png;base64,${readFileSync(fx('e2e-logo.png')).toString('base64')}`; + +function isCompletePdf(file: string): boolean { + const buf = readFileSync(file); + return ( + buf.subarray(0, 5).toString('latin1') === '%PDF-' && + buf.subarray(-8).toString('latin1').trim().endsWith('%%EOF') + ); +} + +async function save(name: string, render: Promise): Promise { + const out = join(outDir, `${name}.pdf`); + const pdf = await render; + expect(pdf, `${name} did not return bytes`).toBeInstanceOf(Uint8Array); + writeFileSync(out, pdf); + expect(isCompletePdf(out), `${name} is not a complete PDF`).toBe(true); + return out; +} + +describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => { + beforeAll(() => { + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + }); + + afterAll(() => { + // eslint-disable-next-line no-console + console.log(`\n library-rendered PDFs kept for review in ${outDir}\n`); + }); + + it('is imported from the built package, not from src', () => { + const resolved = createRequire(import.meta.url).resolve('ksef-client-ts/pdf'); + expect(resolved, 'the spec must exercise dist/ through the exports map').toContain( + join('dist', 'pdf'), + ); + }); + + describe('surface the CLI has no flag for', () => { + it('accepts a template as an object', async () => { + const template: InvoiceTemplate = { + schema: 'FA(3)', + page: { size: 'A4', margins: [40, 40, 40, 40] }, + styles: { title: { fontSize: 18, bold: true } }, + blocks: [ + { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, + { type: 'divider' }, + { + type: 'parties', + left: { label: 'seller', fields: ['Podmiot1.DaneIdentyfikacyjne.Nazwa'] }, + right: { label: 'buyer', fields: ['Podmiot2.DaneIdentyfikacyjne.Nazwa'] }, + }, + { type: 'totals', rows: [{ label: 'totalDue', path: 'Fa.P_15', format: 'money' }] }, + ], + }; + await save('L1-template-object', renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template)); + }); + + it('exposes theme.accent to a template as the `opts.accent` binding', async () => { + // No built-in template consumes it — styles in the DSL are static, so an + // accent cannot colour anything today; it reaches a template only as a + // string binding. Rendering it through a template that actually reads the + // binding keeps the option exercised instead of silently ignored. + const template: InvoiceTemplate = { + schema: 'FA(3)', + blocks: [ + { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, + { type: 'text', path: 'opts.accent' }, + { type: 'totals', rows: [{ label: 'totalDue', path: 'Fa.P_15', format: 'money' }] }, + ], + }; + await save( + 'L2-theme-accent-binding', + renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template, { + theme: { accent: '#B0004E' }, + logo: LOGO, + ksefNumber: KSEF_NUMBER, + }), + ); + }); + + it('honours a custom bilingual separator', async () => { + await save( + 'L3-bilingual-newline-separator', + renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { + locale: 'en+pl', + bilingualSeparator: '\n', + ksefNumber: KSEF_NUMBER, + }), + ); + }); + + it('overrides the QR base URL for an offline/non-standard verifier', async () => { + await save( + 'L4-custom-qr-base-url', + renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { + qr: true, + baseQrUrl: 'https://verify.example/ksef', + ksefNumber: KSEF_NUMBER, + }), + ); + }); + + it('takes a precomputed invoice hash verbatim for the QR', async () => { + const raw = bytes('e2e-vat-multi.xml'); + const invoiceHash = createHash('sha256').update(raw).digest('base64'); + await save( + 'L5-precomputed-invoice-hash', + renderInvoicePdf(raw, 'fa3-default', { qr: true, invoiceHash, ksefNumber: KSEF_NUMBER }), + ); + }); + + it('renders in strict mode against a fixture that populates every binding', async () => { + // fa3.xml exists precisely so the built-in templates can be rendered with + // every dot-path resolved; strict turns a typo in our own preset into a + // thrown error rather than a blank line. + await save('L6-strict-mode', renderInvoicePdf(bytes('fa3.xml'), 'fa3-default', { strict: true })); + }); + + it('accepts the XML as a string as well as bytes', async () => { + await save('L7-string-input', renderInvoicePdf(text('e2e-vat-multi.xml'), 'fa3-default')); + }); + + it('loads a custom template from a JSON file', async () => { + const path = join(outDir, 'minimal-template.json'); + writeFileSync( + path, + JSON.stringify({ + schema: 'FA(3)', + blocks: [ + { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, + { type: 'lines', from: 'Fa.FaWiersz', columns: [ + { label: 'name', path: 'P_7', width: '*' }, + { label: 'net', path: 'P_11', format: 'money', width: 70 }, + ] }, + ], + }), + ); + await save('L8-template-from-file', renderInvoicePdfFromFile(bytes('e2e-vat-multi.xml'), path)); + }); + + it('renders a UPO through the library entry point', async () => { + await save('L9-upo', renderUpoPdf(bytes('upo-4_3.xml'), { locale: 'en+pl' })); + }); + }); + + describe('detectors are part of the public surface', () => { + it('identifies invoices and receipts, and rejects everything else', () => { + expect(detectInvoiceVersion(text('e2e-vat-multi.xml'))).toBe('FA(3)'); + expect(detectInvoiceVersion(text('fa2.xml'))).toBe('FA(2)'); + expect(detectUpoVersion(text('upo-4_3.xml'))).toBe('UPO(4.3)'); + expect(detectUpoVersion(text('upo-4_2.xml'))).toBe('UPO(4.2)'); + expect(detectInvoiceVersion(text('upo-4_3.xml'))).toBeNull(); + expect(detectUpoVersion(text('e2e-vat-multi.xml'))).toBeNull(); + expect(detectInvoiceVersion('')).toBeNull(); + }); + }); + + describe('rejections surface as errors, not as blank pages', () => { + it('rejects an unknown built-in template by name', async () => { + await expect(renderInvoicePdf(bytes('fa3.xml'), 'no-such-template')).rejects.toThrow( + /Unknown built-in template/, + ); + }); + + it('rejects a template file that does not exist', async () => { + await expect( + renderInvoicePdfFromFile(bytes('fa3.xml'), join(outDir, 'absent.json')), + ).rejects.toThrow(/Failed to read template file/); + }); + + it('rejects a template object that fails validation', async () => { + await expect( + renderInvoicePdfFromTemplate(bytes('fa3.xml'), { schema: 'FA(3)' } as unknown as InvoiceTemplate), + ).rejects.toThrow(); + }); + + it('rejects a document the template does not target', async () => { + await expect(renderInvoicePdf(bytes('upo-4_3.xml'), 'fa3-default')).rejects.toThrow( + /not recognized as a FA\(3\)/, + ); + await expect(renderInvoicePdf(bytes('fa2.xml'), 'fa3-default')).rejects.toThrow( + /detected as FA\(2\)/, + ); + }); + + it('rejects a non-UPO document handed to renderUpoPdf', async () => { + await expect(renderUpoPdf(bytes('fa3.xml'))).rejects.toThrow(/UPO/); + }); + }); +}); From 5511b5aefb50885cfbf347dc9b1510d0a33b7d0a Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 17:50:17 +0200 Subject: [PATCH 15/67] test(pdf): render both preview sets into one directory, grouped by prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two specs wrote to different places and numbered their outputs independently, so reviewing a run meant looking in two directories and mentally mapping `L4` to what it exercised. Both now render into `.pdf-preview` with a `cli-`/`lib-` prefix and a number that sorts the way the set reads: invoices first, receipts last, since a receipt is a different document. Generated inputs — the single-bucket template, the five-document receipt, the throwaway custom template — move to an `_inputs` subdirectory; they are inputs, not results, and no longer sit among the pages being reviewed. Sharing a directory means neither spec may clear it: they run in parallel, and whichever started second would delete the other's output. Each now removes only files carrying its own prefix. Verified by running them together three times and alone, with all 21 pages present each time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- .../tests/e2e/35-invoice-pdf-cli.test.ts | 43 ++++++----- .../tests/e2e/36-invoice-pdf-library.test.ts | 71 +++++++++++-------- 2 files changed, 65 insertions(+), 49 deletions(-) diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 942b22d6..6506a416 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -18,12 +18,16 @@ import { fileURLToPath } from 'node:url'; // flag that stops being wired, an optional peer that fails to load. // // Output goes to a stable directory so the rendered PDFs can be opened and -// reviewed after a run; override it with KSEF_PDF_OUT. +// reviewed after a run; override it with KSEF_PDF_OUT. Spec 36 writes its own +// `lib-` prefixed set into the same directory, so this one clears only what it +// owns — wiping the directory would race the sibling spec under a parallel run. const repoRoot = resolve(fileURLToPath(import.meta.url), '..', '..', '..'); const cliEntry = join(repoRoot, 'dist', 'cli.js'); const fixtures = join(repoRoot, 'tests', 'fixtures', 'pdf'); const outDir = process.env.KSEF_PDF_OUT ?? join(repoRoot, '.pdf-preview'); +const inputsDir = join(outDir, '_inputs'); +const PREFIX = 'cli'; const fx = (name: string) => join(fixtures, name); @@ -61,7 +65,7 @@ function writeDerivedInputs(): void { if (row.label === 'totalVat') { delete row.sum; row.path = 'Fa.P_14_1'; } } } - oldTotalsTemplate = join(outDir, 'fa3-single-bucket-totals.json'); + oldTotalsTemplate = join(inputsDir, 'cli-fa3-single-bucket-totals.json'); writeFileSync(oldTotalsTemplate, JSON.stringify(template, null, 2)); // A five-document session UPO, cloned from the single-document fixture. @@ -74,7 +78,7 @@ function writeDerivedInputs(): void { .replace('010000000000-00', `${String(i).padStart(2, '0')}0000000000-00`) .replace('FA/2025/01/001', `FA/2025/01/00${i}`), ); - multiDocumentUpo = join(outDir, 'upo-4_3-five-documents.xml'); + multiDocumentUpo = join(inputsDir, 'cli-upo-4_3-five-documents.xml'); writeFileSync(multiDocumentUpo, upo.slice(0, end) + '\n' + clones.join('\n') + upo.slice(end)); } @@ -83,8 +87,10 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { if (!existsSync(cliEntry)) { throw new Error(`Missing ${cliEntry}. Run \`yarn build\` before \`yarn test:e2e\`.`); } - rmSync(outDir, { recursive: true, force: true }); - mkdirSync(outDir, { recursive: true }); + mkdirSync(inputsDir, { recursive: true }); + for (const stale of readdirSync(outDir)) { + if (stale.startsWith(`${PREFIX}-`)) rmSync(join(outDir, stale), { force: true }); + } writeDerivedInputs(); }); @@ -94,18 +100,19 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { }); const variants: Array<[name: string, args: () => string[]]> = [ - ['01-invoice-pl-qr', () => [fx('e2e-services-np.xml'), '--qr', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], - ['02-invoice-en', () => [fx('e2e-services-np.xml'), '--locale', 'en', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], - ['03-invoice-bilingual', () => [fx('e2e-services-np.xml'), '--locale', 'en+pl', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], - ['04-invoice-offline', () => [fx('e2e-services-np.xml'), '--logo', fx('e2e-logo.png')]], - ['05-upo-pl', () => [fx('upo-4_3.xml')]], - ['06-upo-bilingual', () => [fx('upo-4_3.xml'), '--locale', 'en+pl']], - ['07-invoice-standard-rate', () => [fx('fa3.xml')]], - ['08-invoice-buyer-without-id', () => [fx('e2e-buyer-no-id.xml'), '--logo', fx('e2e-logo.png')]], - ['09-invoice-single-bucket-totals', () => [fx('e2e-vat-multi.xml'), '--template-file', oldTotalsTemplate, '--ksef-number', KSEF_NUMBER]], - ['10-upo-five-documents', () => [multiDocumentUpo]], - ['11-invoice-mixed-vat-pl', () => [fx('e2e-vat-multi.xml'), '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], - ['12-invoice-mixed-vat-bilingual', () => [fx('e2e-vat-multi.xml'), '--locale', 'en+pl', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + [`${PREFIX}-01-invoice-pl-qr`, () => [fx('e2e-services-np.xml'), '--qr', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + [`${PREFIX}-02-invoice-en`, () => [fx('e2e-services-np.xml'), '--locale', 'en', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + [`${PREFIX}-03-invoice-bilingual`, () => [fx('e2e-services-np.xml'), '--locale', 'en+pl', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + [`${PREFIX}-04-invoice-offline`, () => [fx('e2e-services-np.xml'), '--logo', fx('e2e-logo.png')]], + [`${PREFIX}-05-invoice-standard-rate`, () => [fx('fa3.xml')]], + [`${PREFIX}-06-invoice-buyer-without-id`, () => [fx('e2e-buyer-no-id.xml'), '--logo', fx('e2e-logo.png')]], + [`${PREFIX}-07-invoice-mixed-vat-pl`, () => [fx('e2e-vat-multi.xml'), '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + [`${PREFIX}-08-invoice-mixed-vat-bilingual`, () => [fx('e2e-vat-multi.xml'), '--locale', 'en+pl', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], + [`${PREFIX}-09-invoice-single-bucket-totals`, () => [fx('e2e-vat-multi.xml'), '--template-file', oldTotalsTemplate, '--ksef-number', KSEF_NUMBER]], + // Receipts last: they are a different document and read as their own group. + [`${PREFIX}-10-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-11-upo-bilingual`, () => [fx('upo-4_3.xml'), '--locale', 'en+pl']], + [`${PREFIX}-12-upo-five-documents`, () => [multiDocumentUpo]], ]; it.each(variants)('renders %s', (name, args) => { diff --git a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts index f426c45d..ccc1b7b1 100644 --- a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts +++ b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createRequire } from 'node:module'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -26,10 +26,16 @@ import { // // Assertions stay shallow for the same reason as spec 35 — a complete PDF is // written, and the rendered files are kept for review. Layout is judged by eye. +// +// Both specs render into the same directory, distinguished by a `lib-`/`cli-` +// prefix, so clearing is scoped to this spec's own files: wiping the directory +// would race the sibling spec under a parallel run. const repoRoot = resolve(fileURLToPath(import.meta.url), '..', '..', '..'); const fixtures = join(repoRoot, 'tests', 'fixtures', 'pdf'); -const outDir = join(process.env.KSEF_PDF_OUT ?? join(repoRoot, '.pdf-preview'), 'library'); +const outDir = process.env.KSEF_PDF_OUT ?? join(repoRoot, '.pdf-preview'); +const inputsDir = join(outDir, '_inputs'); +const PREFIX = 'lib'; const fx = (name: string) => join(fixtures, name); const bytes = (name: string) => new Uint8Array(readFileSync(fx(name))); @@ -57,8 +63,10 @@ async function save(name: string, render: Promise): Promise describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => { beforeAll(() => { - rmSync(outDir, { recursive: true, force: true }); - mkdirSync(outDir, { recursive: true }); + mkdirSync(inputsDir, { recursive: true }); + for (const stale of readdirSync(outDir)) { + if (stale.startsWith(`${PREFIX}-`)) rmSync(join(outDir, stale), { force: true }); + } }); afterAll(() => { @@ -90,7 +98,25 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => { type: 'totals', rows: [{ label: 'totalDue', path: 'Fa.P_15', format: 'money' }] }, ], }; - await save('L1-template-object', renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template)); + await save(`${PREFIX}-01-template-object`, renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template)); + }); + + it('loads a custom template from a JSON file', async () => { + const path = join(inputsDir, 'lib-minimal-template.json'); + writeFileSync( + path, + JSON.stringify({ + schema: 'FA(3)', + blocks: [ + { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, + { type: 'lines', from: 'Fa.FaWiersz', columns: [ + { label: 'name', path: 'P_7', width: '*' }, + { label: 'net', path: 'P_11', format: 'money', width: 70 }, + ] }, + ], + }), + ); + await save(`${PREFIX}-02-template-from-file`, renderInvoicePdfFromFile(bytes('e2e-vat-multi.xml'), path)); }); it('exposes theme.accent to a template as the `opts.accent` binding', async () => { @@ -107,7 +133,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => ], }; await save( - 'L2-theme-accent-binding', + `${PREFIX}-03-theme-accent-binding`, renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template, { theme: { accent: '#B0004E' }, logo: LOGO, @@ -118,7 +144,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => it('honours a custom bilingual separator', async () => { await save( - 'L3-bilingual-newline-separator', + `${PREFIX}-04-bilingual-newline-separator`, renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { locale: 'en+pl', bilingualSeparator: '\n', @@ -129,7 +155,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => it('overrides the QR base URL for an offline/non-standard verifier', async () => { await save( - 'L4-custom-qr-base-url', + `${PREFIX}-05-custom-qr-base-url`, renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { qr: true, baseQrUrl: 'https://verify.example/ksef', @@ -142,7 +168,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => const raw = bytes('e2e-vat-multi.xml'); const invoiceHash = createHash('sha256').update(raw).digest('base64'); await save( - 'L5-precomputed-invoice-hash', + `${PREFIX}-06-precomputed-invoice-hash`, renderInvoicePdf(raw, 'fa3-default', { qr: true, invoiceHash, ksefNumber: KSEF_NUMBER }), ); }); @@ -151,33 +177,16 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => // fa3.xml exists precisely so the built-in templates can be rendered with // every dot-path resolved; strict turns a typo in our own preset into a // thrown error rather than a blank line. - await save('L6-strict-mode', renderInvoicePdf(bytes('fa3.xml'), 'fa3-default', { strict: true })); + await save(`${PREFIX}-07-strict-mode`, renderInvoicePdf(bytes('fa3.xml'), 'fa3-default', { strict: true })); }); it('accepts the XML as a string as well as bytes', async () => { - await save('L7-string-input', renderInvoicePdf(text('e2e-vat-multi.xml'), 'fa3-default')); - }); - - it('loads a custom template from a JSON file', async () => { - const path = join(outDir, 'minimal-template.json'); - writeFileSync( - path, - JSON.stringify({ - schema: 'FA(3)', - blocks: [ - { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, - { type: 'lines', from: 'Fa.FaWiersz', columns: [ - { label: 'name', path: 'P_7', width: '*' }, - { label: 'net', path: 'P_11', format: 'money', width: 70 }, - ] }, - ], - }), - ); - await save('L8-template-from-file', renderInvoicePdfFromFile(bytes('e2e-vat-multi.xml'), path)); + await save(`${PREFIX}-08-string-input`, renderInvoicePdf(text('e2e-vat-multi.xml'), 'fa3-default')); }); + // Receipt last, as in spec 35. it('renders a UPO through the library entry point', async () => { - await save('L9-upo', renderUpoPdf(bytes('upo-4_3.xml'), { locale: 'en+pl' })); + await save(`${PREFIX}-09-upo`, renderUpoPdf(bytes('upo-4_3.xml'), { locale: 'en+pl' })); }); }); @@ -202,7 +211,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => it('rejects a template file that does not exist', async () => { await expect( - renderInvoicePdfFromFile(bytes('fa3.xml'), join(outDir, 'absent.json')), + renderInvoicePdfFromFile(bytes('fa3.xml'), join(inputsDir, 'absent.json')), ).rejects.toThrow(/Failed to read template file/); }); From 75e4eabb1143846368d28aa137c1213873632ac4 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 18:37:57 +0200 Subject: [PATCH 16/67] feat(pdf): let the reader choose which totals a visualization shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KSeF invoice states no net or VAT total. Net sales are split across the `P_13_*` rate buckets and the tax across `P_14_*`; the only total in the document is `P_15`, the amount due. The template printed a single computed net and VAT figure and labelled them like any other field, so two of the three numbers under the line items were the renderer's arithmetic, visually indistinguishable from the one read straight from the XML. On an invoice whose buckets do not reconcile, that is the renderer putting words in the issuer's mouth. `totals` now picks what appears above the amount due, which is always shown: `none`, `buckets` (a row per bucket the invoice carries, every figure a direct field reading), `summary` (the computed totals) or `both`. The default is `buckets` — always correct, never duplicated, and nothing on the page that the document does not say. It works through row-level `when` on the totals block and two context flags, the same mechanism that already governs the QR and the OFFLINE marker, so the template keeps control of layout and a custom one can regroup freely. Rows that resolve empty are skipped, which is what lets a template list all eighteen buckets and print only the two or three in use. Two consequences worth naming. Strict mode can no longer police this block: with every bucket listed, most rows are legitimately absent, so all totals reads are lenient — the built-in template lint now requires the amount due and at least one bucket to resolve instead. And the zero-rated buckets were missing from the sum entirely: `P_13_6_1/2/3` (domestic, intra-EU supply, export) are separate fields, not a `P_13_6`, so an exporter's net total was understated until now. Full suites: 2540 unit, 153 E2E. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/docs/pdf-export.md | 21 +++- .../src/cli/commands/invoice.ts | 10 ++ packages/ksef-client-ts/src/pdf/i18n/en.ts | 19 +++ packages/ksef-client-ts/src/pdf/i18n/pl.ts | 19 +++ packages/ksef-client-ts/src/pdf/index.ts | 19 +++ .../src/pdf/template/blocks/totals.ts | 33 +++-- .../src/pdf/template/builtin/fa2-default.json | 23 ++++ .../src/pdf/template/builtin/fa3-default.json | 23 ++++ .../ksef-client-ts/src/pdf/template/dsl.ts | 8 ++ .../tests/e2e/35-invoice-pdf-cli.test.ts | 26 ++-- .../tests/fixtures/pdf/e2e-vat-multi.xml | 18 +-- .../unit/pdf/builtin-template-lint.test.ts | 26 +++- .../tests/unit/pdf/totals-sum.test.ts | 117 ++++++++++++++---- 13 files changed, 311 insertions(+), 51 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 0fb36145..341154cb 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -124,6 +124,7 @@ detectUpoVersion(xml); // 'UPO(4.2)' | 'UPO(4.3)' | null | Option | Type | Purpose | |--------|------|---------| | `locale` | `'pl' \| 'en' \| 'pl+en' \| 'en+pl'` | Label language. Default `'pl'`. | +| `totals` | `'none' \| 'buckets' \| 'summary' \| 'both'` | Which tax breakdown to print above the amount due. Default `'buckets'`. | | `qr` | `boolean` | Embed the KSeF Code I verification QR derived from the invoice XML. | | `ksefNumber` | `string` | KSeF number printed on the visualization; when absent the document is marked **OFFLINE**. | | `env` | `'prod' \| 'test' \| 'demo'` | Environment used to derive the QR base URL. Default `'prod'`. | @@ -184,7 +185,7 @@ The `schema` field binds a template to a single document kind. If you render an - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. - **`width`** (table columns only) sizes a column: a number of points, `'auto'` to fit the content, or `'*'` to share out what is left (the default). Sizing is worth setting explicitly — pdfmake gives every `'*'` column the *same* width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token silently widens the whole table past the page edge. -- **`sum`** (totals rows only) adds several binding paths instead of reading one. A KSeF invoice has no single net or VAT total — the amounts are split across the `P_13_*` and `P_14_*` rate buckets — so the built-in templates aggregate them. Absent buckets are skipped, and the addition is decimal-exact. A totals row takes either `path` or `sum`, never both. +- **`sum`** (totals rows only) adds several binding paths instead of reading one. A KSeF invoice has no single net or VAT total — the amounts are split across the `P_13_*` and `P_14_*` rate buckets, with zero-rated sales split three further ways (`P_13_6_1` domestic, `P_13_6_2` intra-EU supply, `P_13_6_3` export) — so the built-in templates aggregate them. Absent buckets are skipped, and the addition is decimal-exact. A totals row takes either `path` or `sum`, never both. ### Minimal example @@ -235,6 +236,23 @@ The bundled `fa3-default` template is a good, complete starting point to copy an --- +## Totals + +A KSeF invoice records no single net or VAT total. Net sales are split across the `P_13_*` rate buckets and the tax across `P_14_*`; the only total the document actually states is `P_15`, the amount due. `totals` chooses what to print above it — the amount due itself is always shown. + +| Mode | Prints | +|------|--------| +| `none` | The amount due, nothing else. | +| `buckets` | One row per rate bucket the invoice carries, each a direct reading of a `P_13_*`/`P_14_*` field. Nothing is computed. Default. | +| `summary` | Net and VAT totals, added up from every bucket. | +| `both` | The breakdown, then the computed totals. | + +`summary` and `both` print two figures that exist nowhere in the document: the renderer adds them up. On an invoice whose buckets do not reconcile, those figures will not match what the issuer intended, and nothing on the page distinguishes them from `P_15`, which is read straight from the XML. `buckets` never has that problem — every number on the page traces to a field. + +Rows whose value is absent are skipped, so a template may list every bucket the schema allows and only the ones this invoice uses appear. The built-in templates do exactly that, gating the two groups on the `totalsBuckets` and `totalsSummary` context flags; a custom template can regroup them freely. + +--- + ## Locales Labels are localizable, driven by the `locale` option: @@ -304,6 +322,7 @@ ksef invoice pdf upo.xml --template-file ./templates/my-upo.json | `--locale ` | Label language (default `pl`) | | `--qr` | Embed the KSeF Code I QR derived from the XML | | `--ksef-number ` | KSeF number to print (absent → marked OFFLINE) | +| `--totals ` | Tax breakdown above the amount due (default `buckets`) | | `--upo` | Treat the input as a UPO document (otherwise auto-detected); ignored when a template is named explicitly | | `--env ` | Environment for the QR base URL | | `--out ` | Output PDF path (default: alongside the source) | diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 16156f40..0beb524b 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -573,6 +573,9 @@ const validateCmd = defineCommand({ const VALID_PDF_LOCALES = ['pl', 'en', 'pl+en', 'en+pl'] as const; type PdfLocale = (typeof VALID_PDF_LOCALES)[number]; +const VALID_PDF_TOTALS = ['none', 'buckets', 'summary', 'both'] as const; +type PdfTotals = (typeof VALID_PDF_TOTALS)[number]; + const LOGO_MIME_BY_EXT: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', @@ -614,6 +617,7 @@ const pdf = defineCommand({ ksefNumber: { type: 'string', description: 'KSeF number to print (absent → marked OFFLINE)' }, upo: { type: 'boolean', description: 'Treat the input as a UPO document (otherwise auto-detected)' }, logo: { type: 'string', description: 'Path to a logo image (PNG/JPEG/GIF/WebP/SVG) to print in the header' }, + totals: { type: 'string', description: 'Tax breakdown above the amount due: none | buckets (as recorded) | summary (computed) | both (default: buckets)' }, env: { type: 'string', description: 'Environment for the QR base URL (test/demo/prod)' }, json: { type: 'boolean', description: 'Output as JSON' }, }, @@ -632,10 +636,16 @@ const pdf = defineCommand({ throw new Error(`Invalid --locale "${locale}". Valid: ${VALID_PDF_LOCALES.join(', ')}`); } + const totals = (args.totals as string | undefined) ?? 'buckets'; + if (!VALID_PDF_TOTALS.includes(totals as PdfTotals)) { + throw new Error(`Invalid --totals "${totals}". Valid: ${VALID_PDF_TOTALS.join(', ')}`); + } + const env = args.env as 'prod' | 'test' | 'demo' | undefined; const logo = args.logo ? readImageAsDataUri(args.logo as string) : undefined; const renderOpts = { locale: locale as PdfLocale, + totals: totals as PdfTotals, qr: Boolean(args.qr), ...(args.ksefNumber ? { ksefNumber: args.ksefNumber as string } : {}), ...(env ? { env } : {}), diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 86486580..49fa07f1 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -21,6 +21,25 @@ export const en: LabelBundle = { net: 'Net amount', vat: 'VAT amount', gross: 'Gross amount', + // per-rate buckets (P_13_* / P_14_*) + net23: 'Net 23%', + vat23: 'VAT 23%', + net8: 'Net 8%', + vat8: 'VAT 8%', + net5: 'Net 5%', + vat5: 'VAT 5%', + net4: 'Net 4%', + vat4: 'VAT 4%', + netSpecial: 'Net — special procedure', + vatSpecial: 'VAT — special procedure', + net0Domestic: 'Net 0% domestic', + net0Wdt: 'Net 0% intra-EU supply', + net0Export: 'Net 0% export', + netExempt: 'Net exempt', + netOutsideTerritory: 'Net outside Poland', + netArticle100: 'Net art. 100(1)(4)', + netReverseCharge: 'Net reverse charge', + netMargin: 'Net margin scheme', totalNet: 'Total net', totalVat: 'Total VAT', totalDue: 'Amount due', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 124fa195..29564964 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -23,6 +23,25 @@ export const pl: LabelBundle = { vat: 'Kwota VAT', gross: 'Wartość brutto', // totals + // per-rate buckets (P_13_* / P_14_*) + net23: 'Netto 23%', + vat23: 'VAT 23%', + net8: 'Netto 8%', + vat8: 'VAT 8%', + net5: 'Netto 5%', + vat5: 'VAT 5%', + net4: 'Netto 4%', + vat4: 'VAT 4%', + netSpecial: 'Netto — procedura szczególna', + vatSpecial: 'VAT — procedura szczególna', + net0Domestic: 'Netto 0% krajowa', + net0Wdt: 'Netto 0% WDT', + net0Export: 'Netto 0% eksport', + netExempt: 'Netto zwolnione', + netOutsideTerritory: 'Netto poza terytorium kraju', + netArticle100: 'Netto art. 100 ust. 1 pkt 4', + netReverseCharge: 'Netto odwrotne obciążenie', + netMargin: 'Netto procedura marży', totalNet: 'Razem netto', totalVat: 'Razem VAT', totalDue: 'Do zapłaty', diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index a1319d16..30d45a3f 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -30,9 +30,25 @@ export type { Locale } from './i18n/types.js'; export type { InvoiceTemplate } from './template/dsl.js'; export { detectInvoiceVersion, detectUpoVersion } from './parse.js'; +/** + * Which parts of the totals block a reader gets. `Do zapłaty` is always shown — + * it is `P_15`, a real field. What varies is the tax breakdown above it: + * + * - `'none'` — nothing but the amount due. + * - `'buckets'` — one row per rate bucket the invoice actually carries, each a + * direct reading of a `P_13_*`/`P_14_*` field. Nothing is computed. + * - `'summary'` — net and VAT totals, added up from every bucket. Convenient, + * but these two figures exist nowhere in the document: the renderer computes + * them. + * - `'both'` — the breakdown followed by the computed totals. + */ +export type TotalsMode = 'none' | 'buckets' | 'summary' | 'both'; + export interface RenderOptions { /** Label language. Default `'pl'`. */ locale?: Locale; + /** Which totals to print above the amount due. Default `'buckets'`. */ + totals?: TotalsMode; /** KSeF number printed on the visualization; absent → marked OFFLINE. */ ksefNumber?: string; /** Embed the KSeF Code I QR derived from the invoice XML. */ @@ -88,10 +104,13 @@ function buildContext( qrUrl, }; + const totals = opts.totals ?? 'buckets'; const flags: Record = { hasKsefNumber: Boolean(opts.ksefNumber), offline: !opts.ksefNumber, qr: Boolean(opts.qr) && qrUrl !== '', + totalsBuckets: totals === 'buckets' || totals === 'both', + totalsSummary: totals === 'summary' || totals === 'both', }; return { root, strict: opts.strict ?? false, label, bindings, flags }; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts index 8658b6ab..7046d901 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts @@ -1,6 +1,6 @@ import { applyFormat, sumDecimal } from '../../format.js'; import type { TotalsBlock } from '../dsl.js'; -import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; +import { evalWhen, resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; /** * Totals summary: a compact, right-aligned label/value table. Each row of @@ -9,22 +9,35 @@ import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.j * sum of several (`sum`), because net sales and tax are split across the * `P_13_*`/`P_14_*` rate buckets and no single total field exists. The * borderless table is pushed to the right edge by an elastic spacer column. + * + * A row whose value resolves empty is dropped, so a template may list every + * rate bucket the schema allows and only the ones this invoice carries appear. */ export const totalsRenderer: BlockRenderer = (block, ctx) => { - // A `sum` lists every bucket the schema allows and a real invoice fills only - // the one or two that apply, so its paths are read non-strictly: an absent - // bucket is the normal case here, not the dot-path typo `strict` hunts for. + // Every read here is non-strict, `sum` and `path` alike. A totals row prints + // only when its value resolves, so a template lists every bucket the schema + // allows and a real invoice fills one or two — an absent bucket is the normal + // case, not the dot-path typo `strict` hunts for. Typos in our own presets are + // caught instead by the built-in template lint, which requires the amount due + // and at least one bucket to resolve against the reference fixtures. const lenient = { ...ctx, strict: false }; - const body: PdfNode[][] = block.rows.map((row) => { + const body: PdfNode[][] = []; + for (const row of block.rows) { + if (!evalWhen(row.when, ctx)) continue; const raw = row.sum ? sumDecimal(row.sum.map((p) => resolveBinding(p, lenient))) - : resolveBinding(row.path ?? '', ctx); - return [ + : resolveBinding(row.path ?? '', lenient); + const value = applyFormat(raw, row.format); + // A row that resolves empty is skipped, as in `payment` and `parties`: a + // template listing every rate bucket must not print a dangling label for + // each one an invoice does not use. + if (value === '') continue; + body.push([ { text: ctx.label(row.label), bold: true }, - { text: applyFormat(raw, row.format), alignment: 'right' }, - ]; - }); + { text: value, alignment: 'right' }, + ]); + } return { columns: [ diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 133ba25e..29cc802f 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -85,6 +85,24 @@ { "type": "totals", "rows": [ + { "label": "net23", "path": "Fa.P_13_1", "when": "totalsBuckets", "format": "money" }, + { "label": "vat23", "path": "Fa.P_14_1", "when": "totalsBuckets", "format": "money" }, + { "label": "net8", "path": "Fa.P_13_2", "when": "totalsBuckets", "format": "money" }, + { "label": "vat8", "path": "Fa.P_14_2", "when": "totalsBuckets", "format": "money" }, + { "label": "net5", "path": "Fa.P_13_3", "when": "totalsBuckets", "format": "money" }, + { "label": "vat5", "path": "Fa.P_14_3", "when": "totalsBuckets", "format": "money" }, + { "label": "net4", "path": "Fa.P_13_4", "when": "totalsBuckets", "format": "money" }, + { "label": "vat4", "path": "Fa.P_14_4", "when": "totalsBuckets", "format": "money" }, + { "label": "netSpecial", "path": "Fa.P_13_5", "when": "totalsBuckets", "format": "money" }, + { "label": "vatSpecial", "path": "Fa.P_14_5", "when": "totalsBuckets", "format": "money" }, + { "label": "net0Domestic", "path": "Fa.P_13_6_1", "when": "totalsBuckets", "format": "money" }, + { "label": "net0Wdt", "path": "Fa.P_13_6_2", "when": "totalsBuckets", "format": "money" }, + { "label": "net0Export", "path": "Fa.P_13_6_3", "when": "totalsBuckets", "format": "money" }, + { "label": "netExempt", "path": "Fa.P_13_7", "when": "totalsBuckets", "format": "money" }, + { "label": "netOutsideTerritory", "path": "Fa.P_13_8", "when": "totalsBuckets", "format": "money" }, + { "label": "netArticle100", "path": "Fa.P_13_9", "when": "totalsBuckets", "format": "money" }, + { "label": "netReverseCharge", "path": "Fa.P_13_10", "when": "totalsBuckets", "format": "money" }, + { "label": "netMargin", "path": "Fa.P_13_11", "when": "totalsBuckets", "format": "money" }, { "label": "totalNet", "sum": [ @@ -93,17 +111,22 @@ "Fa.P_13_3", "Fa.P_13_4", "Fa.P_13_5", + "Fa.P_13_6_1", + "Fa.P_13_6_2", + "Fa.P_13_6_3", "Fa.P_13_7", "Fa.P_13_8", "Fa.P_13_9", "Fa.P_13_10", "Fa.P_13_11" ], + "when": "totalsSummary", "format": "money" }, { "label": "totalVat", "sum": ["Fa.P_14_1", "Fa.P_14_2", "Fa.P_14_3", "Fa.P_14_4", "Fa.P_14_5"], + "when": "totalsSummary", "format": "money" }, { "label": "totalDue", "path": "Fa.P_15", "format": "money" } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index f9315b13..98ac6bf5 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -85,6 +85,24 @@ { "type": "totals", "rows": [ + { "label": "net23", "path": "Fa.P_13_1", "when": "totalsBuckets", "format": "money" }, + { "label": "vat23", "path": "Fa.P_14_1", "when": "totalsBuckets", "format": "money" }, + { "label": "net8", "path": "Fa.P_13_2", "when": "totalsBuckets", "format": "money" }, + { "label": "vat8", "path": "Fa.P_14_2", "when": "totalsBuckets", "format": "money" }, + { "label": "net5", "path": "Fa.P_13_3", "when": "totalsBuckets", "format": "money" }, + { "label": "vat5", "path": "Fa.P_14_3", "when": "totalsBuckets", "format": "money" }, + { "label": "net4", "path": "Fa.P_13_4", "when": "totalsBuckets", "format": "money" }, + { "label": "vat4", "path": "Fa.P_14_4", "when": "totalsBuckets", "format": "money" }, + { "label": "netSpecial", "path": "Fa.P_13_5", "when": "totalsBuckets", "format": "money" }, + { "label": "vatSpecial", "path": "Fa.P_14_5", "when": "totalsBuckets", "format": "money" }, + { "label": "net0Domestic", "path": "Fa.P_13_6_1", "when": "totalsBuckets", "format": "money" }, + { "label": "net0Wdt", "path": "Fa.P_13_6_2", "when": "totalsBuckets", "format": "money" }, + { "label": "net0Export", "path": "Fa.P_13_6_3", "when": "totalsBuckets", "format": "money" }, + { "label": "netExempt", "path": "Fa.P_13_7", "when": "totalsBuckets", "format": "money" }, + { "label": "netOutsideTerritory", "path": "Fa.P_13_8", "when": "totalsBuckets", "format": "money" }, + { "label": "netArticle100", "path": "Fa.P_13_9", "when": "totalsBuckets", "format": "money" }, + { "label": "netReverseCharge", "path": "Fa.P_13_10", "when": "totalsBuckets", "format": "money" }, + { "label": "netMargin", "path": "Fa.P_13_11", "when": "totalsBuckets", "format": "money" }, { "label": "totalNet", "sum": [ @@ -93,17 +111,22 @@ "Fa.P_13_3", "Fa.P_13_4", "Fa.P_13_5", + "Fa.P_13_6_1", + "Fa.P_13_6_2", + "Fa.P_13_6_3", "Fa.P_13_7", "Fa.P_13_8", "Fa.P_13_9", "Fa.P_13_10", "Fa.P_13_11" ], + "when": "totalsSummary", "format": "money" }, { "label": "totalVat", "sum": ["Fa.P_14_1", "Fa.P_14_2", "Fa.P_14_3", "Fa.P_14_4", "Fa.P_14_5"], + "when": "totalsSummary", "format": "money" }, { "label": "totalDue", "path": "Fa.P_15", "format": "money" } diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 1795064d..13639417 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -130,6 +130,13 @@ export interface TotalsRow { path?: string; /** Binding paths to add up; absent buckets are skipped. */ sum?: string[]; + /** + * Visibility condition, evaluated like any other `when`. The built-in + * templates gate their per-bucket rows on `totalsBuckets` and their computed + * summary on `totalsSummary`, so {@link RenderOptions.totals} picks which of + * the two a reader gets without the template changing shape. + */ + when?: string; format?: FormatterName; style?: string; } @@ -324,6 +331,7 @@ const totalsRow = z label: z.string(), path: z.string().optional(), sum: z.array(z.string()).nonempty().optional(), + when: z.string().optional(), format: formatEnum.optional(), style: z.string().optional(), }) diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 6506a416..3eb2fa98 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -99,6 +99,9 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { console.log(`\n rendered PDFs kept for review in ${outDir}\n`); }); + /** The mixed-rate document with the flags every totals variant shares. */ + const mixedVat = () => [fx('e2e-vat-multi.xml'), '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]; + const variants: Array<[name: string, args: () => string[]]> = [ [`${PREFIX}-01-invoice-pl-qr`, () => [fx('e2e-services-np.xml'), '--qr', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], [`${PREFIX}-02-invoice-en`, () => [fx('e2e-services-np.xml'), '--locale', 'en', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], @@ -106,13 +109,22 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { [`${PREFIX}-04-invoice-offline`, () => [fx('e2e-services-np.xml'), '--logo', fx('e2e-logo.png')]], [`${PREFIX}-05-invoice-standard-rate`, () => [fx('fa3.xml')]], [`${PREFIX}-06-invoice-buyer-without-id`, () => [fx('e2e-buyer-no-id.xml'), '--logo', fx('e2e-logo.png')]], - [`${PREFIX}-07-invoice-mixed-vat-pl`, () => [fx('e2e-vat-multi.xml'), '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], - [`${PREFIX}-08-invoice-mixed-vat-bilingual`, () => [fx('e2e-vat-multi.xml'), '--locale', 'en+pl', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], - [`${PREFIX}-09-invoice-single-bucket-totals`, () => [fx('e2e-vat-multi.xml'), '--template-file', oldTotalsTemplate, '--ksef-number', KSEF_NUMBER]], + // Every totals mode on one mixed-rate document (23% + 8% + exempt), so the + // four can be compared page by page. Same flags throughout — only --totals + // differs, and the amount due must appear in all of them. + [`${PREFIX}-07-invoice-mixed-vat-totals-none`, () => [...mixedVat(), '--totals', 'none']], + [`${PREFIX}-08-invoice-mixed-vat-totals-buckets`, () => [...mixedVat(), '--totals', 'buckets']], + [`${PREFIX}-09-invoice-mixed-vat-totals-summary`, () => [...mixedVat(), '--totals', 'summary']], + [`${PREFIX}-10-invoice-mixed-vat-totals-both`, () => [...mixedVat(), '--totals', 'both']], + // The A/B against 09: identical document and flags, but the totals read the + // standard-rate bucket alone instead of summing all of them. It needs + // --totals summary, since that is the group those rows belong to. + [`${PREFIX}-11-invoice-mixed-vat-single-bucket-totals`, () => [...mixedVat(), '--totals', 'summary', '--template-file', oldTotalsTemplate]], + [`${PREFIX}-12-invoice-mixed-vat-bilingual`, () => [...mixedVat(), '--locale', 'en+pl', '--totals', 'both']], // Receipts last: they are a different document and read as their own group. - [`${PREFIX}-10-upo-pl`, () => [fx('upo-4_3.xml')]], - [`${PREFIX}-11-upo-bilingual`, () => [fx('upo-4_3.xml'), '--locale', 'en+pl']], - [`${PREFIX}-12-upo-five-documents`, () => [multiDocumentUpo]], + [`${PREFIX}-13-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-14-upo-bilingual`, () => [fx('upo-4_3.xml'), '--locale', 'en+pl']], + [`${PREFIX}-15-upo-five-documents`, () => [multiDocumentUpo]], ]; it.each(variants)('renders %s', (name, args) => { @@ -127,7 +139,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { it('renders every variant of the set', () => { // Guards against a variant being silently dropped from the table above: // regen.sh and this spec are meant to cover the same ground. - expect(variants).toHaveLength(12); + expect(variants).toHaveLength(15); for (const [name] of variants) { expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); } diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/e2e-vat-multi.xml b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-vat-multi.xml index ab99ac9f..8e75b079 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/e2e-vat-multi.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-vat-multi.xml @@ -46,12 +46,12 @@ Warszawa FIX/VAT/2026/001 2026-01-15 - 5000.00 - 1150.00 - 600.00 - 48.00 + 10000.00 + 2300.00 + 1000.00 + 80.00 1200.00 - 7998.00 + 14580.00 2 2 @@ -76,8 +76,8 @@ 62.01.11.0 h 100 - 50.00 - 5000.00 + 100.00 + 10000.00 23 @@ -85,8 +85,8 @@ Uslugi konserwacji sprzetu komputerowego h 10 - 60.00 - 600.00 + 100.00 + 1000.00 8 diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index aea58ab6..dda56647 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { has, list } from '../../../src/pdf/accessor.js'; import { parseXmlForPdf } from '../../../src/pdf/parse.js'; import { getBuiltinTemplate, builtinTemplateNames } from '../../../src/pdf/template/builtin/index.js'; -import type { Block, PartyField } from '../../../src/pdf/template/dsl.js'; +import type { Block, PartyField, TotalsBlock } from '../../../src/pdf/template/dsl.js'; /** * Strict mode throws on a missing *scalar* binding, which catches dot-path @@ -28,7 +28,10 @@ const FIXTURE_BY_TEMPLATE: Record = { }; /** `when` values resolved from the render context, not from the XML. */ -const CONTEXT_CONDITIONS = new Set(['qr', 'offline', 'hasKsefNumber', 'opts.logo', 'opts.ksefNumber', 'opts.accent', 'qrUrl']); +const CONTEXT_CONDITIONS = new Set([ + 'qr', 'offline', 'hasKsefNumber', 'totalsBuckets', 'totalsSummary', + 'opts.logo', 'opts.ksefNumber', 'opts.accent', 'qrUrl', +]); interface CollectedPaths { conditions: string[]; @@ -45,6 +48,11 @@ function collect( const when = (block as { when?: string }).when; if (when !== undefined && !CONTEXT_CONDITIONS.has(when)) acc.conditions.push(when); + if (block.type === 'totals') { + for (const row of block.rows) { + if (row.when !== undefined && !CONTEXT_CONDITIONS.has(row.when)) acc.conditions.push(row.when); + } + } if (block.type === 'lines') acc.repeaters.push(block.from); if (block.type === 'table' && block.from !== undefined) acc.repeaters.push(block.from); if (block.type === 'each') acc.repeaters.push(block.from); @@ -111,6 +119,20 @@ describe('built-in template lint', () => { }, ); + it.each(['fa2-default', 'fa3-default'])( + '%s: the amount due and at least one rate bucket resolve', + (name) => { + // Totals rows are read leniently — they print only when present — so + // `strict` cannot police them. This is what catches a typo instead. + const root = bodyOf(name); + const totals = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'totals') as TotalsBlock; + const due = totals.rows.find((r) => r.label === 'totalDue')!; + expect(has(root, due.path!), 'the amount due must resolve').toBe(true); + const buckets = totals.rows.filter((r) => r.when === 'totalsBuckets' && r.path); + expect(buckets.some((r) => has(root, r.path!)), 'no rate bucket resolves').toBe(true); + }, + ); + it('actually inspects some paths (guards against a walker that finds nothing)', () => { const fa3 = collect(getBuiltinTemplate('fa3-default')!.blocks); expect(fa3.conditions).toContain('Fa.Platnosc'); diff --git a/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts b/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts index a91b49c9..362e9b80 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts @@ -10,19 +10,30 @@ import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; const fa3 = readFileSync(new URL('../../fixtures/pdf/fa3.xml', import.meta.url), 'utf8'); +/** 23% + 8% + exempt, so every totals mode has something to show. */ +const mixedRate = fa3 + .replace('500.00', '500.00200.0050.00') + .replace('115.00', '115.0016.00') + .replace('615.00', '881.00'); + /** * The net/VAT buckets a KSeF invoice can carry, from the FA(2)/FA(3) XSD (both - * schemas declare the same set; there is no `P_13_6`). The `P_14_*W` fields are - * the same tax restated in PLN for foreign-currency invoices, so they are - * deliberately excluded — adding them would double-count. + * schemas declare the same set). The zero-rated sales are split three ways + * rather than sitting in a `P_13_6`: domestic, intra-EU supply and export. + * The `P_14_*W` fields are the same tax restated in PLN for foreign-currency + * invoices, so they are deliberately excluded — adding them would double-count. */ const NET_BUCKETS = [ 'Fa.P_13_1', 'Fa.P_13_2', 'Fa.P_13_3', 'Fa.P_13_4', 'Fa.P_13_5', + 'Fa.P_13_6_1', 'Fa.P_13_6_2', 'Fa.P_13_6_3', 'Fa.P_13_7', 'Fa.P_13_8', 'Fa.P_13_9', 'Fa.P_13_10', 'Fa.P_13_11', ]; const VAT_BUCKETS = ['Fa.P_14_1', 'Fa.P_14_2', 'Fa.P_14_3', 'Fa.P_14_4', 'Fa.P_14_5']; -function totalsBody(xml: string, templateName: string) { +type TotalsMode = 'none' | 'buckets' | 'summary' | 'both'; + +/** Rendered totals as `label -> value`, in the order the rows appear. */ +function totalsRows(xml: string, templateName: string, mode: TotalsMode = 'summary') { const template = getBuiltinTemplate(templateName)!; const parsed = parseXmlForPdf(xml); const ctx = { @@ -30,16 +41,23 @@ function totalsBody(xml: string, templateName: string) { strict: false, label: makeLabelResolver('en', {}), bindings: {}, - flags: {}, + flags: { + totalsBuckets: mode === 'buckets' || mode === 'both', + totalsSummary: mode === 'summary' || mode === 'both', + }, }; const doc = interpretTemplate(template, ctx, blockRegistry); const totals = (doc.content as Array>).find( (n) => Array.isArray(n.columns) && JSON.stringify(n).includes('table'), )!; const cols = totals.columns as Array>; - return (cols[1] as unknown as { table: { body: Array> } }).table.body; + const body = (cols[1] as unknown as { table: { body: Array> } }).table.body; + return body.map(([label, value]) => [label.text, value.text] as const); } +const valueOf = (rows: ReadonlyArray, label: string) => + rows.find(([l]) => l === label)?.[1]; + describe('sumDecimal', () => { it('adds monetary strings without floating-point drift', () => { expect(sumDecimal(['0.1', '0.2'])).toBe('0.3'); @@ -85,28 +103,83 @@ describe('built-in totals aggregate every VAT bucket', () => { .replace('500.00', '500.00') .replace('115.00', '40.00') .replace('615.00', '540.00'); - const body = totalsBody(reduced, 'fa3-default'); - expect(body[0][1].text).toBe('500,00'); - expect(body[1][1].text).toBe('40,00'); - expect(body[2][1].text).toBe('540,00'); + const rows = totalsRows(reduced, 'fa3-default'); + expect(valueOf(rows, 'Total net')).toBe('500,00'); + expect(valueOf(rows, 'Total VAT')).toBe('40,00'); + expect(valueOf(rows, 'Amount due')).toBe('540,00'); }); it('adds the buckets of a mixed-rate invoice', () => { - const mixed = fa3 - .replace('500.00', '500.00200.0050.00') - .replace('115.00', '115.0016.00') - .replace('615.00', '881.00'); - const body = totalsBody(mixed, 'fa3-default'); - expect(body[0][1].text).toBe('750,00'); // 500 + 200 + 50 - expect(body[1][1].text).toBe('131,00'); // 115 + 16 - expect(body[2][1].text).toBe('881,00'); + const rows = totalsRows(mixedRate, 'fa3-default'); + expect(valueOf(rows, 'Total net')).toBe('750,00'); // 500 + 200 + 50 + expect(valueOf(rows, 'Total VAT')).toBe('131,00'); // 115 + 16 + expect(valueOf(rows, 'Amount due')).toBe('881,00'); + }); + + it('counts zero-rated sales, which sit in three separate buckets', () => { + // A 0% line lands in P_13_6_1 (domestic), P_13_6_2 (intra-EU supply) or + // P_13_6_3 (export) depending on why it is zero-rated — an exporter's whole + // turnover can live there and contribute no VAT at all. + const zeroRated = fa3 + .replace( + '500.00', + '500.00100.002000.003000.00', + ) + .replace('615.00', '5715.00'); + const rows = totalsRows(zeroRated, 'fa3-default'); + expect(valueOf(rows, 'Total net')).toBe('5\u00A0600,00'); // 500 + 100 + 2000 + 3000 + expect(valueOf(rows, 'Total VAT')).toBe('115,00'); // zero-rated sales carry no VAT + expect(valueOf(rows, 'Amount due')).toBe('5\u00A0715,00'); }); it('still renders the standard-rate-only fixture unchanged', () => { - const body = totalsBody(fa3, 'fa3-default'); - expect(body[0][1].text).toBe('500,00'); - expect(body[1][1].text).toBe('115,00'); - expect(body[2][1].text).toBe('615,00'); + const rows = totalsRows(fa3, 'fa3-default'); + expect(valueOf(rows, 'Total net')).toBe('500,00'); + expect(valueOf(rows, 'Total VAT')).toBe('115,00'); + expect(valueOf(rows, 'Amount due')).toBe('615,00'); + }); +}); + +describe('the totals mode selects what a reader gets', () => { + it('none: the amount due and nothing else', () => { + expect(totalsRows(mixedRate, 'fa3-default', 'none')).toEqual([['Amount due', '881,00']]); + }); + + it('buckets: one row per bucket the invoice carries, nothing computed', () => { + expect(totalsRows(mixedRate, 'fa3-default', 'buckets')).toEqual([ + ['Net 23%', '500,00'], + ['VAT 23%', '115,00'], + ['Net 8%', '200,00'], + ['VAT 8%', '16,00'], + ['Net exempt', '50,00'], + ['Amount due', '881,00'], + ]); + }); + + it('summary: only the computed totals', () => { + expect(totalsRows(mixedRate, 'fa3-default', 'summary')).toEqual([ + ['Total net', '750,00'], + ['Total VAT', '131,00'], + ['Amount due', '881,00'], + ]); + }); + + it('both: the breakdown followed by the computed totals', () => { + const rows = totalsRows(mixedRate, 'fa3-default', 'both'); + expect(rows.map(([l]) => l)).toEqual([ + 'Net 23%', 'VAT 23%', 'Net 8%', 'VAT 8%', 'Net exempt', 'Total net', 'Total VAT', 'Amount due', + ]); + }); + + it('never prints a bucket the invoice does not carry', () => { + const rows = totalsRows(fa3, 'fa3-default', 'both'); + expect(rows.map(([l]) => l)).toEqual(['Net 23%', 'VAT 23%', 'Total net', 'Total VAT', 'Amount due']); + }); + + it('shows the amount due in every mode', () => { + for (const mode of ['none', 'buckets', 'summary', 'both'] as const) { + expect(valueOf(totalsRows(mixedRate, 'fa3-default', mode), 'Amount due'), mode).toBe('881,00'); + } }); }); From 6b191eade03f9ce2a0137fbab4ca8127ef530548 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 18:50:55 +0200 Subject: [PATCH 17/67] fix(pdf): make strict mode usable, and keep it policing the amount due MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `strict` is meant to turn a dot-path typo into an error instead of a blank line. It could not do that job: the built-in templates bind fields the FA schema declares optional, so the first one an invoice happened not to carry threw. An unpaid invoice has no `Zaplacono`; a line may omit `P_7`; a rate bucket is present only if that rate was used. Enabling strict on a real document was therefore an error, and the previous commit gave up on the totals block entirely — reading even `Fa.P_15` leniently. A binding is now policed unless the template marks it `optional`, and the built-in templates mark exactly the paths the schema allows to be absent — 31 of them, verified against the XSD ancestry rather than guessed. `Fa.P_15` is not among them: it has no optional ancestor, an invoice always states its amount due, so a misspelled `totalDue` path throws again. That was the one binding worth policing most, and it is back. The marker generalizes across the blocks that read bindings — lines, table, payment, parties, totals — so a custom template gets the same contract: say what may be missing, and strict guards the rest. Verified by rendering every fixture strict, including an unpaid invoice and one without the optional second address line, and by misspelling the amount due, a party name and a line column in turn — each throws with the offending path. The trade-off is pinned too: a typo inside a path marked optional is not caught, and a test says so. Full suites: 2559 unit, 153 E2E. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/docs/pdf-export.md | 5 +- .../src/pdf/template/blocks/lines.ts | 2 +- .../src/pdf/template/blocks/parties.ts | 7 +- .../src/pdf/template/blocks/payment.ts | 6 +- .../src/pdf/template/blocks/table.ts | 6 +- .../src/pdf/template/blocks/totals.ts | 13 +- .../src/pdf/template/builtin/fa2-default.json | 94 ++++++++---- .../src/pdf/template/builtin/fa3-default.json | 94 ++++++++---- .../ksef-client-ts/src/pdf/template/dsl.ts | 20 ++- .../unit/pdf/builtin-template-lint.test.ts | 5 +- .../tests/unit/pdf/strict-mode.test.ts | 140 ++++++++++++++++++ 11 files changed, 314 insertions(+), 78 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 341154cb..9847d9ac 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -134,7 +134,9 @@ detectUpoVersion(xml); // 'UPO(4.2)' | 'UPO(4.3)' | null | `bilingualSeparator` | `string` | Separator for the bilingual locales. Default `' / '`. | | `strict` | `boolean` | Throw on a missing binding instead of rendering an empty string. | -`strict` covers the scalar bindings a template *prints*. It deliberately does not apply to `when` conditions or repeater `from` paths: the KSeF schemas make `Platnosc` and `RachunekBankowy` optional, so an absent node there is a cash-paid invoice rather than a template mistake, and throwing would reject valid documents. Typos in those paths are caught for the built-in templates by a lint that resolves every `when` and `from` against the reference fixtures. +`strict` polices the scalar bindings a template prints — a misspelled path throws instead of leaving a blank line. A binding the document may legitimately omit is exempted by marking it `optional` in the template, and the built-in templates mark exactly the paths the FA schema declares optional. `Fa.P_15` is not one of them: an invoice always states its amount due, so a strict render still catches a typo there. + +It deliberately does not apply to `when` conditions, repeater `from` paths, `firstOf` alternatives or `sum` members: those are sets where absence is the normal case, not a mistake. Typos in them are caught for the built-in templates by a lint that resolves every such path against the reference fixtures. | `invoiceHash` | `string` | Precomputed canonical invoice hash (base64), used verbatim for the QR. | --- @@ -183,6 +185,7 @@ The `schema` field binds a template to a single document kind. If you render an - **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. - **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. +- **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. - **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. - **`width`** (table columns only) sizes a column: a number of points, `'auto'` to fit the content, or `'*'` to share out what is left (the default). Sizing is worth setting explicitly — pdfmake gives every `'*'` column the *same* width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token silently widens the whole table past the page edge. - **`sum`** (totals rows only) adds several binding paths instead of reading one. A KSeF invoice has no single net or VAT total — the amounts are split across the `P_13_*` and `P_14_*` rate buckets, with zero-rated sales split three further ways (`P_13_6_1` domestic, `P_13_6_2` intra-EU supply, `P_13_6_3` export) — so the built-in templates aggregate them. Absent buckets are skipped, and the addition is decimal-exact. A totals row takes either `path` or `sum`, never both. diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts b/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts index 13cd1868..aca2eec4 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts @@ -19,7 +19,7 @@ import { type BlockRenderer, type PdfNode } from '../interpret.js'; export const linesRenderer: BlockRenderer = (block, ctx) => { const headerRow: PdfNode[] = block.columns.map((c) => ({ text: ctx.label(c.label), bold: true })); const bodyRows: PdfNode[][] = list(ctx.root, block.from).map((row) => - block.columns.map((c) => ({ text: applyFormat(get(row, c.path, ctx.strict), c.format) })), + block.columns.map((c) => ({ text: applyFormat(get(row, c.path, c.optional ? false : ctx.strict), c.format) })), ); return { diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts index 13d7fbc6..7f4b6f59 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts @@ -32,8 +32,13 @@ function isGroup(field: PartyField): field is PartyGroup { export const partiesRenderer: BlockRenderer = (block, ctx) => { const at = (root: unknown, strict = ctx.strict): RenderContext => ({ ...ctx, root, strict }); - const resolveValue = (field: string | { firstOf: string[] }, root: unknown, strict: boolean): string => { + const resolveValue = ( + field: string | { path: string; optional?: boolean } | { firstOf: string[] }, + root: unknown, + strict: boolean, + ): string => { if (typeof field === 'string') return resolveBinding(field, at(root, strict)); + if ('path' in field) return resolveBinding(field.path, at(root, field.optional ? false : strict)); for (const path of field.firstOf) { const value = resolveBinding(path, at(root, false)); if (value) return value; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts index 2f185874..2cd0d15c 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -17,10 +17,12 @@ import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.j * interpreter, so this renderer always emits its content. */ export const paymentRenderer: BlockRenderer = (block, ctx) => { + // Bindings the schema declares optional are read leniently even under strict. + const lenientCtx = { ...ctx, strict: false }; const stack: PdfNode[] = [{ text: ctx.label('payment'), style: 'h2' }]; for (const row of block.rows) { - const value = applyFormat(resolveBinding(row.path, ctx), row.format); + const value = applyFormat(resolveBinding(row.path, row.optional ? lenientCtx : ctx), row.format); if (value === '') continue; stack.push({ text: `${ctx.label(row.label)}: ${value}` }); } @@ -29,7 +31,7 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { const lines: PdfNode[] = []; for (const account of list(ctx.root, block.accounts.from)) { for (const field of block.accounts.fields) { - const value = applyFormat(get(account, field.path, ctx.strict), field.format); + const value = applyFormat(get(account, field.path, field.optional ? false : ctx.strict), field.format); if (value === '') continue; lines.push({ text: `${ctx.label(field.label)}: ${value}` }); } diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/table.ts b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts index 0d153313..b575799e 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/table.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts @@ -17,6 +17,8 @@ import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.j * share) under a light horizontal-line layout. */ export const tableRenderer: BlockRenderer = (block, ctx) => { + // Bindings the schema declares optional are read leniently even under strict. + const lenientCtx = { ...ctx, strict: false }; const { columns } = block; const showHeaders = block.headers !== false; const body: PdfNode[][] = []; @@ -27,10 +29,10 @@ export const tableRenderer: BlockRenderer = (block, ctx) => { if (block.from !== undefined) { for (const row of list(ctx.root, block.from)) { - body.push(columns.map((col) => ({ text: applyFormat(get(row, col.path, ctx.strict), col.format) }))); + body.push(columns.map((col) => ({ text: applyFormat(get(row, col.path, col.optional ? false : ctx.strict), col.format) }))); } } else { - body.push(columns.map((col) => ({ text: applyFormat(resolveBinding(col.path, ctx), col.format) }))); + body.push(columns.map((col) => ({ text: applyFormat(resolveBinding(col.path, col.optional ? lenientCtx : ctx), col.format) }))); } const node: Record = { diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts index 7046d901..444d8e49 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts @@ -14,12 +14,11 @@ import { evalWhen, resolveBinding, type BlockRenderer, type PdfNode } from '../i * rate bucket the schema allows and only the ones this invoice carries appear. */ export const totalsRenderer: BlockRenderer = (block, ctx) => { - // Every read here is non-strict, `sum` and `path` alike. A totals row prints - // only when its value resolves, so a template lists every bucket the schema - // allows and a real invoice fills one or two — an absent bucket is the normal - // case, not the dot-path typo `strict` hunts for. Typos in our own presets are - // caught instead by the built-in template lint, which requires the amount due - // and at least one bucket to resolve against the reference fixtures. + // A `sum` lists every bucket the schema allows and a real invoice fills one or + // two, so it is always read leniently. A `path` row honours `strict` unless + // the template marks it optional — which is how `Do zapłaty` stays policed: + // `P_15` has no optional ancestor in the FA schema, so its absence is a + // template typo or a broken document, never a normal invoice. const lenient = { ...ctx, strict: false }; const body: PdfNode[][] = []; @@ -27,7 +26,7 @@ export const totalsRenderer: BlockRenderer = (block, ctx) => { if (!evalWhen(row.when, ctx)) continue; const raw = row.sum ? sumDecimal(row.sum.map((p) => resolveBinding(p, lenient))) - : resolveBinding(row.path ?? '', lenient); + : resolveBinding(row.path ?? '', row.optional ? lenient : ctx); const value = applyFormat(raw, row.format); // A row that resolves empty is skipped, as in `payment` and `parties`: a // template listing every rate bucket must not print a dangling label for diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 29cc802f..ea911ca8 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -32,7 +32,11 @@ { "label": "address", "style": "partyAddress", - "fields": ["Podmiot1.Adres.AdresL1", "Podmiot1.Adres.AdresL2", "Podmiot1.Adres.KodKraju"] + "fields": [ + "Podmiot1.Adres.AdresL1", + { "path": "Podmiot1.Adres.AdresL2", "optional": true }, + "Podmiot1.Adres.KodKraju" + ] }, { "label": "contact", @@ -56,7 +60,11 @@ { "label": "address", "style": "partyAddress", - "fields": ["Podmiot2.Adres.AdresL1", "Podmiot2.Adres.AdresL2", "Podmiot2.Adres.KodKraju"] + "fields": [ + "Podmiot2.Adres.AdresL1", + { "path": "Podmiot2.Adres.AdresL2", "optional": true }, + "Podmiot2.Adres.KodKraju" + ] }, { "label": "contact", @@ -73,36 +81,60 @@ "from": "Fa.FaWiersz", "columns": [ { "label": "lp", "path": "NrWierszaFa", "width": 24 }, - { "label": "name", "path": "P_7", "width": "*" }, - { "label": "unit", "path": "P_8A", "width": 36 }, - { "label": "qty", "path": "P_8B", "format": "number", "width": 24 }, - { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 64 }, - { "label": "vatRate", "path": "P_12", "width": 50 }, - { "label": "net", "path": "P_11", "format": "money", "width": 70 } + { "label": "name", "path": "P_7", "width": "*", "optional": true }, + { "label": "unit", "path": "P_8A", "width": 36, "optional": true }, + { "label": "qty", "path": "P_8B", "format": "number", "width": 24, "optional": true }, + { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 64, "optional": true }, + { "label": "vatRate", "path": "P_12", "width": 50, "optional": true }, + { "label": "net", "path": "P_11", "format": "money", "width": 70, "optional": true } ] }, { "type": "spacer", "height": 21 }, { "type": "totals", "rows": [ - { "label": "net23", "path": "Fa.P_13_1", "when": "totalsBuckets", "format": "money" }, - { "label": "vat23", "path": "Fa.P_14_1", "when": "totalsBuckets", "format": "money" }, - { "label": "net8", "path": "Fa.P_13_2", "when": "totalsBuckets", "format": "money" }, - { "label": "vat8", "path": "Fa.P_14_2", "when": "totalsBuckets", "format": "money" }, - { "label": "net5", "path": "Fa.P_13_3", "when": "totalsBuckets", "format": "money" }, - { "label": "vat5", "path": "Fa.P_14_3", "when": "totalsBuckets", "format": "money" }, - { "label": "net4", "path": "Fa.P_13_4", "when": "totalsBuckets", "format": "money" }, - { "label": "vat4", "path": "Fa.P_14_4", "when": "totalsBuckets", "format": "money" }, - { "label": "netSpecial", "path": "Fa.P_13_5", "when": "totalsBuckets", "format": "money" }, - { "label": "vatSpecial", "path": "Fa.P_14_5", "when": "totalsBuckets", "format": "money" }, - { "label": "net0Domestic", "path": "Fa.P_13_6_1", "when": "totalsBuckets", "format": "money" }, - { "label": "net0Wdt", "path": "Fa.P_13_6_2", "when": "totalsBuckets", "format": "money" }, - { "label": "net0Export", "path": "Fa.P_13_6_3", "when": "totalsBuckets", "format": "money" }, - { "label": "netExempt", "path": "Fa.P_13_7", "when": "totalsBuckets", "format": "money" }, - { "label": "netOutsideTerritory", "path": "Fa.P_13_8", "when": "totalsBuckets", "format": "money" }, - { "label": "netArticle100", "path": "Fa.P_13_9", "when": "totalsBuckets", "format": "money" }, - { "label": "netReverseCharge", "path": "Fa.P_13_10", "when": "totalsBuckets", "format": "money" }, - { "label": "netMargin", "path": "Fa.P_13_11", "when": "totalsBuckets", "format": "money" }, + { "label": "net23", "path": "Fa.P_13_1", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat23", "path": "Fa.P_14_1", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "net8", "path": "Fa.P_13_2", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat8", "path": "Fa.P_14_2", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "net5", "path": "Fa.P_13_3", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat5", "path": "Fa.P_14_3", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "net4", "path": "Fa.P_13_4", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat4", "path": "Fa.P_14_4", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "netSpecial", "path": "Fa.P_13_5", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vatSpecial", "path": "Fa.P_14_5", "when": "totalsBuckets", "format": "money", "optional": true }, + { + "label": "net0Domestic", + "path": "Fa.P_13_6_1", + "when": "totalsBuckets", + "format": "money", + "optional": true + }, + { "label": "net0Wdt", "path": "Fa.P_13_6_2", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "net0Export", "path": "Fa.P_13_6_3", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "netExempt", "path": "Fa.P_13_7", "when": "totalsBuckets", "format": "money", "optional": true }, + { + "label": "netOutsideTerritory", + "path": "Fa.P_13_8", + "when": "totalsBuckets", + "format": "money", + "optional": true + }, + { + "label": "netArticle100", + "path": "Fa.P_13_9", + "when": "totalsBuckets", + "format": "money", + "optional": true + }, + { + "label": "netReverseCharge", + "path": "Fa.P_13_10", + "when": "totalsBuckets", + "format": "money", + "optional": true + }, + { "label": "netMargin", "path": "Fa.P_13_11", "when": "totalsBuckets", "format": "money", "optional": true }, { "label": "totalNet", "sum": [ @@ -137,17 +169,17 @@ "type": "payment", "when": "Fa.Platnosc", "rows": [ - { "label": "paid", "path": "Fa.Platnosc.Zaplacono" }, - { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date" }, - { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm" } + { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, + { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", "heading": "bankAccounts", "fields": [ { "label": "bankAccount", "path": "NrRB" }, - { "label": "swift", "path": "SWIFT" }, - { "label": "bankName", "path": "NazwaBanku" } + { "label": "swift", "path": "SWIFT", "optional": true }, + { "label": "bankName", "path": "NazwaBanku", "optional": true } ] } }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 98ac6bf5..3f40feb7 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -32,7 +32,11 @@ { "label": "address", "style": "partyAddress", - "fields": ["Podmiot1.Adres.AdresL1", "Podmiot1.Adres.AdresL2", "Podmiot1.Adres.KodKraju"] + "fields": [ + "Podmiot1.Adres.AdresL1", + { "path": "Podmiot1.Adres.AdresL2", "optional": true }, + "Podmiot1.Adres.KodKraju" + ] }, { "label": "contact", @@ -56,7 +60,11 @@ { "label": "address", "style": "partyAddress", - "fields": ["Podmiot2.Adres.AdresL1", "Podmiot2.Adres.AdresL2", "Podmiot2.Adres.KodKraju"] + "fields": [ + "Podmiot2.Adres.AdresL1", + { "path": "Podmiot2.Adres.AdresL2", "optional": true }, + "Podmiot2.Adres.KodKraju" + ] }, { "label": "contact", @@ -73,36 +81,60 @@ "from": "Fa.FaWiersz", "columns": [ { "label": "lp", "path": "NrWierszaFa", "width": 24 }, - { "label": "name", "path": "P_7", "width": "*" }, - { "label": "unit", "path": "P_8A", "width": 24 }, - { "label": "qty", "path": "P_8B", "format": "number", "width": 24 }, - { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 50 }, - { "label": "vatRate", "path": "P_12", "width": 50 }, - { "label": "net", "path": "P_11", "format": "money", "width": 60 } + { "label": "name", "path": "P_7", "width": "*", "optional": true }, + { "label": "unit", "path": "P_8A", "width": 24, "optional": true }, + { "label": "qty", "path": "P_8B", "format": "number", "width": 24, "optional": true }, + { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 50, "optional": true }, + { "label": "vatRate", "path": "P_12", "width": 50, "optional": true }, + { "label": "net", "path": "P_11", "format": "money", "width": 60, "optional": true } ] }, { "type": "spacer", "height": 21 }, { "type": "totals", "rows": [ - { "label": "net23", "path": "Fa.P_13_1", "when": "totalsBuckets", "format": "money" }, - { "label": "vat23", "path": "Fa.P_14_1", "when": "totalsBuckets", "format": "money" }, - { "label": "net8", "path": "Fa.P_13_2", "when": "totalsBuckets", "format": "money" }, - { "label": "vat8", "path": "Fa.P_14_2", "when": "totalsBuckets", "format": "money" }, - { "label": "net5", "path": "Fa.P_13_3", "when": "totalsBuckets", "format": "money" }, - { "label": "vat5", "path": "Fa.P_14_3", "when": "totalsBuckets", "format": "money" }, - { "label": "net4", "path": "Fa.P_13_4", "when": "totalsBuckets", "format": "money" }, - { "label": "vat4", "path": "Fa.P_14_4", "when": "totalsBuckets", "format": "money" }, - { "label": "netSpecial", "path": "Fa.P_13_5", "when": "totalsBuckets", "format": "money" }, - { "label": "vatSpecial", "path": "Fa.P_14_5", "when": "totalsBuckets", "format": "money" }, - { "label": "net0Domestic", "path": "Fa.P_13_6_1", "when": "totalsBuckets", "format": "money" }, - { "label": "net0Wdt", "path": "Fa.P_13_6_2", "when": "totalsBuckets", "format": "money" }, - { "label": "net0Export", "path": "Fa.P_13_6_3", "when": "totalsBuckets", "format": "money" }, - { "label": "netExempt", "path": "Fa.P_13_7", "when": "totalsBuckets", "format": "money" }, - { "label": "netOutsideTerritory", "path": "Fa.P_13_8", "when": "totalsBuckets", "format": "money" }, - { "label": "netArticle100", "path": "Fa.P_13_9", "when": "totalsBuckets", "format": "money" }, - { "label": "netReverseCharge", "path": "Fa.P_13_10", "when": "totalsBuckets", "format": "money" }, - { "label": "netMargin", "path": "Fa.P_13_11", "when": "totalsBuckets", "format": "money" }, + { "label": "net23", "path": "Fa.P_13_1", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat23", "path": "Fa.P_14_1", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "net8", "path": "Fa.P_13_2", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat8", "path": "Fa.P_14_2", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "net5", "path": "Fa.P_13_3", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat5", "path": "Fa.P_14_3", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "net4", "path": "Fa.P_13_4", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat4", "path": "Fa.P_14_4", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "netSpecial", "path": "Fa.P_13_5", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vatSpecial", "path": "Fa.P_14_5", "when": "totalsBuckets", "format": "money", "optional": true }, + { + "label": "net0Domestic", + "path": "Fa.P_13_6_1", + "when": "totalsBuckets", + "format": "money", + "optional": true + }, + { "label": "net0Wdt", "path": "Fa.P_13_6_2", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "net0Export", "path": "Fa.P_13_6_3", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "netExempt", "path": "Fa.P_13_7", "when": "totalsBuckets", "format": "money", "optional": true }, + { + "label": "netOutsideTerritory", + "path": "Fa.P_13_8", + "when": "totalsBuckets", + "format": "money", + "optional": true + }, + { + "label": "netArticle100", + "path": "Fa.P_13_9", + "when": "totalsBuckets", + "format": "money", + "optional": true + }, + { + "label": "netReverseCharge", + "path": "Fa.P_13_10", + "when": "totalsBuckets", + "format": "money", + "optional": true + }, + { "label": "netMargin", "path": "Fa.P_13_11", "when": "totalsBuckets", "format": "money", "optional": true }, { "label": "totalNet", "sum": [ @@ -137,17 +169,17 @@ "type": "payment", "when": "Fa.Platnosc", "rows": [ - { "label": "paid", "path": "Fa.Platnosc.Zaplacono" }, - { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date" }, - { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm" } + { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, + { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", "heading": "bankAccounts", "fields": [ { "label": "bankAccount", "path": "NrRB" }, - { "label": "swift", "path": "SWIFT" }, - { "label": "bankName", "path": "NazwaBanku" } + { "label": "swift", "path": "SWIFT", "optional": true }, + { "label": "bankName", "path": "NazwaBanku", "optional": true } ] } }, diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 13639417..45dba2e4 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -32,6 +32,14 @@ export interface LabelRef { export interface FieldDef { label: string; path: string; + /** + * The document may legitimately omit this binding, so `strict` must not throw + * on it. Mark exactly the paths the KSeF schema declares optional: everything + * left unmarked is a field the document must carry, and a strict render turns + * its absence — almost always a dot-path typo — into an error instead of a + * blank line. + */ + optional?: boolean; format?: FormatterName; style?: string; } @@ -78,7 +86,11 @@ export interface HeaderBlock { * foreign buyer. Alternatives are read leniently — the ones that do not apply * are absent by design, not by mistake. */ -export type PartyField = string | { firstOf: string[] } | PartyGroup; +export type PartyField = + | string + | { path: string; optional?: boolean } + | { firstOf: string[] } + | PartyGroup; /** * A labelled sub-group inside a party panel — the address, say. The label is a @@ -128,6 +140,8 @@ export interface LinesBlock { export interface TotalsRow { label: string; path?: string; + /** See {@link FieldDef.optional}. A `sum` is always read leniently. */ + optional?: boolean; /** Binding paths to add up; absent buckets are skipped. */ sum?: string[]; /** @@ -296,6 +310,7 @@ const labelRef = z.object({ label: z.string().optional(), text: z.string().optio const partyField: z.ZodType = z.lazy(() => z.union([ z.string(), + z.object({ path: z.string(), optional: z.boolean().optional() }).strict(), z.object({ firstOf: z.array(z.string()).nonempty() }).strict(), z .object({ @@ -311,6 +326,7 @@ const fieldDef = z .object({ label: z.string(), path: z.string(), + optional: z.boolean().optional(), format: formatEnum.optional(), style: z.string().optional(), }) @@ -320,6 +336,7 @@ const columnDef = z .object({ label: z.string(), path: z.string(), + optional: z.boolean().optional(), format: formatEnum.optional(), style: z.string().optional(), width: z.union([z.number().positive(), z.literal('auto'), z.literal('*')]).optional(), @@ -330,6 +347,7 @@ const totalsRow = z .object({ label: z.string(), path: z.string().optional(), + optional: z.boolean().optional(), sum: z.array(z.string()).nonempty().optional(), when: z.string().optional(), format: formatEnum.optional(), diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index dda56647..02fa3ce6 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -64,8 +64,11 @@ function collect( if ('fields' in field) { if (field.from !== undefined) acc.repeaters.push(field.from); walkFields(field.fields); + } else if ('firstOf' in field) { + acc.alternatives.push(field.firstOf); } - else acc.alternatives.push(field.firstOf); + // `{ path, optional }` is a plain binding; strict covers the ones that + // are not marked, and an optional one is absent by design. } }; walkFields(block.left.fields); diff --git a/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts new file mode 100644 index 00000000..8a867c13 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts @@ -0,0 +1,140 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { renderInvoicePdfFromTemplate } from '../../../src/pdf/index.js'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import type { FieldDef, InvoiceTemplate, LinesBlock, PaymentBlock, TotalsBlock } from '../../../src/pdf/template/dsl.js'; + +/** + * `strict` exists to turn a dot-path typo into an error instead of a blank + * line. That only works if the templates say which bindings the document may + * legitimately omit — otherwise the first optional field an invoice happens not + * to carry throws, and the mode is useless on real documents. + * + * So the rule is: a binding is policed unless the template marks it `optional`, + * and it is marked exactly where the FA schema allows the field to be absent. + * `Fa.P_15` has no optional ancestor — an invoice always states its amount due — + * so that one stays policed, which is the whole point of the mode. + */ + +const fx = (name: string) => readFileSync(new URL(`../../fixtures/pdf/${name}`, import.meta.url), 'utf8'); + +const DOCUMENTS = [ + 'fa3.xml', + 'e2e-vat-multi.xml', + 'e2e-services-np.xml', + 'e2e-buyer-no-id.xml', +]; + +const fa3Default = () => JSON.parse(JSON.stringify(getBuiltinTemplate('fa3-default'))) as InvoiceTemplate; + +describe('strict mode survives real documents', () => { + it.each(DOCUMENTS)('%s renders strict without throwing', async (name) => { + await expect( + renderInvoicePdfFromTemplate(fx(name), fa3Default(), { strict: true, totals: 'both' }), + ).resolves.toBeInstanceOf(Uint8Array); + }); + + it('an unpaid invoice renders strict, though is absent', async () => { + // Zaplacono sits inside an optional choice inside an optional Platnosc, so + // an unpaid invoice simply has none. This is the case that made `strict` + // unusable before the bindings were marked. + const unpaid = fx('fa3.xml').replace(/\s*1<\/Zaplacono>/, ''); + expect(unpaid).not.toContain('Zaplacono'); + await expect( + renderInvoicePdfFromTemplate(unpaid, fa3Default(), { strict: true }), + ).resolves.toBeInstanceOf(Uint8Array); + }); + + it('an invoice without the optional second address line renders strict', async () => { + const noAddressL2 = fx('fa3.xml').replace(/\s*[^<]*<\/AdresL2>/g, ''); + expect(noAddressL2).not.toContain('AdresL2'); + await expect( + renderInvoicePdfFromTemplate(noAddressL2, fa3Default(), { strict: true }), + ).resolves.toBeInstanceOf(Uint8Array); + }); +}); + +describe('strict mode still catches a typo in a required binding', () => { + it('throws when the amount due path is misspelled', async () => { + const template = fa3Default(); + const totals = template.blocks.find((b) => b.type === 'totals') as TotalsBlock; + totals.rows.find((r) => r.label === 'totalDue')!.path = 'Fa.P_1S'; + await expect( + renderInvoicePdfFromTemplate(fx('fa3.xml'), template, { strict: true }), + ).rejects.toThrow('Missing binding: "Fa.P_1S"'); + }); + + it('throws when a required party field is misspelled', async () => { + const template = fa3Default(); + const parties = template.blocks.find((b) => b.type === 'parties') as { left: { fields: unknown[] } }; + parties.left.fields[0] = 'Podmiot1.DaneIdentyfikacyjne.Nazwaa'; + await expect( + renderInvoicePdfFromTemplate(fx('fa3.xml'), template, { strict: true }), + ).rejects.toThrow(/Nazwaa/); + }); + + it('throws when a required line column is misspelled', async () => { + const template = fa3Default(); + const lines = template.blocks.find((b) => b.type === 'lines') as LinesBlock; + lines.columns.find((c) => c.label === 'lp')!.path = 'NrWierszaFaa'; + await expect( + renderInvoicePdfFromTemplate(fx('fa3.xml'), template, { strict: true }), + ).rejects.toThrow(/NrWierszaFaa/); + }); + + it('stays silent about the same typo without strict', async () => { + const template = fa3Default(); + const totals = template.blocks.find((b) => b.type === 'totals') as TotalsBlock; + totals.rows.find((r) => r.label === 'totalDue')!.path = 'Fa.P_1S'; + await expect( + renderInvoicePdfFromTemplate(fx('fa3.xml'), template), + ).resolves.toBeInstanceOf(Uint8Array); + }); + + it('does not throw for a misspelled binding that is marked optional', async () => { + // The marker is a promise about the schema, not a licence to be sloppy — + // but it does mean strict cannot police that path. Worth pinning so the + // trade-off is visible rather than discovered later. + const template = fa3Default(); + const lines = template.blocks.find((b) => b.type === 'lines') as LinesBlock; + const name = lines.columns.find((c) => c.label === 'name')!; + expect(name.optional, 'P_7 is minOccurs="0" and must be marked').toBe(true); + name.path = 'P_77'; + await expect( + renderInvoicePdfFromTemplate(fx('fa3.xml'), template, { strict: true }), + ).resolves.toBeInstanceOf(Uint8Array); + }); +}); + +describe('the built-in templates mark the right bindings', () => { + it.each(['fa2-default', 'fa3-default'])('%s polices the amount due', (name) => { + const totals = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'totals') as TotalsBlock; + const due = totals.rows.find((r) => r.label === 'totalDue')!; + expect(due.path).toBe('Fa.P_15'); + expect(due.optional, 'P_15 is required by the schema and must stay policed').toBeUndefined(); + }); + + it.each(['fa2-default', 'fa3-default'])('%s marks every rate bucket optional', (name) => { + const totals = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'totals') as TotalsBlock; + const buckets = totals.rows.filter((r) => r.when === 'totalsBuckets'); + expect(buckets.length).toBeGreaterThan(10); + expect(buckets.every((r) => r.optional === true)).toBe(true); + }); + + it.each(['fa2-default', 'fa3-default'])('%s keeps the mandatory line column policed', (name) => { + const lines = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'lines') as LinesBlock; + const byLabel = Object.fromEntries(lines.columns.map((c) => [c.label, c])); + expect(byLabel.lp?.optional, 'NrWierszaFa is required').toBeUndefined(); + for (const label of ['name', 'unit', 'qty', 'unitPrice', 'vatRate', 'net']) { + expect(byLabel[label]?.optional, `${label} is minOccurs="0"`).toBe(true); + } + }); + + it.each(['fa2-default', 'fa3-default'])('%s keeps the bank account number policed', (name) => { + const payment = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'payment') as PaymentBlock; + const fields = Object.fromEntries((payment.accounts!.fields as FieldDef[]).map((f) => [f.label, f])); + expect(fields.bankAccount?.optional, 'NrRB is required inside RachunekBankowy').toBeUndefined(); + expect(fields.swift?.optional).toBe(true); + expect(fields.bankName?.optional).toBe(true); + }); +}); From 2c03f6f364cddda9588b8e820df7a116c8ff20c7 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 19:10:45 +0200 Subject: [PATCH 18/67] feat(pdf): frame the payment block and move the credit into a page footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layout changes and one that is not really layout. Dividers now bracket the payment section, so bank details read as their own block rather than trailing off the totals. The `ksef-client-ts` note moved out of the content flow into a running page footer: the localized attribution on the left, a `Page 1 of 3` indicator on the right, aligned with the page margins and repeated on every page. It has to be a pdfmake callback rather than a block — only pdfmake knows the page total, and only after the content is laid out. The indicator keeps the body colour instead of the muted credit grey; it is information a reader looks for, not a byline. The page indicator is a single label carrying its own `{page}`/`{pages}` placeholders rather than a phrase assembled from parts, so a bilingual render reads "Page 1 of 2 / Strona 1 z 2" instead of interleaving the two grammars into "Strona / Page 1 z / of 2". The `page` and `of` keys it replaces had sat in both bundles unused. The attribution itself is fixed in the renderer and no longer a template field: a template may restyle the footer or leave it out, but the schema rejects a credit of its own. Full suites: 2573 unit, 153 E2E. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01H7pm4MqoegP7uMsX4hEg7Z --- packages/ksef-client-ts/docs/pdf-export.md | 8 ++ packages/ksef-client-ts/src/pdf/i18n/en.ts | 4 +- packages/ksef-client-ts/src/pdf/i18n/pl.ts | 6 +- .../src/pdf/template/builtin/fa2-default.json | 6 +- .../src/pdf/template/builtin/fa3-default.json | 6 +- .../src/pdf/template/builtin/upo-4_2.json | 4 +- .../src/pdf/template/builtin/upo-4_3.json | 4 +- .../ksef-client-ts/src/pdf/template/dsl.ts | 20 +++ .../src/pdf/template/interpret.ts | 45 +++++++ .../tests/unit/pdf/page-footer.test.ts | 114 ++++++++++++++++++ 10 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/page-footer.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 9847d9ac..1af1c2c8 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -175,6 +175,14 @@ The `schema` field binds a template to a single document kind. If you render an | `qr` | The verification QR image | | `footer` | Footer note | +A template may also declare a running page footer, drawn in the bottom page margin on every page: + +```json +"pageFooter": { "style": "footerNote" } +``` + +It prints the localized attribution on the left and a `Page 1 of 3` indicator on the right, aligned with `page.margins`. The page total is only known once pdfmake has laid the content out, so this cannot be a block. The attribution text is fixed by the renderer — a template may restyle the footer or omit it, but not reword the credit. + **Primitive blocks** are layout building blocks: `text`, `columns`, `stack`, `each`, `table`, `image`, `divider`, `spacer`. `each` repeats a group of blocks once per entry of a collection, with the entry as the binding root, so its children use item-relative paths. Use it where a table cannot fit a record on one row — the built-in UPO templates lay out each confirmed document this way, because a 35-character KSeF number beside a 44-character hash will not share a page-wide row. diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 49fa07f1..366d8feb 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -58,6 +58,6 @@ export const en: LabelBundle = { receiptDate: 'KSeF number assignment date', documentHash: 'Document hash', documents: 'Documents', - page: 'Page', - of: 'of', + generatedWith: 'Generated with', + pageOf: 'Page {page} of {pages}', }; diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 29564964..26b47283 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -63,7 +63,7 @@ export const pl: LabelBundle = { receiptDate: 'Data nadania numeru KSeF', documentHash: 'Skrót dokumentu', documents: 'Dokumenty', - // footer - page: 'Strona', - of: 'z', + // page footer + generatedWith: 'Wygenerowano przez', + pageOf: 'Strona {page} z {pages}', }; diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index ea911ca8..d2a5ecec 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -1,6 +1,7 @@ { "schema": "FA(2)", "page": { "size": "A4", "margins": [40, 40, 40, 50] }, + "pageFooter": { "style": "footerNote" }, "styles": { "title": { "fontSize": 20, "bold": true }, "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, @@ -165,6 +166,7 @@ ] }, { "type": "spacer", "height": 21 }, + { "type": "divider" }, { "type": "payment", "when": "Fa.Platnosc", @@ -183,7 +185,7 @@ ] } }, - { "type": "qr", "when": "qr", "fit": 90 }, - { "type": "footer", "text": "ksef-client-ts", "style": "footerNote" } + { "type": "divider" }, + { "type": "qr", "when": "qr", "fit": 90 } ] } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 3f40feb7..5eb7bfc0 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -1,6 +1,7 @@ { "schema": "FA(3)", "page": { "size": "A4", "margins": [40, 40, 40, 50] }, + "pageFooter": { "style": "footerNote" }, "styles": { "title": { "fontSize": 20, "bold": true }, "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, @@ -165,6 +166,7 @@ ] }, { "type": "spacer", "height": 21 }, + { "type": "divider" }, { "type": "payment", "when": "Fa.Platnosc", @@ -183,7 +185,7 @@ ] } }, - { "type": "qr", "when": "qr", "fit": 90 }, - { "type": "footer", "text": "ksef-client-ts", "style": "footerNote" } + { "type": "divider" }, + { "type": "qr", "when": "qr", "fit": 90 } ] } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json index e9e5c44d..5aa40db1 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json @@ -1,10 +1,12 @@ { "schema": "UPO(4.2)", "page": { "size": "A4", "margins": [40, 40, 40, 40] }, + "pageFooter": { "style": "footerNote" }, "styles": { "title": { "fontSize": 16, "bold": true }, "fieldLabel": { "bold": true, "color": "#444444" }, - "muted": { "color": "#666666" } + "muted": { "color": "#666666" }, + "footerNote": { "fontSize": 7, "color": "#999999" } }, "blocks": [ { "type": "header", "title": { "label": "upoTitle" } }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json index 412150a5..cb988482 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json @@ -1,10 +1,12 @@ { "schema": "UPO(4.3)", "page": { "size": "A4", "margins": [40, 40, 40, 40] }, + "pageFooter": { "style": "footerNote" }, "styles": { "title": { "fontSize": 16, "bold": true }, "fieldLabel": { "bold": true, "color": "#444444" }, - "muted": { "color": "#666666" } + "muted": { "color": "#666666" }, + "footerNote": { "fontSize": 7, "color": "#999999" } }, "blocks": [ { "type": "header", "title": { "label": "upoTitle" } }, diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 45dba2e4..0eeb79b3 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -16,6 +16,21 @@ export type TemplateSchemaId = 'FA(2)' | 'FA(3)' | 'UPO(4.2)' | 'UPO(4.3)'; /** Loose style bag mapping to pdfmake style props (fontSize, bold, color, …). */ export type Style = Record>; +/** + * A running footer drawn in the bottom page margin, on every page: the tool that + * produced the document on the left, the page indicator on the right. Unlike the + * `footer` block — which is one inline node in the content flow — this repeats + * and can count pages, which a block cannot: only pdfmake knows the page total, + * and only once the content has been laid out. + */ +export interface PageFooterConfig { + /** + * Style for the footer line. The attribution text itself is not configurable — + * a template may restyle or omit the footer, but not reword the credit. + */ + style?: string; +} + export interface PageConfig { size?: string; orientation?: 'portrait' | 'landscape'; @@ -294,6 +309,7 @@ export interface InvoiceTemplate { /** Binds this template to one XML kind; the engine rejects a version mismatch. */ schema: TemplateSchemaId; page?: PageConfig; + pageFooter?: PageFooterConfig; defaultStyle?: Style; styles?: Record; /** Per-template label overrides (merged over the i18n bundle). */ @@ -466,6 +482,10 @@ export const invoiceTemplateSchema: z.ZodType = z }) .strict() .optional(), + pageFooter: z + .object({ style: z.string().optional() }) + .strict() + .optional(), defaultStyle: styleSchema.optional(), styles: z.record(z.string(), styleSchema).optional(), labels: z.record(z.string(), z.string()).optional(), diff --git a/packages/ksef-client-ts/src/pdf/template/interpret.ts b/packages/ksef-client-ts/src/pdf/template/interpret.ts index 1db3ff2b..54429b5f 100644 --- a/packages/ksef-client-ts/src/pdf/template/interpret.ts +++ b/packages/ksef-client-ts/src/pdf/template/interpret.ts @@ -19,6 +19,16 @@ import type { Block, BlockType, InvoiceTemplate, Style } from './dsl.js'; export type PdfNode = string | Record; export type PdfContent = PdfNode | PdfNode[]; +/** + * Attribution printed in the page footer. Deliberately not a template field: a + * template can style the footer or leave it out, but cannot rewrite whose + * renderer produced the document. + */ +const TOOL_NAME = 'Flopsstuff/ksef-client-ts'; + +/** The page indicator reads as content, not as a credit. */ +const PAGE_INDICATOR_COLOR = '#333333'; + /** Maximum block-nesting depth before the interpreter bails out. */ export const MAX_DEPTH = 24; @@ -149,6 +159,40 @@ export function interpretBlock( return renderer(block, ctx, render); } +/** + * A running footer for every page: what produced the document on the left, which + * page this is on the right. pdfmake supplies the numbers, so the value has to + * be a callback rather than a content node — the page total is not known until + * the content has been laid out. + * + * The page indicator is one label carrying its own `{page}`/`{pages}` + * placeholders rather than a phrase assembled from parts, so a bilingual locale + * reads "Strona 1 z 3 / Page 1 of 3" instead of interleaving the two grammars. + */ +function buildPageFooter(template: InvoiceTemplate, ctx: RenderContext) { + const { style } = template.pageFooter ?? {}; + // Align the footer with the body: pdfmake lays it out across the full page. + const [left = 40, , right = 40] = template.page?.margins ?? []; + + return (currentPage: number, pageCount: number): PdfNode => ({ + columns: [ + { text: `${ctx.label('generatedWith')} ${TOOL_NAME}`, alignment: 'left' }, + { + text: ctx + .label('pageOf') + .replaceAll('{page}', String(currentPage)) + .replaceAll('{pages}', String(pageCount)), + alignment: 'right', + // The page indicator is information a reader looks for, not a credit — + // it stays in the body colour rather than inheriting the muted style. + color: PAGE_INDICATOR_COLOR, + }, + ], + margin: [left, 0, right, 0], + ...(style ? { style } : {}), + }); +} + /** * Interpret a whole template into a pdfmake document definition. Merges the * caller-provided renderers over the core primitives, wires template styles, @@ -171,6 +215,7 @@ export function interpretTemplate( const defaultStyle: Style = { font: 'Roboto', fontSize: 9, ...(template.defaultStyle ?? {}) }; const doc: Record = { content, defaultStyle }; if (template.styles) doc.styles = template.styles; + if (template.pageFooter) doc.footer = buildPageFooter(template, ctx); if (template.page) { if (template.page.size) doc.pageSize = template.page.size; if (template.page.orientation) doc.pageOrientation = template.page.orientation; diff --git a/packages/ksef-client-ts/tests/unit/pdf/page-footer.test.ts b/packages/ksef-client-ts/tests/unit/pdf/page-footer.test.ts new file mode 100644 index 00000000..666737a6 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/page-footer.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { interpretTemplate, type RenderContext } from '../../../src/pdf/template/interpret.js'; +import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { validateTemplate, type InvoiceTemplate } from '../../../src/pdf/template/dsl.js'; +import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; +import type { Locale } from '../../../src/pdf/i18n/types.js'; + +/** + * The page footer is a pdfmake callback rather than a content node, because the + * page total is not known until the content has been laid out — a block cannot + * produce it. These tests drive that callback directly. + */ + +type FooterFn = (currentPage: number, pageCount: number) => { + columns: Array<{ text: string; alignment: string; color?: string }>; + margin: number[]; + style?: string; +}; + +function ctxFor(locale: Locale = 'pl'): RenderContext { + return { root: {}, strict: false, label: makeLabelResolver(locale, {}), bindings: {}, flags: {} }; +} + +function footerOf(template: InvoiceTemplate, locale: Locale = 'pl'): FooterFn { + const doc = interpretTemplate(template, ctxFor(locale), blockRegistry); + return doc.footer as FooterFn; +} + +const fa3 = () => getBuiltinTemplate('fa3-default')!; + +describe('page footer', () => { + it('is emitted as a callback, not as content', () => { + expect(typeof footerOf(fa3())).toBe('function'); + }); + + it('puts the attribution left and the page indicator right', () => { + const [credit, pages] = footerOf(fa3())(1, 3).columns; + expect(credit.alignment).toBe('left'); + expect(credit.text).toBe('Wygenerowano przez Flopsstuff/ksef-client-ts'); + expect(pages.alignment).toBe('right'); + expect(pages.text).toBe('Strona 1 z 3'); + }); + + it('substitutes the numbers pdfmake supplies on each page', () => { + const footer = footerOf(fa3()); + expect(footer(1, 3).columns[1].text).toBe('Strona 1 z 3'); + expect(footer(2, 3).columns[1].text).toBe('Strona 2 z 3'); + expect(footer(3, 3).columns[1].text).toBe('Strona 3 z 3'); + }); + + it('reads as whole phrases in English and bilingually', () => { + expect(footerOf(fa3(), 'en')(1, 2).columns[1].text).toBe('Page 1 of 2'); + // The indicator is one label carrying its own placeholders, so the two + // grammars stay intact instead of interleaving into "Strona / Page 1 z / of 2". + expect(footerOf(fa3(), 'en+pl')(1, 2).columns[1].text).toBe('Page 1 of 2 / Strona 1 z 2'); + expect(footerOf(fa3(), 'en+pl')(1, 2).columns[0].text).toBe( + 'Generated with / Wygenerowano przez Flopsstuff/ksef-client-ts', + ); + }); + + it('keeps the page indicator out of the muted credit colour', () => { + const [credit, pages] = footerOf(fa3())(1, 1).columns; + expect(pages.color).toBe('#333333'); + expect(credit.color).toBeUndefined(); // inherits the footer style + }); + + it('aligns with the page margins rather than the paper edge', () => { + const template = fa3(); + expect(template.page?.margins).toEqual([40, 40, 40, 50]); + expect(footerOf(template)(1, 1).margin).toEqual([40, 0, 40, 0]); + }); + + it('is absent when a template does not ask for one', () => { + const bare: InvoiceTemplate = { schema: 'FA(3)', blocks: [{ type: 'divider' }] }; + expect(interpretTemplate(bare, ctxFor(), blockRegistry).footer).toBeUndefined(); + }); + + it.each(['fa2-default', 'fa3-default', 'upo-4_2', 'upo-4_3'])('%s carries a footer', (name) => { + expect(typeof footerOf(getBuiltinTemplate(name)!)).toBe('function'); + }); +}); + +describe('the attribution is not template-configurable', () => { + it('no built-in template carries the tool name', () => { + for (const name of ['fa2-default', 'fa3-default', 'upo-4_2', 'upo-4_3']) { + expect(JSON.stringify(getBuiltinTemplate(name))).not.toContain('Flopsstuff'); + } + }); + + it('a template cannot supply its own credit text', () => { + expect(() => + validateTemplate({ + schema: 'FA(3)', + pageFooter: { note: 'Some Other Vendor', style: 'footerNote' }, + blocks: [{ type: 'divider' }], + }), + ).toThrow(); + }); + + it('but may still restyle the footer', () => { + const template = validateTemplate({ + schema: 'FA(3)', + page: { size: 'A4', margins: [20, 20, 20, 30] }, + pageFooter: { style: 'muted' }, + styles: { muted: { fontSize: 6 } }, + blocks: [{ type: 'divider' }], + }); + const footer = footerOf(template)(1, 1); + expect(footer.style).toBe('muted'); + expect(footer.margin).toEqual([20, 0, 20, 0]); + expect(footer.columns[0].text).toContain('Flopsstuff/ksef-client-ts'); + }); +}); From 1bb21d3bcd80e037c625d77d938845eec746ca89 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 20:07:06 +0200 Subject: [PATCH 19/67] feat(pdf): give the party panel a style vocabulary, move the OFFLINE marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `partyAddress` styled the contact block as well as the address, so it is renamed `partyDetails`. The identity lines it never covered — the counterparty's name and tax number — had no style to reach for at all, because a panel could only style a labelled group, never its own lines. A party column now takes a `style` that its groups inherit unless they declare one, so the built-in templates set `partyIdentity` up top and let the address and contact groups drop to the smaller `partyDetails`. The group style is passed down instead of being stamped onto the rendered nodes afterwards, which also stops a nested group's sub-heading from losing its heading style. The OFFLINE marker moves into the KSeF number's slot in the header, via `offlineStyle`, rather than sitting full-width under the title — the condition is unchanged, only the position. A new lint fails a built-in template that references an undefined style: pdfmake ignores an unknown style name silently, so a missed reference in a rename costs a font and no test. A second one checks the two names the renderers reach for without the template naming them, `title` and `h2`. Verified: unit 2588, e2e 153, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 3 +- .../src/pdf/template/blocks/header.ts | 13 +- .../src/pdf/template/blocks/parties.ts | 20 ++- .../src/pdf/template/builtin/fa2-default.json | 17 ++- .../src/pdf/template/builtin/fa3-default.json | 17 ++- .../ksef-client-ts/src/pdf/template/dsl.ts | 22 ++- .../tests/unit/pdf/blocks-semantic.test.ts | 130 +++++++++++++++++- .../unit/pdf/builtin-template-lint.test.ts | 33 +++++ 8 files changed, 221 insertions(+), 34 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 1af1c2c8..03252071 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -166,7 +166,7 @@ The `schema` field binds a template to a single document kind. If you render an | Block | Renders | |-------|---------| -| `header` | Title and optional logo on the left; invoice number, issue date and KSeF number stacked on the right | +| `header` | Title and optional logo on the left; invoice number, issue date and KSeF number stacked on the right. With `offlineStyle` set, the OFFLINE marker takes the KSeF number's place when the document carries none | | `parties` | Seller / buyer two-column panel; a line that resolves empty is skipped | | `lines` | Invoice line-item table | | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | @@ -194,6 +194,7 @@ It prints the localized attribution on the left and a `Page 1 of 3` indicator on - **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. +- **`style`** (party panels) names a style for a panel's value lines. A labelled group inherits it unless it declares its own, so the built-in templates set `partyIdentity` on the panel — the counterparty's name and tax number — and let the address and contact groups drop to the smaller `partyDetails`. Headings keep the panel's heading style either way. - **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. - **`width`** (table columns only) sizes a column: a number of points, `'auto'` to fit the content, or `'*'` to share out what is left (the default). Sizing is worth setting explicitly — pdfmake gives every `'*'` column the *same* width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token silently widens the whole table past the page edge. - **`sum`** (totals rows only) adds several binding paths instead of reading one. A KSeF invoice has no single net or VAT total — the amounts are split across the `P_13_*` and `P_14_*` rate buckets, with zero-rated sales split three further ways (`P_13_6_1` domestic, `P_13_6_2` intra-EU supply, `P_13_6_3` export) — so the built-in templates aggregate them. Absent buckets are skipped, and the addition is decimal-exact. A totals row takes either `path` or `sum`, never both. diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/header.ts b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts index 721b28c8..914b5661 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/header.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts @@ -5,9 +5,10 @@ import { resolveBinding, resolveText, type BlockRenderer, type PdfNode } from '. /** * Invoice header: a title (defaults to the localized "Invoice" label) with the * optional logo beneath it, and the invoice number, issue date and KSeF number stacked on the - * right — one `label: value` line each, in the body font. The KSeF number is - * dropped when it resolves empty, so an offline visualization shows no dangling - * label (the separate OFFLINE marker covers that case). + * right — one `label: value` line each, in the body font. When the KSeF number + * resolves empty the line is replaced by the OFFLINE marker rather than a + * dangling label, so the marker sits where the number would have been instead + * of drifting to the left margin under the title. */ export const headerRenderer: BlockRenderer = (block, ctx) => { const title = resolveText(block.title, ctx) || ctx.label('invoice'); @@ -26,7 +27,11 @@ export const headerRenderer: BlockRenderer = (block, ctx) => { } if (block.ksefNumber) { const value = resolveBinding(block.ksefNumber, ctx); - if (value) right.push({ text: `${ctx.label('ksefNumber')}: ${value}` }); + if (value) { + right.push({ text: `${ctx.label('ksefNumber')}: ${value}` }); + } else if (block.offlineStyle) { + right.push({ text: ctx.label('offline'), style: block.offlineStyle }); + } } return { diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts index 7f4b6f59..66caa111 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts @@ -28,6 +28,10 @@ function isGroup(field: PartyField): field is PartyGroup { * rendered as a sub-heading over its own lines, and repeated per entry when it * carries `from`. An entirely unresolved group is dropped with its heading, so * no counterparty gets a label with nothing under it. + * + * Value lines take {@link PartyColumn.style}; a group may override it for its + * own lines with {@link PartyGroup.style}. Headings always keep the panel's + * heading style. */ export const partiesRenderer: BlockRenderer = (block, ctx) => { const at = (root: unknown, strict = ctx.strict): RenderContext => ({ ...ctx, root, strict }); @@ -46,22 +50,26 @@ export const partiesRenderer: BlockRenderer = (block, ctx) => { return ''; }; - const renderFields = (fields: PartyField[], root: unknown, strict: boolean): PdfNode[] => { + // The style travels down rather than being stamped onto the rendered nodes + // afterwards: a group's sub-heading must keep the heading style, and only the + // value lines take the group's own. + const renderFields = (fields: PartyField[], root: unknown, strict: boolean, style?: string): PdfNode[] => { const out: PdfNode[] = []; for (const field of fields) { if (isGroup(field)) { + const inherited = field.style ?? style; // A repeater's entries carry optional fields, so they are read leniently. const inner = field.from - ? list(root, field.from).flatMap((item) => renderFields(field.fields, item, false)) - : renderFields(field.fields, root, strict); + ? list(root, field.from).flatMap((item) => renderFields(field.fields, item, false, inherited)) + : renderFields(field.fields, root, strict, inherited); if (inner.length === 0) continue; // no heading without content out.push({ text: ctx.label(field.label), style: HEADING_STYLE }); - out.push(...inner.map((n) => (field.style ? { ...(n as object), style: field.style } : n))); + out.push(...inner); continue; } const value = resolveValue(field, root, strict); if (value === '') continue; - out.push({ text: value }); + out.push(style ? { text: value, style } : { text: value }); } return out; }; @@ -70,7 +78,7 @@ export const partiesRenderer: BlockRenderer = (block, ctx) => { width: '*', stack: [ { text: ctx.label(col.label), style: HEADING_STYLE }, - ...renderFields(col.fields, ctx.root, ctx.strict), + ...renderFields(col.fields, ctx.root, ctx.strict, col.style), ], }); diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index d2a5ecec..67f5af7c 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -8,7 +8,8 @@ "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, "footerNote": { "fontSize": 7, "color": "#999999" }, - "partyAddress": { "fontSize": 8 } + "partyIdentity": { "fontSize": 9 }, + "partyDetails": { "fontSize": 8 } }, "blocks": [ { @@ -18,21 +19,22 @@ "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1", - "ksefNumber": "opts.ksefNumber" + "ksefNumber": "opts.ksefNumber", + "offlineStyle": "offline" }, - { "type": "text", "label": "offline", "when": "offline", "style": "offline" }, { "type": "divider" }, { "type": "spacer", "height": 4 }, { "type": "parties", "left": { "label": "seller", + "style": "partyIdentity", "fields": [ "Podmiot1.DaneIdentyfikacyjne.Nazwa", "Podmiot1.DaneIdentyfikacyjne.NIP", { "label": "address", - "style": "partyAddress", + "style": "partyDetails", "fields": [ "Podmiot1.Adres.AdresL1", { "path": "Podmiot1.Adres.AdresL2", "optional": true }, @@ -42,13 +44,14 @@ { "label": "contact", "from": "Podmiot1.DaneKontaktowe", - "style": "partyAddress", + "style": "partyDetails", "fields": ["Email", "Telefon"] } ] }, "right": { "label": "buyer", + "style": "partyIdentity", "fields": [ "Podmiot2.DaneIdentyfikacyjne.Nazwa", { @@ -60,7 +63,7 @@ }, { "label": "address", - "style": "partyAddress", + "style": "partyDetails", "fields": [ "Podmiot2.Adres.AdresL1", { "path": "Podmiot2.Adres.AdresL2", "optional": true }, @@ -70,7 +73,7 @@ { "label": "contact", "from": "Podmiot2.DaneKontaktowe", - "style": "partyAddress", + "style": "partyDetails", "fields": ["Email", "Telefon", "NrKlienta"] } ] diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 5eb7bfc0..181dc2e7 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -8,7 +8,8 @@ "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, "footerNote": { "fontSize": 7, "color": "#999999" }, - "partyAddress": { "fontSize": 8 } + "partyIdentity": { "fontSize": 9 }, + "partyDetails": { "fontSize": 8 } }, "blocks": [ { @@ -18,21 +19,22 @@ "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1", - "ksefNumber": "opts.ksefNumber" + "ksefNumber": "opts.ksefNumber", + "offlineStyle": "offline" }, - { "type": "text", "label": "offline", "when": "offline", "style": "offline" }, { "type": "divider" }, { "type": "spacer", "height": 4 }, { "type": "parties", "left": { "label": "seller", + "style": "partyIdentity", "fields": [ "Podmiot1.DaneIdentyfikacyjne.Nazwa", "Podmiot1.DaneIdentyfikacyjne.NIP", { "label": "address", - "style": "partyAddress", + "style": "partyDetails", "fields": [ "Podmiot1.Adres.AdresL1", { "path": "Podmiot1.Adres.AdresL2", "optional": true }, @@ -42,13 +44,14 @@ { "label": "contact", "from": "Podmiot1.DaneKontaktowe", - "style": "partyAddress", + "style": "partyDetails", "fields": ["Email", "Telefon"] } ] }, "right": { "label": "buyer", + "style": "partyIdentity", "fields": [ "Podmiot2.DaneIdentyfikacyjne.Nazwa", { @@ -60,7 +63,7 @@ }, { "label": "address", - "style": "partyAddress", + "style": "partyDetails", "fields": [ "Podmiot2.Adres.AdresL1", { "path": "Podmiot2.Adres.AdresL2", "optional": true }, @@ -70,7 +73,7 @@ { "label": "contact", "from": "Podmiot2.DaneKontaktowe", - "style": "partyAddress", + "style": "partyDetails", "fields": ["Email", "Telefon", "NrKlienta"] } ] diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 0eeb79b3..c25f6573 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -90,6 +90,13 @@ export interface HeaderBlock { * visualization does not print a dangling label. */ ksefNumber?: string; + /** + * Style for the OFFLINE marker, which takes the place of the KSeF number + * line when that number resolves empty — the invoice is not registered yet. + * Setting it is what asks for the marker at all; without it the line is + * simply left out. Needs `ksefNumber`, whose slot the marker occupies. + */ + offlineStyle?: string; style?: string; } @@ -129,6 +136,13 @@ export interface PartyGroup { export interface PartyColumn { label: string; + /** + * Style for the panel's own value lines — the counterparty's identity, since + * everything below it lives in a labelled group. A group without a `style` of + * its own inherits this one, so a panel styles uniformly by default and a + * group overrides only where it wants to differ. Headings are unaffected. + */ + style?: string; fields: PartyField[]; } @@ -338,6 +352,9 @@ const partyField: z.ZodType = z.lazy(() => .strict(), ]), ); +const partyColumn = z + .object({ label: z.string(), style: z.string().optional(), fields: z.array(partyField) }) + .strict(); const fieldDef = z .object({ label: z.string(), @@ -385,12 +402,13 @@ const blockSchema: z.ZodType = z.lazy(() => number: z.string().optional(), date: z.string().optional(), ksefNumber: z.string().optional(), + offlineStyle: z.string().optional(), style: z.string().optional(), }).strict(), z.object({ type: z.literal('parties'), - left: z.object({ label: z.string(), fields: z.array(partyField) }).strict(), - right: z.object({ label: z.string(), fields: z.array(partyField) }).strict(), + left: partyColumn, + right: partyColumn, style: z.string().optional(), }).strict(), z.object({ diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index 173accdb..300f42df 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -10,7 +10,6 @@ import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; import { applyFormat } from '../../../src/pdf/format.js'; import { headerRenderer } from '../../../src/pdf/template/blocks/header.js'; import { partiesRenderer } from '../../../src/pdf/template/blocks/parties.js'; -import { partiesRenderer } from '../../../src/pdf/template/blocks/parties.js'; import { linesRenderer } from '../../../src/pdf/template/blocks/lines.js'; import { totalsRenderer } from '../../../src/pdf/template/blocks/totals.js'; import { paymentRenderer } from '../../../src/pdf/template/blocks/payment.js'; @@ -110,7 +109,7 @@ describe('partiesRenderer', () => { label: 'buyer', fields: [ 'Podmiot2.DaneIdentyfikacyjne.Nazwa', - { label: 'address', style: 'partyAddress', fields: ['Podmiot2.Adres.AdresL1', 'Podmiot2.Adres.AdresL2'] }, + { label: 'address', style: 'partyDetails', fields: ['Podmiot2.Adres.AdresL1', 'Podmiot2.Adres.AdresL2'] }, ], }, }, @@ -130,8 +129,8 @@ describe('partiesRenderer', () => { ]); // the sub-heading matches the panel heading; the lines carry the group style expect(stack[2].style).toBe(stack[0].style); - expect(stack[3].style).toBe('partyAddress'); - expect(stack[4].style).toBe('partyAddress'); + expect(stack[3].style).toBe('partyDetails'); + expect(stack[4].style).toBe('partyDetails'); }); it('drops an address group whose lines are all absent, heading included', () => { @@ -144,7 +143,7 @@ describe('partiesRenderer', () => { label: 'buyer', fields: [ 'Podmiot2.DaneIdentyfikacyjne.Nazwa', - { label: 'address', style: 'partyAddress', fields: ['Podmiot2.Adres.AdresL1', 'Podmiot2.Adres.AdresL2'] }, + { label: 'address', style: 'partyDetails', fields: ['Podmiot2.Adres.AdresL1', 'Podmiot2.Adres.AdresL2'] }, ], }, }, @@ -167,7 +166,7 @@ describe('partiesRenderer', () => { right: { label: 'buyer', fields: [ - { label: 'address', style: 'partyAddress', fields: ['Podmiot2.Adres.AdresL1', 'Podmiot2.Adres.AdresL2'] }, + { label: 'address', style: 'partyDetails', fields: ['Podmiot2.Adres.AdresL1', 'Podmiot2.Adres.AdresL2'] }, ], }, }, @@ -187,7 +186,7 @@ describe('partiesRenderer', () => { const contact = { label: 'contact', from: 'Podmiot2.DaneKontaktowe', - style: 'partyAddress', + style: 'partyDetails', fields: ['Email', 'Telefon'], }; const node = rec( @@ -283,6 +282,65 @@ describe('partiesRenderer', () => { expect(stack.map((n: { text: string }) => n.text)).toContain('1111111111'); expect(stack.map((n: { text: string }) => n.text)).not.toContain('CR-421169'); }); + + // The panel style covers the identity lines — name and tax number — which sit + // directly under the heading and belong to no group. + describe('panel style', () => { + const styled = (group: Record) => + rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: [] }, + right: { + label: 'buyer', + style: 'partyIdentity', + fields: ['Podmiot2.DaneIdentyfikacyjne.Nazwa', group], + }, + }, + makeCtx({ + Podmiot2: { + DaneIdentyfikacyjne: { Nazwa: 'Nabywca' }, + Adres: { AdresL1: 'ul. Testowa 1' }, + }, + }), + noRender, + ), + ).columns[1].stack; + + it('styles the identity lines but leaves the heading alone', () => { + const stack = styled({ label: 'address', style: 'partyDetails', fields: ['Podmiot2.Adres.AdresL1'] }); + expect(stack[0].text).toBe('buyer'); + expect(stack[0].style).toBe('h2'); // the heading keeps its own style + expect(stack[1]).toEqual({ text: 'Nabywca', style: 'partyIdentity' }); + }); + + it('lets a group override it for its own lines', () => { + const stack = styled({ label: 'address', style: 'partyDetails', fields: ['Podmiot2.Adres.AdresL1'] }); + expect(stack[2].style).toBe('h2'); // the sub-heading, not the group style + expect(stack[3]).toEqual({ text: 'ul. Testowa 1', style: 'partyDetails' }); + }); + + it('passes it down to a group that declares none', () => { + const stack = styled({ label: 'address', fields: ['Podmiot2.Adres.AdresL1'] }); + expect(stack[3].style).toBe('partyIdentity'); + }); + + it('leaves value lines unstyled when the panel declares none', () => { + const stack = rec( + partiesRenderer( + { + type: 'parties', + left: { label: 'seller', fields: [] }, + right: { label: 'buyer', fields: ['Podmiot2.DaneIdentyfikacyjne.Nazwa'] }, + }, + makeCtx({ Podmiot2: { DaneIdentyfikacyjne: { Nazwa: 'Nabywca' } } }), + noRender, + ), + ).columns[1].stack; + expect(stack[1]).toEqual({ text: 'Nabywca' }); + }); + }); }); // ── header (existing renderer) ─────────────────────────────────────────────── @@ -362,6 +420,64 @@ describe('headerRenderer', () => { expect(JSON.stringify(right.stack)).not.toContain('ksefNumber'); }); + it('puts the OFFLINE marker in the KSeF number\'s slot, right-aligned with it', () => { + const node = rec( + headerRenderer( + { + type: 'header', + number: 'Fa.P_2', + date: 'Fa.P_1', + ksefNumber: 'opts.ksefNumber', + offlineStyle: 'offline', + }, + makeCtx({ Fa: { P_2: 'FV/2025/01', P_1: '2025-01-15' } }), + noRender, + ), + ); + const [, right] = node.columns; + expect(right.alignment).toBe('right'); // the marker rides the header's right stack + expect(right.stack).toHaveLength(3); + expect(right.stack[2]).toEqual({ text: 'offline', style: 'offline' }); // label('offline') + }); + + it('drops the marker once the invoice carries a KSeF number', () => { + const ctx = makeCtx( + { Fa: { P_2: 'FV/2025/01', P_1: '2025-01-15' } }, + { bindings: { 'opts.ksefNumber': '1111111111-20250115-010000000000-00' } }, + ); + const node = rec( + headerRenderer( + { + type: 'header', + number: 'Fa.P_2', + date: 'Fa.P_1', + ksefNumber: 'opts.ksefNumber', + offlineStyle: 'offline', + }, + ctx, + noRender, + ), + ); + const [, right] = node.columns; + expect(right.stack).toHaveLength(3); + expect(right.stack[2].style).toBeUndefined(); + expect(right.stack[2].text).toContain('1111111111-20250115-010000000000-00'); + }); + + // The marker stands in for the KSeF number, so a header that prints no such + // number has no slot for it either. + it('leaves the marker out when the header prints no KSeF number at all', () => { + const node = rec( + headerRenderer( + { type: 'header', number: 'Fa.P_2', offlineStyle: 'offline' }, + makeCtx({ Fa: { P_2: 'FV/2025/01' } }), + noRender, + ), + ); + const [, right] = node.columns; + expect(right.stack).toHaveLength(1); + }); + it('drops the logo node when its binding resolves empty', () => { const node = rec(headerRenderer({ type: 'header', logo: 'missing.logo' }, makeCtx({}), noRender)); const [left] = node.columns; diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index 02fa3ce6..db1fc27f 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -154,6 +154,39 @@ describe('built-in template lint', () => { expect(has(root, 'Fa.Platnsoc')).toBe(false); // the typo this lint exists to catch }); + /** + * pdfmake silently ignores a style name it does not know, so a renamed or + * mistyped style reference costs nothing at render time and everything on the + * page. Nothing else checks this: the DSL types a style as a plain string. + */ + it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: every style it references is defined', (name) => { + const template = getBuiltinTemplate(name)!; + const defined = Object.keys(template.styles ?? {}); + const referenced = new Set(); + const walk = (value: unknown): void => { + if (Array.isArray(value)) return value.forEach(walk); + if (value === null || typeof value !== 'object') return; + for (const [key, inner] of Object.entries(value)) { + if (key === 'style' && typeof inner === 'string') referenced.add(inner); + else walk(inner); + } + }; + walk(template); + expect([...referenced].filter((style) => !defined.includes(style))).toEqual([]); + }); + + it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: defines the styles the renderers reach for', (name) => { + // Two style names are reached for by the renderers rather than named in the + // template, so nothing in the JSON points at them: `title` is the header's + // default, and `h2` is hardcoded as the heading of the parties, payment and + // annotations blocks. A template that omits one loses those headings. + const template = getBuiltinTemplate(name)!; + const defined = Object.keys(template.styles ?? {}); + const needsH2 = template.blocks.some((b) => ['parties', 'payment', 'annotations'].includes(b.type)); + expect(defined).toContain('title'); + if (needsH2) expect(defined).toContain('h2'); + }); + it('fails a template whose repeater path is misspelled', () => { const root = bodyOf('fa3-default'); expect(list(root, 'Fa.FaWiersz').length).toBeGreaterThan(0); From c40529afc5ea70ad66f3eed1b8f003ecc2fbccda Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 21:20:32 +0200 Subject: [PATCH 20/67] feat(pdf): print both KSeF verification codes, sized to a real scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A visualization carried Code I and nothing else. Code II — the link that verifies the issuer of an invoice written offline — is now printed beside it, and both take a clickable link when asked. Code II is supplied rather than derived: its URL is signed with the private key of a KSeF offline certificate, which a PDF renderer has no business holding. The codes are encoded here and handed to pdfmake as SVG instead of going through its QR node. That node sizes a code at whole points per module, so a code existed at only a handful of sizes and `fit` was a ceiling rather than a measurement — two codes of different data lengths could not be made to match at all, and a `fit` under the module count silently rendered nothing. Drawing the modules ourselves makes the size exact, so equal `fit` means equal footprint whatever each code carries, and brings the quiet zone the standard requires and that node omits. Error correction goes from the 7% default to 15%, the level KSeF's own reference clients use: an invoice gets folded. Both codes sit under a heading, in a row that holds them against the right margin however wide the heading runs. An absent code drops its column rather than emitting an empty node, which would have claimed an elastic column and pushed its neighbour off the margin. Sizing is the thing no render test can see — an unreadable code is still a valid PDF — so it has its own spec, measuring the built-in templates against real Code I and Code II URLs of both signature kinds. Verified: unit 2620, e2e 161, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 31 +++- .../src/cli/commands/invoice.ts | 6 + packages/ksef-client-ts/src/pdf/i18n/en.ts | 2 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 3 + packages/ksef-client-ts/src/pdf/index.ts | 44 ++++- .../src/pdf/template/blocks/qr.ts | 113 ++++++++++++- .../src/pdf/template/builtin/fa2-default.json | 11 +- .../src/pdf/template/builtin/fa3-default.json | 11 +- .../ksef-client-ts/src/pdf/template/dsl.ts | 28 +++- .../tests/e2e/35-invoice-pdf-cli.test.ts | 58 ++++++- .../tests/e2e/36-invoice-pdf-library.test.ts | 58 ++++++- .../tests/unit/pdf/qr-sizing.test.ts | 114 +++++++++++++ .../ksef-client-ts/tests/unit/pdf/qr.test.ts | 153 ++++++++++++++++-- .../tests/unit/pdf/render-builtins.test.ts | 71 ++++++++ 14 files changed, 654 insertions(+), 49 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/qr-sizing.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 03252071..8055635e 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -172,7 +172,7 @@ The `schema` field binds a template to a single document kind. If you render an | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | | `payment` | Payment details (amount paid, date, method) | | `annotations` | Miscellaneous labelled fields | -| `qr` | The verification QR image | +| `qr` | One KSeF verification QR — `code: "invoice"` (Code I, the default) or `code: "certificate"` (Code II) | | `footer` | Footer note | A template may also declare a running page footer, drawn in the bottom page margin on every page: @@ -183,6 +183,8 @@ A template may also declare a running page footer, drawn in the bottom page marg It prints the localized attribution on the left and a `Page 1 of 3` indicator on the right, aligned with `page.margins`. The page total is only known once pdfmake has laid the content out, so this cannot be a block. The attribution text is fixed by the renderer — a template may restyle the footer or omit it, but not reword the credit. +A `qr` block's `fit` is the printed side in points, quiet zone included, and it is exact — give two blocks the same `fit` and they come out the same size on the page, however much data each code carries. What differs instead is the module width, which is what a scanner cares about: Code I is 41 modules while Code II carries a signature and runs 57 over an EC key or 85 over RSA, so the same box makes Code II's modules roughly half as wide as Code I's. The renderer refuses a `fit` that would leave less than a point per module rather than printing a code nothing can read. The built-in templates use 104 for both. + **Primitive blocks** are layout building blocks: `text`, `columns`, `stack`, `each`, `table`, `image`, `divider`, `spacer`. `each` repeats a group of blocks once per entry of a collection, with the entry as the binding root, so its children use item-relative paths. Use it where a table cannot fit a record on one row — the built-in UPO templates lay out each confirmed document this way, because a 35-character KSeF number beside a 44-character hash will not share a page-wide row. @@ -287,18 +289,34 @@ const pdf = await renderInvoicePdf(xml, 'fa3-default', { --- -## Verification QR (Code I) +## Verification QR codes + +KSeF defines two verification codes, and the built-in templates print both when both are available. -Set `qr: true` to embed the KSeF **Code I** verification QR. It is derived automatically from the invoice XML — you do not supply a URL. The verification hash is computed over the original invoice bytes so it matches the value KSeF registered. Use `env` to pick the correct portal (`prod` by default), or `baseQrUrl` to override it for offline / non-standard cases. +**Code I** verifies the invoice. Set `qr: true` and it is derived from the XML — you do not supply a URL. The verification hash is computed over the original invoice bytes so it matches the value KSeF registered. Use `env` to pick the correct portal (`prod` by default), or `baseQrUrl` to override it for offline / non-standard cases. Pass `qrUrl` to skip derivation and print a URL you built yourself. + +**Code II** verifies the *issuer* and only exists for invoices issued offline. It cannot be derived here: the link carries a signature made with the private key of a KSeF offline certificate, which a PDF renderer has no business holding. Build it with `VerificationLinkService.buildCertificateVerificationUrl` (or `ksef qr certificate`) and pass the result as `certificateQrUrl`. ```ts +import { VerificationLinkService } from 'ksef-client-ts'; + +const certificateQrUrl = new VerificationLinkService('https://qr.ksef.mf.gov.pl') + .buildCertificateVerificationUrl('Nip', nip, sellerNip, certSerial, invoiceHash, privateKeyPem); + const pdf = await renderInvoicePdf(xml, 'fa3-default', { - qr: true, + qr: true, // Code I, derived from the document + certificateQrUrl, // Code II, supplied + qrLinks: true, // a clickable link under each code env: 'test', - ksefNumber: '1234567890-20260705-ABCDEF012345-01', }); ``` +`qrLinks` repeats each URL under its code as a clickable link, for readers who have the PDF on screen rather than on paper. + +Both codes are encoded here and handed to pdfmake as vector SVG rather than through its own QR node, which sizes a code at whole points per module and so can only produce a handful of sizes — two codes of different data lengths cannot be made to match under that rule. Drawing the modules ourselves makes the size exact and gets the standard's quiet zone, which that node omits. Error correction is 15% (`M`), the level KSeF's own reference clients use: an invoice gets folded, and the 7% default leaves no margin for a crease. + +At the built-in `fit` of 104 (37 mm square) the modules come out at 0.75 mm for Code I and 0.56 mm for a Code II signed with an EC key. An RSA signature is four times longer and drops that to 0.39 mm — legal (both key types are), but at the edge of what prints and scans reliably, so raise `fit` if your certificate is RSA. + When no `ksefNumber` is provided the visualization is marked **OFFLINE**. UPO receipts do not carry a QR code. --- @@ -333,6 +351,9 @@ ksef invoice pdf upo.xml --template-file ./templates/my-upo.json | `--template-file ` | Custom JSON template path (mutually exclusive with `--template`) | | `--locale ` | Label language (default `pl`) | | `--qr` | Embed the KSeF Code I QR derived from the XML | +| `--qr-url ` | Code I URL used verbatim, instead of deriving one | +| `--qr-cert-url ` | Code II (offline certificate) URL — build it with `ksef qr certificate` | +| `--qr-links` | Print a clickable link under each QR code | | `--ksef-number ` | KSeF number to print (absent → marked OFFLINE) | | `--totals ` | Tax breakdown above the amount due (default `buckets`) | | `--upo` | Treat the input as a UPO document (otherwise auto-detected); ignored when a template is named explicitly | diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 0beb524b..10a025df 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -614,6 +614,9 @@ const pdf = defineCommand({ locale: { type: 'string', description: 'Label language: pl | en | pl+en | en+pl (default: pl)' }, out: { type: 'string', description: 'Output PDF path (default: alongside the source, .pdf)' }, qr: { type: 'boolean', description: 'Embed the KSeF Code I QR derived from the XML' }, + qrUrl: { type: 'string', description: 'Code I verification URL, used verbatim instead of deriving it' }, + qrCertUrl: { type: 'string', description: 'Code II (offline certificate) verification URL — build it with `ksef qr certificate`' }, + qrLinks: { type: 'boolean', description: 'Print a clickable link under each QR code' }, ksefNumber: { type: 'string', description: 'KSeF number to print (absent → marked OFFLINE)' }, upo: { type: 'boolean', description: 'Treat the input as a UPO document (otherwise auto-detected)' }, logo: { type: 'string', description: 'Path to a logo image (PNG/JPEG/GIF/WebP/SVG) to print in the header' }, @@ -647,6 +650,9 @@ const pdf = defineCommand({ locale: locale as PdfLocale, totals: totals as PdfTotals, qr: Boolean(args.qr), + qrLinks: Boolean(args.qrLinks), + ...(args.qrUrl ? { qrUrl: args.qrUrl as string } : {}), + ...(args.qrCertUrl ? { certificateQrUrl: args.qrCertUrl as string } : {}), ...(args.ksefNumber ? { ksefNumber: args.ksefNumber as string } : {}), ...(env ? { env } : {}), ...(logo ? { logo } : {}), diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 366d8feb..9ec51309 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -58,6 +58,8 @@ export const en: LabelBundle = { receiptDate: 'KSeF number assignment date', documentHash: 'Document hash', documents: 'Documents', + verifyInKsef: 'Verify the invoice in KSeF!', + openLink: 'Open', generatedWith: 'Generated with', pageOf: 'Page {page} of {pages}', }; diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 26b47283..64a3d5dd 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -63,6 +63,9 @@ export const pl: LabelBundle = { receiptDate: 'Data nadania numeru KSeF', documentHash: 'Skrót dokumentu', documents: 'Dokumenty', + // qr + verifyInKsef: 'Sprawdź fakturę w KSeF!', + openLink: 'Otwórz', // page footer generatedWith: 'Wygenerowano przez', pageOf: 'Strona {page} z {pages}', diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index 30d45a3f..ce8a7a35 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -53,6 +53,25 @@ export interface RenderOptions { ksefNumber?: string; /** Embed the KSeF Code I QR derived from the invoice XML. */ qr?: boolean; + /** + * Code I URL, used verbatim instead of being derived from the document. + * Supplying it is intent enough — `qr` need not also be set. + */ + qrUrl?: string; + /** + * Code II URL — the issuer's offline-certificate verification link, which + * only an invoice issued offline carries. It cannot be derived here: the link + * is signed with the private key of a KSeF offline certificate, and a PDF + * renderer has no business holding one. Build it with + * `VerificationLinkService.buildCertificateVerificationUrl` (or `ksef qr + * certificate`) and pass the result. + */ + certificateQrUrl?: string; + /** + * Print the URL under each QR as a clickable link, for readers who have the + * PDF on screen rather than on paper. + */ + qrLinks?: boolean; /** Environment used to derive the QR base URL. Default `'prod'`. */ env?: 'prod' | 'test' | 'demo'; /** Override the QR base URL (offline / non-standard). */ @@ -90,7 +109,7 @@ function buildContext( root: unknown, template: InvoiceTemplate, opts: RenderOptions, - qrUrl: string, + qrUrls: { invoice: string; certificate: string }, ): RenderContext { const label = makeLabelResolver(opts.locale ?? 'pl', { bilingualSeparator: opts.bilingualSeparator, @@ -101,14 +120,18 @@ function buildContext( 'opts.logo': opts.logo ?? '', 'opts.ksefNumber': opts.ksefNumber ?? '', 'opts.accent': opts.theme?.accent ?? '', - qrUrl, + qrUrl: qrUrls.invoice, + certificateQrUrl: qrUrls.certificate, }; const totals = opts.totals ?? 'buckets'; const flags: Record = { hasKsefNumber: Boolean(opts.ksefNumber), offline: !opts.ksefNumber, - qr: Boolean(opts.qr) && qrUrl !== '', + // Either code is enough to keep the QR area on the page: an offline invoice + // may be waiting for its number and still carry Code II. + qr: qrUrls.invoice !== '' || qrUrls.certificate !== '', + qrLinks: Boolean(opts.qrLinks), totalsBuckets: totals === 'buckets' || totals === 'both', totalsSummary: totals === 'summary' || totals === 'both', }; @@ -151,10 +174,12 @@ async function renderWithTemplate( const parsed = parseXmlForPdf(xml); const body = extractBody(parsed, template.schema); - // QR (Code I) is derived only for invoices — the hash is computed over the - // ORIGINAL input bytes (bypassing the parser) so it matches the KSeF registry. - let qrUrl = ''; - if (opts.qr && !template.schema.startsWith('UPO')) { + // QR (Code I) is derived only for invoices, and only when the caller did not + // hand us the URL — the hash is computed over the ORIGINAL input bytes + // (bypassing the parser) so it matches the KSeF registry. Code II is never + // derived: it carries a signature made with the issuer's private key. + let qrUrl = opts.qrUrl ?? ''; + if (!qrUrl && opts.qr && !template.schema.startsWith('UPO')) { qrUrl = deriveInvoiceQrUrl({ rawInput, body, @@ -165,7 +190,10 @@ async function renderWithTemplate( }); } - const ctx = buildContext(body, template, opts, qrUrl); + const ctx = buildContext(body, template, opts, { + invoice: qrUrl, + certificate: opts.certificateQrUrl ?? '', + }); const doc = interpretTemplate(template, ctx, blockRegistry); const pdfMake = await loadPdfMake(); return createPdfBuffer(pdfMake, doc); diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts b/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts index d437d4c8..80764bc6 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts @@ -1,14 +1,111 @@ +import * as QRCode from 'qrcode'; +import { KSeFPdfError } from '../../errors.js'; import type { QrBlock } from '../dsl.js'; -import type { BlockRenderer } from '../interpret.js'; +import type { BlockRenderer, PdfNode } from '../interpret.js'; /** - * Renders the invoice verification QR ("Code I"). The URL is derived by the - * orchestrator and injected as the `qrUrl` binding — an empty binding (no - * derivable hash/NIP/date) collapses to an empty text node rather than emitting - * a broken code. `when` is handled centrally by the interpreter. + * Error-correction level for every code we draw. The QR default of `'L'` (7% + * recovery) is thin for a document that gets printed, folded and scanned off + * paper by a phone camera, so we take `'M'` (15%) — the level KSeF's own + * reference clients use. It costs a few modules: Code I grows from 37 to 41. + */ +const ECC_LEVEL = 'M'; + +/** Blank border the QR spec requires around a code, in modules. */ +const QUIET_ZONE = 4; + +/** + * Hard floor for a module's printed size, in points. This is not a quality + * threshold — 1pt is 0.35 mm, already marginal — it is the line below which the + * code is decoration rather than data, and a caller who crosses it has almost + * certainly mis-sized the block rather than chosen this. + */ +const MIN_MODULE_PT = 1; + +/** Binding name carrying each code's URL, injected by the orchestrator. */ +const URL_BINDING: Record, string> = { + invoice: 'qrUrl', + certificate: 'certificateQrUrl', +}; + +/** + * A QR as an SVG path, one unit per module, with the quiet zone folded into the + * viewBox so the whole thing scales as a unit. + * + * We encode the code ourselves rather than handing the URL to pdfmake's `qr` + * node, because that node sizes a code at `floor(fit / modules)` points per + * module: module sizes are whole points and nothing else, so a code can only + * exist at a handful of sizes and `fit` is a ceiling rather than a measurement. + * Two codes of different data lengths then cannot be made the same size at all. + * Drawing the modules ourselves makes the size exact and continuous, and gets + * the quiet zone — which that node omits — for free. + */ +function buildQrSvg(url: string): { svg: string; span: number } { + const { modules } = QRCode.create(url, { errorCorrectionLevel: ECC_LEVEL }); + const n = modules.size; + const span = n + QUIET_ZONE * 2; + + let path = ''; + for (let y = 0; y < n; y += 1) { + for (let x = 0; x < n; x += 1) { + if (modules.data[y * n + x]) path += `M${x + QUIET_ZONE} ${y + QUIET_ZONE}h1v1h-1z`; + } + } + + const svg = + `` + + `` + + `` + + ``; + return { svg, span }; +} + +/** + * Renders one KSeF verification QR — Code I (the invoice) or Code II (the + * issuer's offline certificate), selected by {@link QrBlock.code}. The URL is + * built by the orchestrator and injected as a binding; an empty binding renders + * nothing at all, which is what keeps a template that asks for Code II harmless + * on an online invoice. Dropping the node rather than emitting an empty one + * matters inside a `columns` row: an empty text node would claim an elastic + * column and shove the remaining code away from the margin. + * `when` is handled centrally by the interpreter. + * + * `fit` is the printed side in points, quiet zone included, and it is exact: + * two blocks given the same `fit` come out the same size however much data each + * code carries. What varies instead is the module size, which is what a scanner + * actually cares about — see {@link MIN_MODULE_PT}. + * + * With `qrLinks` set, the same URL is repeated under the code as a clickable + * link, so a reader on screen does not have to photograph their own monitor. */ export const qrRenderer: BlockRenderer = (block, ctx) => { - const url = ctx.bindings['qrUrl'] ?? ''; - if (!url) return { text: '' }; - return { qr: url, fit: block.fit ?? 100 }; + const url = ctx.bindings[URL_BINDING[block.code ?? 'invoice']] ?? ''; + if (!url) return null; + + const side = block.fit ?? 100; + const { svg, span } = buildQrSvg(url); + if (side / span < MIN_MODULE_PT) { + throw new KSeFPdfError( + `QR too small to be readable: fit ${side}pt over ${span} modules leaves ` + + `${(side / span).toFixed(2)}pt per module. This code needs fit ${Math.ceil(span * MIN_MODULE_PT)} or more.`, + ); + } + + const code: PdfNode = { svg, width: side, height: side }; + const link: PdfNode[] = ctx.flags['qrLinks'] + ? [ + { + text: ctx.label('openLink'), + link: url, + ...(block.linkStyle ? { style: block.linkStyle } : {}), + }, + ] + : []; + + // Wrapped rather than returned bare, and always `width: 'auto'`: inside a + // `columns` row pdfmake would otherwise treat the SVG's own `width` as the + // column width and stretch the code across its share of the page. Hugging the + // content is also what lets a row put a heading in an elastic column beside + // the codes and have them sit against the right margin. + return { width: 'auto', stack: [code, ...link] }; }; diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 67f5af7c..efe619b8 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -8,6 +8,7 @@ "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, "footerNote": { "fontSize": 7, "color": "#999999" }, + "qrLink": { "fontSize": 8, "color": "#0645ad", "decoration": "underline" }, "partyIdentity": { "fontSize": 9 }, "partyDetails": { "fontSize": 8 } }, @@ -189,6 +190,14 @@ } }, { "type": "divider" }, - { "type": "qr", "when": "qr", "fit": 90 } + { + "type": "columns", + "when": "qr", + "columns": [ + { "type": "text", "label": "verifyInKsef", "style": "h2" }, + { "type": "qr", "code": "invoice", "fit": 104, "linkStyle": "qrLink" }, + { "type": "qr", "code": "certificate", "fit": 104, "linkStyle": "qrLink" } + ] + } ] } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 181dc2e7..04b61e3c 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -8,6 +8,7 @@ "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, "footerNote": { "fontSize": 7, "color": "#999999" }, + "qrLink": { "fontSize": 8, "color": "#0645ad", "decoration": "underline" }, "partyIdentity": { "fontSize": 9 }, "partyDetails": { "fontSize": 8 } }, @@ -189,6 +190,14 @@ } }, { "type": "divider" }, - { "type": "qr", "when": "qr", "fit": 90 } + { + "type": "columns", + "when": "qr", + "columns": [ + { "type": "text", "label": "verifyInKsef", "style": "h2" }, + { "type": "qr", "code": "invoice", "fit": 104, "linkStyle": "qrLink" }, + { "type": "qr", "code": "certificate", "fit": 104, "linkStyle": "qrLink" } + ] + } ] } diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index c25f6573..5fae59c4 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -216,10 +216,30 @@ export interface AnnotationsBlock { style?: string; } +/** + * One KSeF verification QR. `code` picks which one: `'invoice'` is Code I, + * derived from the document; `'certificate'` is Code II, which only offline + * invoices carry and which the caller must supply as a ready-made URL. + * + * `fit` is the printed side in points, quiet zone included, and it is exact: two + * blocks given the same `fit` come out the same size however much data each code + * carries. What varies instead is the module size — Code II carries a signature + * and runs 57–85 modules against Code I's 41, so the same box makes its modules + * roughly half as wide. That is the number a scanner cares about, and the + * renderer refuses a `fit` that drives it below a point per module. + */ export interface QrBlock { type: 'qr'; when?: string; fit?: number; + /** Which verification code to print. Default `'invoice'` (Code I). */ + code?: 'invoice' | 'certificate'; + /** + * Style for the clickable link printed under the code. The link itself is + * switched on by the render options, not by the template; this only says how + * it looks. + */ + linkStyle?: string; } export interface FooterBlock { @@ -433,7 +453,13 @@ const blockSchema: z.ZodType = z.lazy(() => style: z.string().optional(), }).strict(), z.object({ type: z.literal('annotations'), fields: z.array(fieldDef), style: z.string().optional() }).strict(), - z.object({ type: z.literal('qr'), when: z.string().optional(), fit: z.number().optional() }).strict(), + z.object({ + type: z.literal('qr'), + when: z.string().optional(), + fit: z.number().optional(), + code: z.enum(['invoice', 'certificate']).optional(), + linkStyle: z.string().optional(), + }).strict(), z.object({ type: z.literal('footer'), label: z.string().optional(), diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 3eb2fa98..7143780b 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'node:child_process'; +import { generateKeyPairSync, randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { VerificationLinkService } from '../../src/qr/verification-link-service.js'; // Spawn-based coverage for `ksef invoice pdf` — renders the whole preview set // through the built CLI (dist/cli.js), no network and no authentication. It @@ -34,6 +36,13 @@ const fx = (name: string) => join(fixtures, name); /** A KSeF number shaped like the real thing; this one identifies nobody. */ const KSEF_NUMBER = '1111111111-20260115-010000000000-00'; +/** + * The QR group renders against DEMO. The documents are invented, so no verifier + * will resolve them anywhere — but a demo link is the one a reader can safely + * click, and it keeps every code in the group pointing at the same host. + */ +const DEMO_QR_HOST = 'https://qr-demo.ksef.mf.gov.pl'; + function run(args: string[]) { const result = spawnSync('node', [cliEntry, ...args], { encoding: 'utf-8', cwd: repoRoot }); return { status: result.status ?? -1, stdout: result.stdout, stderr: result.stderr }; @@ -50,6 +59,7 @@ function isCompletePdf(file: string): boolean { /** Derived inputs that no fixture can hold on its own. */ let oldTotalsTemplate: string; let multiDocumentUpo: string; +let certificateQrUrl: string; function writeDerivedInputs(): void { // A copy of fa3-default whose totals read a single rate bucket — the shape the @@ -80,6 +90,22 @@ function writeDerivedInputs(): void { ); multiDocumentUpo = join(inputsDir, 'cli-upo-4_3-five-documents.xml'); writeFileSync(multiDocumentUpo, upo.slice(0, end) + '\n' + clones.join('\n') + upo.slice(end)); + + // Code II is signed with the issuer's offline-certificate key, so it can only + // be built, never derived from the document. A throwaway EC key gives a URL of + // realistic length — which is what decides how dense the printed code is. + const key = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + certificateQrUrl = new VerificationLinkService(DEMO_QR_HOST).buildCertificateVerificationUrl( + 'Nip', + '1111111111', + '1111111111', + '01F20A5D352AE590', + randomBytes(32).toString('base64'), + key, + ); } describe('35 - `ksef invoice pdf` renders the preview set', () => { @@ -101,6 +127,10 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { /** The mixed-rate document with the flags every totals variant shares. */ const mixedVat = () => [fx('e2e-vat-multi.xml'), '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]; + /** The QR group: the same document before KSeF gave it a number, against DEMO. */ + const qrGroup = () => [fx('e2e-vat-multi.xml'), '--logo', fx('e2e-logo.png'), '--env', 'demo']; + /** Both codes on the page; links and locale are left to the variant. */ + const bothCodes = () => [...qrGroup(), '--qr', '--qr-cert-url', certificateQrUrl]; const variants: Array<[name: string, args: () => string[]]> = [ [`${PREFIX}-01-invoice-pl-qr`, () => [fx('e2e-services-np.xml'), '--qr', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], @@ -121,10 +151,30 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // --totals summary, since that is the group those rows belong to. [`${PREFIX}-11-invoice-mixed-vat-single-bucket-totals`, () => [...mixedVat(), '--totals', 'summary', '--template-file', oldTotalsTemplate]], [`${PREFIX}-12-invoice-mixed-vat-bilingual`, () => [...mixedVat(), '--locale', 'en+pl', '--totals', 'both']], + // Six variants covering four dimensions — which codes, links on or off, + // the three locales, and a supplied versus derived Code I. Every value of + // every dimension appears at least twice, paired with different values of + // the others, so a regression in any one of them shows up somewhere. The + // single-code rows also carry the layout case that matters: with one code + // absent, the other must still sit against the right margin. + // + // # codes links locale Code I + // 13 I no pl derived + // 14 I yes en supplied + // 15 II yes pl — + // 16 II no en+pl — + // 17 both yes en+pl derived + // 18 both no en derived + [`${PREFIX}-13-qr-code-i`, () => [...qrGroup(), '--qr']], + [`${PREFIX}-14-qr-code-i-links-en-supplied-url`, () => [...qrGroup(), '--qr-url', `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`, '--qr-links', '--locale', 'en']], + [`${PREFIX}-15-qr-code-ii-links`, () => [...qrGroup(), '--qr-cert-url', certificateQrUrl, '--qr-links']], + [`${PREFIX}-16-qr-code-ii-bilingual`, () => [...qrGroup(), '--qr-cert-url', certificateQrUrl, '--locale', 'en+pl']], + [`${PREFIX}-17-qr-both-codes-links-bilingual`, () => [...bothCodes(), '--qr-links', '--locale', 'en+pl']], + [`${PREFIX}-18-qr-both-codes-en`, () => [...bothCodes(), '--locale', 'en']], // Receipts last: they are a different document and read as their own group. - [`${PREFIX}-13-upo-pl`, () => [fx('upo-4_3.xml')]], - [`${PREFIX}-14-upo-bilingual`, () => [fx('upo-4_3.xml'), '--locale', 'en+pl']], - [`${PREFIX}-15-upo-five-documents`, () => [multiDocumentUpo]], + [`${PREFIX}-19-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-20-upo-bilingual`, () => [fx('upo-4_3.xml'), '--locale', 'en+pl']], + [`${PREFIX}-21-upo-five-documents`, () => [multiDocumentUpo]], ]; it.each(variants)('renders %s', (name, args) => { @@ -139,7 +189,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { it('renders every variant of the set', () => { // Guards against a variant being silently dropped from the table above: // regen.sh and this spec are meant to cover the same ground. - expect(variants).toHaveLength(15); + expect(variants).toHaveLength(21); for (const [name] of variants) { expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); } diff --git a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts index ccc1b7b1..99573d2b 100644 --- a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts +++ b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createRequire } from 'node:module'; import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; -import { createHash } from 'node:crypto'; +import { createHash, generateKeyPairSync } from 'node:crypto'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -13,13 +13,14 @@ import { detectUpoVersion, type InvoiceTemplate, } from 'ksef-client-ts/pdf'; +import { VerificationLinkService } from 'ksef-client-ts'; // Companion to spec 35, which drives the same renderer through the CLI. The CLI -// is a strict subset of the library: it wires five of the ten RenderOptions -// (locale, qr, ksefNumber, env, logo) and cannot pass a template as an object at -// all. This spec covers what the command line cannot reach — baseQrUrl, theme, -// bilingualSeparator, strict, invoiceHash, and renderInvoicePdfFromTemplate — -// so a regression there is not invisible just because no flag exposes it. +// is a strict subset of the library: it wires most of RenderOptions but cannot +// pass a template as an object at all. This spec covers what the command line +// cannot reach — baseQrUrl, theme, bilingualSeparator, strict, invoiceHash, and +// renderInvoicePdfFromTemplate — so a regression there is not invisible just +// because no flag exposes it. // // It imports by package specifier on purpose: that resolves through the exports // map to dist/, so the published artifact is what gets exercised, not src. @@ -36,6 +37,8 @@ const fixtures = join(repoRoot, 'tests', 'fixtures', 'pdf'); const outDir = process.env.KSEF_PDF_OUT ?? join(repoRoot, '.pdf-preview'); const inputsDir = join(outDir, '_inputs'); const PREFIX = 'lib'; +/** Same host as the CLI preview set: a demo link is one a reader can click. */ +const DEMO_QR_HOST = 'https://qr-demo.ksef.mf.gov.pl'; const fx = (name: string) => join(fixtures, name); const bytes = (name: string) => new Uint8Array(readFileSync(fx(name))); @@ -184,9 +187,50 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => await save(`${PREFIX}-08-string-input`, renderInvoicePdf(text('e2e-vat-multi.xml'), 'fa3-default')); }); + it('prints both verification codes, each with a clickable link', async () => { + // Code II cannot be derived here — it is signed with the issuer's offline + // certificate key — so the library takes it as a ready-made URL. Built + // with a throwaway key so the code has a realistic density. + const key = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + const certificateQrUrl = new VerificationLinkService( + DEMO_QR_HOST, + ).buildCertificateVerificationUrl( + 'Nip', + '1111111111', + '1111111111', + '01F20A5D352AE590', + createHash('sha256').update(bytes('e2e-vat-multi.xml')).digest('base64'), + key, + ); + await save( + `${PREFIX}-09-both-qr-codes-with-links`, + renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { + qr: true, + env: 'demo', + certificateQrUrl, + qrLinks: true, + locale: 'en+pl', + }), + ); + }); + + it('takes a Code I URL verbatim, skipping derivation entirely', async () => { + await save( + `${PREFIX}-10-supplied-code-i-url`, + renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { + qrUrl: `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`, + qrLinks: true, + ksefNumber: KSEF_NUMBER, + }), + ); + }); + // Receipt last, as in spec 35. it('renders a UPO through the library entry point', async () => { - await save(`${PREFIX}-09-upo`, renderUpoPdf(bytes('upo-4_3.xml'), { locale: 'en+pl' })); + await save(`${PREFIX}-11-upo`, renderUpoPdf(bytes('upo-4_3.xml'), { locale: 'en+pl' })); }); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/qr-sizing.test.ts b/packages/ksef-client-ts/tests/unit/pdf/qr-sizing.test.ts new file mode 100644 index 00000000..9b036af8 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/qr-sizing.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import crypto from 'node:crypto'; +import * as QRCode from 'qrcode'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { VerificationLinkService } from '../../../src/qr/verification-link-service.js'; +import type { Block, ColumnsBlock, QrBlock } from '../../../src/pdf/template/dsl.js'; + +/** + * A QR is only as readable as its modules are wide, and that width is the box + * divided by the module count — which is set by how much data the code carries, + * not by the template. Code I is 41 modules; Code II carries a signature and + * runs 57 modules over an EC key and 85 over RSA, both of which KSeF issues. So + * one box size gives the two codes very different module widths, and a box that + * suits Code I can leave Code II a smudge. + * + * Nothing else catches this: an unreadable code is still a structurally valid + * PDF, so every render test passes while the page is useless. This measures the + * built-in templates against real URLs of both kinds. + */ + +/** Module count including the quiet zone — the divisor that decides readability. */ +function span(url: string): number { + return QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size + 8; +} + +/** + * Floor for a printed module, in points. 1pt is 0.35 mm — confirmed scannable + * off a laser-printed page, and low enough that crossing it means the block was + * mis-sized rather than deliberately shrunk. + */ +const MIN_MODULE_PT = 1; + +const svc = new VerificationLinkService('https://qr-demo.ksef.mf.gov.pl'); +const hash = crypto.randomBytes(32).toString('base64'); + +const pem = (type: 'ec' | 'rsa') => + (type === 'ec' + ? crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }) + : crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) + ).privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + +const codeII = (type: 'ec' | 'rsa') => + svc.buildCertificateVerificationUrl('Nip', '1111111111', '1111111111', '01F20A5D352AE590', hash, pem(type)); + +const CODE_I = svc.buildInvoiceVerificationUrl('1111111111', '2026-01-15', hash); +const CODE_II_EC = codeII('ec'); +const CODE_II_RSA = codeII('rsa'); + +/** The `fit` each built-in template gives each code. */ +function fits(templateName: string): Record { + const template = getBuiltinTemplate(templateName)!; + const qrBlocks: QrBlock[] = []; + const walk = (blocks: Block[]): void => { + for (const b of blocks) { + if (b.type === 'qr') qrBlocks.push(b); + if (b.type === 'columns') walk((b as ColumnsBlock).columns); + if (b.type === 'stack') walk(b.stack); + } + }; + walk(template.blocks); + return Object.fromEntries(qrBlocks.map((b) => [b.code ?? 'invoice', b.fit ?? 100])); +} + +const TEMPLATES = ['fa2-default', 'fa3-default']; + +describe('built-in QR sizing', () => { + it.each(TEMPLATES)('%s prints both codes', (name) => { + expect(Object.keys(fits(name)).sort()).toEqual(['certificate', 'invoice']); + }); + + it.each(TEMPLATES)('%s gives the two codes the same footprint', (name) => { + // The point of drawing the codes ourselves: the box is exact, so equal `fit` + // means equal size on the page however much data each code carries. + const fit = fits(name); + expect(fit.invoice).toBe(fit.certificate); + }); + + it.each(TEMPLATES)('%s keeps every module above the floor', (name) => { + const fit = fits(name); + const cases: Array<[code: string, url: string]> = [ + ['invoice', CODE_I], + ['certificate', CODE_II_EC], + ]; + for (const [code, url] of cases) { + const modulePt = fit[code]! / span(url); + expect(modulePt, `${code} at fit ${fit[code]}`).toBeGreaterThanOrEqual(MIN_MODULE_PT); + } + }); + + it.each(TEMPLATES)('%s puts Code II at roughly 1.5pt per module', (name) => { + // The size the whole layout is pinned to: Code II is the denser code, so it + // sets the box, and Code I is scaled up to match it. + const modulePt = fits(name).certificate! / span(CODE_II_EC); + expect(modulePt).toBeGreaterThan(1.4); + expect(modulePt).toBeLessThan(1.7); + }); + + it('leaves Code I with the wider modules of the two', () => { + // Same box, less data — Code I ends up comfortably above Code II, which is + // the right way round: the code every invoice carries is the readable one. + const fit = fits('fa3-default'); + expect(fit.invoice! / span(CODE_I)).toBeGreaterThan(fit.certificate! / span(CODE_II_EC)); + }); + + it('an RSA-signed Code II is denser than the box comfortably allows', () => { + // Both key types are legal (certyfikaty-KSeF.md), and RSA's signature is + // four times longer. Pinned as a known limit rather than a passing grade: + // it still clears the hard floor, but only just. + const modulePt = fits('fa3-default').certificate! / span(CODE_II_RSA); + expect(span(CODE_II_RSA)).toBeGreaterThan(span(CODE_II_EC)); + expect(modulePt).toBeGreaterThanOrEqual(MIN_MODULE_PT); + expect(modulePt).toBeLessThan(1.4); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts index 8cd9753e..6abc49e3 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts @@ -5,6 +5,7 @@ import { resolveBaseQrUrl, deriveInvoiceQrUrl, } from '../../../src/pdf/qr.js'; +import * as QRCode from 'qrcode'; import { qrRenderer } from '../../../src/pdf/template/blocks/qr.js'; import { VerificationLinkService } from '../../../src/qr/verification-link-service.js'; import { Environment } from '../../../src/config/environments.js'; @@ -126,35 +127,159 @@ describe('deriveInvoiceQrUrl', () => { }); describe('qrRenderer', () => { - function ctxWith(bindings: Record): RenderContext { + function ctxWith(bindings: Record, flags: Record = {}): RenderContext { return { root: body, strict: false, label: (k: string) => k, bindings, - flags: {}, + flags, }; } const noopRender = () => null; const block: QrBlock = { type: 'qr' }; + const CODE_I = 'https://qr/invoice/x'; + const CODE_II = 'https://qr/certificate/Nip/1/2/3/4/5'; - it('emits a qr node with default fit 100 when qrUrl is present', () => { - const out = qrRenderer(block, ctxWith({ qrUrl: 'https://qr/invoice/x' }), noopRender); - expect(out).toEqual({ qr: 'https://qr/invoice/x', fit: 100 }); + /** + * A rendered code is always `{ width: 'auto', stack: [code, link?] }` — the + * wrapper is what lets a `columns` row pin the codes to the right margin. + */ + type QrNode = { width: string; stack: Array> }; + const codeOf = (node: unknown) => (node as QrNode).stack?.[0] as { svg: string; width: number; height: number }; + /** The SVG a given URL must produce — the code's identity, in one value. */ + const svgFor = (url: string) => codeOf(qrRenderer({ type: 'qr' }, ctxWith({ qrUrl: url }), noopRender)).svg; + const svgOf = (node: unknown) => codeOf(node)?.svg; + + it('draws the code itself, as a square SVG of the requested side', () => { + const code = codeOf(qrRenderer(block, ctxWith({ qrUrl: CODE_I }), noopRender)); + expect(code.width).toBe(100); // the default fit + expect(code.height).toBe(100); + expect(String(code.svg)).toContain(' { + const out = qrRenderer(block, ctxWith({ qrUrl: CODE_I }), noopRender) as QrNode; + expect(out.width).toBe('auto'); + }); + + it('honors a custom fit exactly, with no rounding down', () => { + // pdfmake's QR node quantizes to whole points per module, so it can only + // produce a handful of sizes; drawing the modules ourselves means the side + // is whatever was asked for. + for (const fit of [64, 77, 103]) { + const code = codeOf(qrRenderer({ type: 'qr', fit }, ctxWith({ qrUrl: CODE_I }), noopRender)); + expect(code.width).toBe(fit); + expect(code.height).toBe(fit); + } + }); + + it('surrounds the code with the quiet zone the QR standard requires', () => { + const svg = codeOf(qrRenderer(block, ctxWith({ qrUrl: CODE_I }), noopRender)).svg; + const [, span] = /viewBox="0 0 (\d+) \1"/.exec(svg)!; + const modules = QRCode.create(CODE_I, { errorCorrectionLevel: 'M' }).modules.size; + expect(Number(span)).toBe(modules + 8); // four modules of margin on each side + }); + + it('refuses a fit that would leave the modules unreadable', () => { + const modules = QRCode.create(CODE_I, { errorCorrectionLevel: 'M' }).modules.size + 8; + expect(() => qrRenderer({ type: 'qr', fit: modules - 1 }, ctxWith({ qrUrl: CODE_I }), noopRender)).toThrow( + /QR too small/, + ); + expect(() => qrRenderer({ type: 'qr', fit: modules }, ctxWith({ qrUrl: CODE_I }), noopRender)).not.toThrow(); + }); + + it('encodes at 15% error correction, not the 7% default', () => { + // An invoice is printed, folded and scanned off paper; `L` leaves no margin + // for a crease across the code. A stronger level needs more modules, which + // is how this is observable from the outside. + // Measured on a real-length Code I URL: at the short test URL above, both + // levels happen to land on the same QR version and the difference is + // invisible. + const url = 'https://qr.ksef.mf.gov.pl/invoice/1111111111/15-01-2026/QCGVZWPVMG32C3qH6CXWlMlsJRbDtkuul7N-H92YWsE'; + const svg = codeOf(qrRenderer(block, ctxWith({ qrUrl: url }), noopRender)).svg; + const span = Number(/viewBox="0 0 (\d+)/.exec(svg)![1]); + const atL = QRCode.create(url, { errorCorrectionLevel: 'L' }).modules.size + 8; + const atM = QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size + 8; + expect(span).toBe(atM); + expect(atM).toBeGreaterThan(atL); }); - it('honors a custom fit', () => { - const out = qrRenderer({ type: 'qr', fit: 64 }, ctxWith({ qrUrl: 'https://qr/invoice/x' }), noopRender); - expect(out).toEqual({ qr: 'https://qr/invoice/x', fit: 64 }); + it('renders nothing when the qrUrl binding is empty', () => { + expect(qrRenderer(block, ctxWith({ qrUrl: '' }), noopRender)).toBeNull(); }); - it('emits an empty text node when qrUrl binding is empty', () => { - const out = qrRenderer(block, ctxWith({ qrUrl: '' }), noopRender); - expect(out).toEqual({ text: '' }); + it('renders nothing when the qrUrl binding is absent', () => { + expect(qrRenderer(block, ctxWith({}), noopRender)).toBeNull(); }); - it('emits an empty text node when qrUrl binding is absent', () => { - const out = qrRenderer(block, ctxWith({}), noopRender); - expect(out).toEqual({ text: '' }); + describe('code selection', () => { + const both = { qrUrl: CODE_I, certificateQrUrl: CODE_II }; + + it('defaults to Code I', () => { + expect(svgOf(qrRenderer(block, ctxWith(both), noopRender))).toBe(svgFor(CODE_I)); + }); + + it('reads Code I explicitly', () => { + const out = qrRenderer({ type: 'qr', code: 'invoice' }, ctxWith(both), noopRender); + expect(svgOf(out)).toBe(svgFor(CODE_I)); + }); + + it('reads Code II from its own binding', () => { + const out = qrRenderer({ type: 'qr', code: 'certificate' }, ctxWith(both), noopRender); + expect(svgOf(out)).toBe(svgFor(CODE_II)); + }); + + it('drops Code II on an invoice that carries none', () => { + // The built-in templates always ask for both, and the block sits in a + // columns row: an empty node here would take an elastic column of its own + // and push Code I off the right margin, so the node must disappear. + const out = qrRenderer({ type: 'qr', code: 'certificate' }, ctxWith({ qrUrl: CODE_I }), noopRender); + expect(out).toBeNull(); + }); + + it('prints Code II even when Code I is missing', () => { + const out = qrRenderer({ type: 'qr', code: 'certificate' }, ctxWith({ certificateQrUrl: CODE_II }), noopRender); + expect(svgOf(out)).toBe(svgFor(CODE_II)); + }); + }); + + describe('the clickable link', () => { + it('is absent unless the render asks for it', () => { + const out = qrRenderer(block, ctxWith({ qrUrl: CODE_I }), noopRender) as QrNode; + expect(out.stack).toHaveLength(1); // the code alone, nothing under it + expect(codeOf(out).svg).toBe(svgFor(CODE_I)); + }); + + it('sits under the code and points at the same URL', () => { + const out = qrRenderer(block, ctxWith({ qrUrl: CODE_I }, { qrLinks: true }), noopRender) as { + stack: Array>; + }; + expect(out.stack[0]).toMatchObject({ svg: svgFor(CODE_I) }); + expect(out.stack[1]).toEqual({ text: 'openLink', link: CODE_I }); + }); + + it('links Code II to the certificate URL, not the invoice one', () => { + const out = qrRenderer( + { type: 'qr', code: 'certificate' }, + ctxWith({ qrUrl: CODE_I, certificateQrUrl: CODE_II }, { qrLinks: true }), + noopRender, + ) as { stack: Array> }; + expect(out.stack[1]).toMatchObject({ link: CODE_II }); + }); + + it('takes the style the template names for it', () => { + const out = qrRenderer( + { type: 'qr', linkStyle: 'qrLink' }, + ctxWith({ qrUrl: CODE_I }, { qrLinks: true }), + noopRender, + ) as { stack: Array> }; + expect(out.stack[1]).toEqual({ text: 'openLink', link: CODE_I, style: 'qrLink' }); + }); + + it('adds no link to a code that is not printed', () => { + expect(qrRenderer(block, ctxWith({ qrUrl: '' }, { qrLinks: true }), noopRender)).toBeNull(); + }); }); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts index 6657d41e..4cce23b9 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts @@ -58,6 +58,77 @@ describe('QR embedding', () => { }); }); +/** + * The two KSeF verification codes reach the renderer differently: Code I is + * derived from the document (or handed over ready-made), while Code II is + * always supplied — its URL carries a signature made with the issuer's offline + * certificate key, which this module never sees. + * + * A code itself leaves no readable text in the PDF: pdfmake draws it as one + * vector rectangle per module, so the URL is nowhere in the bytes. What is + * observable is the link annotation, which carries the same URL verbatim — so + * these render with `qrLinks` on and read the URL back out of the annotation. + */ +describe('two verification codes', () => { + const text = (b: Uint8Array) => Buffer.from(b).toString('latin1'); + const CODE_II = + 'https://qr.ksef.mf.gov.pl/certificate/Nip/1111111111/1111111111/01F20A5D352AE590/HASH/SIGNATURE'; + const CODE_I_CUSTOM = 'https://qr-test.ksef.mf.gov.pl/invoice/9999999999/01-01-2000/NOTDERIVABLE'; + + it('derives Code I from the document', async () => { + const out = text(await renderInvoicePdf(fa3, 'fa3-default', { qr: true, qrLinks: true })); + expect(out).toContain('qr.ksef.mf.gov.pl/invoice/'); + }); + + it('uses a supplied Code I verbatim, without deriving one', async () => { + // The date and NIP here appear nowhere in the fixture, so a derived URL + // could not look like this. + const out = text(await renderInvoicePdf(fa3, 'fa3-default', { qrUrl: CODE_I_CUSTOM, qrLinks: true })); + expect(out).toContain(CODE_I_CUSTOM); + expect(out).not.toContain('qr.ksef.mf.gov.pl/invoice/'); + }); + + it('prints Code II beside Code I', async () => { + const out = text( + await renderInvoicePdf(fa3, 'fa3-default', { qr: true, certificateQrUrl: CODE_II, qrLinks: true }), + ); + expect(out).toContain(CODE_II); + expect(out).toContain('qr.ksef.mf.gov.pl/invoice/'); + }); + + it('prints Code II on its own, for an invoice still waiting for its number', async () => { + const out = text(await renderInvoicePdf(fa3, 'fa3-default', { certificateQrUrl: CODE_II, qrLinks: true })); + expect(out).toContain(CODE_II); + }); + + it('leaves no trace of Code II when none is supplied', async () => { + const out = text(await renderInvoicePdf(fa3, 'fa3-default', { qr: true, qrLinks: true })); + expect(out).toContain('qr.ksef.mf.gov.pl/invoice/'); + expect(out).not.toContain('/certificate/'); + }); + + it('makes each code clickable when asked', async () => { + const out = text( + await renderInvoicePdf(fa3, 'fa3-default', { qr: true, certificateQrUrl: CODE_II, qrLinks: true }), + ); + expect(out).toContain('/URI'); + expect(out).toContain(CODE_II); + }); + + it('embeds no link annotation by default', async () => { + const out = text(await renderInvoicePdf(fa3, 'fa3-default', { qr: true, certificateQrUrl: CODE_II })); + expect(out).not.toContain('/URI'); + }); + + it('renders the same set through fa2-default', async () => { + const out = text( + await renderInvoicePdf(fa2, 'fa2-default', { qr: true, certificateQrUrl: CODE_II, qrLinks: true }), + ); + expect(out).toContain(CODE_II); + expect(out).toContain('/URI'); + }); +}); + describe('a template rejects a document it does not target', () => { it('rejects a UPO fed to an invoice template', async () => { await expect(renderInvoicePdf(upo43, 'fa3-default')).rejects.toThrow(/not recognized as a FA\(3\)/); From bcc6608ed3a2d7e552ec9d56a7a61d7facc9b6b7 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 21:49:31 +0200 Subject: [PATCH 21/67] feat(pdf): add Ukrainian labels, and cover the preview set by design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ukrainian joins Polish and English, and the bilingual locales stop being a hand-kept list of pairs: a locale's halves are read from its own name, so all six orderings work and the next language needs only its bundle. Two things stay Polish in every locale — the VAT rates and the payment forms, which decode from a Polish fiscal enum the official visualizations print untranslated. A missing translation does not fail a render, it falls back to Polish and a Ukrainian invoice quietly grows Polish headings, so the bundles now have to stay key-complete. The preview set had grown to 34 PDFs, one per feature, with the same document rendered over and over for one flag at a time. It is now 18: a covering design where each row varies several dimensions at once and every value of every dimension still appears. The totals modes are deliberately exempt — they only mean anything compared side by side. Two checks keep the design honest: the CLI set reads its own rows back and fails if a dimension loses its last cover, and the library set reads the option list off the published types, so a new render option fails until something exercises it. The QR row's right-margin layout moves out of the preview set and into a unit test, where it belongs — it is a property, not a picture. The link under each code now lines up with the code's first module rather than with the quiet zone around it. Verified: unit 2638, e2e 149, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 14 +- .../src/cli/commands/invoice.ts | 4 +- packages/ksef-client-ts/src/pdf/i18n/index.ts | 31 +++-- packages/ksef-client-ts/src/pdf/i18n/types.ts | 20 ++- packages/ksef-client-ts/src/pdf/i18n/uk.ts | 73 ++++++++++ packages/ksef-client-ts/src/pdf/index.ts | 2 +- .../src/pdf/template/blocks/qr.ts | 6 + .../src/pdf/template/builtin/fa3-default.json | 5 +- .../tests/e2e/35-invoice-pdf-cli.test.ts | 131 ++++++++++++------ .../tests/e2e/36-invoice-pdf-library.test.ts | 128 +++++++++-------- .../tests/unit/pdf/i18n.test.ts | 61 +++++++- .../ksef-client-ts/tests/unit/pdf/qr.test.ts | 79 ++++++++++- 12 files changed, 416 insertions(+), 138 deletions(-) create mode 100644 packages/ksef-client-ts/src/pdf/i18n/uk.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 8055635e..0e69cbed 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -123,7 +123,7 @@ detectUpoVersion(xml); // 'UPO(4.2)' | 'UPO(4.3)' | null | Option | Type | Purpose | |--------|------|---------| -| `locale` | `'pl' \| 'en' \| 'pl+en' \| 'en+pl'` | Label language. Default `'pl'`. | +| `locale` | `'pl' \| 'en' \| 'uk'`, or any two joined by `+` | Label language. Default `'pl'`. | | `totals` | `'none' \| 'buckets' \| 'summary' \| 'both'` | Which tax breakdown to print above the amount due. Default `'buckets'`. | | `qr` | `boolean` | Embed the KSeF Code I verification QR derived from the invoice XML. | | `ksefNumber` | `string` | KSeF number printed on the visualization; when absent the document is marked **OFFLINE**. | @@ -275,10 +275,14 @@ Labels are localizable, driven by the `locale` option: |--------|--------| | `pl` (default) | Polish labels | | `en` | English labels | -| `pl+en` | Both, Polish first | -| `en+pl` | Both, English first | +| `uk` | Ukrainian labels | +| `pl+en`, `en+pl` | Both, in the order named | +| `pl+uk`, `uk+pl` | Both, in the order named | +| `en+uk`, `uk+en` | Both, in the order named | -For a bilingual locale, each label is the Polish and English text joined by `bilingualSeparator` (default `' / '`), in the order the locale name spells out. A template can also override individual labels via its `labels` map — useful for company-specific wording. +Any two of the three languages combine, in either order. For a bilingual locale each label is the two texts joined by `bilingualSeparator` (default `' / '`), in the order the locale name spells out. A template can also override individual labels via its `labels` map — useful for company-specific wording; an override replaces **both** halves of a bilingual label. + +One thing stays Polish in every locale: the `FormaPlatnosci` payment forms. They decode from a Polish fiscal enum, and the official visualizations print them untranslated. ```ts const pdf = await renderInvoicePdf(xml, 'fa3-default', { @@ -349,7 +353,7 @@ ksef invoice pdf upo.xml --template-file ./templates/my-upo.json |------|-------------| | `--template ` | Built-in template name (mutually exclusive with `--template-file`) | | `--template-file ` | Custom JSON template path (mutually exclusive with `--template`) | -| `--locale ` | Label language (default `pl`) | +| `--locale ` | Label language, single or any two joined by `+` (default `pl`) | | `--qr` | Embed the KSeF Code I QR derived from the XML | | `--qr-url ` | Code I URL used verbatim, instead of deriving one | | `--qr-cert-url ` | Code II (offline certificate) URL — build it with `ksef qr certificate` | diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 10a025df..9223a226 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -570,7 +570,7 @@ const validateCmd = defineCommand({ }, }); -const VALID_PDF_LOCALES = ['pl', 'en', 'pl+en', 'en+pl'] as const; +const VALID_PDF_LOCALES = ['pl', 'en', 'uk', 'pl+en', 'en+pl', 'pl+uk', 'uk+pl', 'en+uk', 'uk+en'] as const; type PdfLocale = (typeof VALID_PDF_LOCALES)[number]; const VALID_PDF_TOTALS = ['none', 'buckets', 'summary', 'both'] as const; @@ -611,7 +611,7 @@ const pdf = defineCommand({ file: { type: 'positional', description: 'Path to the invoice or UPO XML file', required: true }, template: { type: 'string', description: 'Built-in template name (e.g. fa3-default). Mutually exclusive with --template-file.' }, templateFile: { type: 'string', description: 'Path to a custom JSON template. Mutually exclusive with --template.' }, - locale: { type: 'string', description: 'Label language: pl | en | pl+en | en+pl (default: pl)' }, + locale: { type: 'string', description: 'Label language: pl | en | uk, or any two joined by + (e.g. pl+uk); default pl' }, out: { type: 'string', description: 'Output PDF path (default: alongside the source, .pdf)' }, qr: { type: 'boolean', description: 'Embed the KSeF Code I QR derived from the XML' }, qrUrl: { type: 'string', description: 'Code I verification URL, used verbatim instead of deriving it' }, diff --git a/packages/ksef-client-ts/src/pdf/i18n/index.ts b/packages/ksef-client-ts/src/pdf/i18n/index.ts index 04aa4f0e..185de3d5 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/index.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/index.ts @@ -1,24 +1,33 @@ /** - * Label localization. Only `pl` and `en` bundles are maintained; the bilingual - * locales are produced on the fly by concatenation with a configurable - * separator, so there is no third bundle to keep in sync. A missing key falls - * back to Polish, then to the key itself. + * Label localization. One bundle per language; the bilingual locales are + * produced on the fly by concatenation with a configurable separator, so there + * is no combined bundle to keep in sync — adding a language adds its bundle and + * every pairing it can take part in. A missing key falls back to Polish, then to + * the key itself. */ import type { Locale, BaseLocale, LabelBundle } from './types.js'; import { pl } from './pl.js'; import { en } from './en.js'; +import { uk } from './uk.js'; export type { Locale, BaseLocale, LabelBundle } from './types.js'; export { pl } from './pl.js'; export { en } from './en.js'; +export { uk } from './uk.js'; -const BUNDLES: Record = { pl, en }; +const BUNDLES: Record = { pl, en, uk }; -/** Bilingual locales, in the order their name spells out. */ -const BILINGUAL: Record = { - 'pl+en': ['pl', 'en'], - 'en+pl': ['en', 'pl'], -}; +/** + * Split a bilingual locale into its halves, in the order its name spells out. + * Read from the name rather than from a table of pairs, so a new language needs + * no second registration to be combinable with the others. + */ +function bilingualPair(locale: Locale): [BaseLocale, BaseLocale] | null { + const [first, second, ...rest] = locale.split('+'); + if (second === undefined || rest.length > 0) return null; + if (!(first! in BUNDLES) || !(second in BUNDLES)) return null; + return [first as BaseLocale, second as BaseLocale]; +} export interface LabelOptions { /** Separator for the bilingual locales. Default `' / '`. */ @@ -42,7 +51,7 @@ function resolveOne(key: string, locale: BaseLocale, overrides?: LabelBundle): s * (default `' / '`). */ export function resolveLabel(key: string, locale: Locale, opts: LabelOptions = {}): string { - const pair = BILINGUAL[locale]; + const pair = bilingualPair(locale); if (pair) { const sep = opts.bilingualSeparator ?? ' / '; return `${resolveOne(key, pair[0], opts.overrides)}${sep}${resolveOne(key, pair[1], opts.overrides)}`; diff --git a/packages/ksef-client-ts/src/pdf/i18n/types.ts b/packages/ksef-client-ts/src/pdf/i18n/types.ts index 4e2e0f52..026f7e2e 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/types.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/types.ts @@ -1,12 +1,20 @@ /** - * Label language for the rendered PDF. The bilingual locales are built by - * concatenation and named for their order: `pl+en` puts Polish first, `en+pl` - * English first. + * Label language for the rendered PDF. A bilingual locale is any two base + * locales joined by `+`, and it is named for its order: `pl+en` puts Polish + * first, `en+pl` English first. Every ordered pair is valid, so an invoice can + * be issued in Polish alongside the reader's own language. */ -export type Locale = 'pl' | 'en' | 'pl+en' | 'en+pl'; +export type Locale = + | BaseLocale + | 'pl+en' + | 'en+pl' + | 'pl+uk' + | 'uk+pl' + | 'en+uk' + | 'uk+en'; -/** The two single-language bundles a bilingual locale is composed from. */ -export type BaseLocale = 'pl' | 'en'; +/** The single-language bundles a bilingual locale is composed from. */ +export type BaseLocale = 'pl' | 'en' | 'uk'; /** * Known label keys referenced by built-in templates. Custom templates may use diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts new file mode 100644 index 00000000..0c9d97c4 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -0,0 +1,73 @@ +import type { LabelBundle } from './types.js'; + +/** + * Ukrainian label bundle (mirrors the Polish key set). + * + * Terminology follows the document rather than Ukrainian invoicing practice + * where the two differ: this is a Polish KSeF invoice being read in Ukrainian, + * so `Фактура` keeps the document's own name and the VAT rates stay Polish + * ones. Payment forms are not here at all — the `FormaPlatnosci` codes decode + * to Polish in every locale, matching the official visualizations. + */ +export const uk: LabelBundle = { + invoice: 'Фактура', + duplicate: 'Дублікат', + seller: 'Продавець', + buyer: 'Покупець', + address: 'Адреса', + contact: 'Контактні дані', + issueDate: 'Дата виставлення', + invoiceNumber: 'Номер фактури', + ksefNumber: 'Номер KSeF', + offline: 'OFFLINE', + lp: '№', + name: 'Найменування', + unit: 'Од.', + qty: 'Кількість', + unitPrice: 'Ціна нетто', + net: 'Сума нетто', + vatRate: 'Ставка ПДВ', + vat: 'Сума ПДВ', + gross: 'Сума брутто', + // per-rate buckets (P_13_* / P_14_*) + net23: 'Нетто 23%', + vat23: 'ПДВ 23%', + net8: 'Нетто 8%', + vat8: 'ПДВ 8%', + net5: 'Нетто 5%', + vat5: 'ПДВ 5%', + net4: 'Нетто 4%', + vat4: 'ПДВ 4%', + netSpecial: 'Нетто — особлива процедура', + vatSpecial: 'ПДВ — особлива процедура', + net0Domestic: 'Нетто 0% у країні', + net0Wdt: 'Нетто 0% постачання в ЄС', + net0Export: 'Нетто 0% експорт', + netExempt: 'Нетто звільнено від ПДВ', + netOutsideTerritory: 'Нетто поза територією Польщі', + netArticle100: 'Нетто ст. 100 ч. 1 п. 4', + netReverseCharge: 'Нетто зворотне нарахування', + netMargin: 'Нетто маржинальна схема', + totalNet: 'Разом нетто', + totalVat: 'Разом ПДВ', + totalDue: 'До сплати', + payment: 'Оплата', + paid: 'Сплачено', + paymentDate: 'Термін оплати', + paymentMethod: 'Спосіб оплати', + bankAccounts: 'Банківський рахунок', + bankAccount: 'Номер рахунку', + swift: 'SWIFT / BIC', + bankName: 'Назва банку', + annotations: 'Примітки', + upoTitle: 'Офіційне підтвердження отримання (UPO)', + ksefDocNumber: 'Номер документа в KSeF', + sessionRef: 'Референсний номер сесії', + receiptDate: 'Дата присвоєння номера KSeF', + documentHash: 'Хеш документа', + documents: 'Документи', + verifyInKsef: 'Перевірте фактуру в KSeF!', + openLink: 'Відкрити', + generatedWith: 'Згенеровано за допомогою', + pageOf: 'Сторінка {page} з {pages}', +}; diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index ce8a7a35..24d99295 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -80,7 +80,7 @@ export interface RenderOptions { logo?: string; /** Theming (accent colour only; the font is the bundled Roboto). */ theme?: { accent?: string }; - /** Separator for the bilingual locales (`pl+en`, `en+pl`). Default `' / '`. */ + /** Separator for the bilingual locales (`pl+en`, `pl+uk`, …). Default `' / '`. */ bilingualSeparator?: string; /** Throw on a missing binding instead of rendering an empty string. */ strict?: boolean; diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts b/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts index 80764bc6..f539163f 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts @@ -92,11 +92,17 @@ export const qrRenderer: BlockRenderer = (block, ctx) => { } const code: PdfNode = { svg, width: side, height: side }; + // The code's visible edge is inset by the quiet zone, so a link flush with the + // box would sit to the left of everything above it. Indent it to line up with + // the first module — a different amount per code, since a denser code has + // narrower modules and therefore a narrower quiet zone. + const inset = (side / span) * QUIET_ZONE; const link: PdfNode[] = ctx.flags['qrLinks'] ? [ { text: ctx.label('openLink'), link: url, + margin: [inset, 0, 0, 0], ...(block.linkStyle ? { style: block.linkStyle } : {}), }, ] diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 04b61e3c..0ae09fe8 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -80,7 +80,7 @@ ] } }, - { "type": "spacer", "height": 23 }, + { "type": "spacer", "height": 9 }, { "type": "lines", "from": "Fa.FaWiersz", @@ -94,7 +94,7 @@ { "label": "net", "path": "P_11", "format": "money", "width": 60, "optional": true } ] }, - { "type": "spacer", "height": 21 }, + { "type": "spacer", "height": 9 }, { "type": "totals", "rows": [ @@ -169,7 +169,6 @@ { "label": "totalDue", "path": "Fa.P_15", "format": "money" } ] }, - { "type": "spacer", "height": 21 }, { "type": "divider" }, { "type": "payment", diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 7143780b..40c777b4 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -125,58 +125,105 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { console.log(`\n rendered PDFs kept for review in ${outDir}\n`); }); + const LOGO = () => ['--logo', fx('e2e-logo.png')]; + const SUPPLIED_CODE_I = `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`; + /** The mixed-rate document with the flags every totals variant shares. */ - const mixedVat = () => [fx('e2e-vat-multi.xml'), '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]; - /** The QR group: the same document before KSeF gave it a number, against DEMO. */ - const qrGroup = () => [fx('e2e-vat-multi.xml'), '--logo', fx('e2e-logo.png'), '--env', 'demo']; - /** Both codes on the page; links and locale are left to the variant. */ - const bothCodes = () => [...qrGroup(), '--qr', '--qr-cert-url', certificateQrUrl]; + const mixedVat = () => [fx('e2e-vat-multi.xml'), '--ksef-number', KSEF_NUMBER, ...LOGO()]; + /** + * The preview set, laid out as a covering design rather than one variant per + * feature. Eight dimensions are in play — document, locale, which QR codes, + * links, logo, KSeF number, totals mode, and where Code I comes from — and a + * row per combination would be hundreds of PDFs nobody looks at. Instead each + * row varies several at once so that every value of every dimension appears, + * and the pairs that actually interact are covered. + * + * Rows 01–05 are that covering design. Rows 06–10 are deliberately NOT: the + * totals modes only mean anything compared side by side, so those five hold + * every other flag identical and vary one thing. + * + * # document locale QR links logo KSeF nr totals + * 01 services-np pl I no yes yes buckets + * 02 fa3 en I yes no yes summary (Code I supplied) + * 03 buyer-no-id uk II yes yes no both + * 04 vat-multi en+pl II no no no none + * 05 vat-multi pl+uk both yes yes no both + * + * What each row is there to show, beyond its share of the grid: 01 the + * everyday online invoice; 02 an invoice whose Code I URL was handed over + * ready-made; 03 a foreign buyer with no NIP, issued offline; 04 and 05 the + * layout cases — one code absent, then both present — where the codes must + * stay against the right margin. + */ const variants: Array<[name: string, args: () => string[]]> = [ - [`${PREFIX}-01-invoice-pl-qr`, () => [fx('e2e-services-np.xml'), '--qr', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], - [`${PREFIX}-02-invoice-en`, () => [fx('e2e-services-np.xml'), '--locale', 'en', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], - [`${PREFIX}-03-invoice-bilingual`, () => [fx('e2e-services-np.xml'), '--locale', 'en+pl', '--ksef-number', KSEF_NUMBER, '--logo', fx('e2e-logo.png')]], - [`${PREFIX}-04-invoice-offline`, () => [fx('e2e-services-np.xml'), '--logo', fx('e2e-logo.png')]], - [`${PREFIX}-05-invoice-standard-rate`, () => [fx('fa3.xml')]], - [`${PREFIX}-06-invoice-buyer-without-id`, () => [fx('e2e-buyer-no-id.xml'), '--logo', fx('e2e-logo.png')]], + [`${PREFIX}-01-invoice-pl-code-i`, () => [ + fx('e2e-services-np.xml'), '--ksef-number', KSEF_NUMBER, ...LOGO(), + '--env', 'demo', '--qr', '--totals', 'buckets', + ]], + [`${PREFIX}-02-invoice-en-supplied-code-i-links`, () => [ + fx('fa3.xml'), '--ksef-number', KSEF_NUMBER, '--locale', 'en', + '--qr-url', SUPPLIED_CODE_I, '--qr-links', '--totals', 'summary', + ]], + [`${PREFIX}-03-invoice-uk-offline-code-ii-links`, () => [ + fx('e2e-buyer-no-id.xml'), ...LOGO(), '--locale', 'uk', + '--env', 'demo', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', + ]], + [`${PREFIX}-04-invoice-bilingual-offline-code-ii`, () => [ + fx('e2e-vat-multi.xml'), '--locale', 'en+pl', + '--env', 'demo', '--qr-cert-url', certificateQrUrl, '--totals', 'none', + ]], + [`${PREFIX}-05-invoice-pl-uk-offline-both-codes-links`, () => [ + fx('e2e-vat-multi.xml'), ...LOGO(), '--locale', 'pl+uk', + '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', + ]], // Every totals mode on one mixed-rate document (23% + 8% + exempt), so the // four can be compared page by page. Same flags throughout — only --totals // differs, and the amount due must appear in all of them. - [`${PREFIX}-07-invoice-mixed-vat-totals-none`, () => [...mixedVat(), '--totals', 'none']], - [`${PREFIX}-08-invoice-mixed-vat-totals-buckets`, () => [...mixedVat(), '--totals', 'buckets']], - [`${PREFIX}-09-invoice-mixed-vat-totals-summary`, () => [...mixedVat(), '--totals', 'summary']], - [`${PREFIX}-10-invoice-mixed-vat-totals-both`, () => [...mixedVat(), '--totals', 'both']], - // The A/B against 09: identical document and flags, but the totals read the + [`${PREFIX}-06-totals-none`, () => [...mixedVat(), '--totals', 'none']], + [`${PREFIX}-07-totals-buckets`, () => [...mixedVat(), '--totals', 'buckets']], + [`${PREFIX}-08-totals-summary`, () => [...mixedVat(), '--totals', 'summary']], + [`${PREFIX}-09-totals-both`, () => [...mixedVat(), '--totals', 'both']], + // The A/B against 08: identical document and flags, but the totals read the // standard-rate bucket alone instead of summing all of them. It needs // --totals summary, since that is the group those rows belong to. - [`${PREFIX}-11-invoice-mixed-vat-single-bucket-totals`, () => [...mixedVat(), '--totals', 'summary', '--template-file', oldTotalsTemplate]], - [`${PREFIX}-12-invoice-mixed-vat-bilingual`, () => [...mixedVat(), '--locale', 'en+pl', '--totals', 'both']], - // Six variants covering four dimensions — which codes, links on or off, - // the three locales, and a supplied versus derived Code I. Every value of - // every dimension appears at least twice, paired with different values of - // the others, so a regression in any one of them shows up somewhere. The - // single-code rows also carry the layout case that matters: with one code - // absent, the other must still sit against the right margin. - // - // # codes links locale Code I - // 13 I no pl derived - // 14 I yes en supplied - // 15 II yes pl — - // 16 II no en+pl — - // 17 both yes en+pl derived - // 18 both no en derived - [`${PREFIX}-13-qr-code-i`, () => [...qrGroup(), '--qr']], - [`${PREFIX}-14-qr-code-i-links-en-supplied-url`, () => [...qrGroup(), '--qr-url', `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`, '--qr-links', '--locale', 'en']], - [`${PREFIX}-15-qr-code-ii-links`, () => [...qrGroup(), '--qr-cert-url', certificateQrUrl, '--qr-links']], - [`${PREFIX}-16-qr-code-ii-bilingual`, () => [...qrGroup(), '--qr-cert-url', certificateQrUrl, '--locale', 'en+pl']], - [`${PREFIX}-17-qr-both-codes-links-bilingual`, () => [...bothCodes(), '--qr-links', '--locale', 'en+pl']], - [`${PREFIX}-18-qr-both-codes-en`, () => [...bothCodes(), '--locale', 'en']], + [`${PREFIX}-10-totals-summary-single-bucket-template`, () => [...mixedVat(), '--totals', 'summary', '--template-file', oldTotalsTemplate]], // Receipts last: they are a different document and read as their own group. - [`${PREFIX}-19-upo-pl`, () => [fx('upo-4_3.xml')]], - [`${PREFIX}-20-upo-bilingual`, () => [fx('upo-4_3.xml'), '--locale', 'en+pl']], - [`${PREFIX}-21-upo-five-documents`, () => [multiDocumentUpo]], + [`${PREFIX}-11-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-12-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], ]; + /** + * The grid above is only worth trusting if it actually is one. This reads the + * rows back and checks that every value of every dimension is present, so a + * row edited for one reason cannot quietly drop the only coverage of another. + */ + it('covers every value of every dimension', () => { + const args = variants.map(([, build]) => build().join(' ')); + const covered = (needle: string) => args.some((a) => a.includes(needle)); + + for (const doc of ['e2e-services-np.xml', 'fa3.xml', 'e2e-buyer-no-id.xml', 'e2e-vat-multi.xml', 'upo-4_3.xml']) { + expect(covered(doc), `no variant renders ${doc}`).toBe(true); + } + for (const locale of ['en', 'uk', 'en+pl', 'pl+uk']) { + expect(covered(`--locale ${locale}`), `no variant renders in ${locale}`).toBe(true); + } + for (const totals of ['none', 'buckets', 'summary', 'both']) { + expect(covered(`--totals ${totals}`), `no variant renders --totals ${totals}`).toBe(true); + } + expect(covered('--qr '), 'Code I is never derived').toBe(true); + expect(covered('--qr-url'), 'Code I is never supplied ready-made').toBe(true); + expect(covered('--qr-cert-url'), 'Code II is never printed').toBe(true); + expect(covered('--qr-links'), 'the links are never printed').toBe(true); + expect(covered('--template-file'), 'a custom template file is never used').toBe(true); + expect(covered('--logo'), 'the logo is never printed').toBe(true); + // The absences matter as much: a Polish default locale, an invoice with no + // logo, and one still waiting for its KSeF number. + expect(args.some((a) => !a.includes('--locale')), 'nothing renders in the default locale').toBe(true); + expect(args.some((a) => !a.includes('--logo')), 'nothing renders without a logo').toBe(true); + expect(args.some((a) => !a.includes('--ksef-number')), 'nothing renders as OFFLINE').toBe(true); + }); + it.each(variants)('renders %s', (name, args) => { const out = join(outDir, `${name}.pdf`); const res = run(['invoice', 'pdf', ...args(), '--out', out]); @@ -189,7 +236,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { it('renders every variant of the set', () => { // Guards against a variant being silently dropped from the table above: // regen.sh and this spec are meant to cover the same ground. - expect(variants).toHaveLength(21); + expect(variants).toHaveLength(12); for (const [name] of variants) { expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); } diff --git a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts index 99573d2b..de454e7d 100644 --- a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts +++ b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts @@ -40,6 +40,8 @@ const PREFIX = 'lib'; /** Same host as the CLI preview set: a demo link is one a reader can click. */ const DEMO_QR_HOST = 'https://qr-demo.ksef.mf.gov.pl'; +const require = createRequire(import.meta.url); + const fx = (name: string) => join(fixtures, name); const bytes = (name: string) => new Uint8Array(readFileSync(fx(name))); const text = (name: string) => readFileSync(fx(name), 'utf-8'); @@ -84,8 +86,19 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => ); }); + /** + * Each render below carries several library-only options at once, rather than + * one option per PDF. The options are orthogonal — a separator does not + * interact with a hash — so isolating them costs a PDF each and proves nothing + * extra; what has to hold is that every one of them is exercised, which the + * grid check at the end of this block asserts by reading the calls back. + */ describe('surface the CLI has no flag for', () => { - it('accepts a template as an object', async () => { + it('takes a template object, and hands it theme.accent as a binding', async () => { + // No built-in template consumes the accent — styles in the DSL are static, + // so it cannot colour anything today and reaches a template only as a + // string binding. Rendering through a template that reads that binding + // keeps the option exercised instead of silently ignored. const template: InvoiceTemplate = { schema: 'FA(3)', page: { size: 'A4', margins: [40, 40, 40, 40] }, @@ -98,13 +111,23 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => left: { label: 'seller', fields: ['Podmiot1.DaneIdentyfikacyjne.Nazwa'] }, right: { label: 'buyer', fields: ['Podmiot2.DaneIdentyfikacyjne.Nazwa'] }, }, + { type: 'text', path: 'opts.accent' }, { type: 'totals', rows: [{ label: 'totalDue', path: 'Fa.P_15', format: 'money' }] }, ], }; - await save(`${PREFIX}-01-template-object`, renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template)); + await save( + `${PREFIX}-01-template-object-accent`, + renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template, { + theme: { accent: '#B0004E' }, + logo: LOGO, + ksefNumber: KSEF_NUMBER, + }), + ); }); - it('loads a custom template from a JSON file', async () => { + it('loads a custom template from a JSON file, and renders it strict', async () => { + // fa3.xml populates every path the template names, so strict has nothing + // to complain about — and would throw on a dot-path typo in the file. const path = join(inputsDir, 'lib-minimal-template.json'); writeFileSync( path, @@ -119,78 +142,33 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => ], }), ); - await save(`${PREFIX}-02-template-from-file`, renderInvoicePdfFromFile(bytes('e2e-vat-multi.xml'), path)); - }); - - it('exposes theme.accent to a template as the `opts.accent` binding', async () => { - // No built-in template consumes it — styles in the DSL are static, so an - // accent cannot colour anything today; it reaches a template only as a - // string binding. Rendering it through a template that actually reads the - // binding keeps the option exercised instead of silently ignored. - const template: InvoiceTemplate = { - schema: 'FA(3)', - blocks: [ - { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, - { type: 'text', path: 'opts.accent' }, - { type: 'totals', rows: [{ label: 'totalDue', path: 'Fa.P_15', format: 'money' }] }, - ], - }; await save( - `${PREFIX}-03-theme-accent-binding`, - renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template, { - theme: { accent: '#B0004E' }, - logo: LOGO, - ksefNumber: KSEF_NUMBER, - }), + `${PREFIX}-02-template-file-strict`, + renderInvoicePdfFromFile(bytes('fa3.xml'), path, { strict: true }), ); }); - it('honours a custom bilingual separator', async () => { + it('accepts the XML as a string, with a custom separator and QR host', async () => { await save( - `${PREFIX}-04-bilingual-newline-separator`, - renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { + `${PREFIX}-03-string-input-newline-separator-custom-qr-host`, + renderInvoicePdf(text('e2e-vat-multi.xml'), 'fa3-default', { locale: 'en+pl', bilingualSeparator: '\n', - ksefNumber: KSEF_NUMBER, - }), - ); - }); - - it('overrides the QR base URL for an offline/non-standard verifier', async () => { - await save( - `${PREFIX}-05-custom-qr-base-url`, - renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { qr: true, baseQrUrl: 'https://verify.example/ksef', + qrLinks: true, + totals: 'both', ksefNumber: KSEF_NUMBER, }), ); }); - it('takes a precomputed invoice hash verbatim for the QR', async () => { + it('takes a precomputed hash for Code I and a ready-made Code II', async () => { + // Code II cannot be derived here — it is signed with the issuer's offline + // certificate key — so the library takes it as a URL. Built with a + // throwaway key so the code has a realistic density. const raw = bytes('e2e-vat-multi.xml'); const invoiceHash = createHash('sha256').update(raw).digest('base64'); - await save( - `${PREFIX}-06-precomputed-invoice-hash`, - renderInvoicePdf(raw, 'fa3-default', { qr: true, invoiceHash, ksefNumber: KSEF_NUMBER }), - ); - }); - - it('renders in strict mode against a fixture that populates every binding', async () => { - // fa3.xml exists precisely so the built-in templates can be rendered with - // every dot-path resolved; strict turns a typo in our own preset into a - // thrown error rather than a blank line. - await save(`${PREFIX}-07-strict-mode`, renderInvoicePdf(bytes('fa3.xml'), 'fa3-default', { strict: true })); - }); - - it('accepts the XML as a string as well as bytes', async () => { - await save(`${PREFIX}-08-string-input`, renderInvoicePdf(text('e2e-vat-multi.xml'), 'fa3-default')); - }); - - it('prints both verification codes, each with a clickable link', async () => { - // Code II cannot be derived here — it is signed with the issuer's offline - // certificate key — so the library takes it as a ready-made URL. Built - // with a throwaway key so the code has a realistic density. const key = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem', @@ -202,27 +180,29 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => '1111111111', '1111111111', '01F20A5D352AE590', - createHash('sha256').update(bytes('e2e-vat-multi.xml')).digest('base64'), + invoiceHash, key, ); await save( - `${PREFIX}-09-both-qr-codes-with-links`, - renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { + `${PREFIX}-04-precomputed-hash-both-codes-links`, + renderInvoicePdf(raw, 'fa3-default', { qr: true, env: 'demo', + invoiceHash, certificateQrUrl, qrLinks: true, - locale: 'en+pl', + locale: 'en+uk', }), ); }); it('takes a Code I URL verbatim, skipping derivation entirely', async () => { await save( - `${PREFIX}-10-supplied-code-i-url`, + `${PREFIX}-05-supplied-code-i-url`, renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { qrUrl: `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`, qrLinks: true, + locale: 'pl+uk', ksefNumber: KSEF_NUMBER, }), ); @@ -230,7 +210,25 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => // Receipt last, as in spec 35. it('renders a UPO through the library entry point', async () => { - await save(`${PREFIX}-11-upo`, renderUpoPdf(bytes('upo-4_3.xml'), { locale: 'en+pl' })); + await save(`${PREFIX}-06-upo`, renderUpoPdf(bytes('upo-4_3.xml'), { locale: 'uk' })); + }); + + it('leaves no render option unexercised', () => { + // The renders above each carry several options, which is what keeps the + // preview set small — but it also makes it easy to drop the last use of + // one while editing a row for another reason. So the option list is read + // off the published type rather than kept by hand here: add a field to + // RenderOptions and this fails until some render uses it. + const dts = readFileSync(join(resolve(require.resolve('ksef-client-ts/pdf'), '..'), 'index.d.ts'), 'utf-8'); + const body = /interface RenderOptions \{([\s\S]*?)\n\}/.exec(dts)?.[1]; + expect(body, 'RenderOptions not found in the published types').toBeTruthy(); + + const options = [...body!.matchAll(/^\s{4}(\w+)\??:/gm)].map((m) => m[1]!); + expect(options.length, 'the type parse found nothing').toBeGreaterThan(10); + + const spec = readFileSync(fileURLToPath(import.meta.url), 'utf-8'); + const unused = options.filter((name) => !new RegExp(`\\b${name}[,:]`).test(spec)); + expect(unused, 'render options no preview exercises').toEqual([]); }); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts b/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts index 1355f6cf..1343f45b 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { resolveLabel, makeLabelResolver } from '../../../src/pdf/i18n/index.js'; +import { resolveLabel, makeLabelResolver, pl, en, uk } from '../../../src/pdf/i18n/index.js'; +import type { LabelBundle } from '../../../src/pdf/i18n/types.js'; describe('resolveLabel', () => { it('resolves a Polish label from the pl bundle', () => { @@ -79,3 +80,61 @@ describe('makeLabelResolver', () => { expect(resolve('buyer')).toBe('Klient'); }); }); + +describe('the Ukrainian bundle', () => { + it('resolves its own labels', () => { + expect(resolveLabel('seller', 'uk')).toBe('Продавець'); + expect(resolveLabel('totalDue', 'uk')).toBe('До сплати'); + }); + + it('pairs with Polish in both orders', () => { + expect(resolveLabel('buyer', 'pl+uk')).toBe('Nabywca / Покупець'); + expect(resolveLabel('buyer', 'uk+pl')).toBe('Покупець / Nabywca'); + }); + + it('pairs with English too, since any two base locales combine', () => { + expect(resolveLabel('buyer', 'en+uk')).toBe('Buyer / Покупець'); + expect(resolveLabel('buyer', 'uk+en')).toBe('Покупець / Buyer'); + }); + + it('carries the page-footer placeholders through', () => { + expect(resolveLabel('pageOf', 'uk')).toBe('Сторінка {page} з {pages}'); + }); + + it('falls back to Polish for a key it somehow lacks', () => { + // Not reachable through the bundles below — they are key-complete — but the + // fallback is what keeps a half-translated bundle rendering a document. + expect(resolveLabel('unknownKey', 'uk')).toBe('unknownKey'); + }); +}); + +describe('the bundles stay key-complete', () => { + // A translation that silently misses a key does not fail a render: it falls + // back to Polish, and a Ukrainian invoice quietly grows Polish headings. This + // is the only thing that catches it. + const bundles: Array<[string, LabelBundle]> = [ + ['en', en], + ['uk', uk], + ]; + + it.each(bundles)('%s covers every Polish key', (_name, bundle) => { + expect(Object.keys(pl).filter((key) => !(key in bundle))).toEqual([]); + }); + + it.each(bundles)('%s adds no key Polish does not have', (_name, bundle) => { + expect(Object.keys(bundle).filter((key) => !(key in pl))).toEqual([]); + }); + + it.each(bundles)('%s leaves no label blank', (_name, bundle) => { + expect(Object.entries(bundle).filter(([, value]) => value.trim() === '')).toEqual([]); + }); + + it('translates rather than copying Polish across', () => { + // A handful of labels are legitimately identical everywhere (SWIFT / BIC, + // OFFLINE), but a bundle that is mostly Polish is a bundle nobody filled in. + for (const [, bundle] of bundles) { + const copied = Object.keys(pl).filter((key) => pl[key] === bundle[key]); + expect(copied.length).toBeLessThan(Object.keys(pl).length / 4); + } + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts index 6abc49e3..e204507f 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts @@ -7,6 +7,11 @@ import { } from '../../../src/pdf/qr.js'; import * as QRCode from 'qrcode'; import { qrRenderer } from '../../../src/pdf/template/blocks/qr.js'; +import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { interpretTemplate } from '../../../src/pdf/template/interpret.js'; +import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; +import type { Locale } from '../../../src/pdf/i18n/types.js'; import { VerificationLinkService } from '../../../src/qr/verification-link-service.js'; import { Environment } from '../../../src/config/environments.js'; import type { RenderContext } from '../../../src/pdf/template/interpret.js'; @@ -257,7 +262,29 @@ describe('qrRenderer', () => { stack: Array>; }; expect(out.stack[0]).toMatchObject({ svg: svgFor(CODE_I) }); - expect(out.stack[1]).toEqual({ text: 'openLink', link: CODE_I }); + expect(out.stack[1]).toMatchObject({ text: 'openLink', link: CODE_I }); + }); + + it('lines up with the code rather than with the box around it', () => { + // The code is inset by its quiet zone, so a link flush with the box edge + // reads as shifted left of everything above it. + const fit = 104; + const out = qrRenderer({ type: 'qr', fit }, ctxWith({ qrUrl: CODE_I }, { qrLinks: true }), noopRender) as { + stack: Array<{ margin?: number[] }>; + }; + const span = QRCode.create(CODE_I, { errorCorrectionLevel: 'M' }).modules.size + 8; + expect(out.stack[1].margin).toEqual([(fit / span) * 4, 0, 0, 0]); + }); + + it('indents each code by its own quiet zone, not by a fixed amount', () => { + // Same box, more data: Code II's modules are narrower, so its quiet zone + // is narrower and its link starts further left than Code I's. + const at = (bindings: Record, code: 'invoice' | 'certificate') => + (qrRenderer({ type: 'qr', code, fit: 104 }, ctxWith(bindings, { qrLinks: true }), noopRender) as { + stack: Array<{ margin?: number[] }>; + }).stack[1].margin![0]; + const denser = 'https://qr/certificate/Nip/1111111111/1111111111/SERIAL/'.padEnd(400, 'x'); + expect(at({ qrUrl: CODE_I }, 'invoice')).toBeGreaterThan(at({ certificateQrUrl: denser }, 'certificate')); }); it('links Code II to the certificate URL, not the invoice one', () => { @@ -275,7 +302,7 @@ describe('qrRenderer', () => { ctxWith({ qrUrl: CODE_I }, { qrLinks: true }), noopRender, ) as { stack: Array> }; - expect(out.stack[1]).toEqual({ text: 'openLink', link: CODE_I, style: 'qrLink' }); + expect(out.stack[1]).toMatchObject({ text: 'openLink', link: CODE_I, style: 'qrLink' }); }); it('adds no link to a code that is not printed', () => { @@ -283,3 +310,51 @@ describe('qrRenderer', () => { }); }); }); + +/** + * The row itself, not the renderer: a heading in an elastic column with the + * codes beside it, so they sit against the right margin. This used to be judged + * by eye across a handful of preview PDFs; it is a property, so it belongs here. + */ +describe('the QR row keeps the codes on the right margin', () => { + const CODE_I = 'https://qr-demo.ksef.mf.gov.pl/invoice/1111111111/15-01-2026/HASH'; + const CODE_II = 'https://qr-demo.ksef.mf.gov.pl/certificate/Nip/1111111111/1111111111/SERIAL/HASH/SIG'; + + /** Column widths of the built-in QR row, for a given pair of codes. */ + function widths(bindings: Record, locale: Locale = 'pl'): string[] { + const ctx: RenderContext = { + root: {}, + strict: false, + label: makeLabelResolver(locale, {}), + bindings, + flags: { qr: true, qrLinks: true }, + }; + const doc = interpretTemplate(getBuiltinTemplate('fa3-default')!, ctx, blockRegistry); + const row = (doc.content as Array>) + .filter((n) => Array.isArray(n.columns)) + .pop() as { columns: Array<{ width?: string; text?: string }> }; + // An absent width is pdfmake's elastic default; name it so the assertions read. + return row.columns.map((c) => c.width ?? '*'); + } + + it('gives the heading the only elastic column when both codes are present', () => { + expect(widths({ qrUrl: CODE_I, certificateQrUrl: CODE_II })).toEqual(['*', 'auto', 'auto']); + }); + + it('drops the empty column when only Code I is present', () => { + // The regression this pins: an empty node here took an elastic column of its + // own and left the single code stranded mid-page instead of at the margin. + expect(widths({ qrUrl: CODE_I })).toEqual(['*', 'auto']); + }); + + it('drops it just the same when only Code II is present', () => { + expect(widths({ certificateQrUrl: CODE_II })).toEqual(['*', 'auto']); + }); + + it('holds however wide the heading runs', () => { + // A bilingual heading is twice the length; the codes must not move. + for (const locale of ['pl', 'uk', 'pl+uk'] as const) { + expect(widths({ qrUrl: CODE_I, certificateQrUrl: CODE_II }, locale)).toEqual(['*', 'auto', 'auto']); + } + }); +}); From 9f601de6a20dc8b5f314fd2e36d8189e2e7f25f8 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 22:05:56 +0200 Subject: [PATCH 22/67] feat(pdf): let a template style block headings, on two levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A block's heading is the block's own, not the template's, so the renderer reached for a style name by convention: `h2`, hardcoded, invisible in the template JSON. A template could redefine what `h2` looked like but could not say which style its section headings should take, and a template that omitted `h2` lost every heading silently. `headingStyle` names it per block, and it reaches only the first line the block prints. The labels nested inside — the address and contact groups, the bank-account heading — sit a level down and stay on `h2`, so lifting section headings does not drag every label in the document along. The built-in templates now name both levels explicitly and set them apart: `h1` for the section headings, `h2` for what sits under them. Two gaps in the style lint closed on the way. It matched the key `style` exactly, so `linkStyle` and `offlineStyle` were never checked and a typo in either silently dropped the style. It now takes any key ending in `Style`. And it demands both heading levels exist, not just the one a block happens to name. Verified: unit 2644, e2e 149, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 1 + .../src/pdf/template/blocks/annotations.ts | 2 +- .../src/pdf/template/blocks/parties.ts | 22 +++-- .../src/pdf/template/blocks/payment.ts | 8 +- .../src/pdf/template/builtin/fa2-default.json | 5 +- .../src/pdf/template/builtin/fa3-default.json | 5 +- .../ksef-client-ts/src/pdf/template/dsl.ts | 32 ++++++- .../tests/unit/pdf/blocks-semantic.test.ts | 90 +++++++++++++++++++ .../unit/pdf/builtin-template-lint.test.ts | 36 ++++++-- 9 files changed, 181 insertions(+), 20 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 0e69cbed..c4d2ecd4 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -196,6 +196,7 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it - **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. +- **`headingStyle`** (`parties`, `payment`, `annotations`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. - **`style`** (party panels) names a style for a panel's value lines. A labelled group inherits it unless it declares its own, so the built-in templates set `partyIdentity` on the panel — the counterparty's name and tax number — and let the address and contact groups drop to the smaller `partyDetails`. Headings keep the panel's heading style either way. - **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. - **`width`** (table columns only) sizes a column: a number of points, `'auto'` to fit the content, or `'*'` to share out what is left (the default). Sizing is worth setting explicitly — pdfmake gives every `'*'` column the *same* width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token silently widens the whole table past the page edge. diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts index 5a1dccf9..c90185aa 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts @@ -8,7 +8,7 @@ import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.j * scalar binding). */ export const annotationsRenderer: BlockRenderer = (block, ctx) => { - const stack: PdfNode[] = [{ text: ctx.label('annotations'), style: 'h2' }]; + const stack: PdfNode[] = [{ text: ctx.label('annotations'), style: block.headingStyle ?? 'h2' }]; for (const field of block.fields) { stack.push({ text: `${ctx.label(field.label)}: ${applyFormat(resolveBinding(field.path, ctx), field.format)}`, diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts index 66caa111..3a6b5df2 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts @@ -2,8 +2,17 @@ import { list } from '../../accessor.js'; import type { PartiesBlock, PartyColumn, PartyField, PartyGroup } from '../dsl.js'; import { resolveBinding, type BlockRenderer, type PdfNode, type RenderContext } from '../interpret.js'; -/** Heading style shared by the panel label and its sub-group labels. */ -const HEADING_STYLE = 'h2'; +/** + * The panel's own heading — `Sprzedawca` / `Nabywca` — takes the block's + * `headingStyle`, defaulting to this. The labels *inside* a panel (`Adres`, + * `Dane kontaktowe`) are a level below and always take {@link SUBHEADING_STYLE}: + * a template redirecting its section headings is not asking for every label in + * the document to move with them. + */ +const DEFAULT_HEADING_STYLE = 'h2'; + +/** Sub-headings within a block. Fixed, and the same across every block. */ +const SUBHEADING_STYLE = 'h2'; function isGroup(field: PartyField): field is PartyGroup { return typeof field !== 'string' && 'fields' in field; @@ -30,10 +39,11 @@ function isGroup(field: PartyField): field is PartyGroup { * no counterparty gets a label with nothing under it. * * Value lines take {@link PartyColumn.style}; a group may override it for its - * own lines with {@link PartyGroup.style}. Headings always keep the panel's - * heading style. + * own lines with {@link PartyGroup.style}. The panel heading takes the block's + * `headingStyle`; a group's own label stays at {@link SUBHEADING_STYLE}. */ export const partiesRenderer: BlockRenderer = (block, ctx) => { + const heading = block.headingStyle ?? DEFAULT_HEADING_STYLE; const at = (root: unknown, strict = ctx.strict): RenderContext => ({ ...ctx, root, strict }); const resolveValue = ( @@ -63,7 +73,7 @@ export const partiesRenderer: BlockRenderer = (block, ctx) => { ? list(root, field.from).flatMap((item) => renderFields(field.fields, item, false, inherited)) : renderFields(field.fields, root, strict, inherited); if (inner.length === 0) continue; // no heading without content - out.push({ text: ctx.label(field.label), style: HEADING_STYLE }); + out.push({ text: ctx.label(field.label), style: SUBHEADING_STYLE }); out.push(...inner); continue; } @@ -77,7 +87,7 @@ export const partiesRenderer: BlockRenderer = (block, ctx) => { const side = (col: PartyColumn): PdfNode => ({ width: '*', stack: [ - { text: ctx.label(col.label), style: HEADING_STYLE }, + { text: ctx.label(col.label), style: heading }, ...renderFields(col.fields, ctx.root, ctx.strict, col.style), ], }); diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts index 2cd0d15c..9cc82e49 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -19,7 +19,11 @@ import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.j export const paymentRenderer: BlockRenderer = (block, ctx) => { // Bindings the schema declares optional are read leniently even under strict. const lenientCtx = { ...ctx, strict: false }; - const stack: PdfNode[] = [{ text: ctx.label('payment'), style: 'h2' }]; + // The block's own heading follows `headingStyle`; `Rachunek bankowy` is a + // level below it and stays put, as sub-headings do everywhere. + const heading = block.headingStyle ?? 'h2'; + const subheading = 'h2'; + const stack: PdfNode[] = [{ text: ctx.label('payment'), style: heading }]; for (const row of block.rows) { const value = applyFormat(resolveBinding(row.path, row.optional ? lenientCtx : ctx), row.format); @@ -37,7 +41,7 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { } } if (lines.length > 0) { - if (block.accounts.heading) stack.push({ text: ctx.label(block.accounts.heading), style: 'h2' }); + if (block.accounts.heading) stack.push({ text: ctx.label(block.accounts.heading), style: subheading }); stack.push(...lines); } } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index efe619b8..687dd9cd 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -4,6 +4,7 @@ "pageFooter": { "style": "footerNote" }, "styles": { "title": { "fontSize": 20, "bold": true }, + "h1": { "fontSize": 11, "bold": true, "margin": [0, 10, 0, 3], "color": "#000000" }, "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, @@ -27,6 +28,7 @@ { "type": "spacer", "height": 4 }, { "type": "parties", + "headingStyle": "h1", "left": { "label": "seller", "style": "partyIdentity", @@ -173,6 +175,7 @@ { "type": "divider" }, { "type": "payment", + "headingStyle": "h1", "when": "Fa.Platnosc", "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, @@ -194,7 +197,7 @@ "type": "columns", "when": "qr", "columns": [ - { "type": "text", "label": "verifyInKsef", "style": "h2" }, + { "type": "text", "label": "verifyInKsef", "style": "h1" }, { "type": "qr", "code": "invoice", "fit": 104, "linkStyle": "qrLink" }, { "type": "qr", "code": "certificate", "fit": 104, "linkStyle": "qrLink" } ] diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 0ae09fe8..15716afc 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -4,6 +4,7 @@ "pageFooter": { "style": "footerNote" }, "styles": { "title": { "fontSize": 20, "bold": true }, + "h1": { "fontSize": 11, "bold": true, "margin": [0, 10, 0, 3], "color": "#000000" }, "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, @@ -27,6 +28,7 @@ { "type": "spacer", "height": 4 }, { "type": "parties", + "headingStyle": "h1", "left": { "label": "seller", "style": "partyIdentity", @@ -172,6 +174,7 @@ { "type": "divider" }, { "type": "payment", + "headingStyle": "h1", "when": "Fa.Platnosc", "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, @@ -193,7 +196,7 @@ "type": "columns", "when": "qr", "columns": [ - { "type": "text", "label": "verifyInKsef", "style": "h2" }, + { "type": "text", "label": "verifyInKsef", "style": "h1" }, { "type": "qr", "code": "invoice", "fit": 104, "linkStyle": "qrLink" }, { "type": "qr", "code": "certificate", "fit": 104, "linkStyle": "qrLink" } ] diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 5fae59c4..67dabb4c 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -71,6 +71,23 @@ export interface ColumnDef extends FieldDef { width?: number | 'auto' | '*'; } +/** + * Blocks that print a heading of their own reach for a style name rather than + * being given one, because the heading is theirs and not the template's. + * `headingStyle` overrides that name per block. + * + * It reaches only the block's *own* heading — the first line it prints, like + * `Sprzedawca` or `Płatność`. Labels nested inside a block (`Adres`, `Dane + * kontaktowe`, `Rachunek bankowy`) are one level down and stay at `h2` whatever + * the block heading does, so a template can lift its section headings without + * dragging every label in the document along. Both default to `h2`, which is + * what the built-in templates rely on, so leaving the option out is the normal + * case. + * + * @see HEADING_STYLE_DOC — referenced from each block that takes the option. + */ +export const HEADING_STYLE_DOC = 'h2'; + // ── Semantic blocks ──────────────────────────────────────────────────────── export interface HeaderBlock { @@ -150,6 +167,8 @@ export interface PartiesBlock { type: 'parties'; left: PartyColumn; right: PartyColumn; + /** See {@link HEADING_STYLE_DOC}. The panel labels only, not the group labels. */ + headingStyle?: string; style?: string; } @@ -207,12 +226,16 @@ export interface PaymentBlock { when?: string; rows: FieldDef[]; accounts?: PaymentAccounts; + /** See {@link HEADING_STYLE_DOC}. The block label only, not `accounts.heading`. */ + headingStyle?: string; style?: string; } export interface AnnotationsBlock { type: 'annotations'; fields: FieldDef[]; + /** See {@link HEADING_STYLE_DOC}. */ + headingStyle?: string; style?: string; } @@ -429,6 +452,7 @@ const blockSchema: z.ZodType = z.lazy(() => type: z.literal('parties'), left: partyColumn, right: partyColumn, + headingStyle: z.string().optional(), style: z.string().optional(), }).strict(), z.object({ @@ -450,9 +474,15 @@ const blockSchema: z.ZodType = z.lazy(() => }) .strict() .optional(), + headingStyle: z.string().optional(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('annotations'), + fields: z.array(fieldDef), + headingStyle: z.string().optional(), style: z.string().optional(), }).strict(), - z.object({ type: z.literal('annotations'), fields: z.array(fieldDef), style: z.string().optional() }).strict(), z.object({ type: z.literal('qr'), when: z.string().optional(), diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index 300f42df..c09f2bac 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -879,3 +879,93 @@ describe('footerRenderer', () => { expect(node.alignment).toBe('center'); }); }); + +// ── heading styles ─────────────────────────────────────────────────────────── + +/** + * A block that prints a heading of its own reaches for a style name rather than + * being handed one, so `headingStyle` is the only way a template can redirect + * it. The default has to stay `h2`, since every built-in template defines that + * and nothing else points at it. + */ +describe('headingStyle', () => { + const partiesBlock = (headingStyle?: string) => + rec( + partiesRenderer( + { + type: 'parties', + ...(headingStyle ? { headingStyle } : {}), + left: { label: 'seller', fields: [] }, + right: { + label: 'buyer', + fields: [{ label: 'address', style: 'partyDetails', fields: ['Podmiot2.Adres.AdresL1'] }], + }, + }, + makeCtx({ Podmiot2: { Adres: { AdresL1: 'ul. Testowa 1' } } }), + noRender, + ), + ).columns[1].stack; + + const paymentBlock = (headingStyle?: string) => + rec( + paymentRenderer( + { + type: 'payment', + ...(headingStyle ? { headingStyle } : {}), + rows: [{ label: 'paid', path: 'Fa.Platnosc.Zaplacono' }], + accounts: { + from: 'Fa.Platnosc.RachunekBankowy', + heading: 'bankAccounts', + fields: [{ label: 'bankAccount', path: 'NrRB' }], + }, + }, + makeCtx({ Fa: { Platnosc: { Zaplacono: '1', RachunekBankowy: { NrRB: 'PL01' } } } }), + noRender, + ), + ).stack; + + const annotationsBlock = (headingStyle?: string) => + rec( + annotationsRenderer( + { + type: 'annotations', + ...(headingStyle ? { headingStyle } : {}), + fields: [{ label: 'annotations', path: 'Fa.Adnotacje.P_16' }], + }, + makeCtx({ Fa: { Adnotacje: { P_16: '2' } } }), + noRender, + ), + ).stack; + + it('defaults to h2 in every block that prints a heading', () => { + expect(partiesBlock()[0].style).toBe('h2'); + expect(paymentBlock()[0].style).toBe('h2'); + expect(annotationsBlock()[0].style).toBe('h2'); + }); + + it('redirects the heading when a template names another style', () => { + expect(partiesBlock('sectionHead')[0].style).toBe('sectionHead'); + expect(paymentBlock('sectionHead')[0].style).toBe('sectionHead'); + expect(annotationsBlock('sectionHead')[0].style).toBe('sectionHead'); + }); + + it('leaves the sub-headings a block prints one level down', () => { + // The address group inside a party panel, and the bank-account heading + // inside payment: both are a level below the block's own heading, so + // lifting section headings must not drag every label along with them. + const [, groupHeading] = partiesBlock('sectionHead'); + expect(groupHeading.style).toBe('h2'); + expect(paymentBlock('sectionHead').find((n: { text: string }) => n.text === 'bankAccounts').style).toBe('h2'); + }); + + it('still puts both levels on h2 by default, so a plain template looks flat', () => { + expect(partiesBlock()[1].style).toBe('h2'); + expect(paymentBlock().find((n: { text: string }) => n.text === 'bankAccounts').style).toBe('h2'); + }); + + it('leaves the value lines alone', () => { + // Only headings move: the group keeps its own style for its content. + expect(partiesBlock('sectionHead')[2].style).toBe('partyDetails'); + expect(paymentBlock('sectionHead')[1].style).toBeUndefined(); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index db1fc27f..c2541d34 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -158,16 +158,21 @@ describe('built-in template lint', () => { * pdfmake silently ignores a style name it does not know, so a renamed or * mistyped style reference costs nothing at render time and everything on the * page. Nothing else checks this: the DSL types a style as a plain string. + * + * Several keys name a style — `style` itself, plus the per-block overrides + * `headingStyle`, `linkStyle` and `offlineStyle` — so the walk takes any key + * that ends in `Style` and a new one is covered the day it is added. */ it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: every style it references is defined', (name) => { const template = getBuiltinTemplate(name)!; const defined = Object.keys(template.styles ?? {}); const referenced = new Set(); + const namesAStyle = (key: string) => key === 'style' || key.endsWith('Style'); const walk = (value: unknown): void => { if (Array.isArray(value)) return value.forEach(walk); if (value === null || typeof value !== 'object') return; for (const [key, inner] of Object.entries(value)) { - if (key === 'style' && typeof inner === 'string') referenced.add(inner); + if (namesAStyle(key) && typeof inner === 'string') referenced.add(inner); else walk(inner); } }; @@ -175,16 +180,31 @@ describe('built-in template lint', () => { expect([...referenced].filter((style) => !defined.includes(style))).toEqual([]); }); - it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: defines the styles the renderers reach for', (name) => { - // Two style names are reached for by the renderers rather than named in the - // template, so nothing in the JSON points at them: `title` is the header's - // default, and `h2` is hardcoded as the heading of the parties, payment and - // annotations blocks. A template that omits one loses those headings. + it('the style walk actually finds the per-block overrides', () => { + // Guards the `endsWith('Style')` rule above: before it, `linkStyle` and + // `offlineStyle` were invisible to this lint. + const json = JSON.stringify(getBuiltinTemplate('fa3-default')); + for (const key of ['"linkStyle"', '"offlineStyle"']) { + expect(json, `${key} is no longer in the template — retarget this guard`).toContain(key); + } + }); + + it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: defines the heading styles its blocks will use', (name) => { + // A heading belongs to its block, not to the template, so the block reaches + // for a style name instead of being handed one — `h2` unless `headingStyle` + // says otherwise, and `title` for the header. Nothing in the JSON refers to + // those defaults, so a template that omits them loses its headings silently. const template = getBuiltinTemplate(name)!; const defined = Object.keys(template.styles ?? {}); - const needsH2 = template.blocks.some((b) => ['parties', 'payment', 'annotations'].includes(b.type)); expect(defined).toContain('title'); - if (needsH2) expect(defined).toContain('h2'); + + const printsHeading = template.blocks.filter((b) => ['parties', 'payment', 'annotations'].includes(b.type)); + const headings = printsHeading.map((b) => (b as { headingStyle?: string }).headingStyle ?? 'h2'); + // Sub-headings inside those blocks — `Adres`, `Rachunek bankowy` — sit one + // level down and stay at `h2` whatever the block heading is set to, so `h2` + // has to exist too even when nothing names it. + if (printsHeading.length > 0) headings.push('h2'); + expect(headings.filter((style) => !defined.includes(style))).toEqual([]); }); it('fails a template whose repeater path is misspelled', () => { From 919a81d94f17dd98e9798b8f8716c28bbf513ee0 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 22:19:48 +0200 Subject: [PATCH 23/67] feat(pdf): let the caller add sections of their own to a visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some of what belongs on an invoice is not in the invoice — delivery terms, a payment reminder, a line the accountant wants on every document. There was nowhere to put it: a template binds to the XML, and the XML is what KSeF holds. `notes` takes those as a list of heading-and-body sections and prints them where the template puts its `notes` block, between the payment details and the verification codes in the built-in ones. Both halves are plain text, so a note can neither reach into the document nor disturb the layout around it. An entry blank on both halves is dropped, one with only a head or only a body prints that half, and a render supplying none leaves no trace of the block at all — the block renders nothing rather than an empty gap, so a template carries it unconditionally. The rule that closes the section had to become conditional with it, so a `divider` now takes a `when`. Without that an invoice carrying no notes showed a line hanging over its verification codes. From the command line the sections come from a JSON file, whose shape is checked when it is read: a hand-written file is where a shape mistake happens, and printing nothing at all is worse than failing with a reason. Verified: unit 2667, e2e 149, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 34 ++- .../src/cli/commands/invoice.ts | 30 +++ packages/ksef-client-ts/src/pdf/index.ts | 18 +- .../src/pdf/template/blocks/index.ts | 2 + .../src/pdf/template/blocks/notes.ts | 37 ++++ .../src/pdf/template/builtin/fa2-default.json | 2 + .../src/pdf/template/builtin/fa3-default.json | 2 + .../ksef-client-ts/src/pdf/template/dsl.ts | 29 ++- .../src/pdf/template/interpret.ts | 18 ++ .../tests/e2e/35-invoice-pdf-cli.test.ts | 16 +- .../tests/e2e/36-invoice-pdf-library.test.ts | 1 + .../unit/pdf/builtin-template-lint.test.ts | 2 +- .../ksef-client-ts/tests/unit/pdf/dsl.test.ts | 12 ++ .../tests/unit/pdf/notes.test.ts | 197 ++++++++++++++++++ 14 files changed, 393 insertions(+), 7 deletions(-) create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/notes.ts create mode 100644 packages/ksef-client-ts/tests/unit/pdf/notes.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index c4d2ecd4..ff805499 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -138,6 +138,7 @@ detectUpoVersion(xml); // 'UPO(4.2)' | 'UPO(4.3)' | null It deliberately does not apply to `when` conditions, repeater `from` paths, `firstOf` alternatives or `sum` members: those are sets where absence is the normal case, not a mistake. Typos in them are caught for the built-in templates by a lint that resolves every such path against the reference fixtures. | `invoiceHash` | `string` | Precomputed canonical invoice hash (base64), used verbatim for the QR. | +| `notes` | `{ head, body }[]` | Extra sections printed where the template puts its `notes` block. | --- @@ -172,6 +173,7 @@ The `schema` field binds a template to a single document kind. If you render an | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | | `payment` | Payment details (amount paid, date, method) | | `annotations` | Miscellaneous labelled fields | +| `notes` | The caller's own sections, from `notes` — a heading over a body, each | | `qr` | One KSeF verification QR — `code: "invoice"` (Code I, the default) or `code: "certificate"` (Code II) | | `footer` | Footer note | @@ -193,7 +195,7 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it - **Binding paths** are dot-paths into the document body — e.g. `Fa.P_2` (invoice number), `Podmiot1.DaneIdentyfikacyjne.Nazwa` (seller name). Paths are relative to the body element, not the document wrapper. - **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. -- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`. +- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`. A `divider` takes it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. - **`headingStyle`** (`parties`, `payment`, `annotations`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. @@ -294,6 +296,35 @@ const pdf = await renderInvoicePdf(xml, 'fa3-default', { --- +## Notes + +Some of what belongs on an invoice is not in the invoice: delivery terms, a payment reminder, a line the accountant wants on every document. `notes` takes those as an array of sections and prints them where the template puts its `notes` block — in the built-in templates, between the payment details and the verification codes. + +```ts +const pdf = await renderInvoicePdf(xml, 'fa3-default', { + notes: [ + { head: 'Warunki dostawy', body: 'Towar wydany w magazynie sprzedawcy.' }, + { head: 'Uwaga', body: 'Prosimy o podanie numeru faktury w tytule przelewu.' }, + ], +}); +``` + +Both halves are plain text — no bindings, no markup, and a `\n` is a line break. A note therefore cannot reach into the document or disturb the layout around it. An entry blank on both halves is dropped, one with only a head or only a body prints that half, and a render with no notes leaves no trace of the block at all. + +Each note's heading takes the block's `headingStyle` — the built-in templates set `h1`, the same level as `Płatność`, since a note is a section of its own rather than a label inside one — and the bodies are body text. From the CLI the sections come from a JSON file: + +```bash +ksef invoice pdf invoice.xml --notes ./notes.json +``` + +```json +[ + { "head": "Warunki dostawy", "body": "Towar wydany w magazynie sprzedawcy." } +] +``` + +--- + ## Verification QR codes KSeF defines two verification codes, and the built-in templates print both when both are available. @@ -361,6 +392,7 @@ ksef invoice pdf upo.xml --template-file ./templates/my-upo.json | `--qr-links` | Print a clickable link under each QR code | | `--ksef-number ` | KSeF number to print (absent → marked OFFLINE) | | `--totals ` | Tax breakdown above the amount due (default `buckets`) | +| `--notes ` | JSON file of extra sections: `[{ "head": …, "body": … }]` | | `--upo` | Treat the input as a UPO document (otherwise auto-detected); ignored when a template is named explicitly | | `--env ` | Environment for the QR base URL | | `--out ` | Output PDF path (default: alongside the source) | diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 9223a226..19329fdd 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -605,6 +605,33 @@ function readImageAsDataUri(file: string): string { return `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`; } +/** + * Read the `--notes` file: an array of `{ head, body }`. Validated here rather + * than left to the renderer, because a hand-written JSON file is exactly where a + * shape mistake happens and a silent one would print nothing at all. + */ +function readNotesFile(file: string): Array<{ head: string; body: string }> { + if (!fs.existsSync(file)) { + throw new Error(`Notes file not found: ${file}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(file, 'utf-8')); + } catch (error) { + throw new Error(`Notes file is not valid JSON: ${file} (${(error as Error).message})`); + } + if (!Array.isArray(parsed)) { + throw new Error(`Notes file must hold an array of { head, body } objects: ${file}`); + } + return parsed.map((entry, i) => { + const note = entry as { head?: unknown; body?: unknown }; + if (typeof note?.head !== 'string' || typeof note?.body !== 'string') { + throw new Error(`Notes entry ${i} must have string "head" and "body": ${file}`); + } + return { head: note.head, body: note.body }; + }); +} + const pdf = defineCommand({ meta: { name: 'pdf', description: 'Render an invoice or UPO XML to a PDF (offline; requires the optional pdfmake peer)' }, args: { @@ -621,6 +648,7 @@ const pdf = defineCommand({ upo: { type: 'boolean', description: 'Treat the input as a UPO document (otherwise auto-detected)' }, logo: { type: 'string', description: 'Path to a logo image (PNG/JPEG/GIF/WebP/SVG) to print in the header' }, totals: { type: 'string', description: 'Tax breakdown above the amount due: none | buckets (as recorded) | summary (computed) | both (default: buckets)' }, + notes: { type: 'string', description: 'Path to a JSON file with extra sections: [{ "head": "…", "body": "…" }, …]' }, env: { type: 'string', description: 'Environment for the QR base URL (test/demo/prod)' }, json: { type: 'boolean', description: 'Output as JSON' }, }, @@ -646,6 +674,7 @@ const pdf = defineCommand({ const env = args.env as 'prod' | 'test' | 'demo' | undefined; const logo = args.logo ? readImageAsDataUri(args.logo as string) : undefined; + const notes = args.notes ? readNotesFile(args.notes as string) : undefined; const renderOpts = { locale: locale as PdfLocale, totals: totals as PdfTotals, @@ -656,6 +685,7 @@ const pdf = defineCommand({ ...(args.ksefNumber ? { ksefNumber: args.ksefNumber as string } : {}), ...(env ? { env } : {}), ...(logo ? { logo } : {}), + ...(notes ? { notes } : {}), }; // Exact bytes preserve the QR hash; pass the raw file as a Uint8Array. diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index 24d99295..e1697ce3 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -20,7 +20,7 @@ import { KSeFPdfError } from './errors.js'; import { makeLabelResolver } from './i18n/index.js'; import type { Locale } from './i18n/types.js'; import { validateTemplate, type InvoiceTemplate, type TemplateSchemaId } from './template/dsl.js'; -import { interpretTemplate, type RenderContext } from './template/interpret.js'; +import { interpretTemplate, type RenderContext, type RenderNote } from './template/interpret.js'; import { blockRegistry } from './template/blocks/index.js'; import { getBuiltinTemplate, builtinTemplateNames } from './template/builtin/index.js'; import { loadPdfMake, createPdfBuffer } from './fonts.js'; @@ -28,6 +28,7 @@ import { deriveInvoiceQrUrl } from './qr.js'; export type { Locale } from './i18n/types.js'; export type { InvoiceTemplate } from './template/dsl.js'; +export type { RenderNote } from './template/interpret.js'; export { detectInvoiceVersion, detectUpoVersion } from './parse.js'; /** @@ -86,6 +87,14 @@ export interface RenderOptions { strict?: boolean; /** Precomputed canonical invoice hash (base64) — used verbatim for the QR. */ invoiceHash?: string; + /** + * Extra sections to print where the template puts its `notes` block — in the + * built-in templates, between the payment details and the verification codes. + * Each is a heading over a body, both plain text, and they appear in the order + * given. Nothing here comes from the invoice: this is what the sender wants to + * say alongside it. + */ + notes?: RenderNote[]; } type RawXml = string | Uint8Array; @@ -124,6 +133,8 @@ function buildContext( certificateQrUrl: qrUrls.certificate, }; + const notes = (opts.notes ?? []).filter((n) => (n?.head ?? '').trim() !== '' || (n?.body ?? '').trim() !== ''); + const totals = opts.totals ?? 'buckets'; const flags: Record = { hasKsefNumber: Boolean(opts.ksefNumber), @@ -134,9 +145,12 @@ function buildContext( qrLinks: Boolean(opts.qrLinks), totalsBuckets: totals === 'buckets' || totals === 'both', totalsSummary: totals === 'summary' || totals === 'both', + // So a template can gate other things on notes being present — a divider + // around them, say — without the block itself needing a condition. + notes: notes.length > 0, }; - return { root, strict: opts.strict ?? false, label, bindings, flags }; + return { root, strict: opts.strict ?? false, label, bindings, flags, notes }; } /** diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/index.ts b/packages/ksef-client-ts/src/pdf/template/blocks/index.ts index 60d2a630..ce226de8 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/index.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/index.ts @@ -12,6 +12,7 @@ import { linesRenderer } from './lines.js'; import { totalsRenderer } from './totals.js'; import { paymentRenderer } from './payment.js'; import { annotationsRenderer } from './annotations.js'; +import { notesRenderer } from './notes.js'; import { footerRenderer } from './footer.js'; import { tableRenderer } from './table.js'; import { eachRenderer } from './each.js'; @@ -25,6 +26,7 @@ export const blockRegistry: BlockRegistry = { totals: totalsRenderer as BlockRenderer, payment: paymentRenderer as BlockRenderer, annotations: annotationsRenderer as BlockRenderer, + notes: notesRenderer as BlockRenderer, footer: footerRenderer as BlockRenderer, table: tableRenderer as BlockRenderer, each: eachRenderer as BlockRenderer, diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/notes.ts b/packages/ksef-client-ts/src/pdf/template/blocks/notes.ts new file mode 100644 index 00000000..0daefcd0 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/notes.ts @@ -0,0 +1,37 @@ +import type { NotesBlock } from '../dsl.js'; +import type { BlockRenderer, PdfNode } from '../interpret.js'; + +/** + * Caller-supplied sections, printed in order where the template puts this + * block: each note's `head` as a heading over its `body`. The content is not in + * the document — it comes from the render options — so this block is the seam + * between what KSeF holds and what the sender wants to add beside it: terms of + * delivery, a thank-you, a legal footnote. + * + * Both halves are plain text. A note carries no bindings and no markup, so it + * can neither reach into the invoice nor disturb the layout around it; a `\n` + * in the body is a line break and that is the whole of it. + * + * An entry with nothing in it is skipped, and a block with no notes at all + * renders nothing rather than an empty gap — so a template can carry the block + * unconditionally and a render that supplies no notes looks as if it were never + * there. + */ +export const notesRenderer: BlockRenderer = (block, ctx) => { + const stack: PdfNode[] = []; + + for (const note of ctx.notes ?? []) { + const head = (note.head ?? '').trim(); + const body = (note.body ?? '').trim(); + if (head === '' && body === '') continue; + if (head !== '') stack.push({ text: head, style: block.headingStyle ?? 'h2' }); + if (body !== '') stack.push({ text: body }); + } + + if (stack.length === 0) return null; + return { + stack, + margin: [0, 4, 0, 8], + ...(block.style ? { style: block.style } : {}), + }; +}; diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 687dd9cd..67b22d84 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -193,6 +193,8 @@ } }, { "type": "divider" }, + { "type": "notes", "headingStyle": "h1" }, + { "type": "divider", "when": "notes" }, { "type": "columns", "when": "qr", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 15716afc..1e2fd94c 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -192,6 +192,8 @@ } }, { "type": "divider" }, + { "type": "notes", "headingStyle": "h1" }, + { "type": "divider", "when": "notes" }, { "type": "columns", "when": "qr", diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 67dabb4c..b696e76d 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -231,6 +231,20 @@ export interface PaymentBlock { style?: string; } +/** + * Placeholder for the sections the caller passes to the render, printed in + * order, each as a heading over its body. The template decides *where* they go + * and how they look; the content comes from {@link RenderOptions.notes} and is + * not in the document at all. The block renders nothing when no notes were + * supplied, so a template can carry it unconditionally. + */ +export interface NotesBlock { + type: 'notes'; + /** See {@link HEADING_STYLE_DOC}. Applies to each note's heading. */ + headingStyle?: string; + style?: string; +} + export interface AnnotationsBlock { type: 'annotations'; fields: FieldDef[]; @@ -334,6 +348,13 @@ export interface ImageBlock { export interface DividerBlock { type: 'divider'; + /** + * A rule is only ever there to separate two things, so it has to be able to + * disappear with the thing it separates: the built-in templates close the + * `notes` block with one, and an invoice carrying no notes must not show a + * stray line above its verification codes. + */ + when?: string; style?: string; } @@ -349,6 +370,7 @@ export type Block = | TotalsBlock | PaymentBlock | AnnotationsBlock + | NotesBlock | QrBlock | FooterBlock | TextBlock @@ -477,6 +499,11 @@ const blockSchema: z.ZodType = z.lazy(() => headingStyle: z.string().optional(), style: z.string().optional(), }).strict(), + z.object({ + type: z.literal('notes'), + headingStyle: z.string().optional(), + style: z.string().optional(), + }).strict(), z.object({ type: z.literal('annotations'), fields: z.array(fieldDef), @@ -540,7 +567,7 @@ const blockSchema: z.ZodType = z.lazy(() => width: z.number().optional(), when: z.string().optional(), }).strict(), - z.object({ type: z.literal('divider'), style: z.string().optional() }).strict(), + z.object({ type: z.literal('divider'), when: z.string().optional(), style: z.string().optional() }).strict(), z.object({ type: z.literal('spacer'), height: z.number().optional() }).strict(), ]), ) as z.ZodType; diff --git a/packages/ksef-client-ts/src/pdf/template/interpret.ts b/packages/ksef-client-ts/src/pdf/template/interpret.ts index 54429b5f..aca8b3c5 100644 --- a/packages/ksef-client-ts/src/pdf/template/interpret.ts +++ b/packages/ksef-client-ts/src/pdf/template/interpret.ts @@ -47,6 +47,24 @@ export interface RenderContext { bindings: Record; /** Boolean aliases for `when` (e.g. `hasKsefNumber`, `qr`). */ flags: Record; + /** + * Extra sections supplied by the caller, printed where the template puts its + * `notes` block. They travel beside `bindings` rather than in it because a + * binding is a single string and these are a list of records — and beside + * `root` because they are not in the document: the XML is what KSeF has, the + * notes are what the sender wants to say alongside it. + */ + notes?: RenderNote[]; +} + +/** + * One caller-supplied section: a heading over a paragraph. Both are plain text + * — no bindings, no markup — so a note cannot reach into the document or change + * the layout around it. + */ +export interface RenderNote { + head: string; + body: string; } /** diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 40c777b4..a2ea8dd5 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -60,6 +60,7 @@ function isCompletePdf(file: string): boolean { let oldTotalsTemplate: string; let multiDocumentUpo: string; let certificateQrUrl: string; +let notesFile: string; function writeDerivedInputs(): void { // A copy of fa3-default whose totals read a single rate bucket — the shape the @@ -98,6 +99,15 @@ function writeDerivedInputs(): void { type: 'pkcs8', format: 'pem', }) as string; + notesFile = join(inputsDir, 'cli-notes.json'); + writeFileSync( + notesFile, + JSON.stringify([ + { head: 'Warunki dostawy', body: 'Towar wydany w magazynie sprzedawcy. Ryzyko przechodzi na kupującego z chwilą wydania.' }, + { head: 'Uwaga', body: 'Prosimy o podanie numeru faktury w tytule przelewu.' }, + ]), + ); + certificateQrUrl = new VerificationLinkService(DEMO_QR_HOST).buildCertificateVerificationUrl( 'Nip', '1111111111', @@ -148,7 +158,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { * 02 fa3 en I yes no yes summary (Code I supplied) * 03 buyer-no-id uk II yes yes no both * 04 vat-multi en+pl II no no no none - * 05 vat-multi pl+uk both yes yes no both + * 05 vat-multi pl+uk both yes yes no both (+ notes) * * What each row is there to show, beyond its share of the grid: 01 the * everyday online invoice; 02 an invoice whose Code I URL was handed over @@ -173,9 +183,10 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { fx('e2e-vat-multi.xml'), '--locale', 'en+pl', '--env', 'demo', '--qr-cert-url', certificateQrUrl, '--totals', 'none', ]], - [`${PREFIX}-05-invoice-pl-uk-offline-both-codes-links`, () => [ + [`${PREFIX}-05-invoice-pl-uk-offline-both-codes-links-notes`, () => [ fx('e2e-vat-multi.xml'), ...LOGO(), '--locale', 'pl+uk', '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', + '--notes', notesFile, ]], // Every totals mode on one mixed-rate document (23% + 8% + exempt), so the // four can be compared page by page. Same flags throughout — only --totals @@ -216,6 +227,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { expect(covered('--qr-cert-url'), 'Code II is never printed').toBe(true); expect(covered('--qr-links'), 'the links are never printed').toBe(true); expect(covered('--template-file'), 'a custom template file is never used').toBe(true); + expect(covered('--notes'), 'caller-supplied notes are never printed').toBe(true); expect(covered('--logo'), 'the logo is never printed').toBe(true); // The absences matter as much: a Polish default locale, an invoice with no // logo, and one still waiting for its KSeF number. diff --git a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts index de454e7d..5c146bf5 100644 --- a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts +++ b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts @@ -203,6 +203,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => qrUrl: `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`, qrLinks: true, locale: 'pl+uk', + notes: [{ head: 'Delivery terms', body: 'Goods released at the seller warehouse.' }], ksefNumber: KSEF_NUMBER, }), ); diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index c2541d34..8c4ad5e6 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -29,7 +29,7 @@ const FIXTURE_BY_TEMPLATE: Record = { /** `when` values resolved from the render context, not from the XML. */ const CONTEXT_CONDITIONS = new Set([ - 'qr', 'offline', 'hasKsefNumber', 'totalsBuckets', 'totalsSummary', + 'qr', 'offline', 'hasKsefNumber', 'totalsBuckets', 'totalsSummary', 'notes', 'opts.logo', 'opts.ksefNumber', 'opts.accent', 'qrUrl', ]); diff --git a/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts index da15ad4e..011aeda8 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts @@ -136,3 +136,15 @@ describe('validateTemplate', () => { } }); }); + +describe('a divider can be conditional', () => { + it('accepts a `when`', () => { + expect(() => + validateTemplate({ schema: 'FA(3)', blocks: [{ type: 'divider', when: 'notes' }] }), + ).not.toThrow(); + }); + + it('still accepts a plain one', () => { + expect(() => validateTemplate({ schema: 'FA(3)', blocks: [{ type: 'divider' }] })).not.toThrow(); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts b/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts new file mode 100644 index 00000000..46667cc8 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { notesRenderer } from '../../../src/pdf/template/blocks/notes.js'; +import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { validateTemplate } from '../../../src/pdf/template/dsl.js'; +import { interpretTemplate, type RenderContext, type RenderNote } from '../../../src/pdf/template/interpret.js'; +import { renderInvoicePdfFromTemplate } from '../../../src/pdf/index.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; +import type { Block } from '../../../src/pdf/template/dsl.js'; + +/** + * `notes` is the one block whose content comes from the caller rather than from + * the document, so the tests split along that seam: what the renderer does with + * a list of notes, and what the render options do with the list on the way in. + */ + +const noRender = () => null; + +function ctxWith(notes?: RenderNote[]): RenderContext { + return { root: {}, strict: false, label: (k) => k, bindings: {}, flags: {}, ...(notes ? { notes } : {}) }; +} + +const rec = (n: unknown) => n as { stack: Array<{ text: string; style?: string }>; margin?: number[]; style?: string }; + +describe('notesRenderer', () => { + const two: RenderNote[] = [ + { head: 'Warunki dostawy', body: 'Towar wydany w magazynie sprzedawcy.' }, + { head: 'Uwaga', body: 'Prosimy o podanie numeru faktury w tytule przelewu.' }, + ]; + + it('prints each note as a heading over its body, in order', () => { + const out = rec(notesRenderer({ type: 'notes' }, ctxWith(two), noRender)); + expect(out.stack.map((n) => n.text)).toEqual([ + 'Warunki dostawy', + 'Towar wydany w magazynie sprzedawcy.', + 'Uwaga', + 'Prosimy o podanie numeru faktury w tytule przelewu.', + ]); + }); + + it('puts the heading on h2 by default', () => { + const out = rec(notesRenderer({ type: 'notes' }, ctxWith(two), noRender)); + expect(out.stack[0].style).toBe('h2'); + expect(out.stack[1].style).toBeUndefined(); // the body is body text + }); + + it('takes the heading style the template names', () => { + const out = rec(notesRenderer({ type: 'notes', headingStyle: 'h1' }, ctxWith(two), noRender)); + expect(out.stack[0].style).toBe('h1'); + expect(out.stack[2].style).toBe('h1'); + }); + + it('renders nothing at all when no notes were supplied', () => { + // Not an empty stack: a template carries this block unconditionally, and a + // render without notes has to look as though it were never there. + expect(notesRenderer({ type: 'notes' }, ctxWith(), noRender)).toBeNull(); + expect(notesRenderer({ type: 'notes' }, ctxWith([]), noRender)).toBeNull(); + }); + + it('skips an entry with nothing in it', () => { + const out = rec(notesRenderer({ type: 'notes' }, ctxWith([ + { head: '', body: '' }, + { head: ' ', body: '\n' }, + { head: 'Kept', body: 'Also kept' }, + ]), noRender)); + expect(out.stack.map((n) => n.text)).toEqual(['Kept', 'Also kept']); + }); + + it('prints a note that has only one half', () => { + const out = rec(notesRenderer({ type: 'notes' }, ctxWith([ + { head: 'Heading alone', body: '' }, + { head: '', body: 'Body alone' }, + ]), noRender)); + expect(out.stack.map((n) => n.text)).toEqual(['Heading alone', 'Body alone']); + expect(out.stack[0].style).toBe('h2'); + expect(out.stack[1].style).toBeUndefined(); + }); + + it('carries the block style when the template names one', () => { + const out = rec(notesRenderer({ type: 'notes', style: 'muted' }, ctxWith(two), noRender)); + expect(out.style).toBe('muted'); + }); + + it('treats the text as text, not as a binding', () => { + // A note is plain text: no dot-path resolution, no label lookup, nothing + // that could reach into the document or fail on a stray brace. + const out = rec(notesRenderer({ type: 'notes' }, ctxWith([ + { head: 'Fa.P_15', body: '{{ not a template }} — 100% & ' }, + ]), noRender)); + expect(out.stack.map((n) => n.text)).toEqual(['Fa.P_15', '{{ not a template }} — 100% & ']); + }); +}); + +describe('the notes option reaches the block', () => { + const xml = readFileSync(new URL('../../fixtures/pdf/fa3.xml', import.meta.url), 'utf8'); + + function notesStack(notes?: RenderNote[]) { + const template = getBuiltinTemplate('fa3-default')!; + const ctx: RenderContext = { + root: (parseXmlForPdf(xml) as Record).Faktura, + strict: false, + label: makeLabelResolver('pl', {}), + bindings: {}, + flags: {}, + ...(notes ? { notes } : {}), + }; + const doc = interpretTemplate(template, ctx, blockRegistry); + return (doc.content as Array>).find( + (n) => Array.isArray(n.stack) && JSON.stringify(n).includes('Warunki'), + ); + } + + it('prints the supplied notes through the built-in template', () => { + const node = notesStack([{ head: 'Warunki dostawy', body: 'DAP Warszawa' }]); + expect(node).toBeDefined(); + expect((node!.stack as Array<{ text: string }>).map((n) => n.text)).toEqual(['Warunki dostawy', 'DAP Warszawa']); + }); + + it('leaves the page untouched when none are supplied', () => { + expect(notesStack()).toBeUndefined(); + }); + + it('is closed by a rule that appears only with it', () => { + // Without the condition the page would show a line hanging over the + // verification codes on every invoice that carries no notes. + const blocks = getBuiltinTemplate('fa3-default')!.blocks; + const notes = blocks.findIndex((b) => b.type === 'notes'); + const after = blocks[notes + 1] as { type: string; when?: string }; + expect(after.type).toBe('divider'); + expect(after.when).toBe('notes'); + }); + + it('renders that rule with the notes and drops it without them', () => { + const template = getBuiltinTemplate('fa3-default')!; + const render = (notes?: RenderNote[]) => { + const ctx: RenderContext = { + root: (parseXmlForPdf(xml) as Record).Faktura, + strict: false, + label: makeLabelResolver('pl', {}), + bindings: {}, + flags: { notes: (notes ?? []).length > 0 }, + ...(notes ? { notes } : {}), + }; + const doc = interpretTemplate(template, ctx, blockRegistry); + return (doc.content as Array>).filter((n) => Array.isArray(n.canvas)).length; + }; + expect(render([{ head: 'Uwaga', body: 'Treść' }])).toBe(render() + 1); + }); + + it('sits between the payment details and the verification codes', () => { + // The place the block occupies is the template's decision, and this is what + // pins it: after payment, before the QR row. + const blocks = getBuiltinTemplate('fa3-default')!.blocks; + const at = (predicate: (b: Block) => boolean) => blocks.findIndex(predicate); + const payment = at((b) => b.type === 'payment'); + const notes = at((b) => b.type === 'notes'); + const qrRow = at((b) => b.type === 'columns' && (b as { when?: string }).when === 'qr'); + expect(payment).toBeGreaterThanOrEqual(0); + expect(notes).toBeGreaterThan(payment); + expect(qrRow).toBeGreaterThan(notes); + }); + + it.each(['fa2-default', 'fa3-default'])('%s carries the block', (name) => { + expect(getBuiltinTemplate(name)!.blocks.some((b) => b.type === 'notes')).toBe(true); + }); + + it.each(['fa2-default', 'fa3-default'])('%s heads its notes at section level', (name) => { + // A note is a section of its own, like Płatność — not a label inside one. + const block = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'notes') as { headingStyle?: string }; + expect(block.headingStyle).toBe('h1'); + }); + + it('drops an entry that is blank on both halves before it reaches the block', async () => { + const template = getBuiltinTemplate('fa3-default')!; + await expect( + renderInvoicePdfFromTemplate(xml, template, { notes: [{ head: ' ', body: '' }] }), + ).resolves.toBeInstanceOf(Uint8Array); + }); +}); + +describe('the notes block validates like any other', () => { + const wrap = (block: unknown) => ({ schema: 'FA(3)', blocks: [block] }); + + it('accepts a bare block', () => { + expect(() => validateTemplate(wrap({ type: 'notes' }))).not.toThrow(); + }); + + it('accepts the style options', () => { + expect(() => validateTemplate(wrap({ type: 'notes', headingStyle: 'h1', style: 'muted' }))).not.toThrow(); + }); + + it('rejects content in the template — notes come from the caller', () => { + expect(() => validateTemplate(wrap({ type: 'notes', head: 'x', body: 'y' }))).toThrow(); + }); +}); From dc642c3b6727ed4a10d5ca822dd4bed37e3540be Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 22:40:21 +0200 Subject: [PATCH 24/67] feat(pdf): add fa3-showcase, a built-in that exercises the template DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default templates are deliberately plain, which leaves the question of what a template can actually reach unanswered — and answered wrongly by assumption. `fa3-showcase` renders the same FA(3) invoice in a full palette, with letter-spaced headings, highlighted text, its own label wording, and full-width colour bars drawn as data-URI images, since the DSL has no drawing primitive of its own. It is as useful for its omissions. The line-item table's header fill and the rule colours belong to the renderer, not to a template, and Roboto is the only bundled font — so a design asking for any of those needs a code change, not a template. Registered like any other built-in rather than parked in an examples folder, which puts it under the lints the others answer to: every `when` and repeater path resolving against a fixture, every style it names being defined, both heading levels present. A render smoke test and a preview row cover what a lint cannot — that the shape still produces a page. The preview row also closes a dimension the grid never had: selecting a built-in by name. Every row until now took the auto-detected template or a file. Verified: unit 2673, e2e 150, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 10 +- .../pdf/template/builtin/fa3-showcase.json | 178 ++++++++++++++++++ .../src/pdf/template/builtin/index.ts | 2 + .../tests/e2e/35-invoice-pdf-cli.test.ts | 16 +- .../unit/pdf/builtin-template-lint.test.ts | 1 + .../tests/unit/pdf/render-builtins.test.ts | 6 + 6 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index ff805499..d6936b7b 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -17,7 +17,7 @@ Supported documents: | Document | Versions | Default built-in template | |----------|----------|---------------------------| -| Standard invoice | `FA(2)`, `FA(3)` | `fa2-default`, `fa3-default` | +| Standard invoice | `FA(2)`, `FA(3)` | `fa2-default`, `fa3-default` (plus `fa3-showcase`, a demo of the DSL) | | UPO receipt | `UPO(4.2)`, `UPO(4.3)` | `upo-4_2`, `upo-4_3` | `FA(1)` is not supported. @@ -251,6 +251,14 @@ A trimmed `FA(3)` template with a header, a seller/buyer panel, a line table, a The bundled `fa3-default` template is a good, complete starting point to copy and adapt. +There is also `fa3-showcase`, an FA(3) template built to exercise the DSL rather than to be shipped on a real invoice: a full palette, letter-spaced headings, highlighted text, its own label wording, and full-width colour bars drawn as data-URI images (a solid PNG stretched to the content width, since the DSL has no drawing primitive). Useful as a reference for what a template can reach — and, by its omissions, for what it cannot: the line-item table's header fill and rule colours belong to the renderer, and Roboto is the only bundled font. + +```bash +ksef invoice pdf invoice.xml --template fa3-showcase --qr --qr-links +``` + +Note that its `labels` overrides make it Polish in every locale — an override replaces the bundle in all of them. + --- ## Totals diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json new file mode 100644 index 00000000..1e27892e --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -0,0 +1,178 @@ +{ + "schema": "FA(3)", + "page": { "size": "A4", "margins": [44, 36, 44, 44] }, + "defaultStyle": { "fontSize": 8, "color": "#0B2545", "lineHeight": 1.1 }, + "pageFooter": { "style": "footerNote" }, + "labels": { + "invoice": "FAKTURA", + "seller": "SPRZEDAWCA", + "buyer": "NABYWCA", + "address": "adres", + "contact": "kontakt", + "payment": "PŁATNOŚĆ", + "bankAccounts": "rachunek", + "totalDue": "DO ZAPŁATY", + "verifyInKsef": "ZWERYFIKUJ W KSeF", + "openLink": "otwórz" + }, + "styles": { + "title": { "fontSize": 22, "bold": true, "characterSpacing": 5, "color": "#0B2545", "margin": [0, 0, 0, 2] }, + "h1": { "fontSize": 9, "bold": true, "color": "#EF476F", "characterSpacing": 3, "margin": [0, 8, 0, 3] }, + "h2": { "fontSize": 7, "bold": true, "color": "#8AA4BD", "characterSpacing": 2, "margin": [0, 5, 0, 2] }, + "partyName": { "fontSize": 11, "bold": true, "color": "#0B2545", "lineHeight": 1.05 }, + "partyMeta": { "fontSize": 7.5, "color": "#5B7B9A", "lineHeight": 1.3 }, + "lines": { "fontSize": 7.5, "color": "#0B2545" }, + "totals": { "fontSize": 9 }, + "band": { "background": "#3BC9F5", "color": "#0B2545", "bold": true, "fontSize": 10, "characterSpacing": 4 }, + "notes": { "fontSize": 8, "color": "#5B7B9A", "italics": true }, + "offline": { "color": "#EF476F", "bold": true, "characterSpacing": 3 }, + "qrLink": { "fontSize": 7, "color": "#EF476F", "decoration": "underline", "characterSpacing": 1 }, + "footerNote": { "fontSize": 6.5, "color": "#9FB3C8", "characterSpacing": 1 } + }, + "blocks": [ + { + "type": "header", + "logo": "opts.logo", + "logoWidth": 40, + "title": { "label": "invoice" }, + "number": "Fa.P_2", + "date": "Fa.P_1", + "ksefNumber": "opts.ksefNumber", + "offlineStyle": "offline", + "style": "title" + }, + { + "type": "image", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAAECAIAAAB3FBCSAAAAKklEQVR42u3VMQ0AAAgEsd/RgRT8W8IHNKmCWy7VA8BDkQDAAAAwAACuWyqcqC5LMyDiAAAAAElFTkSuQmCC", + "width": 507 + }, + { + "type": "parties", + "headingStyle": "h1", + "left": { + "label": "seller", + "style": "partyName", + "fields": [ + "Podmiot1.DaneIdentyfikacyjne.Nazwa", + "Podmiot1.DaneIdentyfikacyjne.NIP", + { + "label": "address", + "style": "partyMeta", + "fields": [ + "Podmiot1.Adres.AdresL1", + { "path": "Podmiot1.Adres.AdresL2", "optional": true }, + "Podmiot1.Adres.KodKraju" + ] + }, + { "label": "contact", "from": "Podmiot1.DaneKontaktowe", "style": "partyMeta", "fields": ["Email", "Telefon"] } + ] + }, + "right": { + "label": "buyer", + "style": "partyName", + "fields": [ + "Podmiot2.DaneIdentyfikacyjne.Nazwa", + { + "firstOf": [ + "Podmiot2.DaneIdentyfikacyjne.NIP", + "Podmiot2.DaneIdentyfikacyjne.NrVatUE", + "Podmiot2.DaneIdentyfikacyjne.NrID" + ] + }, + { + "label": "address", + "style": "partyMeta", + "fields": [ + "Podmiot2.Adres.AdresL1", + { "path": "Podmiot2.Adres.AdresL2", "optional": true }, + "Podmiot2.Adres.KodKraju" + ] + }, + { "label": "contact", "from": "Podmiot2.DaneKontaktowe", "style": "partyMeta", "fields": ["Email", "Telefon", "NrKlienta"] } + ] + } + }, + { "type": "spacer", "height": 2 }, + { + "type": "image", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAAICAIAAAAA1tDpAAAANUlEQVR42u3VMQ0AAAgEsfeF/wFB7OggNKmCWy7VA8BDkQDAAAAwAAAMAAADAMAAADAAAC5aO9mR0qHKZWwAAAAASUVORK5CYII=", + "width": 507 + }, + { "type": "spacer", "height": 4 }, + { + "type": "lines", + "from": "Fa.FaWiersz", + "style": "lines", + "columns": [ + { "label": "lp", "path": "NrWierszaFa", "width": 18 }, + { "label": "name", "path": "P_7", "width": "*", "optional": true }, + { "label": "unit", "path": "P_8A", "width": 26, "optional": true }, + { "label": "qty", "path": "P_8B", "format": "number", "width": 30, "optional": true }, + { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 58, "optional": true }, + { "label": "vatRate", "path": "P_12", "width": 40, "optional": true }, + { "label": "net", "path": "P_11", "format": "money", "width": 66, "optional": true } + ] + }, + { + "type": "image", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAADCAIAAABqESAqAAAAJUlEQVR42u3VQREAAAjDsKnFL4pAyHIXBf00twNAoUgAYAAAFHmOoUTEAzCYAwAAAABJRU5ErkJggg==", + "width": 507 + }, + { "type": "spacer", "height": 4 }, + { + "type": "totals", + "style": "totals", + "rows": [ + { "label": "net23", "path": "Fa.P_13_1", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat23", "path": "Fa.P_14_1", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "net8", "path": "Fa.P_13_2", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "vat8", "path": "Fa.P_14_2", "when": "totalsBuckets", "format": "money", "optional": true }, + { "label": "netExempt", "path": "Fa.P_13_7", "when": "totalsBuckets", "format": "money", "optional": true }, + { + "label": "totalNet", + "sum": ["Fa.P_13_1", "Fa.P_13_2", "Fa.P_13_3", "Fa.P_13_4", "Fa.P_13_5", "Fa.P_13_6_1", "Fa.P_13_6_2", "Fa.P_13_6_3", "Fa.P_13_7", "Fa.P_13_8", "Fa.P_13_9", "Fa.P_13_10", "Fa.P_13_11"], + "when": "totalsSummary", + "format": "money" + }, + { + "label": "totalVat", + "sum": ["Fa.P_14_1", "Fa.P_14_2", "Fa.P_14_3", "Fa.P_14_4", "Fa.P_14_5"], + "when": "totalsSummary", + "format": "money" + }, + { "label": "totalDue", "path": "Fa.P_15", "format": "money" } + ] + }, + { "type": "spacer", "height": 4 }, + { + "type": "payment", + "headingStyle": "h1", + "when": "Fa.Platnosc", + "rows": [ + { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, + { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true } + ], + "accounts": { + "from": "Fa.Platnosc.RachunekBankowy", + "heading": "bankAccounts", + "fields": [ + { "label": "bankAccount", "path": "NrRB" }, + { "label": "swift", "path": "SWIFT", "optional": true }, + { "label": "bankName", "path": "NazwaBanku", "optional": true } + ] + } + }, + { "type": "notes", "headingStyle": "h1", "style": "notes" }, + { "type": "spacer", "height": 2 }, + { + "type": "columns", + "when": "qr", + "columns": [ + { "type": "text", "label": "verifyInKsef", "style": "band" }, + { "type": "qr", "code": "invoice", "fit": 78, "linkStyle": "qrLink" }, + { "type": "qr", "code": "certificate", "fit": 78, "linkStyle": "qrLink" } + ] + } + ] +} diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/index.ts b/packages/ksef-client-ts/src/pdf/template/builtin/index.ts index 8806ce9a..4c725815 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/index.ts +++ b/packages/ksef-client-ts/src/pdf/template/builtin/index.ts @@ -5,6 +5,7 @@ import { validateTemplate, type InvoiceTemplate } from '../dsl.js'; import fa2Default from './fa2-default.json'; import fa3Default from './fa3-default.json'; +import fa3Showcase from './fa3-showcase.json'; import upo42 from './upo-4_2.json'; import upo43 from './upo-4_3.json'; @@ -13,6 +14,7 @@ import upo43 from './upo-4_3.json'; export const BUILTIN_TEMPLATES: Record = { 'fa2-default': validateTemplate(fa2Default), 'fa3-default': validateTemplate(fa3Default), + 'fa3-showcase': validateTemplate(fa3Showcase), 'upo-4_2': validateTemplate(upo42), 'upo-4_3': validateTemplate(upo43), }; diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index a2ea8dd5..6b52d662 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -199,9 +199,18 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // standard-rate bucket alone instead of summing all of them. It needs // --totals summary, since that is the group those rows belong to. [`${PREFIX}-10-totals-summary-single-bucket-template`, () => [...mixedVat(), '--totals', 'summary', '--template-file', oldTotalsTemplate]], + // Not part of the grid: `fa3-showcase` is a built-in whose point is to + // exercise the DSL — palette, letter spacing, highlighted text, colour bars + // drawn as data-URI images. Rendered with everything switched on, so a DSL + // change that breaks it is visible rather than discovered by a reader. + [`${PREFIX}-11-showcase-template`, () => [ + fx('e2e-vat-multi.xml'), '--template', 'fa3-showcase', ...LOGO(), + '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', + '--totals', 'both', '--notes', notesFile, + ]], // Receipts last: they are a different document and read as their own group. - [`${PREFIX}-11-upo-pl`, () => [fx('upo-4_3.xml')]], - [`${PREFIX}-12-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], + [`${PREFIX}-12-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-13-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], ]; /** @@ -227,6 +236,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { expect(covered('--qr-cert-url'), 'Code II is never printed').toBe(true); expect(covered('--qr-links'), 'the links are never printed').toBe(true); expect(covered('--template-file'), 'a custom template file is never used').toBe(true); + expect(covered('--template '), 'a built-in is never selected by name').toBe(true); expect(covered('--notes'), 'caller-supplied notes are never printed').toBe(true); expect(covered('--logo'), 'the logo is never printed').toBe(true); // The absences matter as much: a Polish default locale, an invoice with no @@ -248,7 +258,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { it('renders every variant of the set', () => { // Guards against a variant being silently dropped from the table above: // regen.sh and this spec are meant to cover the same ground. - expect(variants).toHaveLength(12); + expect(variants).toHaveLength(13); for (const [name] of variants) { expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); } diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index 8c4ad5e6..aa1182d3 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -23,6 +23,7 @@ import type { Block, PartyField, TotalsBlock } from '../../../src/pdf/template/d const FIXTURE_BY_TEMPLATE: Record = { 'fa2-default': 'pdf/fa2.xml', 'fa3-default': 'pdf/fa3.xml', + 'fa3-showcase': 'pdf/fa3.xml', 'upo-4_2': 'pdf/upo-4_2.xml', 'upo-4_3': 'pdf/upo-4_3.xml', }; diff --git a/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts index 4cce23b9..e2889006 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts @@ -29,6 +29,12 @@ describe('built-in templates render valid PDFs', () => { expect(isPdf(await renderUpoPdf(upo42))).toBe(true); }); + it('fa3-showcase renders FA(3)', async () => { + // Registered like any other built-in, so it has to keep rendering: the DSL + // lints check its shape, this checks that the shape still produces a page. + expect(isPdf(await renderInvoicePdf(fa3, 'fa3-showcase', { qr: true, qrLinks: true }))).toBe(true); + }); + it('rejects a non-UPO document in renderUpoPdf', async () => { await expect(renderUpoPdf(fa3)).rejects.toThrow(/UPO/); }); From 78a71c91eb2c1ca364ef69b9db5148f6137d65dc Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 22:59:28 +0200 Subject: [PATCH 25/67] fix(pdf): let a failed render reject instead of killing the process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pdfmake assembles the document asynchronously, so a failure raised in that phase — an image it cannot decode, a font it cannot load — landed long after createPdfBuffer's `try` had returned. `getBuffer`'s callback has no error channel, so nothing settled the promise: the rejection escaped to the process and Node terminated with ERR_UNHANDLED_REJECTION and a raw stack trace, where the caller should have seen a KSeFPdfError. Drain the document stream instead. Its `error` event is where pdfmake reports these failures, and it carries a plain string, so anything that is not an Error is wrapped. Verified against pdfmake 0.2.23 with a valid 1x1 GIF as the logo — a format pdfmake refuses. Before: exit 1, ERR_UNHANDLED_REJECTION. After: the promise rejects with "Invalid image: Unknown image format." Regression tests cover the stream's three outcomes and the end-to-end render. Full unit (2678) and E2E (150) suites pass. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/src/pdf/fonts.ts | 62 ++++++++++++++++-- .../tests/unit/pdf/fonts.test.ts | 65 ++++++++++++++++++- .../tests/unit/pdf/render-smoke.test.ts | 14 ++++ 3 files changed, 135 insertions(+), 6 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/fonts.ts b/packages/ksef-client-ts/src/pdf/fonts.ts index c00f5a6b..727a1be8 100644 --- a/packages/ksef-client-ts/src/pdf/fonts.ts +++ b/packages/ksef-client-ts/src/pdf/fonts.ts @@ -14,12 +14,26 @@ import { KSeFPdfError } from './errors.js'; const REQUIRED_RANGE = '^0.2.20'; const INSTALL_HINT = `npm i "pdfmake@${REQUIRED_RANGE}"`; -/** Minimal structural view of the pdfmake browser build we depend on. */ +/** + * Minimal structural view of the pdfmake browser build we depend on. + * + * `getStream` rather than `getBuffer`: pdfmake reports a failure raised inside + * document assembly (a malformed or unsupported image, say) on the stream's + * `error` event, whereas `getBuffer`'s callback has no error channel at all. + */ export interface PdfMakeLike { - createPdf(docDefinition: unknown): { getBuffer(cb: (buffer: Uint8Array) => void): void }; + createPdf(docDefinition: unknown): { getStream(): PdfDocStream }; vfs?: unknown; } +/** The subset of pdfmake's PDFKit document stream `createPdfBuffer` drives. */ +export interface PdfDocStream { + on(event: 'data', cb: (chunk: Uint8Array) => void): void; + on(event: 'end', cb: () => void): void; + on(event: 'error', cb: (err: unknown) => void): void; + end(): void; +} + function missingError(): KSeFPdfError { return new KSeFPdfError( `PDF rendering requires the optional peer dependency "pdfmake" (${REQUIRED_RANGE}), ` + @@ -98,13 +112,51 @@ export async function loadPdfMake(): Promise { return pdfMake; } -/** Render a pdfmake document definition to PDF bytes. */ +/** + * Render a pdfmake document definition to PDF bytes. + * + * Document assembly is asynchronous, so a failure inside it lands long after + * this function has returned and cannot be caught by a `try` around the call. + * Draining the document stream gives those failures somewhere to go: the + * `error` event settles the promise instead of leaving it pending and letting + * the rejection escape to the process. pdfmake emits a plain string there, so + * anything that is not an `Error` is wrapped. + */ export function createPdfBuffer(pdfMake: PdfMakeLike, docDefinition: unknown): Promise { return new Promise((resolve, reject) => { + const fail = (err: unknown): void => { + reject(err instanceof Error ? err : new KSeFPdfError(String(err))); + }; + + let stream: PdfDocStream; try { - pdfMake.createPdf(docDefinition).getBuffer((buffer) => resolve(Uint8Array.from(buffer))); + stream = pdfMake.createPdf(docDefinition).getStream(); } catch (err) { - reject(err instanceof Error ? err : new KSeFPdfError(String(err))); + fail(err); + return; + } + + const chunks: Uint8Array[] = []; + let size = 0; + stream.on('data', (chunk) => { + chunks.push(chunk); + size += chunk.length; + }); + stream.on('end', () => { + const out = new Uint8Array(size); + let at = 0; + for (const chunk of chunks) { + out.set(chunk, at); + at += chunk.length; + } + resolve(out); + }); + stream.on('error', fail); + + try { + stream.end(); + } catch (err) { + fail(err); } }); } diff --git a/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts b/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts index 483ccf6b..69574807 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { satisfiesRequiredRange, normalizeVfs } from '../../../src/pdf/fonts.js'; +import { satisfiesRequiredRange, normalizeVfs, createPdfBuffer } from '../../../src/pdf/fonts.js'; +import type { PdfMakeLike, PdfDocStream } from '../../../src/pdf/fonts.js'; +import { KSeFPdfError } from '../../../src/pdf/errors.js'; describe('satisfiesRequiredRange', () => { it('accepts the exact lower bound 0.2.20', () => { @@ -63,3 +65,64 @@ describe('normalizeVfs', () => { expect(normalizeVfs(fontMap)).toBe(fontMap); }); }); + +describe('createPdfBuffer', () => { + /** + * A stand-in for pdfmake's document stream. `emit` decides what the stream + * does once `end()` is called, which is where pdfmake reports a failure + * raised during asynchronous document assembly. + */ + function fakePdfMake(emit: (h: Record void>) => void): PdfMakeLike { + return { + createPdf(): { getStream(): PdfDocStream } { + const handlers: Record void> = {}; + const stream = { + on(event: string, cb: (arg?: never) => void) { + handlers[event] = cb; + }, + end() { + emit(handlers); + }, + }; + return { getStream: () => stream as unknown as PdfDocStream }; + }, + }; + } + + it('concatenates the streamed chunks in order', async () => { + const pdfMake = fakePdfMake((h) => { + (h.data as unknown as (c: Uint8Array) => void)(Uint8Array.from([1, 2])); + (h.data as unknown as (c: Uint8Array) => void)(Uint8Array.from([3])); + h.end?.(); + }); + await expect(createPdfBuffer(pdfMake, {})).resolves.toEqual(Uint8Array.from([1, 2, 3])); + }); + + // pdfmake raises image and font failures long after createPdf() returned, so + // a `try` around the call cannot see them. Left unhandled they take the + // process down instead of rejecting the caller's promise. + it('rejects when the stream reports an asynchronous failure', async () => { + const pdfMake = fakePdfMake((h) => { + (h.error as unknown as (e: unknown) => void)('Invalid image: Unknown image format.'); + }); + await expect(createPdfBuffer(pdfMake, {})).rejects.toBeInstanceOf(KSeFPdfError); + await expect(createPdfBuffer(pdfMake, {})).rejects.toThrow(/Unknown image format/); + }); + + it('preserves an Error the stream reports as-is', async () => { + const boom = new TypeError('bad font'); + const pdfMake = fakePdfMake((h) => { + (h.error as unknown as (e: unknown) => void)(boom); + }); + await expect(createPdfBuffer(pdfMake, {})).rejects.toBe(boom); + }); + + it('rejects when getStream itself throws', async () => { + const pdfMake = { + createPdf(): { getStream(): PdfDocStream } { + throw new Error('no document'); + }, + }; + await expect(createPdfBuffer(pdfMake, {})).rejects.toThrow('no document'); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/render-smoke.test.ts b/packages/ksef-client-ts/tests/unit/pdf/render-smoke.test.ts index 1a8c27aa..b73db8f1 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/render-smoke.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/render-smoke.test.ts @@ -67,3 +67,17 @@ describe('renderInvoicePdfFromTemplate — custom object', () => { await expect(renderInvoicePdfFromTemplate(fa3, bad)).rejects.toThrow(); }); }); + +describe('a render failure raised inside pdfmake', () => { + // A valid 1x1 GIF: well-formed, and a format pdfmake cannot draw. pdfmake + // reports it during asynchronous document assembly, so before the renderer + // drained the document stream this took the process down with an unhandled + // rejection instead of rejecting the caller. + const gif = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + + it('rejects the caller instead of terminating the process', async () => { + await expect(renderInvoicePdf(fa3, 'fa3-default', { logo: gif })).rejects.toThrow( + /Unknown image format/, + ); + }); +}); From 796200661a99479bdb4601e765cf8e8cb514ba8d Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 23:02:47 +0200 Subject: [PATCH 26/67] fix(pdf): stop accepting logo formats the renderer cannot draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--logo` advertised PNG, JPEG, GIF, WebP and SVG, but pdfmake's `image` node draws only PNG and JPEG. The other three were read, base64-encoded and handed to the renderer, which then failed with "Unknown image format" partway through — so `ksef invoice pdf --logo brand.svg`, spelled exactly as the flag's own help suggested, never produced a PDF. Accept only what renders, and say so in the flag description and in the `logo` option's docs. Verified against the built CLI with pdfmake 0.2.23: --logo logo.svg -> exit 1, "Unsupported logo format \".svg\". Supported: .png, .jpg, .jpeg" (no file written) --logo logo.png -> PDF written, 22861 bytes Before the change the same SVG reached the renderer and died there. Full unit (2678) and E2E (151) suites pass. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 2 +- packages/ksef-client-ts/src/cli/commands/invoice.ts | 11 +++++++---- packages/ksef-client-ts/src/pdf/index.ts | 2 +- .../tests/e2e/35-invoice-pdf-cli.test.ts | 13 +++++++++++++ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index d6936b7b..a6efb3d5 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -129,7 +129,7 @@ detectUpoVersion(xml); // 'UPO(4.2)' | 'UPO(4.3)' | null | `ksefNumber` | `string` | KSeF number printed on the visualization; when absent the document is marked **OFFLINE**. | | `env` | `'prod' \| 'test' \| 'demo'` | Environment used to derive the QR base URL. Default `'prod'`. | | `baseQrUrl` | `string` | Override the QR base URL (offline / non-standard). | -| `logo` | `string` | Logo image as a `data:` URI. | +| `logo` | `string` | Logo image as a `data:` URI. PNG or JPEG only. | | `theme` | `{ accent?: string }` | Accent colour. | | `bilingualSeparator` | `string` | Separator for the bilingual locales. Default `' / '`. | | `strict` | `boolean` | Throw on a missing binding instead of rendering an empty string. | diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 19329fdd..a9e2cde2 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -576,13 +576,16 @@ type PdfLocale = (typeof VALID_PDF_LOCALES)[number]; const VALID_PDF_TOTALS = ['none', 'buckets', 'summary', 'both'] as const; type PdfTotals = (typeof VALID_PDF_TOTALS)[number]; +/** + * The formats pdfmake's `image` node can actually draw. GIF, WebP and SVG were + * accepted here once, but pdfmake 0.2.x rejects every one of them with + * "Unknown image format" partway through rendering — so the PDF never + * materialized. Better to refuse the file at the flag than to fail at render. + */ const LOGO_MIME_BY_EXT: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.svg': 'image/svg+xml', }; /** @@ -646,7 +649,7 @@ const pdf = defineCommand({ qrLinks: { type: 'boolean', description: 'Print a clickable link under each QR code' }, ksefNumber: { type: 'string', description: 'KSeF number to print (absent → marked OFFLINE)' }, upo: { type: 'boolean', description: 'Treat the input as a UPO document (otherwise auto-detected)' }, - logo: { type: 'string', description: 'Path to a logo image (PNG/JPEG/GIF/WebP/SVG) to print in the header' }, + logo: { type: 'string', description: 'Path to a logo image (PNG/JPEG) to print in the header' }, totals: { type: 'string', description: 'Tax breakdown above the amount due: none | buckets (as recorded) | summary (computed) | both (default: buckets)' }, notes: { type: 'string', description: 'Path to a JSON file with extra sections: [{ "head": "…", "body": "…" }, …]' }, env: { type: 'string', description: 'Environment for the QR base URL (test/demo/prod)' }, diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index e1697ce3..f37992e4 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -77,7 +77,7 @@ export interface RenderOptions { env?: 'prod' | 'test' | 'demo'; /** Override the QR base URL (offline / non-standard). */ baseQrUrl?: string; - /** Logo as a `data:` URI. */ + /** Logo as a `data:` URI. PNG or JPEG — pdfmake draws no other format. */ logo?: string; /** Theming (accent colour only; the font is the bundled Roboto). */ theme?: { accent?: string }; diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 6b52d662..44986d92 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -277,4 +277,17 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { expect(res.status).not.toBe(0); expect(existsSync(out)).toBe(false); }); + + // pdfmake draws PNG and JPEG and nothing else, so a vector or animated logo + // has to be refused at the flag — accepting it only moves the failure into + // the middle of the render, where the message names no file the caller passed. + it('refuses a logo format the renderer cannot draw', () => { + const svg = join(inputsDir, 'logo.svg'); + writeFileSync(svg, ''); + const out = join(outDir, 'should-not-exist-3.pdf'); + const res = run(['invoice', 'pdf', fx('fa3.xml'), '--logo', svg, '--out', out]); + expect(res.status).not.toBe(0); + expect(`${res.stdout}${res.stderr}`).toMatch(/Unsupported logo format/); + expect(existsSync(out)).toBe(false); + }); }); From f17046db24b18681317d09ec2cc2ab80194709bc Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 23:06:18 +0200 Subject: [PATCH 27/67] feat(pdf): name the currency an invoice is settled in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every built-in invoice layout printed monetary values bare and bound Fa.KodWaluty nowhere, so an invoice settled in EUR or USD rendered identically to one settled in złoty. A reader has no way to tell them apart, and the default assumption on a Polish invoice is the wrong one. Add the currency as the closing totals row in fa2-default, fa3-default and fa3-showcase, with a label in all three bundles. KodWaluty is mandatory in both FA(2) and FA(3), so the row never resolves empty and needs no optional marker. Verified on the EUR fixture (e2e-buyer-no-id.xml): the totals now close with "Currency EUR" where they previously ended at the amount due. The existing totals-mode assertions were updated for the extra row, and new ones pin the currency across all four modes and all three built-ins. Full unit (2680) and E2E (151) suites pass. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/src/pdf/i18n/en.ts | 1 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 1 + packages/ksef-client-ts/src/pdf/i18n/uk.ts | 1 + .../src/pdf/template/builtin/fa2-default.json | 3 +- .../src/pdf/template/builtin/fa3-default.json | 3 +- .../pdf/template/builtin/fa3-showcase.json | 4 ++- .../tests/unit/pdf/totals-sum.test.ts | 32 +++++++++++++++++-- 7 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 9ec51309..03a96599 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -43,6 +43,7 @@ export const en: LabelBundle = { totalNet: 'Total net', totalVat: 'Total VAT', totalDue: 'Amount due', + currency: 'Currency', payment: 'Payment', paid: 'Paid', paymentDate: 'Payment due', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 64a3d5dd..6158ad35 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -45,6 +45,7 @@ export const pl: LabelBundle = { totalNet: 'Razem netto', totalVat: 'Razem VAT', totalDue: 'Do zapłaty', + currency: 'Waluta', // payment payment: 'Płatność', paid: 'Zapłacono', diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index 0c9d97c4..0f028df5 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -51,6 +51,7 @@ export const uk: LabelBundle = { totalNet: 'Разом нетто', totalVat: 'Разом ПДВ', totalDue: 'До сплати', + currency: 'Валюта', payment: 'Оплата', paid: 'Сплачено', paymentDate: 'Термін оплати', diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 67b22d84..192baa27 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -168,7 +168,8 @@ "when": "totalsSummary", "format": "money" }, - { "label": "totalDue", "path": "Fa.P_15", "format": "money" } + { "label": "totalDue", "path": "Fa.P_15", "format": "money" }, + { "label": "currency", "path": "Fa.KodWaluty" } ] }, { "type": "spacer", "height": 21 }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 1e2fd94c..87b2ceda 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -168,7 +168,8 @@ "when": "totalsSummary", "format": "money" }, - { "label": "totalDue", "path": "Fa.P_15", "format": "money" } + { "label": "totalDue", "path": "Fa.P_15", "format": "money" }, + { "label": "currency", "path": "Fa.KodWaluty" } ] }, { "type": "divider" }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 1e27892e..35321fa8 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -12,6 +12,7 @@ "payment": "PŁATNOŚĆ", "bankAccounts": "rachunek", "totalDue": "DO ZAPŁATY", + "currency": "waluta", "verifyInKsef": "ZWERYFIKUJ W KSeF", "openLink": "otwórz" }, @@ -140,7 +141,8 @@ "when": "totalsSummary", "format": "money" }, - { "label": "totalDue", "path": "Fa.P_15", "format": "money" } + { "label": "totalDue", "path": "Fa.P_15", "format": "money" }, + { "label": "currency", "path": "Fa.KodWaluty" } ] }, { "type": "spacer", "height": 4 }, diff --git a/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts b/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts index 362e9b80..a1b9d1a2 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts @@ -9,6 +9,9 @@ import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; const fa3 = readFileSync(new URL('../../fixtures/pdf/fa3.xml', import.meta.url), 'utf8'); +const fa2 = readFileSync(new URL('../../fixtures/pdf/fa2.xml', import.meta.url), 'utf8'); +/** Settled in EUR — the case where an unqualified amount misleads. */ +const eurInvoice = readFileSync(new URL('../../fixtures/pdf/e2e-buyer-no-id.xml', import.meta.url), 'utf8'); /** 23% + 8% + exempt, so every totals mode has something to show. */ const mixedRate = fa3 @@ -142,7 +145,10 @@ describe('built-in totals aggregate every VAT bucket', () => { describe('the totals mode selects what a reader gets', () => { it('none: the amount due and nothing else', () => { - expect(totalsRows(mixedRate, 'fa3-default', 'none')).toEqual([['Amount due', '881,00']]); + expect(totalsRows(mixedRate, 'fa3-default', 'none')).toEqual([ + ['Amount due', '881,00'], + ['Currency', 'PLN'], + ]); }); it('buckets: one row per bucket the invoice carries, nothing computed', () => { @@ -153,6 +159,7 @@ describe('the totals mode selects what a reader gets', () => { ['VAT 8%', '16,00'], ['Net exempt', '50,00'], ['Amount due', '881,00'], + ['Currency', 'PLN'], ]); }); @@ -161,6 +168,7 @@ describe('the totals mode selects what a reader gets', () => { ['Total net', '750,00'], ['Total VAT', '131,00'], ['Amount due', '881,00'], + ['Currency', 'PLN'], ]); }); @@ -168,12 +176,15 @@ describe('the totals mode selects what a reader gets', () => { const rows = totalsRows(mixedRate, 'fa3-default', 'both'); expect(rows.map(([l]) => l)).toEqual([ 'Net 23%', 'VAT 23%', 'Net 8%', 'VAT 8%', 'Net exempt', 'Total net', 'Total VAT', 'Amount due', + 'Currency', ]); }); it('never prints a bucket the invoice does not carry', () => { const rows = totalsRows(fa3, 'fa3-default', 'both'); - expect(rows.map(([l]) => l)).toEqual(['Net 23%', 'VAT 23%', 'Total net', 'Total VAT', 'Amount due']); + expect(rows.map(([l]) => l)).toEqual([ + 'Net 23%', 'VAT 23%', 'Total net', 'Total VAT', 'Amount due', 'Currency', + ]); }); it('shows the amount due in every mode', () => { @@ -181,6 +192,23 @@ describe('the totals mode selects what a reader gets', () => { expect(valueOf(totalsRows(mixedRate, 'fa3-default', mode), 'Amount due'), mode).toBe('881,00'); } }); + + // Money is printed unqualified, so without the currency an invoice settled in + // EUR reads as one settled in PLN. KodWaluty is mandatory in both schemas, so + // every invoice can say which it is, in every mode. + it('names the currency in every mode and every invoice built-in', () => { + for (const template of ['fa2-default', 'fa3-default', 'fa3-showcase'] as const) { + const xml = template === 'fa2-default' ? fa2 : fa3; + for (const mode of ['none', 'buckets', 'summary', 'both'] as const) { + const rows = totalsRows(xml, template, mode); + expect(valueOf(rows, 'Currency'), `${template}/${mode}`).toBe('PLN'); + } + } + }); + + it('prints the invoice currency, not an assumed one', () => { + expect(valueOf(totalsRows(eurInvoice, 'fa3-default'), 'Currency')).toBe('EUR'); + }); }); describe('totals row validation', () => { From 0b345ef25e68552fe28e05b8f80da6e6035a4079 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 23:09:30 +0200 Subject: [PATCH 28/67] fix(pdf): print amounts as written, not as a double can hold them The money and number formatters parsed their input with `Number` before formatting it. TKwotowy allows 18 digits, well past what a double holds exactly, so a schema-valid amount was rewritten on the way to the page: 9999999999999999.99 printed as 10 000 000 000 000 000,00. The same round trip undid the decimal-safe totals summation, which had gone to some trouble to keep those digits. Format the decimal string directly instead, reusing the shape sumDecimal already parses. A third decimal now rounds half away from zero rather than inheriting whatever binary rounding produced, and a value that rounds to nothing no longer prints a minus sign. Verified with the values from the review: before formatMoney('9999999999999999.99') -> 10 000 000 000 000 000,00 after formatMoney('9999999999999999.99') -> 9 999 999 999 999 999,99 before formatMoney('0.145') -> 0,14 after -> 0,15 Every existing formatter assertion is unchanged. Full unit (2688) and E2E (151) suites pass. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/src/pdf/format.ts | 58 +++++++++++++++---- .../tests/unit/pdf/format.test.ts | 44 ++++++++++++++ 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/format.ts b/packages/ksef-client-ts/src/pdf/format.ts index 38f35eb3..87d2a297 100644 --- a/packages/ksef-client-ts/src/pdf/format.ts +++ b/packages/ksef-client-ts/src/pdf/format.ts @@ -11,25 +11,59 @@ function groupThousands(intPart: string): string { return intPart.replace(/\B(?=(\d{3})+(?!\d))/g, NBSP); } +/** `"007"` → `"7"`, `"000"` → `"0"`. */ +function trimLeadingZeros(digits: string): string { + return digits.replace(/^0+(?=\d)/, ''); +} + +/** + * The shape KSeF's decimal types take, same as {@link sumDecimal} reads. + * + * The number formatters work on this parse rather than on `Number`, because + * TKwotowy allows 18 digits — more than a double holds — so a round trip + * through binary floating point silently rewrites the value before it is ever + * printed: `9999999999999999.99` came out as `10 000 000 000 000 000,00`. That + * also threw away what the decimal-safe totals summation had just preserved. + */ +const DECIMAL = /^([+-]?)(\d+)(?:\.(\d+))?$/; + +function parseDecimal(raw: string): { negative: boolean; int: string; frac: string } | null { + const m = DECIMAL.exec(raw.trim()); + if (!m) return null; + return { negative: m[1] === '-', int: m[2] ?? '0', frac: m[3] ?? '' }; +} + +/** + * The digits of `int.frac` restated with exactly `scale` fraction digits, + * rounding half away from zero. A value already within scale is only padded, so + * nothing that fits the schema is ever touched. + */ +function rescale(int: string, frac: string, scale: number): string { + if (frac.length <= scale) return int + frac.padEnd(scale, '0'); + const kept = int + frac.slice(0, scale); + return frac.charCodeAt(scale) - 48 >= 5 ? (BigInt(kept) + 1n).toString() : kept; +} + /** `"1234.5"` → `"1 234,50"` (Polish monetary style, 2 decimals). */ export function formatMoney(raw: string): string { - const n = Number(raw); - if (raw.trim() === '' || Number.isNaN(n)) return raw; - const fixed = Math.abs(n).toFixed(2); - const dot = fixed.indexOf('.'); - const intPart = fixed.slice(0, dot); - const frac = fixed.slice(dot + 1); - const sign = n < 0 ? '-' : ''; + const parsed = parseDecimal(raw); + if (parsed === null) return raw; + const digits = rescale(parsed.int, parsed.frac, 2).padStart(3, '0'); + const intPart = trimLeadingZeros(digits.slice(0, digits.length - 2)); + const frac = digits.slice(digits.length - 2); + // Nobody writes -0,00 on an invoice; a value that rounds to nothing is zero. + const sign = parsed.negative && /[1-9]/.test(digits) ? '-' : ''; return `${sign}${groupThousands(intPart)},${frac}`; } /** `"1234.5"` → `"1 234,5"` (grouped, no forced decimals). */ export function formatNumber(raw: string): string { - const n = Number(raw); - if (raw.trim() === '' || Number.isNaN(n)) return raw; - const [intPart, frac] = Math.abs(n).toString().split('.'); - const sign = n < 0 ? '-' : ''; - const grouped = groupThousands(intPart ?? '0'); + const parsed = parseDecimal(raw); + if (parsed === null) return raw; + const frac = parsed.frac.replace(/0+$/, ''); + const intPart = trimLeadingZeros(parsed.int); + const sign = parsed.negative && /[1-9]/.test(intPart + frac) ? '-' : ''; + const grouped = groupThousands(intPart); return frac ? `${sign}${grouped},${frac}` : `${sign}${grouped}`; } diff --git a/packages/ksef-client-ts/tests/unit/pdf/format.test.ts b/packages/ksef-client-ts/tests/unit/pdf/format.test.ts index c83b3824..14b7046c 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/format.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/format.test.ts @@ -42,6 +42,35 @@ describe('formatMoney', () => { it('returns a whitespace-only string unchanged', () => { expect(formatMoney(' ')).toBe(' '); }); + + // TKwotowy is 18 digits with 2 after the point — wider than a double holds + // exactly, so anything routed through Number is rewritten before it prints. + it('prints an 18-digit amount exactly', () => { + expect(formatMoney('9999999999999999.99')).toBe( + `9${NBSP}999${NBSP}999${NBSP}999${NBSP}999${NBSP}999,99`, + ); + }); + + it('keeps the last złoty of a large amount', () => { + expect(formatMoney('123456789012345.67')).toBe( + `123${NBSP}456${NBSP}789${NBSP}012${NBSP}345,67`, + ); + }); + + it('rounds a third decimal half away from zero', () => { + expect(formatMoney('0.145')).toBe('0,15'); + expect(formatMoney('0.144')).toBe('0,14'); + expect(formatMoney('-0.145')).toBe('-0,15'); + }); + + it('carries the rounding into the integer part', () => { + expect(formatMoney('9.999')).toBe('10,00'); + }); + + it('does not print a negative zero', () => { + expect(formatMoney('-0.00')).toBe('0,00'); + expect(formatMoney('-0.001')).toBe('0,00'); + }); }); describe('formatNumber', () => { @@ -64,6 +93,21 @@ describe('formatNumber', () => { it('returns an empty string unchanged', () => { expect(formatNumber('')).toBe(''); }); + + it('prints a quantity too wide for a double exactly', () => { + expect(formatNumber('12345678901234567.5')).toBe( + `12${NBSP}345${NBSP}678${NBSP}901${NBSP}234${NBSP}567,5`, + ); + }); + + it('keeps every decimal the document carries', () => { + expect(formatNumber('1.23456789')).toBe('1,23456789'); + }); + + it('drops trailing zeros rather than forcing a scale', () => { + expect(formatNumber('1234.500')).toBe(`1${NBSP}234,5`); + expect(formatNumber('1234.000')).toBe(`1${NBSP}234`); + }); }); describe('formatDate', () => { From 875c2f652f641549ea3f4f636571c69c4baa78fe Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 23:13:34 +0200 Subject: [PATCH 29/67] fix(pdf): make the accent colour actually paint something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `theme.accent` was documented as the visualization's accent colour, but the value only ever became a text binding — and pdfmake reads a colour from a style, never from text. No renderer consumed it and every built-in colour stayed hard-coded, so a caller who set an accent got byte-for-byte the same layout as one who set none. Merge the accent into the template's styles instead, repainting the document title and both heading levels. A template that names no styles of its own still gets them, because those are the names the blocks fall back to. Without an accent the template is passed through untouched. Verified by capturing the document definition on its way into pdfmake: with an accent the title/h1/h2 colours carry it and every other property of those styles survives; the remaining styles and a no-accent render are unchanged. Three of the five new assertions fail against the previous code. Full unit (2693) and E2E (151) suites pass. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 2 +- packages/ksef-client-ts/src/pdf/index.ts | 32 ++++++- .../tests/unit/pdf/theme-accent.test.ts | 93 +++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/theme-accent.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index a6efb3d5..fc8f162b 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -130,7 +130,7 @@ detectUpoVersion(xml); // 'UPO(4.2)' | 'UPO(4.3)' | null | `env` | `'prod' \| 'test' \| 'demo'` | Environment used to derive the QR base URL. Default `'prod'`. | | `baseQrUrl` | `string` | Override the QR base URL (offline / non-standard). | | `logo` | `string` | Logo image as a `data:` URI. PNG or JPEG only. | -| `theme` | `{ accent?: string }` | Accent colour. | +| `theme` | `{ accent?: string }` | Accent colour for the title and headings. | | `bilingualSeparator` | `string` | Separator for the bilingual locales. Default `' / '`. | | `strict` | `boolean` | Throw on a missing binding instead of rendering an empty string. | diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index f37992e4..f125c8c1 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -79,7 +79,11 @@ export interface RenderOptions { baseQrUrl?: string; /** Logo as a `data:` URI. PNG or JPEG — pdfmake draws no other format. */ logo?: string; - /** Theming (accent colour only; the font is the bundled Roboto). */ + /** + * Theming (accent colour only; the font is the bundled Roboto). The accent + * repaints the document title and both heading levels; anything finer is a + * custom template's `styles`. + */ theme?: { accent?: string }; /** Separator for the bilingual locales (`pl+en`, `pl+uk`, …). Default `' / '`. */ bilingualSeparator?: string; @@ -178,6 +182,30 @@ function assertVersionMatch(xml: string, schema: TemplateSchemaId): void { ); } +/** + * The style names an accent colour repaints: the document title and both + * heading levels. Blocks fall back to exactly these names when a template names + * no style of its own, so an accent reaches a template that defines none. + */ +const ACCENTED_STYLES = ['title', 'h1', 'h2'] as const; + +/** + * Repaint the template's title and headings in the caller's accent colour. + * + * The colour has to reach the document as a *style*, not as a binding: bindings + * resolve to text, and a style is the only thing pdfmake reads a colour from. + * Returns the template untouched when no accent is set, so a render without one + * is byte-for-byte what it was. + */ +function applyAccent(template: InvoiceTemplate, accent: string | undefined): InvoiceTemplate { + if (accent === undefined || accent.trim() === '') return template; + const styles = { ...(template.styles ?? {}) }; + for (const name of ACCENTED_STYLES) { + styles[name] = { ...(styles[name] ?? {}), color: accent }; + } + return { ...template, styles }; +} + async function renderWithTemplate( rawInput: RawXml, template: InvoiceTemplate, @@ -208,7 +236,7 @@ async function renderWithTemplate( invoice: qrUrl, certificate: opts.certificateQrUrl ?? '', }); - const doc = interpretTemplate(template, ctx, blockRegistry); + const doc = interpretTemplate(applyAccent(template, opts.theme?.accent), ctx, blockRegistry); const pdfMake = await loadPdfMake(); return createPdfBuffer(pdfMake, doc); } diff --git a/packages/ksef-client-ts/tests/unit/pdf/theme-accent.test.ts b/packages/ksef-client-ts/tests/unit/pdf/theme-accent.test.ts new file mode 100644 index 00000000..6a1df167 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/theme-accent.test.ts @@ -0,0 +1,93 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * `theme.accent` has to reach the document as a *style*: bindings resolve to + * text, and pdfmake reads a colour from nowhere else. Asserting on rendered + * bytes cannot show that — pdfmake compresses its content streams and stamps a + * fresh creation date and file id into every render, so two runs of the same + * document never match anyway. So capture the document definition on its way + * into pdfmake and read the styles off it. + */ +const captured: Array> = []; + +vi.mock('pdfmake/build/pdfmake.js', () => ({ + default: { + createPdf(docDefinition: Record) { + captured.push(docDefinition); + return { + getStream() { + const handlers: Record void> = {}; + return { + on(event: string, cb: (arg?: never) => void) { + handlers[event] = cb; + }, + end() { + (handlers.data as unknown as (c: Uint8Array) => void)?.(Uint8Array.from([37, 80])); + handlers.end?.(); + }, + }; + }, + }; + }, + }, +})); + +vi.mock('pdfmake/build/vfs_fonts.js', () => ({ default: { 'Roboto-Regular.ttf': '' } })); + +const { renderInvoicePdf } = await import('../../../src/pdf/index.js'); + +const fa3 = readFileSync(new URL('../../fixtures/pdf/fa3.xml', import.meta.url), 'utf8'); + +/** The styles of the last document handed to pdfmake. */ +function lastStyles(): Record { + return captured[captured.length - 1]?.styles as Record; +} + +describe('theme.accent', () => { + beforeEach(() => { + captured.length = 0; + }); + + it('repaints the title and both heading levels', async () => { + await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: '#B00043' } }); + const styles = lastStyles(); + expect(styles.title?.color).toBe('#B00043'); + expect(styles.h1?.color).toBe('#B00043'); + expect(styles.h2?.color).toBe('#B00043'); + }); + + it('keeps every other property of the styles it repaints', async () => { + await renderInvoicePdf(fa3, 'fa3-default'); + const before = lastStyles(); + await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: '#B00043' } }); + const after = lastStyles(); + expect(after.h1).toEqual({ ...before.h1, color: '#B00043' }); + }); + + it('leaves styles it does not own alone', async () => { + await renderInvoicePdf(fa3, 'fa3-default'); + const before = lastStyles(); + await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: '#B00043' } }); + expect(lastStyles().muted).toEqual(before.muted); + }); + + it('renders identically to an unthemed document when no accent is given', async () => { + await renderInvoicePdf(fa3, 'fa3-default'); + const plain = lastStyles(); + await renderInvoicePdf(fa3, 'fa3-default', { theme: {} }); + expect(lastStyles()).toEqual(plain); + await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: ' ' } }); + expect(lastStyles()).toEqual(plain); + }); + + it('reaches a template that names no styles of its own', async () => { + const template = { + schema: 'FA(3)' as const, + blocks: [{ type: 'header' as const, title: { label: 'invoice' }, number: 'Fa.P_2' }], + }; + const { renderInvoicePdfFromTemplate } = await import('../../../src/pdf/index.js'); + await renderInvoicePdfFromTemplate(fa3, template, { theme: { accent: '#123456' } }); + expect(lastStyles().title?.color).toBe('#123456'); + }); +}); From c0a36fb1110cc72bc1fa53a0d98c10d804ffded0 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 23:15:58 +0200 Subject: [PATCH 30/67] fix(pdf): honour the optional marker in an annotations block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every field-bearing renderer reads a binding the template marked `optional` leniently, even under strict — that marker is the template saying the schema allows the field to be absent. The annotations block resolved all of its fields against the strict context regardless, so a custom template that marked an annotation optional still failed to render the moment a document left it out. That is the one case the marker exists to cover, and no built-in uses the block, so nothing caught it. Verified with a custom annotations block pointing at a path fa3.xml does not carry: marked optional it now renders strict and previously threw; unmarked it still throws "Missing binding", and neither form throws without strict. Full unit (2696) and E2E (151) suites pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pdf/template/blocks/annotations.ts | 11 +++-- .../tests/unit/pdf/strict-mode.test.ts | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts index c90185aa..10f623fc 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts @@ -8,11 +8,16 @@ import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.j * scalar binding). */ export const annotationsRenderer: BlockRenderer = (block, ctx) => { + // Bindings the schema declares optional are read leniently even under strict, + // as the lines, payment, table and totals renderers do. Without this a field + // the template marked optional still throws when the document omits it, which + // is the one thing the marker exists to prevent. + const lenientCtx = { ...ctx, strict: false }; + const stack: PdfNode[] = [{ text: ctx.label('annotations'), style: block.headingStyle ?? 'h2' }]; for (const field of block.fields) { - stack.push({ - text: `${ctx.label(field.label)}: ${applyFormat(resolveBinding(field.path, ctx), field.format)}`, - }); + const value = resolveBinding(field.path, field.optional ? lenientCtx : ctx); + stack.push({ text: `${ctx.label(field.label)}: ${applyFormat(value, field.format)}` }); } return { diff --git a/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts index 8a867c13..55fddae4 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts @@ -106,6 +106,46 @@ describe('strict mode still catches a typo in a required binding', () => { }); }); +/** + * `annotations` is a custom-template block — no built-in uses one — which is + * how it came to be the only field-bearing renderer that read every path + * strictly. Its fields take the same `optional` marker as every other block's, + * so they have to mean the same thing. + */ +describe('an annotations block honours the optional marker', () => { + const withAnnotations = (fields: FieldDef[]): InvoiceTemplate => ({ + schema: 'FA(3)', + blocks: [{ type: 'annotations', fields }], + }); + + it('renders strict when a field the document omits is marked optional', async () => { + const template = withAnnotations([ + { label: 'paid', path: 'Fa.Adnotacje.NieIstnieje', optional: true }, + ]); + await expect( + renderInvoicePdfFromTemplate(fx('fa3.xml'), template, { strict: true }), + ).resolves.toBeInstanceOf(Uint8Array); + }); + + it('still throws strict on an unmarked field the document omits', async () => { + const template = withAnnotations([{ label: 'paid', path: 'Fa.Adnotacje.NieIstnieje' }]); + await expect( + renderInvoicePdfFromTemplate(fx('fa3.xml'), template, { strict: true }), + ).rejects.toThrow('Missing binding: "Fa.Adnotacje.NieIstnieje"'); + }); + + it('renders either way without strict', async () => { + for (const optional of [true, undefined]) { + const template = withAnnotations([ + { label: 'paid', path: 'Fa.Adnotacje.NieIstnieje', ...(optional ? { optional } : {}) }, + ]); + await expect( + renderInvoicePdfFromTemplate(fx('fa3.xml'), template), + ).resolves.toBeInstanceOf(Uint8Array); + } + }); +}); + describe('the built-in templates mark the right bindings', () => { it.each(['fa2-default', 'fa3-default'])('%s polices the amount due', (name) => { const totals = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'totals') as TotalsBlock; From 5647c584d40a17e47d21b0fd86829d2d541ea70d Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 23:34:05 +0200 Subject: [PATCH 31/67] feat(pdf): set the accent colour from the command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accent was reachable only in code, so a CLI user had no way to put their own colour on a visualization short of writing a custom template. The flag takes a hex colour and nothing else. pdfmake silently ignores a value it cannot parse — the document then renders exactly as if no accent had been given — so a misspelled colour name has to be caught at the flag or it becomes a PDF that is quietly unthemed. Named CSS colours still work through the library option, where the caller sees what they passed. Verified against the built CLI: #B00043 and #b04 both render, "crimsonn" exits 1 with "Invalid --accent" and writes no file. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 7 +++++++ .../src/cli/commands/invoice.ts | 16 ++++++++++++++ .../tests/e2e/35-invoice-pdf-cli.test.ts | 21 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index fc8f162b..c5268a4b 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -387,6 +387,9 @@ ksef invoice pdf upo.xml --upo # UPO receipt with a custom layout ksef invoice pdf upo.xml --template-file ./templates/my-upo.json + +# Your own logo and accent colour +ksef invoice pdf invoice.xml --logo ./brand/logo.png --accent '#B00043' ``` | Flag | Description | @@ -401,10 +404,14 @@ ksef invoice pdf upo.xml --template-file ./templates/my-upo.json | `--ksef-number ` | KSeF number to print (absent → marked OFFLINE) | | `--totals ` | Tax breakdown above the amount due (default `buckets`) | | `--notes ` | JSON file of extra sections: `[{ "head": …, "body": … }]` | +| `--logo ` | Logo image printed in the header — PNG or JPEG | +| `--accent ` | Accent colour for the title and headings, e.g. `#B00043` | | `--upo` | Treat the input as a UPO document (otherwise auto-detected); ignored when a template is named explicitly | | `--env ` | Environment for the QR base URL | | `--out ` | Output PDF path (default: alongside the source) | +`--accent` takes a hex colour only. pdfmake silently ignores a value it does not recognize — the document then renders exactly as if no accent had been given — so a misspelled colour name is rejected here rather than turning into a PDF that is quietly unthemed. Named CSS colours still work through the `theme` option in code. + `--template` and `--template-file` are mutually exclusive — pass at most one. An explicit template takes precedence over document auto-detection, so a custom UPO layout is selected the same way an invoice one is; the renderer still rejects a template whose `schema` does not match the document. If `pdfmake` is not installed, the command exits with the same friendly install hint shown above. --- diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index a9e2cde2..1376618f 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -576,6 +576,15 @@ type PdfLocale = (typeof VALID_PDF_LOCALES)[number]; const VALID_PDF_TOTALS = ['none', 'buckets', 'summary', 'both'] as const; type PdfTotals = (typeof VALID_PDF_TOTALS)[number]; +/** + * A CSS hex colour, the one unambiguous way to name a brand colour. pdfmake + * silently ignores a value it does not recognize — the document comes out + * exactly as if no accent had been given — so a typo has to be caught at the + * flag or it becomes a PDF that is quietly wrong. Named colours still work + * through the library option, where the caller can see what they passed. + */ +const HEX_COLOUR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + /** * The formats pdfmake's `image` node can actually draw. GIF, WebP and SVG were * accepted here once, but pdfmake 0.2.x rejects every one of them with @@ -650,6 +659,7 @@ const pdf = defineCommand({ ksefNumber: { type: 'string', description: 'KSeF number to print (absent → marked OFFLINE)' }, upo: { type: 'boolean', description: 'Treat the input as a UPO document (otherwise auto-detected)' }, logo: { type: 'string', description: 'Path to a logo image (PNG/JPEG) to print in the header' }, + accent: { type: 'string', description: 'Accent colour for the title and headings, as hex (e.g. #B00043)' }, totals: { type: 'string', description: 'Tax breakdown above the amount due: none | buckets (as recorded) | summary (computed) | both (default: buckets)' }, notes: { type: 'string', description: 'Path to a JSON file with extra sections: [{ "head": "…", "body": "…" }, …]' }, env: { type: 'string', description: 'Environment for the QR base URL (test/demo/prod)' }, @@ -675,6 +685,11 @@ const pdf = defineCommand({ throw new Error(`Invalid --totals "${totals}". Valid: ${VALID_PDF_TOTALS.join(', ')}`); } + const accent = args.accent as string | undefined; + if (accent !== undefined && !HEX_COLOUR.test(accent)) { + throw new Error(`Invalid --accent "${accent}". Expected a hex colour such as #B00043 or #b04.`); + } + const env = args.env as 'prod' | 'test' | 'demo' | undefined; const logo = args.logo ? readImageAsDataUri(args.logo as string) : undefined; const notes = args.notes ? readNotesFile(args.notes as string) : undefined; @@ -688,6 +703,7 @@ const pdf = defineCommand({ ...(args.ksefNumber ? { ksefNumber: args.ksefNumber as string } : {}), ...(env ? { env } : {}), ...(logo ? { logo } : {}), + ...(accent ? { theme: { accent } } : {}), ...(notes ? { notes } : {}), }; diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 44986d92..0b6051b5 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -278,6 +278,27 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { expect(existsSync(out)).toBe(false); }); + // pdfmake silently ignores a colour it cannot parse, so an unrecognized accent + // renders a document identical to an unthemed one. A misspelled colour name + // has to fail at the flag, or it becomes a PDF that is quietly wrong. + it('refuses an accent colour that is not hex', () => { + const out = join(outDir, 'should-not-exist-4.pdf'); + const res = run(['invoice', 'pdf', fx('fa3.xml'), '--accent', 'crimsonn', '--out', out]); + expect(res.status).not.toBe(0); + expect(`${res.stdout}${res.stderr}`).toMatch(/Invalid --accent/); + expect(existsSync(out)).toBe(false); + }); + + it('accepts both hex forms of an accent colour', () => { + for (const accent of ['#B00043', '#b04']) { + // Prefixed so this spec's own cleanup owns the file, as the header requires. + const out = join(outDir, `${PREFIX}-accent-${accent.slice(1)}.pdf`); + const res = run(['invoice', 'pdf', fx('fa3.xml'), '--accent', accent, '--out', out]); + expect(res.status, `exit ${res.status}\n${res.stderr}`).toBe(0); + expect(isCompletePdf(out), `${out} is not a complete PDF`).toBe(true); + } + }); + // pdfmake draws PNG and JPEG and nothing else, so a vector or animated logo // has to be refused at the flag — accepting it only moves the failure into // the middle of the render, where the message names no file the caller passed. From 08fdd8a3a8d4199df338d3b219a448dbcaf0b25e Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sat, 29 Aug 2026 23:38:29 +0200 Subject: [PATCH 32/67] feat(pdf): set the accent colour from the command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accent was reachable only in code, so a CLI user had no way to put their own colour on a visualization short of writing a custom template. The flag takes a hex colour and nothing else. pdfmake silently ignores a value it cannot parse — the document then renders exactly as if no accent had been given — so a misspelled colour name has to be caught at the flag or it becomes a PDF that is quietly unthemed. Named CSS colours still work through the library option, where the caller sees what they passed. The preview set carries the accent rather than rendering throwaway pages for it: row 01 against the default palette, row 06 on the sparsest page in the set, and the showcase row — whose own heading colours make it the page that shows whether an accent wins over a template's palette — in the short hex form. Accent joins the grid as a ninth dimension, absence included. The colour throughout is the patina green of the fixture logo, so the samples show an accent doing the job it exists for. Verified against the built CLI: "crimsonn" exits 1 with "Invalid --accent" and writes no file. Spec 35 and the accent unit tests pass, 24 tests. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 4 +- .../src/cli/commands/invoice.ts | 4 +- .../tests/e2e/35-invoice-pdf-cli.test.ts | 61 ++++++++++--------- .../tests/fixtures/pdf/e2e-services-np.xml | 38 ++++++------ .../tests/unit/pdf/theme-accent.test.ts | 14 ++--- 5 files changed, 62 insertions(+), 59 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index c5268a4b..53421b56 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -389,7 +389,7 @@ ksef invoice pdf upo.xml --upo ksef invoice pdf upo.xml --template-file ./templates/my-upo.json # Your own logo and accent colour -ksef invoice pdf invoice.xml --logo ./brand/logo.png --accent '#B00043' +ksef invoice pdf invoice.xml --logo ./brand/logo.png --accent '#5AB595' ``` | Flag | Description | @@ -405,7 +405,7 @@ ksef invoice pdf invoice.xml --logo ./brand/logo.png --accent '#B00043' | `--totals ` | Tax breakdown above the amount due (default `buckets`) | | `--notes ` | JSON file of extra sections: `[{ "head": …, "body": … }]` | | `--logo ` | Logo image printed in the header — PNG or JPEG | -| `--accent ` | Accent colour for the title and headings, e.g. `#B00043` | +| `--accent ` | Accent colour for the title and headings, e.g. `#5AB595` | | `--upo` | Treat the input as a UPO document (otherwise auto-detected); ignored when a template is named explicitly | | `--env ` | Environment for the QR base URL | | `--out ` | Output PDF path (default: alongside the source) | diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 1376618f..937f162c 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -659,7 +659,7 @@ const pdf = defineCommand({ ksefNumber: { type: 'string', description: 'KSeF number to print (absent → marked OFFLINE)' }, upo: { type: 'boolean', description: 'Treat the input as a UPO document (otherwise auto-detected)' }, logo: { type: 'string', description: 'Path to a logo image (PNG/JPEG) to print in the header' }, - accent: { type: 'string', description: 'Accent colour for the title and headings, as hex (e.g. #B00043)' }, + accent: { type: 'string', description: 'Accent colour for the title and headings, as hex (e.g. #5AB595)' }, totals: { type: 'string', description: 'Tax breakdown above the amount due: none | buckets (as recorded) | summary (computed) | both (default: buckets)' }, notes: { type: 'string', description: 'Path to a JSON file with extra sections: [{ "head": "…", "body": "…" }, …]' }, env: { type: 'string', description: 'Environment for the QR base URL (test/demo/prod)' }, @@ -687,7 +687,7 @@ const pdf = defineCommand({ const accent = args.accent as string | undefined; if (accent !== undefined && !HEX_COLOUR.test(accent)) { - throw new Error(`Invalid --accent "${accent}". Expected a hex colour such as #B00043 or #b04.`); + throw new Error(`Invalid --accent "${accent}". Expected a hex colour such as #5AB595 or #b04.`); } const env = args.env as 'prod' | 'test' | 'demo' | undefined; diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 0b6051b5..f0461fc7 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -136,6 +136,9 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { }); const LOGO = () => ['--logo', fx('e2e-logo.png')]; + /** The two hex forms the flag accepts, so the preview set exercises both. */ + const ACCENT = '#5AB595'; + const ACCENT_SHORT = '#b04'; const SUPPLIED_CODE_I = `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`; /** The mixed-rate document with the flags every totals variant shares. */ @@ -143,8 +146,9 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { /** * The preview set, laid out as a covering design rather than one variant per - * feature. Eight dimensions are in play — document, locale, which QR codes, - * links, logo, KSeF number, totals mode, and where Code I comes from — and a + * feature. Nine dimensions are in play — document, locale, which QR codes, + * links, logo, KSeF number, totals mode, accent colour, and where Code I + * comes from — and a * row per combination would be hundreds of PDFs nobody looks at. Instead each * row varies several at once so that every value of every dimension appears, * and the pairs that actually interact are covered. @@ -153,23 +157,23 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { * totals modes only mean anything compared side by side, so those five hold * every other flag identical and vary one thing. * - * # document locale QR links logo KSeF nr totals - * 01 services-np pl I no yes yes buckets - * 02 fa3 en I yes no yes summary (Code I supplied) - * 03 buyer-no-id uk II yes yes no both - * 04 vat-multi en+pl II no no no none - * 05 vat-multi pl+uk both yes yes no both (+ notes) + * # document locale QR links logo KSeF nr totals accent + * 01 services-np pl I no yes yes buckets #5AB595 + * 02 fa3 en I yes no yes summary no (Code I supplied) + * 03 buyer-no-id uk II yes yes no both no + * 04 vat-multi en+pl II no no no none no + * 05 vat-multi pl+uk both yes yes no both no (+ notes) * * What each row is there to show, beyond its share of the grid: 01 the - * everyday online invoice; 02 an invoice whose Code I URL was handed over - * ready-made; 03 a foreign buyer with no NIP, issued offline; 04 and 05 the - * layout cases — one code absent, then both present — where the codes must - * stay against the right margin. + * everyday online invoice, and the accent against the default palette; 02 an + * invoice whose Code I URL was handed over ready-made; 03 a foreign buyer + * with no NIP, issued offline; 04 and 05 the layout cases — one code absent, + * then both present — where the codes must stay against the right margin. */ const variants: Array<[name: string, args: () => string[]]> = [ - [`${PREFIX}-01-invoice-pl-code-i`, () => [ + [`${PREFIX}-01-invoice-pl-code-i-accent`, () => [ fx('e2e-services-np.xml'), '--ksef-number', KSEF_NUMBER, ...LOGO(), - '--env', 'demo', '--qr', '--totals', 'buckets', + '--env', 'demo', '--qr', '--totals', 'buckets', '--accent', ACCENT, ]], [`${PREFIX}-02-invoice-en-supplied-code-i-links`, () => [ fx('fa3.xml'), '--ksef-number', KSEF_NUMBER, '--locale', 'en', @@ -189,9 +193,11 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { '--notes', notesFile, ]], // Every totals mode on one mixed-rate document (23% + 8% + exempt), so the - // four can be compared page by page. Same flags throughout — only --totals - // differs, and the amount due must appear in all of them. - [`${PREFIX}-06-totals-none`, () => [...mixedVat(), '--totals', 'none']], + // four can be compared page by page. Only --totals differs between them — + // and the accent on 06, which is safe here because it reaches the title and + // the section headings, never the totals rows the group exists to compare. + // The amount due must appear in all of them. + [`${PREFIX}-06-totals-none-accent`, () => [...mixedVat(), '--totals', 'none', '--accent', ACCENT]], [`${PREFIX}-07-totals-buckets`, () => [...mixedVat(), '--totals', 'buckets']], [`${PREFIX}-08-totals-summary`, () => [...mixedVat(), '--totals', 'summary']], [`${PREFIX}-09-totals-both`, () => [...mixedVat(), '--totals', 'both']], @@ -203,10 +209,13 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // exercise the DSL — palette, letter spacing, highlighted text, colour bars // drawn as data-URI images. Rendered with everything switched on, so a DSL // change that breaks it is visible rather than discovered by a reader. - [`${PREFIX}-11-showcase-template`, () => [ + // It also carries the accent, in its short hex form: this template sets its + // own heading colours, so it is the page that shows whether an accent wins + // over a template's palette. + [`${PREFIX}-11-showcase-template-accent`, () => [ fx('e2e-vat-multi.xml'), '--template', 'fa3-showcase', ...LOGO(), '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', - '--totals', 'both', '--notes', notesFile, + '--totals', 'both', '--notes', notesFile, '--accent', ACCENT_SHORT, ]], // Receipts last: they are a different document and read as their own group. [`${PREFIX}-12-upo-pl`, () => [fx('upo-4_3.xml')]], @@ -239,11 +248,15 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { expect(covered('--template '), 'a built-in is never selected by name').toBe(true); expect(covered('--notes'), 'caller-supplied notes are never printed').toBe(true); expect(covered('--logo'), 'the logo is never printed').toBe(true); + for (const accent of ['#5AB595', '#b04']) { + expect(covered(`--accent ${accent}`), `no variant renders with accent ${accent}`).toBe(true); + } // The absences matter as much: a Polish default locale, an invoice with no // logo, and one still waiting for its KSeF number. expect(args.some((a) => !a.includes('--locale')), 'nothing renders in the default locale').toBe(true); expect(args.some((a) => !a.includes('--logo')), 'nothing renders without a logo').toBe(true); expect(args.some((a) => !a.includes('--ksef-number')), 'nothing renders as OFFLINE').toBe(true); + expect(args.some((a) => !a.includes('--accent')), 'nothing renders in the default colours').toBe(true); }); it.each(variants)('renders %s', (name, args) => { @@ -289,16 +302,6 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { expect(existsSync(out)).toBe(false); }); - it('accepts both hex forms of an accent colour', () => { - for (const accent of ['#B00043', '#b04']) { - // Prefixed so this spec's own cleanup owns the file, as the header requires. - const out = join(outDir, `${PREFIX}-accent-${accent.slice(1)}.pdf`); - const res = run(['invoice', 'pdf', fx('fa3.xml'), '--accent', accent, '--out', out]); - expect(res.status, `exit ${res.status}\n${res.stderr}`).toBe(0); - expect(isCompletePdf(out), `${out} is not a complete PDF`).toBe(true); - } - }); - // pdfmake draws PNG and JPEG and nothing else, so a vector or animated logo // has to be refused at the flag — accepting it only moves the failure into // the middle of the render, where the message names no file the caller passed. diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/e2e-services-np.xml b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-services-np.xml index b7bacec2..d484509c 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/e2e-services-np.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-services-np.xml @@ -25,16 +25,16 @@ - REG-000123 - Example Overseas Ltd + GB-000123 + Example Trading Ltd - KY - 1 Example Bay Road, 3rd Floor - Suite 100, P.O. Box 10000 - Grand Cayman, Cayman Islands + GB + 12 Example Street, Floor 3 + London EC1A 1BB, United Kingdom - ap@overseas.example + accounts@trading.example 2 2 @@ -45,8 +45,8 @@ Warszawa FIX/NP/2026/001 2026-01-15 - 6966.00 - 6966.00 + 800.00 + 800.00 2 2 @@ -66,22 +66,22 @@ VAT 1 - Software development services according to Master Services Agreement - PKD 62.01.Z - 62.01.11.0 - h - 174 - 39.00 - 6786.00 + Kalibracja urzadzen pomiarowych / Calibration of measuring equipment + PKD 71.20.B + 71.20.19.0 + szt + 20 + 20.00 + 400.00 np I 2 - Tooling compensation / Kompensacja narzedzi + Przeglad techniczny sprzetu / Technical inspection of equipment szt - 1 - 180.00 - 180.00 + 20 + 20.00 + 400.00 np I diff --git a/packages/ksef-client-ts/tests/unit/pdf/theme-accent.test.ts b/packages/ksef-client-ts/tests/unit/pdf/theme-accent.test.ts index 6a1df167..67a6d407 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/theme-accent.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/theme-accent.test.ts @@ -50,25 +50,25 @@ describe('theme.accent', () => { }); it('repaints the title and both heading levels', async () => { - await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: '#B00043' } }); + await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: '#5AB595' } }); const styles = lastStyles(); - expect(styles.title?.color).toBe('#B00043'); - expect(styles.h1?.color).toBe('#B00043'); - expect(styles.h2?.color).toBe('#B00043'); + expect(styles.title?.color).toBe('#5AB595'); + expect(styles.h1?.color).toBe('#5AB595'); + expect(styles.h2?.color).toBe('#5AB595'); }); it('keeps every other property of the styles it repaints', async () => { await renderInvoicePdf(fa3, 'fa3-default'); const before = lastStyles(); - await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: '#B00043' } }); + await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: '#5AB595' } }); const after = lastStyles(); - expect(after.h1).toEqual({ ...before.h1, color: '#B00043' }); + expect(after.h1).toEqual({ ...before.h1, color: '#5AB595' }); }); it('leaves styles it does not own alone', async () => { await renderInvoicePdf(fa3, 'fa3-default'); const before = lastStyles(); - await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: '#B00043' } }); + await renderInvoicePdf(fa3, 'fa3-default', { theme: { accent: '#5AB595' } }); expect(lastStyles().muted).toEqual(before.muted); }); From cdf9ad36510088f409205512d82b8c93af8de1d2 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:01:59 +0200 Subject: [PATCH 33/67] feat(pdf): expose the built-in templates, and make the preview set earn its PDFs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapting a built-in was impossible from outside: only the four `render*` entry points and the detectors were exported, so changing two colours meant writing a full FA(3) layout by hand. `getBuiltinTemplate` returns one as a plain object to edit and pass to `renderInvoicePdfFromTemplate`, and `builtinTemplateNames` says what there is. It hands back a copy. The built-ins are validated once at import and held for the life of the process, so returning the stored object would let one caller's edit repaint every later render by that name — including one in another part of their program. Two library previews were testing the API and showing nothing. The template-object one rendered a five-line stub; it now starts from a built-in and rebrands it, which is both the realistic use of that entry point and a real page. The template-file one is gone: its file-loading half is what the CLI's --template-file drives end to end in spec 35, and its `strict` half — which has no flag — moves onto the fullest invoice in the set, where a dot-path typo would actually have somewhere to hide. The accent moves off the hand-built template, which never used the style names it repaints, onto a built-in render where it shows. `theme` comes off the spec's list of what the CLI cannot reach, since --accent landed, and the CLI grid now fails if the accent loses its last cover. Verified: unit 2696, e2e 153, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 17 +++ packages/ksef-client-ts/src/pdf/index.ts | 24 +++- .../tests/e2e/35-invoice-pdf-cli.test.ts | 3 + .../tests/e2e/36-invoice-pdf-library.test.ts | 126 ++++++++++-------- 4 files changed, 111 insertions(+), 59 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 53421b56..679dc7d4 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -106,6 +106,23 @@ const pdf = await renderUpoPdf(await readFile('upo.xml')); ### Version detection helpers +### `getBuiltinTemplate(name)` / `builtinTemplateNames()` — start from a built-in + +Writing a full FA(3) layout by hand to change two colours is not a reasonable way to get a custom template. `getBuiltinTemplate` returns one as a plain object to adapt and pass to `renderInvoicePdfFromTemplate`; `builtinTemplateNames` lists what is available. + +```ts +import { getBuiltinTemplate, renderInvoicePdfFromTemplate } from 'ksef-client-ts/pdf'; + +const template = getBuiltinTemplate('fa3-default')!; +template.styles = { + ...template.styles, + title: { ...template.styles?.title, color: '#1B4965' }, +}; +const pdf = await renderInvoicePdfFromTemplate(xml, template); +``` + +What comes back is a **copy**. The built-ins are validated once at import and held for the life of the process, so editing the returned object cannot repaint a later render by that name. + `detectInvoiceVersion` and `detectUpoVersion` inspect the XML and return the detected version or `null`. ```ts diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index f125c8c1..692381ba 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -22,7 +22,7 @@ import type { Locale } from './i18n/types.js'; import { validateTemplate, type InvoiceTemplate, type TemplateSchemaId } from './template/dsl.js'; import { interpretTemplate, type RenderContext, type RenderNote } from './template/interpret.js'; import { blockRegistry } from './template/blocks/index.js'; -import { getBuiltinTemplate, builtinTemplateNames } from './template/builtin/index.js'; +import { getBuiltinTemplate as loadBuiltinTemplate, builtinTemplateNames } from './template/builtin/index.js'; import { loadPdfMake, createPdfBuffer } from './fonts.js'; import { deriveInvoiceQrUrl } from './qr.js'; @@ -30,6 +30,26 @@ export type { Locale } from './i18n/types.js'; export type { InvoiceTemplate } from './template/dsl.js'; export type { RenderNote } from './template/interpret.js'; export { detectInvoiceVersion, detectUpoVersion } from './parse.js'; +export { builtinTemplateNames } from './template/builtin/index.js'; + +/** + * A built-in template as a plain object, for callers who want to start from one + * and adapt it rather than write a layout from nothing — a different palette, + * a company's own wording — then render it with + * {@link renderInvoicePdfFromTemplate}. + * + * The result is a **copy**. The built-ins are validated once at import and held + * for the life of the process, so handing out the stored object would let one + * caller's edit silently repaint every later render by that name. Editing what + * comes back here affects nothing else. + * + * `undefined` for a name that is not built in; {@link builtinTemplateNames} + * lists the ones that are. + */ +export function getBuiltinTemplate(name: string): InvoiceTemplate | undefined { + const template = loadBuiltinTemplate(name); + return template && structuredClone(template); +} /** * Which parts of the totals block a reader gets. `Do zapłaty` is always shown — @@ -247,7 +267,7 @@ export async function renderInvoicePdf( name: string, opts: RenderOptions = {}, ): Promise { - const template = getBuiltinTemplate(name); + const template = loadBuiltinTemplate(name); if (!template) { throw new KSeFPdfError( `Unknown built-in template "${name}". Available: ${builtinTemplateNames().join(', ')}`, diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index f0461fc7..572c0008 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -247,6 +247,9 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { expect(covered('--template-file'), 'a custom template file is never used').toBe(true); expect(covered('--template '), 'a built-in is never selected by name').toBe(true); expect(covered('--notes'), 'caller-supplied notes are never printed').toBe(true); + // The accent repaints the title and both heading levels, so a themed render + // differs from an unthemed one on every page — worth its own cover. + expect(covered('--accent'), 'the accent colour is never applied').toBe(true); expect(covered('--logo'), 'the logo is never printed').toBe(true); for (const accent of ['#5AB595', '#b04']) { expect(covered(`--accent ${accent}`), `no variant renders with accent ${accent}`).toBe(true); diff --git a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts index 5c146bf5..4f4f46ec 100644 --- a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts +++ b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts @@ -9,6 +9,8 @@ import { renderInvoicePdfFromFile, renderInvoicePdfFromTemplate, renderUpoPdf, + getBuiltinTemplate, + builtinTemplateNames, detectInvoiceVersion, detectUpoVersion, type InvoiceTemplate, @@ -18,9 +20,17 @@ import { VerificationLinkService } from 'ksef-client-ts'; // Companion to spec 35, which drives the same renderer through the CLI. The CLI // is a strict subset of the library: it wires most of RenderOptions but cannot // pass a template as an object at all. This spec covers what the command line -// cannot reach — baseQrUrl, theme, bilingualSeparator, strict, invoiceHash, and +// cannot reach — baseQrUrl, bilingualSeparator, strict, invoiceHash, and // renderInvoicePdfFromTemplate — so a regression there is not invisible just -// because no flag exposes it. +// because no flag exposes it. Options the CLI does expose still appear here +// where they ride along; `theme` is one, since `--accent` landed. +// +// Each render carries several of those at once rather than one apiece: they are +// orthogonal, so isolating them costs a PDF each and proves nothing extra. What +// has to hold is that none is left unexercised, which the check at the end of +// the block asserts by reading the option list off the published types. +// `renderInvoicePdfFromFile` is not among them — the CLI's `--template-file` +// drives that path end to end in spec 35. // // It imports by package specifier on purpose: that resolves through the exports // map to dist/, so the published artifact is what gets exercised, not src. @@ -86,74 +96,70 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => ); }); - /** - * Each render below carries several library-only options at once, rather than - * one option per PDF. The options are orthogonal — a separator does not - * interact with a hash — so isolating them costs a PDF each and proves nothing - * extra; what has to hold is that every one of them is exercised, which the - * grid check at the end of this block asserts by reading the calls back. - */ describe('surface the CLI has no flag for', () => { - it('takes a template object, and hands it theme.accent as a binding', async () => { - // No built-in template consumes the accent — styles in the DSL are static, - // so it cannot colour anything today and reaches a template only as a - // string binding. Rendering through a template that reads that binding - // keeps the option exercised instead of silently ignored. - const template: InvoiceTemplate = { - schema: 'FA(3)', - page: { size: 'A4', margins: [40, 40, 40, 40] }, - styles: { title: { fontSize: 18, bold: true } }, - blocks: [ - { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, - { type: 'divider' }, - { - type: 'parties', - left: { label: 'seller', fields: ['Podmiot1.DaneIdentyfikacyjne.Nazwa'] }, - right: { label: 'buyer', fields: ['Podmiot2.DaneIdentyfikacyjne.Nazwa'] }, - }, - { type: 'text', path: 'opts.accent' }, - { type: 'totals', rows: [{ label: 'totalDue', path: 'Fa.P_15', format: 'money' }] }, - ], + it('takes a template as an object — a built-in, rebranded', async () => { + // Neither a built-in name nor a file: the caller hands over the template + // itself, which is how a layout assembled at runtime — from settings, a + // database, a tenant's branding — reaches the renderer. The CLI has no way + // to express this. + // + // Starting from a built-in rather than from nothing is the realistic + // shape of it, and the reason `getBuiltinTemplate` is public: writing a + // full FA(3) layout by hand to change two colours is not a thing anyone + // should have to do. + const template = getBuiltinTemplate('fa3-default')!; + template.styles = { + ...template.styles, + title: { ...template.styles?.title, fontSize: 30, color: '#1B4965' }, + h1: { ...template.styles?.h1, color: '#5FA8D3', characterSpacing: 2 }, + partyIdentity: { ...template.styles?.partyIdentity, bold: true }, }; + template.labels = { ...template.labels, seller: 'Wystawca', buyer: 'Odbiorca' }; + await save( - `${PREFIX}-01-template-object-accent`, + `${PREFIX}-01-template-object`, renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template, { - theme: { accent: '#B0004E' }, logo: LOGO, ksefNumber: KSEF_NUMBER, + qr: true, + env: 'demo', }), ); }); - it('loads a custom template from a JSON file, and renders it strict', async () => { - // fa3.xml populates every path the template names, so strict has nothing - // to complain about — and would throw on a dot-path typo in the file. - const path = join(inputsDir, 'lib-minimal-template.json'); - writeFileSync( - path, - JSON.stringify({ - schema: 'FA(3)', - blocks: [ - { type: 'header', title: { label: 'invoice' }, number: 'Fa.P_2', date: 'Fa.P_1' }, - { type: 'lines', from: 'Fa.FaWiersz', columns: [ - { label: 'name', path: 'P_7', width: '*' }, - { label: 'net', path: 'P_11', format: 'money', width: 70 }, - ] }, - ], - }), - ); - await save( - `${PREFIX}-02-template-file-strict`, - renderInvoicePdfFromFile(bytes('fa3.xml'), path, { strict: true }), + it('hands out a copy, so editing one does not repaint the built-in', async () => { + // The built-ins are validated once at import and held for the life of the + // process. Handing out the stored object would let the edit above leak + // into every later render by that name — including one in another part of + // the caller's program. + const edited = getBuiltinTemplate('fa3-default')!; + edited.styles = { ...edited.styles, title: { fontSize: 99, color: '#FF00FF' } }; + + const fresh = getBuiltinTemplate('fa3-default')!; + expect(fresh.styles?.title).not.toEqual(edited.styles?.title); + expect(fresh.styles?.title).toMatchObject({ bold: true }); + + // …and a render by name is unaffected too. + await expect(renderInvoicePdf(bytes('fa3.xml'), 'fa3-default')).resolves.toBeInstanceOf(Uint8Array); + }); + + it('lists the built-ins it can hand over', () => { + expect(builtinTemplateNames()).toEqual( + expect.arrayContaining(['fa2-default', 'fa3-default', 'fa3-showcase', 'upo-4_2', 'upo-4_3']), ); + expect(getBuiltinTemplate('no-such-template')).toBeUndefined(); }); - it('accepts the XML as a string, with a custom separator and QR host', async () => { + it('accepts the XML as a string, with a custom separator, QR host and accent', async () => { + // The accent repaints `title`, `h1` and `h2`, so it only shows on a + // template that uses those names — a built-in does, which is why it rides + // here rather than on the hand-built template above. await save( - `${PREFIX}-03-string-input-newline-separator-custom-qr-host`, + `${PREFIX}-02-string-input-newline-separator-custom-qr-host-accent`, renderInvoicePdf(text('e2e-vat-multi.xml'), 'fa3-default', { locale: 'en+pl', bilingualSeparator: '\n', + theme: { accent: '#B0004E' }, qr: true, baseQrUrl: 'https://verify.example/ksef', qrLinks: true, @@ -163,7 +169,12 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => ); }); - it('takes a precomputed hash for Code I and a ready-made Code II', async () => { + it('takes a precomputed hash for Code I and a ready-made Code II, strictly', async () => { + // `strict` has no flag, so this is the only place it is exercised on a + // real page. It turns a dot-path typo into a thrown error instead of a + // blank line, which only works because every binding the FA schema lets a + // document omit is marked optional in the template. + // Code II cannot be derived here — it is signed with the issuer's offline // certificate key — so the library takes it as a URL. Built with a // throwaway key so the code has a realistic density. @@ -184,9 +195,10 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => key, ); await save( - `${PREFIX}-04-precomputed-hash-both-codes-links`, + `${PREFIX}-03-precomputed-hash-both-codes-links-strict`, renderInvoicePdf(raw, 'fa3-default', { qr: true, + strict: true, env: 'demo', invoiceHash, certificateQrUrl, @@ -198,7 +210,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => it('takes a Code I URL verbatim, skipping derivation entirely', async () => { await save( - `${PREFIX}-05-supplied-code-i-url`, + `${PREFIX}-04-supplied-code-i-url`, renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { qrUrl: `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`, qrLinks: true, @@ -211,7 +223,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => // Receipt last, as in spec 35. it('renders a UPO through the library entry point', async () => { - await save(`${PREFIX}-06-upo`, renderUpoPdf(bytes('upo-4_3.xml'), { locale: 'uk' })); + await save(`${PREFIX}-05-upo`, renderUpoPdf(bytes('upo-4_3.xml'), { locale: 'uk' })); }); it('leaves no render option unexercised', () => { From cbf3a7a6cb6c169c99accc0264c7e06910877ca8 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:14:13 +0200 Subject: [PATCH 34/67] feat(pdf): print the line-item classifiers under the item they describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A KSeF line item can carry five classifiers — Indeks, GTIN, PKWiU, CN and PKOB — and none of them were printed. Giving each a column of its own is what a table invites and the wrong answer: all five are optional and a real invoice fills one or two, while a column's width is fixed for the whole table and cannot shrink away per row, so most invoices would carry several empty ones and the item name would lose the width to them. They now share one smaller line under the item's own value, built by joining `label value` pairs and dropping every entry the row leaves empty. An item carrying none of them emits a plain cell, exactly as before — not a stack with a blank second line. The cell is built in one place and used by both table blocks, so the generic one cannot fall behind the line-item one. That also gives `ColumnDef.style` a reader: it was declared, documented, and used by nothing, which would have read as arbitrary next to a `subStyle` that works. Verified: unit 2703, e2e 153, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 1 + packages/ksef-client-ts/src/pdf/i18n/en.ts | 5 ++ packages/ksef-client-ts/src/pdf/i18n/pl.ts | 5 ++ packages/ksef-client-ts/src/pdf/i18n/uk.ts | 5 ++ .../src/pdf/template/blocks/cell.ts | 53 ++++++++++++ .../src/pdf/template/blocks/lines.ts | 12 ++- .../src/pdf/template/blocks/table.ts | 10 ++- .../src/pdf/template/builtin/fa2-default.json | 16 +++- .../src/pdf/template/builtin/fa3-default.json | 16 +++- .../pdf/template/builtin/fa3-showcase.json | 16 +++- .../ksef-client-ts/src/pdf/template/dsl.ts | 17 ++++ .../tests/unit/pdf/blocks-semantic.test.ts | 84 +++++++++++++++++++ 12 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/cell.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 679dc7d4..10ef51a8 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -218,6 +218,7 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it - **`headingStyle`** (`parties`, `payment`, `annotations`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. - **`style`** (party panels) names a style for a panel's value lines. A labelled group inherits it unless it declares its own, so the built-in templates set `partyIdentity` on the panel — the counterparty's name and tax number — and let the address and contact groups drop to the smaller `partyDetails`. Headings keep the panel's heading style either way. - **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. +- **`sub`** (table columns only) prints a second, smaller line under the cell's value, joining `label value` pairs and dropping every entry the row leaves empty. It exists for the line-item classifiers — `Indeks`, `GTIN`, `PKWiU`, `CN`, `PKOB` — which are all optional and of which a real invoice carries one or two: a column's width is fixed for the whole table, so giving each its own column would leave most invoices with several empty ones. `subStyle` names the style for that line and `subSeparator` replaces the default `' · '`. - **`width`** (table columns only) sizes a column: a number of points, `'auto'` to fit the content, or `'*'` to share out what is left (the default). Sizing is worth setting explicitly — pdfmake gives every `'*'` column the *same* width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token silently widens the whole table past the page edge. - **`sum`** (totals rows only) adds several binding paths instead of reading one. A KSeF invoice has no single net or VAT total — the amounts are split across the `P_13_*` and `P_14_*` rate buckets, with zero-rated sales split three further ways (`P_13_6_1` domestic, `P_13_6_2` intra-EU supply, `P_13_6_3` export) — so the built-in templates aggregate them. Absent buckets are skipped, and the addition is decimal-exact. A totals row takes either `path` or `sum`, never both. diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 03a96599..3b84499c 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -20,6 +20,11 @@ export const en: LabelBundle = { vatRate: 'VAT rate', net: 'Net amount', vat: 'VAT amount', + indeks: 'Item code', + gtin: 'GTIN', + pkwiu: 'PKWiU', + cn: 'CN', + pkob: 'PKOB', gross: 'Gross amount', // per-rate buckets (P_13_* / P_14_*) net23: 'Net 23%', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 6158ad35..b3a4b95a 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -21,6 +21,11 @@ export const pl: LabelBundle = { vatRate: 'Stawka VAT', net: 'Wartość netto', vat: 'Kwota VAT', + indeks: 'Indeks', + gtin: 'GTIN', + pkwiu: 'PKWiU', + cn: 'CN', + pkob: 'PKOB', gross: 'Wartość brutto', // totals // per-rate buckets (P_13_* / P_14_*) diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index 0f028df5..f6283b4f 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -28,6 +28,11 @@ export const uk: LabelBundle = { net: 'Сума нетто', vatRate: 'Ставка ПДВ', vat: 'Сума ПДВ', + indeks: 'Код позиції', + gtin: 'GTIN', + pkwiu: 'PKWiU', + cn: 'CN', + pkob: 'PKOB', gross: 'Сума брутто', // per-rate buckets (P_13_* / P_14_*) net23: 'Нетто 23%', diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/cell.ts b/packages/ksef-client-ts/src/pdf/template/blocks/cell.ts new file mode 100644 index 00000000..ffa4427f --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/cell.ts @@ -0,0 +1,53 @@ +import { applyFormat } from '../../format.js'; +import type { ColumnDef } from '../dsl.js'; +import type { PdfNode, RenderContext } from '../interpret.js'; + +/** Separator between `sub` entries when the column names none. */ +const DEFAULT_SUB_SEPARATOR = ' · '; + +/** + * One table cell: the column's own value, and — when the column declares `sub` + * — a second line carrying the classifiers that row happens to have. + * + * The second line is built by joining `label value` pairs and dropping every + * entry that resolves empty, which is the whole point of putting them here + * rather than in columns of their own: `Indeks`, `GTIN`, `PKWiU`, `CN` and + * `PKOB` are all optional and a real invoice carries one or two, but a column's + * width is fixed for the whole table and cannot shrink away per row. + * + * Reading is left to the caller because the same column definition is resolved + * row-relative in a repeater and against the document root in a single-row + * table. + */ +export function buildCell( + column: ColumnDef, + read: (path: string, optional: boolean) => string, + ctx: RenderContext, +): PdfNode { + const value = applyFormat(read(column.path, column.optional === true), column.format); + + const parts: string[] = []; + for (const sub of column.sub ?? []) { + const text = applyFormat(read(sub.path, sub.optional === true), sub.format); + if (text !== '') parts.push(`${ctx.label(sub.label)} ${text}`); + } + + if (parts.length === 0) { + return column.style ? { text: value, style: column.style } : { text: value }; + } + return { + stack: [ + { text: value }, + { + text: parts.join(column.subSeparator ?? DEFAULT_SUB_SEPARATOR), + ...(column.subStyle ? { style: column.subStyle } : {}), + }, + ], + ...(column.style ? { style: column.style } : {}), + }; +} + +/** The header cell for a column: its localized label, in the column's style. */ +export function buildHeaderCell(column: ColumnDef, ctx: RenderContext): PdfNode { + return { text: ctx.label(column.label), bold: true, ...(column.style ? { style: column.style } : {}) }; +} diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts b/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts index aca2eec4..a2712675 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts @@ -1,7 +1,7 @@ -import { applyFormat } from '../../format.js'; import { get, list } from '../../accessor.js'; import type { LinesBlock } from '../dsl.js'; import { type BlockRenderer, type PdfNode } from '../interpret.js'; +import { buildCell, buildHeaderCell } from './cell.js'; /** * Invoice line items as a pdfmake `table`. The header row echoes each column's @@ -9,7 +9,9 @@ import { type BlockRenderer, type PdfNode } from '../interpret.js'; * {@link LinesBlock.from} via {@link list} (a single collapsed line is * normalized to a one-element array, so one line renders like many). Each cell * reads its column path row-relative with {@link get} and applies the column's - * formatter. With no entries only the header row is emitted. + * formatter. With no entries only the header row is emitted. A column may also + * carry `sub` fields, printed as one smaller line under the cell's value — see + * {@link buildCell}. * * Column widths come from the template (`'*'` when unset). Leaving them all * `'*'` is rarely right: pdfmake sizes star columns identically and never below @@ -17,9 +19,11 @@ import { type BlockRenderer, type PdfNode } from '../interpret.js'; * silently widens the whole table past the page edge. */ export const linesRenderer: BlockRenderer = (block, ctx) => { - const headerRow: PdfNode[] = block.columns.map((c) => ({ text: ctx.label(c.label), bold: true })); + const headerRow: PdfNode[] = block.columns.map((c) => buildHeaderCell(c, ctx)); const bodyRows: PdfNode[][] = list(ctx.root, block.from).map((row) => - block.columns.map((c) => ({ text: applyFormat(get(row, c.path, c.optional ? false : ctx.strict), c.format) })), + block.columns.map((c) => + buildCell(c, (path, optional) => get(row, path, optional ? false : ctx.strict), ctx), + ), ); return { diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/table.ts b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts index b575799e..d3b78962 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/table.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts @@ -1,7 +1,7 @@ import { get, list } from '../../accessor.js'; -import { applyFormat } from '../../format.js'; import type { TableBlock } from '../dsl.js'; import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; +import { buildCell, buildHeaderCell } from './cell.js'; /** * Generic pdfmake `table`. Two modes: @@ -24,15 +24,17 @@ export const tableRenderer: BlockRenderer = (block, ctx) => { const body: PdfNode[][] = []; if (showHeaders) { - body.push(columns.map((col) => ({ text: ctx.label(col.label), bold: true }))); + body.push(columns.map((col) => buildHeaderCell(col, ctx))); } if (block.from !== undefined) { for (const row of list(ctx.root, block.from)) { - body.push(columns.map((col) => ({ text: applyFormat(get(row, col.path, col.optional ? false : ctx.strict), col.format) }))); + body.push(columns.map((col) => buildCell(col, (path, optional) => get(row, path, optional ? false : ctx.strict), ctx))); } } else { - body.push(columns.map((col) => ({ text: applyFormat(resolveBinding(col.path, col.optional ? lenientCtx : ctx), col.format) }))); + body.push( + columns.map((col) => buildCell(col, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx), ctx)), + ); } const node: Record = { diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 192baa27..16681bbe 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -5,6 +5,7 @@ "styles": { "title": { "fontSize": 20, "bold": true }, "h1": { "fontSize": 11, "bold": true, "margin": [0, 10, 0, 3], "color": "#000000" }, + "lineMeta": { "fontSize": 6.5, "color": "#7A8CA0" }, "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, @@ -88,7 +89,20 @@ "from": "Fa.FaWiersz", "columns": [ { "label": "lp", "path": "NrWierszaFa", "width": 24 }, - { "label": "name", "path": "P_7", "width": "*", "optional": true }, + { + "label": "name", + "path": "P_7", + "width": "*", + "optional": true, + "subStyle": "lineMeta", + "sub": [ + { "label": "pkwiu", "path": "PKWiU", "optional": true }, + { "label": "indeks", "path": "Indeks", "optional": true }, + { "label": "gtin", "path": "GTIN", "optional": true }, + { "label": "cn", "path": "CN", "optional": true }, + { "label": "pkob", "path": "PKOB", "optional": true } + ] + }, { "label": "unit", "path": "P_8A", "width": 36, "optional": true }, { "label": "qty", "path": "P_8B", "format": "number", "width": 24, "optional": true }, { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 64, "optional": true }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 87b2ceda..7d866d0e 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -5,6 +5,7 @@ "styles": { "title": { "fontSize": 20, "bold": true }, "h1": { "fontSize": 11, "bold": true, "margin": [0, 10, 0, 3], "color": "#000000" }, + "lineMeta": { "fontSize": 6.5, "color": "#7A8CA0" }, "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, @@ -88,7 +89,20 @@ "from": "Fa.FaWiersz", "columns": [ { "label": "lp", "path": "NrWierszaFa", "width": 24 }, - { "label": "name", "path": "P_7", "width": "*", "optional": true }, + { + "label": "name", + "path": "P_7", + "width": "*", + "optional": true, + "subStyle": "lineMeta", + "sub": [ + { "label": "pkwiu", "path": "PKWiU", "optional": true }, + { "label": "indeks", "path": "Indeks", "optional": true }, + { "label": "gtin", "path": "GTIN", "optional": true }, + { "label": "cn", "path": "CN", "optional": true }, + { "label": "pkob", "path": "PKOB", "optional": true } + ] + }, { "label": "unit", "path": "P_8A", "width": 24, "optional": true }, { "label": "qty", "path": "P_8B", "format": "number", "width": 24, "optional": true }, { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 50, "optional": true }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 35321fa8..df2c575b 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -19,6 +19,7 @@ "styles": { "title": { "fontSize": 22, "bold": true, "characterSpacing": 5, "color": "#0B2545", "margin": [0, 0, 0, 2] }, "h1": { "fontSize": 9, "bold": true, "color": "#EF476F", "characterSpacing": 3, "margin": [0, 8, 0, 3] }, + "lineMeta": { "fontSize": 6, "color": "#8AA4BD", "italics": true }, "h2": { "fontSize": 7, "bold": true, "color": "#8AA4BD", "characterSpacing": 2, "margin": [0, 5, 0, 2] }, "partyName": { "fontSize": 11, "bold": true, "color": "#0B2545", "lineHeight": 1.05 }, "partyMeta": { "fontSize": 7.5, "color": "#5B7B9A", "lineHeight": 1.3 }, @@ -106,7 +107,20 @@ "style": "lines", "columns": [ { "label": "lp", "path": "NrWierszaFa", "width": 18 }, - { "label": "name", "path": "P_7", "width": "*", "optional": true }, + { + "label": "name", + "path": "P_7", + "width": "*", + "optional": true, + "subStyle": "lineMeta", + "sub": [ + { "label": "pkwiu", "path": "PKWiU", "optional": true }, + { "label": "indeks", "path": "Indeks", "optional": true }, + { "label": "gtin", "path": "GTIN", "optional": true }, + { "label": "cn", "path": "CN", "optional": true }, + { "label": "pkob", "path": "PKOB", "optional": true } + ] + }, { "label": "unit", "path": "P_8A", "width": 26, "optional": true }, { "label": "qty", "path": "P_8B", "format": "number", "width": 30, "optional": true }, { "label": "unitPrice", "path": "P_9A", "format": "money", "width": 58, "optional": true }, diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index b696e76d..cfc710a5 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -69,6 +69,20 @@ export interface FieldDef { */ export interface ColumnDef extends FieldDef { width?: number | 'auto' | '*'; + /** + * Secondary fields printed as one extra line under the cell's own value — + * `PKWiU 71.20.19.0 · Indeks ABC-1`. Each is `label value`, and an entry that + * resolves empty is left out entirely, so a column may list every classifier + * the schema allows (`Indeks`, `GTIN`, `PKWiU`, `CN`, `PKOB`) and each line + * shows only the one or two a given item actually carries. A column can only + * be one width for the whole table, so classifiers cannot each have a column + * of their own without leaving most invoices with several empty ones. + */ + sub?: FieldDef[]; + /** Style for the `sub` line. Set it smaller: it is a footnote to the value. */ + subStyle?: string; + /** Separator between `sub` entries. Default `' · '`. */ + subSeparator?: string; } /** @@ -438,6 +452,9 @@ const columnDef = z format: formatEnum.optional(), style: z.string().optional(), width: z.union([z.number().positive(), z.literal('auto'), z.literal('*')]).optional(), + sub: z.array(fieldDef).nonempty().optional(), + subStyle: z.string().optional(), + subSeparator: z.string().optional(), }) .strict(); diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index c09f2bac..b55919ba 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -969,3 +969,87 @@ describe('headingStyle', () => { expect(paymentBlock('sectionHead')[1].style).toBeUndefined(); }); }); + +// ── classification sub-lines ───────────────────────────────────────────────── + +/** + * A line-item column may carry a second, smaller line of classifiers. They live + * there rather than in columns of their own because `Indeks`, `GTIN`, `PKWiU`, + * `CN` and `PKOB` are all optional and a real invoice fills one or two — a + * column's width is fixed for the whole table and cannot shrink away per row. + */ +describe('column sub-lines', () => { + const column = (over: Record = {}) => ({ + label: 'name', + path: 'P_7', + width: '*', + sub: [ + { label: 'pkwiu', path: 'PKWiU', optional: true }, + { label: 'indeks', path: 'Indeks', optional: true }, + { label: 'gtin', path: 'GTIN', optional: true }, + ], + ...over, + }); + + const cells = (row: Record, over?: Record) => + rec( + linesRenderer( + { type: 'lines', from: 'Fa.FaWiersz', columns: [column(over)] as never }, + makeCtx({ Fa: { FaWiersz: row } }), + noRender, + ), + ).table.body; + + it('prints the classifiers an item carries, on one line under the value', () => { + const [, [cell]] = cells({ P_7: 'Kalibracja', PKWiU: '71.20.19.0', Indeks: 'ABC-1' }); + expect(cell.stack.map((n: { text: string }) => n.text)).toEqual([ + 'Kalibracja', + 'pkwiu 71.20.19.0 · indeks ABC-1', + ]); + }); + + it('leaves out every classifier the item does not carry', () => { + const [, [cell]] = cells({ P_7: 'Kalibracja', GTIN: '5901234123457' }); + expect(cell.stack[1].text).toBe('gtin 5901234123457'); + }); + + it('emits a plain cell when the item carries none of them', () => { + // Not a stack with an empty second line: an item without classifiers has to + // look exactly as it did before the column grew them. + const [, [cell]] = cells({ P_7: 'Kalibracja' }); + expect(cell).toEqual({ text: 'Kalibracja' }); + }); + + it('takes the style the column names for that line', () => { + const [, [cell]] = cells({ P_7: 'Kalibracja', PKWiU: '71.20.19.0' }, { subStyle: 'lineMeta' }); + expect(cell.stack[0].style).toBeUndefined(); // the value keeps the table's own + expect(cell.stack[1].style).toBe('lineMeta'); + }); + + it('joins with " · " by default and with whatever the column asks for', () => { + const row = { P_7: 'Kalibracja', PKWiU: '71.20.19.0', Indeks: 'ABC-1' }; + expect(cells(row)[1][0].stack[1].text).toContain(' · '); + expect(cells(row, { subSeparator: ' | ' })[1][0].stack[1].text).toBe( + 'pkwiu 71.20.19.0 | indeks ABC-1', + ); + }); + + it('applies the column style to the cell and to its header', () => { + // `style` was declared on a column and read by nothing until the sub-lines + // needed a home; a numeric column wanting `alignment: right` needs it on + // both halves. + const body = cells({ P_7: 'Kalibracja' }, { style: 'numeric' }); + expect(body[0][0].style).toBe('numeric'); // header + expect(body[1][0].style).toBe('numeric'); // value + }); + + it('reads a sub field leniently when it is marked optional, even in strict', () => { + const render = () => + linesRenderer( + { type: 'lines', from: 'Fa.FaWiersz', columns: [column()] as never }, + makeCtx({ Fa: { FaWiersz: { P_7: 'Kalibracja' } } }, { strict: true }), + noRender, + ); + expect(render).not.toThrow(); + }); +}); From 24f1b1a8c66bc6bd998cdb46fd007f619a820637 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:22:53 +0200 Subject: [PATCH 35/67] feat(pdf): restate the amount due, with its currency, under the payment terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payment section named a due date and a method but not the sum they apply to, leaving the reader to carry a figure down from the totals. It now closes with `Kwota należności ogółem: 800,00 EUR`. A field could bind one value, so an amount and its currency could only be printed as a number in one row and a code in another. `suffixPath` binds the second and appends it after a space. The formatter belongs to the value, so the currency does not pass through it; an empty suffix leaves the value alone; and an absent value drops the whole row rather than printing a bare currency code. The suffix is read at the strictness of the value it follows — `KodWaluty` is required by the FA(3) schema, so a typo in it fails a strict render exactly as one in the amount would. Reading a field moves into one place and is used by all four blocks that render one, so the option cannot work in the payment rows and quietly do nothing in the annotations beside them. Verified: unit 2711, e2e 153, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 1 + packages/ksef-client-ts/src/pdf/i18n/en.ts | 1 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 1 + packages/ksef-client-ts/src/pdf/i18n/uk.ts | 1 + .../src/pdf/template/blocks/annotations.ts | 6 +- .../src/pdf/template/blocks/cell.ts | 6 +- .../src/pdf/template/blocks/field.ts | 27 ++++++++ .../src/pdf/template/blocks/payment.ts | 6 +- .../src/pdf/template/builtin/fa2-default.json | 3 +- .../src/pdf/template/builtin/fa3-default.json | 3 +- .../pdf/template/builtin/fa3-showcase.json | 3 +- .../ksef-client-ts/src/pdf/template/dsl.ts | 9 +++ .../tests/unit/pdf/blocks-semantic.test.ts | 68 +++++++++++++++++++ 13 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 packages/ksef-client-ts/src/pdf/template/blocks/field.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 10ef51a8..b97063fc 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -218,6 +218,7 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it - **`headingStyle`** (`parties`, `payment`, `annotations`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. - **`style`** (party panels) names a style for a panel's value lines. A labelled group inherits it unless it declares its own, so the built-in templates set `partyIdentity` on the panel — the counterparty's name and tax number — and let the address and contact groups drop to the smaller `partyDetails`. Headings keep the panel's heading style either way. - **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. +- **`suffixPath`** appends a second binding after the value, separated by a space — an amount and its currency are one fact and print as `800,00 EUR` rather than as a number in one row and a code in another. It is dropped when it resolves empty, never read when the value itself is absent, and read at the same strictness as the value it follows. - **`sub`** (table columns only) prints a second, smaller line under the cell's value, joining `label value` pairs and dropping every entry the row leaves empty. It exists for the line-item classifiers — `Indeks`, `GTIN`, `PKWiU`, `CN`, `PKOB` — which are all optional and of which a real invoice carries one or two: a column's width is fixed for the whole table, so giving each its own column would leave most invoices with several empty ones. `subStyle` names the style for that line and `subSeparator` replaces the default `' · '`. - **`width`** (table columns only) sizes a column: a number of points, `'auto'` to fit the content, or `'*'` to share out what is left (the default). Sizing is worth setting explicitly — pdfmake gives every `'*'` column the *same* width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token silently widens the whole table past the page edge. - **`sum`** (totals rows only) adds several binding paths instead of reading one. A KSeF invoice has no single net or VAT total — the amounts are split across the `P_13_*` and `P_14_*` rate buckets, with zero-rated sales split three further ways (`P_13_6_1` domestic, `P_13_6_2` intra-EU supply, `P_13_6_3` export) — so the built-in templates aggregate them. Absent buckets are skipped, and the addition is decimal-exact. A totals row takes either `path` or `sum`, never both. diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 3b84499c..020d7606 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -53,6 +53,7 @@ export const en: LabelBundle = { paid: 'Paid', paymentDate: 'Payment due', paymentMethod: 'Payment method', + amountDueTotal: 'Total amount due', bankAccounts: 'Bank account', bankAccount: 'Account number', swift: 'SWIFT / BIC', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index b3a4b95a..1fdb488e 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -56,6 +56,7 @@ export const pl: LabelBundle = { paid: 'Zapłacono', paymentDate: 'Termin płatności', paymentMethod: 'Forma płatności', + amountDueTotal: 'Kwota należności ogółem', bankAccounts: 'Rachunek bankowy', bankAccount: 'Numer rachunku', swift: 'Kod SWIFT', diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index f6283b4f..b3574cea 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -61,6 +61,7 @@ export const uk: LabelBundle = { paid: 'Сплачено', paymentDate: 'Термін оплати', paymentMethod: 'Спосіб оплати', + amountDueTotal: 'Загальна сума до сплати', bankAccounts: 'Банківський рахунок', bankAccount: 'Номер рахунку', swift: 'SWIFT / BIC', diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts index 10f623fc..57e50904 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts @@ -1,6 +1,6 @@ -import { applyFormat } from '../../format.js'; import type { AnnotationsBlock } from '../dsl.js'; import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; +import { readField } from './field.js'; /** * Legal annotations: an `annotations` heading followed by one `label: value` @@ -16,8 +16,8 @@ export const annotationsRenderer: BlockRenderer = (block, ctx) const stack: PdfNode[] = [{ text: ctx.label('annotations'), style: block.headingStyle ?? 'h2' }]; for (const field of block.fields) { - const value = resolveBinding(field.path, field.optional ? lenientCtx : ctx); - stack.push({ text: `${ctx.label(field.label)}: ${applyFormat(value, field.format)}` }); + const value = readField(field, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx)); + stack.push({ text: `${ctx.label(field.label)}: ${value}` }); } return { diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/cell.ts b/packages/ksef-client-ts/src/pdf/template/blocks/cell.ts index ffa4427f..33758e00 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/cell.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/cell.ts @@ -1,6 +1,6 @@ -import { applyFormat } from '../../format.js'; import type { ColumnDef } from '../dsl.js'; import type { PdfNode, RenderContext } from '../interpret.js'; +import { readField } from './field.js'; /** Separator between `sub` entries when the column names none. */ const DEFAULT_SUB_SEPARATOR = ' · '; @@ -24,11 +24,11 @@ export function buildCell( read: (path: string, optional: boolean) => string, ctx: RenderContext, ): PdfNode { - const value = applyFormat(read(column.path, column.optional === true), column.format); + const value = readField(column, read); const parts: string[] = []; for (const sub of column.sub ?? []) { - const text = applyFormat(read(sub.path, sub.optional === true), sub.format); + const text = readField(sub, read); if (text !== '') parts.push(`${ctx.label(sub.label)} ${text}`); } diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/field.ts b/packages/ksef-client-ts/src/pdf/template/blocks/field.ts new file mode 100644 index 00000000..95b5fc69 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/field.ts @@ -0,0 +1,27 @@ +import { applyFormat } from '../../format.js'; +import type { FieldDef } from '../dsl.js'; + +/** + * Read one labelled field into its printable value: the binding, formatted, and + * — when the field names a `suffixPath` — a second binding appended after a + * space. An amount and its currency are one fact, and splitting them across two + * lines leaves the reader to join them up. + * + * The suffix is read at the same strictness as the value it follows, so a typo + * in it is caught wherever the value itself would be. It is dropped when it + * resolves empty, and never read at all when the value does — an absent field + * prints nothing, not a bare currency code. + * + * Reading is left to the caller: the same definition resolves against the + * document root in one block and row-relative in another. + */ +export function readField( + field: FieldDef, + read: (path: string, optional: boolean) => string, +): string { + const optional = field.optional === true; + const value = applyFormat(read(field.path, optional), field.format); + if (value === '' || field.suffixPath === undefined) return value; + const suffix = read(field.suffixPath, optional); + return suffix === '' ? value : `${value} ${suffix}`; +} diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts index 9cc82e49..bea5f074 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -1,6 +1,6 @@ import { get, list } from '../../accessor.js'; -import { applyFormat } from '../../format.js'; import type { PaymentBlock } from '../dsl.js'; +import { readField } from './field.js'; import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; /** @@ -26,7 +26,7 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { const stack: PdfNode[] = [{ text: ctx.label('payment'), style: heading }]; for (const row of block.rows) { - const value = applyFormat(resolveBinding(row.path, row.optional ? lenientCtx : ctx), row.format); + const value = readField(row, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx)); if (value === '') continue; stack.push({ text: `${ctx.label(row.label)}: ${value}` }); } @@ -35,7 +35,7 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { const lines: PdfNode[] = []; for (const account of list(ctx.root, block.accounts.from)) { for (const field of block.accounts.fields) { - const value = applyFormat(get(account, field.path, field.optional ? false : ctx.strict), field.format); + const value = readField(field, (path, optional) => get(account, path, optional ? false : ctx.strict)); if (value === '') continue; lines.push({ text: `${ctx.label(field.label)}: ${value}` }); } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 16681bbe..8c174be4 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -195,7 +195,8 @@ "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, - { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true } + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, + { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty" } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 7d866d0e..d46057d1 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -194,7 +194,8 @@ "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, - { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true } + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, + { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty" } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index df2c575b..c84fc86f 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -167,7 +167,8 @@ "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, - { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true } + { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, + { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty" } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index cfc710a5..77cfcbea 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -57,6 +57,13 @@ export interface FieldDef { optional?: boolean; format?: FormatterName; style?: string; + /** + * A second binding appended after the value, separated by a space — an amount + * and its currency are one fact and read as one (`800,00 EUR`), not as two + * lines a reader has to join up. Dropped when it resolves empty, and read at + * the same strictness as the value it follows. + */ + suffixPath?: string; } /** @@ -441,6 +448,7 @@ const fieldDef = z optional: z.boolean().optional(), format: formatEnum.optional(), style: z.string().optional(), + suffixPath: z.string().optional(), }) .strict(); @@ -451,6 +459,7 @@ const columnDef = z optional: z.boolean().optional(), format: formatEnum.optional(), style: z.string().optional(), + suffixPath: z.string().optional(), width: z.union([z.number().positive(), z.literal('auto'), z.literal('*')]).optional(), sub: z.array(fieldDef).nonempty().optional(), subStyle: z.string().optional(), diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index b55919ba..4f31b1d9 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -15,6 +15,7 @@ import { totalsRenderer } from '../../../src/pdf/template/blocks/totals.js'; import { paymentRenderer } from '../../../src/pdf/template/blocks/payment.js'; import { annotationsRenderer } from '../../../src/pdf/template/blocks/annotations.js'; import { footerRenderer } from '../../../src/pdf/template/blocks/footer.js'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; // ── test harness ──────────────────────────────────────────────────────────── @@ -1053,3 +1054,70 @@ describe('column sub-lines', () => { expect(render).not.toThrow(); }); }); + +// ── value suffixes ─────────────────────────────────────────────────────────── + +/** + * An amount and its currency are one fact. `suffixPath` appends the second + * binding to the first so they print as `800,00 EUR`, rather than leaving the + * reader to pair a number in one row with a currency code in another. + */ +describe('suffixPath', () => { + const root = { Fa: { P_15: '800.00', KodWaluty: 'EUR', Platnosc: { FormaPlatnosci: '6' } } }; + + const paymentRows = (rows: unknown[], over: Record = {}) => + rec( + paymentRenderer( + { type: 'payment', rows: rows as never, ...over }, + makeCtx(root), + noRender, + ), + ).stack.map((n: { text: string }) => n.text); + + it('appends the second binding after a space', () => { + const rows = paymentRows([{ label: 'amountDueTotal', path: 'Fa.P_15', format: 'money', suffixPath: 'Fa.KodWaluty' }]); + expect(rows).toContain('amountDueTotal: 800,00 EUR'); + }); + + it('formats the value first, then appends', () => { + // The formatter belongs to the value: the suffix must not be swept into it. + const rows = paymentRows([{ label: 'amountDueTotal', path: 'Fa.P_15', suffixPath: 'Fa.KodWaluty' }]); + expect(rows).toContain('amountDueTotal: 800.00 EUR'); // unformatted value, suffix still appended + }); + + it('prints the value alone when the suffix resolves empty', () => { + const rows = paymentRows([{ label: 'amountDueTotal', path: 'Fa.P_15', format: 'money', suffixPath: 'Fa.Nieistnieje', optional: true }]); + expect(rows).toContain('amountDueTotal: 800,00'); + }); + + it('prints nothing at all when the value itself is absent', () => { + // Never a bare currency code: an absent amount drops the whole row. + const rows = paymentRows([{ label: 'amountDueTotal', path: 'Fa.Brak', format: 'money', suffixPath: 'Fa.KodWaluty', optional: true }]); + expect(rows.some((t: string) => t.includes('EUR'))).toBe(false); + }); + + it('reads the suffix at the strictness of the value it follows', () => { + // A typo in a required field's suffix is a typo, and strict is what exists + // to catch it. + const render = () => + paymentRenderer( + { type: 'payment', rows: [{ label: 'amountDueTotal', path: 'Fa.P_15', suffixPath: 'Fa.KodWalutyy' }] as never }, + makeCtx(root, { strict: true }), + noRender, + ); + expect(render).toThrow(/KodWalutyy/); + }); +}); + +describe('the built-in templates restate the amount due with its currency', () => { + it.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s does it in the payment block', (name) => { + const payment = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'payment') as { + rows: Array<{ label: string; path: string; suffixPath?: string }>; + }; + const row = payment.rows.find((r) => r.label === 'amountDueTotal')!; + expect(row.path).toBe('Fa.P_15'); + expect(row.suffixPath).toBe('Fa.KodWaluty'); + // It restates the total, so it belongs after the terms it settles. + expect(payment.rows.at(-1)).toBe(row); + }); +}); From 9f7b830dd1e971ec5da1fd1a45110f5075cfd430 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:27:27 +0200 Subject: [PATCH 36/67] feat(pdf): head the notes section, and put the notes a level below it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caller's notes arrived on the page with no heading over them, so they read as text that had fallen off the end of the invoice rather than as a part of it. The section is now titled — Pozostałe informacje — and each note's own title sits a level below that, the same relation Adres has to Sprzedawca and Rachunek bankowy has to Płatność. That heading follows the block's headingStyle, which the built-in templates set to section level; the note titles stay on h2 whatever it is set to. Lifting a section heading has never dragged what sits under it along, and this block is no exception. A render supplying no notes still leaves nothing behind — the heading goes with them. Verified: unit 2711, e2e 153, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 2 +- packages/ksef-client-ts/src/pdf/i18n/en.ts | 1 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 1 + packages/ksef-client-ts/src/pdf/i18n/uk.ts | 1 + .../src/pdf/template/blocks/notes.ts | 27 ++++++++---- .../tests/unit/pdf/notes.test.ts | 42 ++++++++++++------- 6 files changed, 52 insertions(+), 22 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index b97063fc..d2e0085d 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -338,7 +338,7 @@ const pdf = await renderInvoicePdf(xml, 'fa3-default', { Both halves are plain text — no bindings, no markup, and a `\n` is a line break. A note therefore cannot reach into the document or disturb the layout around it. An entry blank on both halves is dropped, one with only a head or only a body prints that half, and a render with no notes leaves no trace of the block at all. -Each note's heading takes the block's `headingStyle` — the built-in templates set `h1`, the same level as `Płatność`, since a note is a section of its own rather than a label inside one — and the bodies are body text. From the CLI the sections come from a JSON file: +The section carries its own heading — `Pozostałe informacje` — so the notes read as part of the document rather than as text that fell off the end of it. That heading takes the block's `headingStyle`, which the built-in templates set to `h1`, the same level as `Płatność`; each note's own title sits a level below it on `h2`, as sub-headings do in every block. The bodies are body text. From the CLI the sections come from a JSON file: ```bash ksef invoice pdf invoice.xml --notes ./notes.json diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 020d7606..de98690b 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -59,6 +59,7 @@ export const en: LabelBundle = { swift: 'SWIFT / BIC', bankName: 'Bank name', annotations: 'Annotations', + notes: 'Additional information', upoTitle: 'Official Receipt Confirmation (UPO)', ksefDocNumber: 'KSeF document number', sessionRef: 'Session reference number', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 1fdb488e..823afa34 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -63,6 +63,7 @@ export const pl: LabelBundle = { bankName: 'Nazwa banku', // annotations annotations: 'Adnotacje', + notes: 'Pozostałe informacje', // upo upoTitle: 'Urzędowe Poświadczenie Odbioru (UPO)', ksefDocNumber: 'Numer KSeF dokumentu', diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index b3574cea..030bd3cf 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -67,6 +67,7 @@ export const uk: LabelBundle = { swift: 'SWIFT / BIC', bankName: 'Назва банку', annotations: 'Примітки', + notes: 'Додаткова інформація', upoTitle: 'Офіційне підтвердження отримання (UPO)', ksefDocNumber: 'Номер документа в KSeF', sessionRef: 'Референсний номер сесії', diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/notes.ts b/packages/ksef-client-ts/src/pdf/template/blocks/notes.ts index 0daefcd0..15d9ccd9 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/notes.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/notes.ts @@ -1,6 +1,9 @@ import type { NotesBlock } from '../dsl.js'; import type { BlockRenderer, PdfNode } from '../interpret.js'; +/** Sub-headings within a block. Fixed, and the same across every block. */ +const SUBHEADING_STYLE = 'h2'; + /** * Caller-supplied sections, printed in order where the template puts this * block: each note's `head` as a heading over its `body`. The content is not in @@ -12,23 +15,33 @@ import type { BlockRenderer, PdfNode } from '../interpret.js'; * can neither reach into the invoice nor disturb the layout around it; a `\n` * in the body is a line break and that is the whole of it. * + * The section carries its own heading, so the notes read as part of the + * document rather than as text that fell off the end of it. Each note's title + * sits a level below that, as sub-headings do in every other block. + * * An entry with nothing in it is skipped, and a block with no notes at all - * renders nothing rather than an empty gap — so a template can carry the block - * unconditionally and a render that supplies no notes looks as if it were never - * there. + * renders nothing rather than an empty gap — heading included — so a template + * can carry the block unconditionally and a render that supplies no notes looks + * as if it were never there. */ export const notesRenderer: BlockRenderer = (block, ctx) => { - const stack: PdfNode[] = []; + const notes: PdfNode[] = []; for (const note of ctx.notes ?? []) { const head = (note.head ?? '').trim(); const body = (note.body ?? '').trim(); if (head === '' && body === '') continue; - if (head !== '') stack.push({ text: head, style: block.headingStyle ?? 'h2' }); - if (body !== '') stack.push({ text: body }); + // A note's own title is a heading inside the section, one level below the + // section's — the same relation `Adres` has to `Sprzedawca`. + if (head !== '') notes.push({ text: head, style: SUBHEADING_STYLE }); + if (body !== '') notes.push({ text: body }); } - if (stack.length === 0) return null; + if (notes.length === 0) return null; + const stack: PdfNode[] = [ + { text: ctx.label('notes'), style: block.headingStyle ?? SUBHEADING_STYLE }, + ...notes, + ]; return { stack, margin: [0, 4, 0, 8], diff --git a/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts b/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts index 46667cc8..5c19c1d0 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts @@ -30,9 +30,10 @@ describe('notesRenderer', () => { { head: 'Uwaga', body: 'Prosimy o podanie numeru faktury w tytule przelewu.' }, ]; - it('prints each note as a heading over its body, in order', () => { + it('heads the section, then prints each note over its body, in order', () => { const out = rec(notesRenderer({ type: 'notes' }, ctxWith(two), noRender)); expect(out.stack.map((n) => n.text)).toEqual([ + 'notes', 'Warunki dostawy', 'Towar wydany w magazynie sprzedawcy.', 'Uwaga', @@ -40,16 +41,20 @@ describe('notesRenderer', () => { ]); }); - it('puts the heading on h2 by default', () => { + it('puts both levels on h2 by default, as every block does', () => { const out = rec(notesRenderer({ type: 'notes' }, ctxWith(two), noRender)); - expect(out.stack[0].style).toBe('h2'); - expect(out.stack[1].style).toBeUndefined(); // the body is body text + expect(out.stack[0].style).toBe('h2'); // the section + expect(out.stack[1].style).toBe('h2'); // a note's own title + expect(out.stack[2].style).toBeUndefined(); // the body is body text }); - it('takes the heading style the template names', () => { + it('lifts only the section heading when the template names a style', () => { + // The note titles stay a level below it — lifting a section heading must + // not drag everything under it along. const out = rec(notesRenderer({ type: 'notes', headingStyle: 'h1' }, ctxWith(two), noRender)); expect(out.stack[0].style).toBe('h1'); - expect(out.stack[2].style).toBe('h1'); + expect(out.stack[1].style).toBe('h2'); + expect(out.stack[3].style).toBe('h2'); }); it('renders nothing at all when no notes were supplied', () => { @@ -65,7 +70,7 @@ describe('notesRenderer', () => { { head: ' ', body: '\n' }, { head: 'Kept', body: 'Also kept' }, ]), noRender)); - expect(out.stack.map((n) => n.text)).toEqual(['Kept', 'Also kept']); + expect(out.stack.map((n) => n.text)).toEqual(['notes', 'Kept', 'Also kept']); }); it('prints a note that has only one half', () => { @@ -73,9 +78,9 @@ describe('notesRenderer', () => { { head: 'Heading alone', body: '' }, { head: '', body: 'Body alone' }, ]), noRender)); - expect(out.stack.map((n) => n.text)).toEqual(['Heading alone', 'Body alone']); - expect(out.stack[0].style).toBe('h2'); - expect(out.stack[1].style).toBeUndefined(); + expect(out.stack.map((n) => n.text)).toEqual(['notes', 'Heading alone', 'Body alone']); + expect(out.stack[1].style).toBe('h2'); + expect(out.stack[2].style).toBeUndefined(); }); it('carries the block style when the template names one', () => { @@ -89,7 +94,11 @@ describe('notesRenderer', () => { const out = rec(notesRenderer({ type: 'notes' }, ctxWith([ { head: 'Fa.P_15', body: '{{ not a template }} — 100% & ' }, ]), noRender)); - expect(out.stack.map((n) => n.text)).toEqual(['Fa.P_15', '{{ not a template }} — 100% & ']); + expect(out.stack.map((n) => n.text)).toEqual([ + 'notes', + 'Fa.P_15', + '{{ not a template }} — 100% & ', + ]); }); }); @@ -115,7 +124,11 @@ describe('the notes option reaches the block', () => { it('prints the supplied notes through the built-in template', () => { const node = notesStack([{ head: 'Warunki dostawy', body: 'DAP Warszawa' }]); expect(node).toBeDefined(); - expect((node!.stack as Array<{ text: string }>).map((n) => n.text)).toEqual(['Warunki dostawy', 'DAP Warszawa']); + expect((node!.stack as Array<{ text: string }>).map((n) => n.text)).toEqual([ + 'Pozostałe informacje', + 'Warunki dostawy', + 'DAP Warszawa', + ]); }); it('leaves the page untouched when none are supplied', () => { @@ -166,8 +179,9 @@ describe('the notes option reaches the block', () => { expect(getBuiltinTemplate(name)!.blocks.some((b) => b.type === 'notes')).toBe(true); }); - it.each(['fa2-default', 'fa3-default'])('%s heads its notes at section level', (name) => { - // A note is a section of its own, like Płatność — not a label inside one. + it.each(['fa2-default', 'fa3-default'])('%s heads the section at section level', (name) => { + // The section reads as part of the document, like Płatność; the individual + // notes sit under it, like the labels inside any other block. const block = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'notes') as { headingStyle?: string }; expect(block.headingStyle).toBe('h1'); }); From 5f6a50c8628b514e02e893d7b04d0b0ff8a4e7d4 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:40:57 +0200 Subject: [PATCH 37/67] feat(pdf): let a template emphasise one totals row, and stop emphasising all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The totals table set every label in bold, which is a column of emphasis that emphasises nothing: the one figure a reader is looking for — the amount due — sat among a dozen rate buckets shouting just as loudly. Emphasis is now a template's choice, row by row. `style` on a totals row covers both of its cells, since a label and its figure are one line to a reader and styling half of it reads as a mistake; on a payment row and a bank-account field it covers the line. The built-in templates use it to set the amount due and its currency in bold, in the totals and again under the payment terms, and leave everything else plain. Both of those `style` fields were declared and read by nothing until now, which is why the emphasis had to be hardcoded in the first place. Verified: unit 2717, e2e 153, tsc, markdownlint, build. --- packages/ksef-client-ts/docs/pdf-export.md | 1 + .../src/pdf/template/blocks/payment.ts | 4 +- .../src/pdf/template/blocks/totals.ts | 11 ++- .../src/pdf/template/builtin/fa2-default.json | 7 +- .../src/pdf/template/builtin/fa3-default.json | 7 +- .../pdf/template/builtin/fa3-showcase.json | 7 +- .../tests/e2e/35-invoice-pdf-cli.test.ts | 2 +- .../tests/unit/pdf/blocks-semantic.test.ts | 98 ++++++++++++++++++- 8 files changed, 121 insertions(+), 16 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index d2e0085d..9f5d84c4 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -218,6 +218,7 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it - **`headingStyle`** (`parties`, `payment`, `annotations`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. - **`style`** (party panels) names a style for a panel's value lines. A labelled group inherits it unless it declares its own, so the built-in templates set `partyIdentity` on the panel — the counterparty's name and tax number — and let the address and contact groups drop to the smaller `partyDetails`. Headings keep the panel's heading style either way. - **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. +- **`style`** names a style for one row: a totals row (covering both its label and its figure — they are one line to a reader) or a payment/annotation field. The built-in templates use it to set the amount due and its currency in bold, in the totals and again under the payment terms. - **`suffixPath`** appends a second binding after the value, separated by a space — an amount and its currency are one fact and print as `800,00 EUR` rather than as a number in one row and a code in another. It is dropped when it resolves empty, never read when the value itself is absent, and read at the same strictness as the value it follows. - **`sub`** (table columns only) prints a second, smaller line under the cell's value, joining `label value` pairs and dropping every entry the row leaves empty. It exists for the line-item classifiers — `Indeks`, `GTIN`, `PKWiU`, `CN`, `PKOB` — which are all optional and of which a real invoice carries one or two: a column's width is fixed for the whole table, so giving each its own column would leave most invoices with several empty ones. `subStyle` names the style for that line and `subSeparator` replaces the default `' · '`. - **`width`** (table columns only) sizes a column: a number of points, `'auto'` to fit the content, or `'*'` to share out what is left (the default). Sizing is worth setting explicitly — pdfmake gives every `'*'` column the *same* width and never shrinks it below the widest minimum content width among them, so a single long unbreakable token silently widens the whole table past the page edge. diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts index bea5f074..ac1d3d27 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -28,7 +28,7 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { for (const row of block.rows) { const value = readField(row, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx)); if (value === '') continue; - stack.push({ text: `${ctx.label(row.label)}: ${value}` }); + stack.push({ text: `${ctx.label(row.label)}: ${value}`, ...(row.style ? { style: row.style } : {}) }); } if (block.accounts) { @@ -37,7 +37,7 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { for (const field of block.accounts.fields) { const value = readField(field, (path, optional) => get(account, path, optional ? false : ctx.strict)); if (value === '') continue; - lines.push({ text: `${ctx.label(field.label)}: ${value}` }); + lines.push({ text: `${ctx.label(field.label)}: ${value}`, ...(field.style ? { style: field.style } : {}) }); } } if (lines.length > 0) { diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts index 444d8e49..608b48d5 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts @@ -4,7 +4,7 @@ import { evalWhen, resolveBinding, type BlockRenderer, type PdfNode } from '../i /** * Totals summary: a compact, right-aligned label/value table. Each row of - * {@link TotalsBlock.rows} contributes a bold label (`ctx.label(row.label)`) + * {@link TotalsBlock.rows} contributes a label (`ctx.label(row.label)`) * and its formatted value — either one scalar binding (`path`) or the decimal * sum of several (`sum`), because net sales and tax are split across the * `P_13_*`/`P_14_*` rate buckets and no single total field exists. The @@ -32,9 +32,14 @@ export const totalsRenderer: BlockRenderer = (block, ctx) => { // template listing every rate bucket must not print a dangling label for // each one an invoice does not use. if (value === '') continue; + // A row's style covers both of its cells: label and figure are one line to + // a reader, and styling half of it reads as a mistake. Nothing here is bold + // by default — a column of bold labels emphasises everything and therefore + // nothing; a template picks the one or two rows worth picking out. + const rowStyle = row.style ? { style: row.style } : {}; body.push([ - { text: ctx.label(row.label), bold: true }, - { text: value, alignment: 'right' }, + { text: ctx.label(row.label), ...rowStyle }, + { text: value, alignment: 'right', ...rowStyle }, ]); } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 8c174be4..4dd86fea 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -6,6 +6,7 @@ "title": { "fontSize": 20, "bold": true }, "h1": { "fontSize": 11, "bold": true, "margin": [0, 10, 0, 3], "color": "#000000" }, "lineMeta": { "fontSize": 6.5, "color": "#7A8CA0" }, + "strong": { "bold": true }, "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, @@ -182,8 +183,8 @@ "when": "totalsSummary", "format": "money" }, - { "label": "totalDue", "path": "Fa.P_15", "format": "money" }, - { "label": "currency", "path": "Fa.KodWaluty" } + { "label": "totalDue", "path": "Fa.P_15", "format": "money", "style": "strong" }, + { "label": "currency", "path": "Fa.KodWaluty", "style": "strong" } ] }, { "type": "spacer", "height": 21 }, @@ -196,7 +197,7 @@ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, - { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty" } + { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty", "style": "strong" } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index d46057d1..907eccfa 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -6,6 +6,7 @@ "title": { "fontSize": 20, "bold": true }, "h1": { "fontSize": 11, "bold": true, "margin": [0, 10, 0, 3], "color": "#000000" }, "lineMeta": { "fontSize": 6.5, "color": "#7A8CA0" }, + "strong": { "bold": true }, "h2": { "fontSize": 10, "bold": true, "margin": [0, 10, 0, 3], "color": "#333333" }, "muted": { "color": "#666666", "fontSize": 8 }, "offline": { "color": "#b00020", "bold": true }, @@ -182,8 +183,8 @@ "when": "totalsSummary", "format": "money" }, - { "label": "totalDue", "path": "Fa.P_15", "format": "money" }, - { "label": "currency", "path": "Fa.KodWaluty" } + { "label": "totalDue", "path": "Fa.P_15", "format": "money", "style": "strong" }, + { "label": "currency", "path": "Fa.KodWaluty", "style": "strong" } ] }, { "type": "divider" }, @@ -195,7 +196,7 @@ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, - { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty" } + { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty", "style": "strong" } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index c84fc86f..640fd64b 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -20,6 +20,7 @@ "title": { "fontSize": 22, "bold": true, "characterSpacing": 5, "color": "#0B2545", "margin": [0, 0, 0, 2] }, "h1": { "fontSize": 9, "bold": true, "color": "#EF476F", "characterSpacing": 3, "margin": [0, 8, 0, 3] }, "lineMeta": { "fontSize": 6, "color": "#8AA4BD", "italics": true }, + "strong": { "bold": true, "color": "#0B2545" }, "h2": { "fontSize": 7, "bold": true, "color": "#8AA4BD", "characterSpacing": 2, "margin": [0, 5, 0, 2] }, "partyName": { "fontSize": 11, "bold": true, "color": "#0B2545", "lineHeight": 1.05 }, "partyMeta": { "fontSize": 7.5, "color": "#5B7B9A", "lineHeight": 1.3 }, @@ -155,8 +156,8 @@ "when": "totalsSummary", "format": "money" }, - { "label": "totalDue", "path": "Fa.P_15", "format": "money" }, - { "label": "currency", "path": "Fa.KodWaluty" } + { "label": "totalDue", "path": "Fa.P_15", "format": "money", "style": "strong" }, + { "label": "currency", "path": "Fa.KodWaluty", "style": "strong" } ] }, { "type": "spacer", "height": 4 }, @@ -168,7 +169,7 @@ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, - { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty" } + { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty", "style": "strong" } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 572c0008..830e70f2 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -215,7 +215,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { [`${PREFIX}-11-showcase-template-accent`, () => [ fx('e2e-vat-multi.xml'), '--template', 'fa3-showcase', ...LOGO(), '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', - '--totals', 'both', '--notes', notesFile, '--accent', ACCENT_SHORT, + '--totals', 'summary', '--notes', notesFile, '--accent', ACCENT_SHORT, ]], // Receipts last: they are a different document and read as their own group. [`${PREFIX}-12-upo-pl`, () => [fx('upo-4_3.xml')]], diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index 4f31b1d9..8043c4bc 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -669,7 +669,8 @@ describe('totalsRenderer', () => { expect(summary.table.body).toHaveLength(2); const [labelCell, valueCell] = summary.table.body[0]; expect(labelCell.text).toBe('totalNet'); - expect(labelCell.bold).toBe(true); + // Not bold: emphasis in this table is a template's choice, row by row. + expect(labelCell.bold).toBeUndefined(); expect(valueCell.text).toBe(applyFormat('100', 'money')); // '100,00' expect(valueCell.alignment).toBe('right'); }); @@ -1121,3 +1122,98 @@ describe('the built-in templates restate the amount due with its currency', () = expect(payment.rows.at(-1)).toBe(row); }); }); + +// ── row styles ─────────────────────────────────────────────────────────────── + +/** + * `style` on a totals row or a payment field was declared and read by nothing. + * It has a reader now, which is what lets a template pick out the one figure on + * the page a reader is looking for. + */ +describe('row style', () => { + it('covers both cells of a totals row', () => { + // Label and figure are one line to a reader; styling half of it reads as a + // mistake rather than as emphasis. + const node = rec( + totalsRenderer( + { + type: 'totals', + rows: [ + { label: 'totalNet', path: 'Fa.P_13_1', format: 'money' }, + { label: 'totalDue', path: 'Fa.P_15', format: 'money', style: 'strong' }, + ], + }, + makeCtx({ Fa: { P_13_1: '100.00', P_15: '123.00' } }), + noRender, + ), + ); + const [plain, strong] = node.columns[1].table.body; + expect(plain.map((c: { style?: string }) => c.style)).toEqual([undefined, undefined]); + expect(strong.map((c: { style?: string }) => c.style)).toEqual(['strong', 'strong']); + }); + + it('reaches a payment row and a bank-account field', () => { + const node = rec( + paymentRenderer( + { + type: 'payment', + rows: [ + { label: 'paymentMethod', path: 'Fa.Platnosc.FormaPlatnosci' }, + { label: 'amountDueTotal', path: 'Fa.P_15', style: 'strong' }, + ], + accounts: { + from: 'Fa.Platnosc.RachunekBankowy', + fields: [{ label: 'bankAccount', path: 'NrRB', style: 'strong' }], + }, + }, + makeCtx({ Fa: { P_15: '123.00', Platnosc: { FormaPlatnosci: '6', RachunekBankowy: { NrRB: 'PL01' } } } }), + noRender, + ), + ); + const byText = Object.fromEntries( + node.stack.map((n: { text: string; style?: string }) => [n.text.split(':')[0], n.style]), + ); + expect(byText.paymentMethod).toBeUndefined(); + expect(byText.amountDueTotal).toBe('strong'); + expect(byText.bankAccount).toBe('strong'); + }); +}); + +describe('the built-in templates pick out the amount due', () => { + it.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s emphasises it in both places', (name) => { + const template = getBuiltinTemplate(name)!; + const totals = template.blocks.find((b) => b.type === 'totals') as { + rows: Array<{ label: string; style?: string }>; + }; + const payment = template.blocks.find((b) => b.type === 'payment') as { + rows: Array<{ label: string; style?: string }>; + }; + // The figure and the currency it is denominated in, wherever they appear. + expect(totals.rows.find((r) => r.label === 'totalDue')!.style).toBe('strong'); + expect(totals.rows.find((r) => r.label === 'currency')!.style).toBe('strong'); + expect(payment.rows.find((r) => r.label === 'amountDueTotal')!.style).toBe('strong'); + expect(Object.keys(template.styles ?? {})).toContain('strong'); + }); +}); + +describe('totals emphasise nothing on their own', () => { + it('leaves every row plain until a template says otherwise', () => { + const node = rec( + totalsRenderer( + { + type: 'totals', + rows: [ + { label: 'totalNet', path: 'Fa.P_13_1', format: 'money' }, + { label: 'totalDue', path: 'Fa.P_15', format: 'money', style: 'strong' }, + ], + }, + makeCtx({ Fa: { P_13_1: '100.00', P_15: '123.00' } }), + noRender, + ), + ); + const [plain, strong] = node.columns[1].table.body; + // A column of bold labels emphasises everything and so emphasises nothing. + expect(plain.every((c: { bold?: boolean }) => c.bold === undefined)).toBe(true); + expect(strong.every((c: { style?: string }) => c.style === 'strong')).toBe(true); + }); +}); From d196e4b085465fd7ceb32011cb08e66a1a44f81e Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:48:06 +0200 Subject: [PATCH 38/67] fix(cli): accept a note that carries only one of its two halves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: `--notes` rejected any entry without both `head` and `body`, while the renderer prints whichever half a note carries and docs/pdf-export.md states that in as many words. A CLI stricter than the API it fronts refuses input the reader was told was valid. Either half may now be omitted and is normalized to ''. An entry with neither half, and a half present but not a string, are still refused — those are shape mistakes, not intent. Co-Authored-By: Claude Code --- .../src/cli/commands/invoice.ts | 19 ++++++++-- .../tests/e2e/35-invoice-pdf-cli.test.ts | 35 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 937f162c..6976ded3 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -621,6 +621,11 @@ function readImageAsDataUri(file: string): string { * Read the `--notes` file: an array of `{ head, body }`. Validated here rather * than left to the renderer, because a hand-written JSON file is exactly where a * shape mistake happens and a silent one would print nothing at all. + * + * Either half may be left out — a note that is only a heading, or only a body, + * prints that half, which is what the renderer does with one and what the docs + * promise. What is refused is an entry carrying neither, and a half that is + * present but not a string: both are shape mistakes rather than intent. */ function readNotesFile(file: string): Array<{ head: string; body: string }> { if (!fs.existsSync(file)) { @@ -637,10 +642,18 @@ function readNotesFile(file: string): Array<{ head: string; body: string }> { } return parsed.map((entry, i) => { const note = entry as { head?: unknown; body?: unknown }; - if (typeof note?.head !== 'string' || typeof note?.body !== 'string') { - throw new Error(`Notes entry ${i} must have string "head" and "body": ${file}`); + for (const half of ['head', 'body'] as const) { + if (note?.[half] !== undefined && typeof note[half] !== 'string') { + throw new Error(`Notes entry ${i} has a non-string "${half}": ${file}`); + } + } + if (typeof note?.head !== 'string' && typeof note?.body !== 'string') { + throw new Error(`Notes entry ${i} must have a string "head", a string "body", or both: ${file}`); } - return { head: note.head, body: note.body }; + return { + head: typeof note.head === 'string' ? note.head : '', + body: typeof note.body === 'string' ? note.body : '', + }; }); } diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 830e70f2..2ba18e88 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -294,6 +294,41 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { expect(existsSync(out)).toBe(false); }); + // The renderer prints whichever half a note carries, and the docs say so, so + // the flag has to accept the same shape the library does — a CLI stricter than + // the API it fronts rejects input the user was told was valid. + it('accepts a note with only a head or only a body', () => { + const oneSided = join(inputsDir, `${PREFIX}-notes-one-sided.json`); + writeFileSync( + oneSided, + JSON.stringify([{ head: 'Tylko nagłówek' }, { body: 'Tylko treść.' }]), + ); + const out = join(outDir, `${PREFIX}-notes-one-sided.pdf`); + const res = run(['invoice', 'pdf', fx('fa3.xml'), '--notes', oneSided, '--out', out]); + expect(res.status, `exit ${res.status}\n${res.stderr}`).toBe(0); + expect(isCompletePdf(out), `${out} is not a complete PDF`).toBe(true); + }); + + it('still refuses a note entry that carries neither half', () => { + const empty = join(inputsDir, `${PREFIX}-notes-empty-entry.json`); + writeFileSync(empty, JSON.stringify([{ note: 'wrong key' }])); + const out = join(outDir, 'should-not-exist-5.pdf'); + const res = run(['invoice', 'pdf', fx('fa3.xml'), '--notes', empty, '--out', out]); + expect(res.status).not.toBe(0); + expect(`${res.stdout}${res.stderr}`).toMatch(/must have a string "head", a string "body", or both/); + expect(existsSync(out)).toBe(false); + }); + + it('still refuses a note half that is present but not a string', () => { + const wrongType = join(inputsDir, `${PREFIX}-notes-wrong-type.json`); + writeFileSync(wrongType, JSON.stringify([{ head: 'ok', body: 42 }])); + const out = join(outDir, 'should-not-exist-6.pdf'); + const res = run(['invoice', 'pdf', fx('fa3.xml'), '--notes', wrongType, '--out', out]); + expect(res.status).not.toBe(0); + expect(`${res.stdout}${res.stderr}`).toMatch(/non-string "body"/); + expect(existsSync(out)).toBe(false); + }); + // pdfmake silently ignores a colour it cannot parse, so an unrecognized accent // renders a document identical to an unthemed one. A misspelled colour name // has to fail at the flag, or it becomes a PDF that is quietly wrong. From 8331dfc3a340765ae6c4f09f43d3e7431cfa315d Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:49:02 +0200 Subject: [PATCH 39/67] fix(pdf): reject a pdfmake prerelease the supported range excludes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: the compatibility guard matched only the numeric prefix of the installed version, so `0.2.20-beta.1` passed as supported. A SemVer range admits a prerelease only when the comparator carries one of its own, so `^0.2.20` does not accept that build — and the renderer has never been tried against it. Anchor the pattern at both ends and admit build metadata, which the range does accept, while refusing a prerelease tag and trailing junk. Co-Authored-By: Claude Code --- packages/ksef-client-ts/src/pdf/fonts.ts | 12 +++++++++-- .../tests/unit/pdf/fonts.test.ts | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/fonts.ts b/packages/ksef-client-ts/src/pdf/fonts.ts index 727a1be8..ef55d734 100644 --- a/packages/ksef-client-ts/src/pdf/fonts.ts +++ b/packages/ksef-client-ts/src/pdf/fonts.ts @@ -41,9 +41,17 @@ function missingError(): KSeFPdfError { ); } -/** True iff `version` satisfies `^0.2.20` — i.e. `0.2.x` with `x >= 20`. */ +/** + * True iff `version` satisfies `^0.2.20` — i.e. `0.2.x` with `x >= 20`. + * + * The pattern is anchored at both ends and admits build metadata but not a + * prerelease tag, which is what `^0.2.20` means: SemVer ranges exclude + * prereleases unless the comparator carries one of its own. Matching a numeric + * prefix alone let `0.2.20-beta.1` through the compatibility guard — a build + * the range does not accept and this renderer has not been tried against. + */ export function satisfiesRequiredRange(version: string): boolean { - const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim()); + const m = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim()); if (!m) return false; const major = Number(m[1]); const minor = Number(m[2]); diff --git a/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts b/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts index 69574807..e7a757f5 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts @@ -39,6 +39,26 @@ describe('satisfiesRequiredRange', () => { it('rejects an empty string', () => { expect(satisfiesRequiredRange('')).toBe(false); }); + + // `^0.2.20` excludes prereleases: a SemVer range only admits them when the + // comparator carries a prerelease of its own. A build that calls itself + // 0.2.20-beta.1 is not a version this renderer has been tried against. + it('rejects a prerelease of an otherwise supported version', () => { + expect(satisfiesRequiredRange('0.2.20-beta.1')).toBe(false); + expect(satisfiesRequiredRange('0.2.21-rc.0')).toBe(false); + expect(satisfiesRequiredRange('0.3.0-alpha')).toBe(false); + }); + + it('accepts build metadata, which the range does admit', () => { + expect(satisfiesRequiredRange('0.2.20+build.5')).toBe(true); + expect(satisfiesRequiredRange('0.2.99+20260830')).toBe(true); + }); + + it('rejects a version with trailing junk', () => { + expect(satisfiesRequiredRange('0.2.20foo')).toBe(false); + expect(satisfiesRequiredRange('0.2.20.1')).toBe(false); + expect(satisfiesRequiredRange('v0.2.20')).toBe(false); + }); }); describe('normalizeVfs', () => { From 4d453dbc1915e47413e39321e2b52cb88aaf5b9e Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:50:54 +0200 Subject: [PATCH 40/67] fix(pdf): make the version detectors read the markers they claim to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46, two detectors accepted documents they should not: detectInvoiceVersion took either marker on its own, so a header stating kodSystemowy "FA (2)" alongside WariantFormularza 3 rendered as FA(3) — every binding resolved against the wrong schema, with a plausible page to show for it. Both markers must now agree when both are present; a lone marker still decides, as it did. detectUpoVersion scanned the whole source for the namespace marker, so any Potwierdzenie that merely mentioned the string — in a note, in an embedded document — passed as that version. It now reads the marker from the root element's own tag, prefixed or not. Co-Authored-By: Claude Code --- packages/ksef-client-ts/src/pdf/parse.ts | 31 ++++++++++-- .../tests/unit/pdf/parse.test.ts | 47 +++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/parse.ts b/packages/ksef-client-ts/src/pdf/parse.ts index 75031f44..a3b5915e 100644 --- a/packages/ksef-client-ts/src/pdf/parse.ts +++ b/packages/ksef-client-ts/src/pdf/parse.ts @@ -37,16 +37,32 @@ export function parseXmlForPdf(xml: string): Record { * for anything that is not a recognized FA(2)/FA(3) invoice. FA(1) is not * supported. */ +function versionFromKod(kod: string): InvoiceVersion | null { + return kod === 'FA(3)' ? 'FA(3)' : kod === 'FA(2)' ? 'FA(2)' : null; +} + +function versionFromVariant(variant: string): InvoiceVersion | null { + return variant === '3' ? 'FA(3)' : variant === '2' ? 'FA(2)' : null; +} + export function detectInvoiceVersion(xml: string): InvoiceVersion | null { const parsed = parseXmlForPdf(xml); if (!('Faktura' in parsed)) return null; const kod = get(parsed, 'Faktura.Naglowek.KodFormularza.@kodSystemowy').replace(/\s+/g, ''); const variant = get(parsed, 'Faktura.Naglowek.WariantFormularza').trim(); + const byKod = versionFromKod(kod); + const byVariant = versionFromVariant(variant); - if (kod === 'FA(3)' || variant === '3') return 'FA(3)'; - if (kod === 'FA(2)' || variant === '2') return 'FA(2)'; - return null; + // A document carrying both markers has to mean one version by them. Accepting + // either on its own let `FA (2)` paired with variant 3 render as FA(3) — every + // binding resolved against the wrong schema, and a plausible page to show for + // it. Every real KSeF invoice states both, so demanding they agree costs + // nothing and a disagreement is a document worth refusing. + if (kod !== '' && variant !== '') { + return byKod !== null && byKod === byVariant ? byKod : null; + } + return byKod ?? byVariant; } /** @@ -60,7 +76,12 @@ export function detectUpoVersion(xml: string): UpoVersion | null { const parsed = parseXmlForPdf(xml); if (!('Potwierdzenie' in parsed)) return null; - if (/KSeF\/v4-3\b/.test(xml)) return 'UPO(4.3)'; - if (/KSeF\/v4-2\b/.test(xml)) return 'UPO(4.2)'; + // Read the marker from the root element's own tag. A UPO declares its version + // in the default xmlns on ``, and scanning the whole source + // instead let any Potwierdzenie that merely mentions the string — in a note, + // in an embedded document — pass as that version. + const rootTag = /<(?:[\w.-]+:)?Potwierdzenie\b[^>]*>/.exec(xml)?.[0] ?? ''; + if (/KSeF\/v4-3\b/.test(rootTag)) return 'UPO(4.3)'; + if (/KSeF\/v4-2\b/.test(rootTag)) return 'UPO(4.2)'; return null; } diff --git a/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts index 7f11f817..ea7e5c43 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts @@ -126,6 +126,35 @@ describe('detectInvoiceVersion', () => { it('returns null for unrecognized / non-invoice XML', () => { expect(detectInvoiceVersion('baz')).toBeNull(); }); + + // A document that states both markers has to mean one version by them. + // Accepting either alone let a mismatched pair through, and every binding + // would then resolve against the wrong schema. + it('returns null when the two markers contradict each other', () => { + const kod2variant3 = + 'FA' + + '3'; + expect(detectInvoiceVersion(kod2variant3)).toBeNull(); + + const kod3variant2 = + 'FA' + + '2'; + expect(detectInvoiceVersion(kod3variant2)).toBeNull(); + }); + + it('returns null when one of two present markers is unrecognized', () => { + const xml = + 'FA' + + '3'; + expect(detectInvoiceVersion(xml)).toBeNull(); + }); + + it('still accepts a document whose two markers agree', () => { + const xml = + 'FA' + + '3'; + expect(detectInvoiceVersion(xml)).toBe('FA(3)'); + }); }); describe('detectUpoVersion', () => { @@ -148,6 +177,24 @@ describe('detectUpoVersion', () => { expect(detectUpoVersion(fa3Xml)).toBeNull(); }); + // The version lives in the root element's own xmlns. Scanning the whole + // source let any Potwierdzenie that merely mentions the string pass as that + // version — a quoted URL in a note is enough. + it('ignores the marker when it appears outside the root tag', () => { + const xml = + '' + + 'see http://upo.schematy.mf.gov.pl/KSeF/v4-3 for details' + + ''; + expect(detectUpoVersion(xml)).toBeNull(); + }); + + it('reads the marker from a namespace-prefixed root tag', () => { + const xml = + '' + + '1'; + expect(detectUpoVersion(xml)).toBe('UPO(4.3)'); + }); + it('returns null for unrecognized XML', () => { expect(detectUpoVersion('')).toBeNull(); }); From 602663a9b54136b40a9031014a507e056e263179 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:52:06 +0200 Subject: [PATCH 41/67] fix(pdf): refuse to build a verification code with no seller NIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: the issue date was policed before the Code I URL was built, the seller NIP was not. A default render reads bindings leniently, so an invoice missing Podmiot1/DaneIdentyfikacyjne/NIP produced a URL with a hole where the NIP belongs — a printed code that resolves nowhere and looks correct until someone scans it. Guard the NIP the same way, with the same shape of message. Co-Authored-By: Claude Code --- packages/ksef-client-ts/src/pdf/qr.ts | 10 ++++++++++ packages/ksef-client-ts/tests/unit/pdf/qr.test.ts | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/ksef-client-ts/src/pdf/qr.ts b/packages/ksef-client-ts/src/pdf/qr.ts index a06d2c13..2c865bfd 100644 --- a/packages/ksef-client-ts/src/pdf/qr.ts +++ b/packages/ksef-client-ts/src/pdf/qr.ts @@ -69,6 +69,16 @@ export function deriveInvoiceQrUrl(params: DeriveInvoiceQrUrlParams): string { // Issue date lives at Faktura/Fa/P_1 — the invoice-line group `Fa`, not the // document root (where Podmiot1 sits). Reading it at the root yields '' and a // NaN-NaN-NaN date segment in the URL. + // Both segments are policed here rather than left to `strict`: a default + // render reads them leniently, and an empty one does not fail — it produces a + // URL with a hole in it (`…/invoice//15-01-2026/…`) that only reveals itself + // when someone scans the printed code. + if (nip === '') { + throw new KSeFPdfError( + 'Cannot build the QR verification URL: seller NIP (Podmiot1/DaneIdentyfikacyjne/NIP) ' + + 'is missing from the invoice.', + ); + } const issueDate = get(params.body, 'Fa.P_1', params.strict); if (issueDate === '') { throw new KSeFPdfError( diff --git a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts index e204507f..fccc3a4c 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts @@ -14,6 +14,7 @@ import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; import type { Locale } from '../../../src/pdf/i18n/types.js'; import { VerificationLinkService } from '../../../src/qr/verification-link-service.js'; import { Environment } from '../../../src/config/environments.js'; +import { KSeFPdfError } from '../../../src/pdf/errors.js'; import type { RenderContext } from '../../../src/pdf/template/interpret.js'; import type { QrBlock } from '../../../src/pdf/template/dsl.js'; @@ -129,6 +130,16 @@ describe('deriveInvoiceQrUrl', () => { const noDate = { Podmiot1: { DaneIdentyfikacyjne: { NIP: '5213003700' } } }; expect(() => deriveInvoiceQrUrl({ rawInput: CLEAN, body: noDate })).toThrow(/issue date/i); }); + + // A default render reads bindings leniently, so an absent NIP resolves to '' + // and slid into the URL as an empty path segment — a code that resolves + // nowhere, and looks fine until someone scans the printed page. + it('throws a clear error when the seller NIP is absent, rather than emitting an empty segment', () => { + const noNip = { Fa: { P_1: '2025-01-15' } }; + expect(() => deriveInvoiceQrUrl({ rawInput: CLEAN, body: noNip })).toThrow(KSeFPdfError); + expect(() => deriveInvoiceQrUrl({ rawInput: CLEAN, body: noNip })).toThrow(/seller NIP/i); + }); + }); describe('qrRenderer', () => { From 1ce11bd36a8df89e7b7282a392a5795980e34e8c Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:53:35 +0200 Subject: [PATCH 42/67] fix(pdf): draw no table rather than an empty one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: pdfmake reads `body[0].length` while measuring a table, so an empty body takes the whole render down instead of drawing nothing. Confirmed against pdfmake 0.2.23 — `body: []` fails with "Cannot read properties of undefined (reading 'length')", one row renders. Both renderers can reach that state from a valid template: a `table` with headers switched off whose repeater matched nothing, and a `totals` block whose every row was gated away by `when` or resolved empty. Return null in that case — the interpreter already drops a null block, which is the honest outcome: no rows, no table. Co-Authored-By: Claude Code --- .../src/pdf/template/blocks/table.ts | 6 ++++ .../src/pdf/template/blocks/totals.ts | 5 +++ .../tests/unit/pdf/blocks-primitive.test.ts | 20 ++++++++++++ .../tests/unit/pdf/blocks-semantic.test.ts | 31 +++++++++++++++++++ 4 files changed, 62 insertions(+) diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/table.ts b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts index d3b78962..9601f9d7 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/table.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts @@ -37,6 +37,12 @@ export const tableRenderer: BlockRenderer = (block, ctx) => { ); } + // pdfmake reads `body[0].length` while measuring, so an empty body takes the + // render down rather than drawing nothing. A repeater with headers switched + // off and no rows to show reaches that state, and the honest result there is + // no table at all — the interpreter drops a null block. + if (body.length === 0) return null; + const node: Record = { table: { headerRows: showHeaders ? 1 : 0, diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts index 608b48d5..209cfec5 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts @@ -43,6 +43,11 @@ export const totalsRenderer: BlockRenderer = (block, ctx) => { ]); } + // Every row can be skipped — each is gated on `when` or on resolving to a + // value — and pdfmake reads `body[0].length`, so an empty table takes the + // render down instead of drawing nothing. Show no totals rather than fail. + if (body.length === 0) return null; + return { columns: [ { width: '*', text: '' }, diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts index 744d4e38..75e4af3b 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts @@ -67,6 +67,26 @@ describe('tableRenderer', () => { expect(node.style).toBeUndefined(); }); + // pdfmake reads body[0].length while measuring, so an empty body takes the + // render down instead of drawing nothing. Headers off plus a repeater that + // matched nothing is the reachable way to get there. + it('returns null when headers are off and the repeater matched no rows', () => { + const block: TableBlock = { + type: 'table', + from: 'Fa.NieMaTakiej', + columns: COLS, + headers: false, + }; + expect(tableRenderer(block, makeCtx(ROOT), noRender)).toBeNull(); + }); + + it('still renders a header-only table when headers are on', () => { + const block: TableBlock = { type: 'table', from: 'Fa.NieMaTakiej', columns: COLS }; + const node = asRecord(tableRenderer(block, makeCtx(ROOT), noRender)); + const table = asRecord(node.table); + expect((table.body as unknown[]).length).toBe(1); + }); + it('renders a repeater whose single element collapsed to an object (header + 1 row)', () => { const block: TableBlock = { type: 'table', from: 'Fa.Solo', columns: COLS }; const node = asRecord(tableRenderer(block, makeCtx(ROOT), noRender)); diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index 8043c4bc..5490a71c 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -642,6 +642,37 @@ describe('linesRenderer', () => { // ── totals ────────────────────────────────────────────────────────────────── describe('totalsRenderer', () => { + // Every row is gated — on `when`, or on resolving to a value — so all of them + // can be skipped. pdfmake reads body[0].length, so an empty totals table took + // the render down rather than printing nothing. + it('returns null when every row was skipped', () => { + const ctx = makeCtx({ Fa: {} }); + expect( + totalsRenderer( + { + type: 'totals', + rows: [ + { label: 'net23', path: 'Fa.P_13_1', optional: true, format: 'money' }, + { label: 'vat23', path: 'Fa.P_14_1', optional: true, format: 'money' }, + ], + }, + ctx, + noRender, + ), + ).toBeNull(); + }); + + it('still renders when one row survives', () => { + const ctx = makeCtx({ Fa: { P_15: '123' } }); + expect( + totalsRenderer( + { type: 'totals', rows: [{ label: 'totalDue', path: 'Fa.P_15', format: 'money' }] }, + ctx, + noRender, + ), + ).not.toBeNull(); + }); + it('renders a right-aligned borderless summary table (+ style)', () => { const ctx = makeCtx({ Fa: { P_13_1: '100', P_15: '123' } }); const node = rec( From da857680c60741f2e3b40cbf4cd212e0ebfc22e9 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 00:55:07 +0200 Subject: [PATCH 43/67] fix(qr): reject an issue date that is not a real calendar day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: the guard checked only that the date parsed, and a day that does not exist parses fine — JavaScript rolls it forward, so "2026-02-30" became 2026-03-02. The verification code then carried an issue date the invoice does not state, which resolves against nothing in the KSeF registry and looks correct until someone scans it. Compare the canonical UTC form of a date-only string against the input and refuse a mismatch. A string carrying a time, and a Date the caller built, are left alone — neither round-trips this way and neither is the shape that silently shifts. Covered in the service and through the PDF QR flow, where the date comes straight from P_1. Co-Authored-By: Claude Code --- .../src/qr/verification-link-service.ts | 14 ++++++++++ .../ksef-client-ts/tests/unit/pdf/qr.test.ts | 9 +++++++ .../unit/qr/verification-link-service.test.ts | 27 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/packages/ksef-client-ts/src/qr/verification-link-service.ts b/packages/ksef-client-ts/src/qr/verification-link-service.ts index eb4dfec2..6e51bf99 100644 --- a/packages/ksef-client-ts/src/qr/verification-link-service.ts +++ b/packages/ksef-client-ts/src/qr/verification-link-service.ts @@ -18,6 +18,20 @@ export class VerificationLinkService { `Invalid issueDate for verification URL: ${JSON.stringify(issueDate)} (expected a parseable date, e.g. "2026-06-08").`, ); } + // A date that does not exist does not fail to parse — it rolls forward, so + // "2026-02-30" becomes 2026-03-02 and the code would verify a different + // issue date than the invoice carries, visible only to whoever scans it. + // Date-only strings parse as UTC, so the canonical form round-trips exactly; + // a string carrying a time, or a Date the caller built, is left alone. + if (typeof issueDate === 'string') { + const dateOnly = issueDate.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(dateOnly) && date.toISOString().slice(0, 10) !== dateOnly) { + throw new Error( + `Invalid issueDate for verification URL: ${JSON.stringify(issueDate)} is not a real calendar date ` + + `(it would be read as ${date.toISOString().slice(0, 10)}).`, + ); + } + } const dd = String(date.getUTCDate()).padStart(2, '0'); const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); const yyyy = date.getUTCFullYear(); diff --git a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts index fccc3a4c..b5f47ed2 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts @@ -134,6 +134,15 @@ describe('deriveInvoiceQrUrl', () => { // A default render reads bindings leniently, so an absent NIP resolves to '' // and slid into the URL as an empty path segment — a code that resolves // nowhere, and looks fine until someone scans the printed page. + // The date reaches the URL builder straight from P_1, so a document stating a + // day that does not exist would print a code for its neighbour. + it('throws when the issue date is a day that does not exist', () => { + const badDay = { Podmiot1: { DaneIdentyfikacyjne: { NIP: '5213003700' } }, Fa: { P_1: '2026-02-30' } }; + expect(() => deriveInvoiceQrUrl({ rawInput: CLEAN, body: badDay })).toThrow( + /not a real calendar date/, + ); + }); + it('throws a clear error when the seller NIP is absent, rather than emitting an empty segment', () => { const noNip = { Fa: { P_1: '2025-01-15' } }; expect(() => deriveInvoiceQrUrl({ rawInput: CLEAN, body: noNip })).toThrow(KSeFPdfError); diff --git a/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts b/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts index 44081d54..94e82a48 100644 --- a/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts +++ b/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts @@ -36,6 +36,33 @@ describe('VerificationLinkService', () => { expect(() => service.buildInvoiceVerificationUrl(nip, 'not-a-date', hash)).toThrow(/Invalid issueDate/); }); + // A date that does not exist parses fine and rolls forward, so the code + // would carry a different issue date than the invoice — and only a scan + // would reveal it. + it('throws on a date that does not exist rather than rolling it forward', () => { + expect(() => service.buildInvoiceVerificationUrl(nip, '2026-02-30', hash)).toThrow( + /not a real calendar date/, + ); + expect(() => service.buildInvoiceVerificationUrl(nip, '2026-13-01', hash)).toThrow( + /Invalid issueDate/, + ); + expect(() => service.buildInvoiceVerificationUrl(nip, '2025-02-29', hash)).toThrow( + /not a real calendar date/, + ); + }); + + it('accepts the leap day of an actual leap year', () => { + expect(service.buildInvoiceVerificationUrl(nip, '2024-02-29', hash)).toMatch(/\/29-02-2024\//); + }); + + it('leaves a date carrying a time, and a caller-built Date, alone', () => { + expect(service.buildInvoiceVerificationUrl(nip, '2024-01-01T00:00:00Z', hash)).toMatch( + /\/01-01-2024\//, + ); + const built = new Date(Date.UTC(2024, 0, 31)); + expect(service.buildInvoiceVerificationUrl(nip, built, hash)).toMatch(/\/31-01-2024\//); + }); + it('should use Base64URL encoding without padding', () => { const url = service.buildInvoiceVerificationUrl(nip, '2024-01-01T00:00:00Z', hash); From 4c64f713c36338f089e25d49acbfeaabac8ec846 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 01:14:03 +0200 Subject: [PATCH 44/67] test(pdf): cut the preview set to the pages a reader learns something from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set had grown to thirteen renders, and five of them said the same thing: rows 06–09 varied only --totals on one document, and rows 01–05 already spend all four modes between them. Row 10 then paired with 08 to show summed totals against single-bucket ones — a comparison whose arithmetic is pinned exactly in totals-sum.test.ts, while this suite only ever asserts that a complete PDF appeared. So the four mode pages are gone, and the template-file render moved onto the flags row 05 already carried. That row is now the one page drawn from a template file, which is what keeps --template-file wired, and its name says so; a comment records that its totals read one bucket by design, so the narrower net and VAT lines read as the template choosing rather than the renderer erring. Thirteen pages down to eight, with every dimension of the covering grid still spent and its assertions untouched. Also drops the two references to invoices/temp/regen.sh: that script is outside the repository, so a comment here claiming the two mirror each other is a promise this file cannot keep. Verified: spec 35 renders all eight variants, unit 2734, E2E 151. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/e2e/35-invoice-pdf-cli.test.ts | 61 ++++++++----------- 1 file changed, 25 insertions(+), 36 deletions(-) diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 2ba18e88..edead9aa 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -7,9 +7,8 @@ import { fileURLToPath } from 'node:url'; import { VerificationLinkService } from '../../src/qr/verification-link-service.js'; // Spawn-based coverage for `ksef invoice pdf` — renders the whole preview set -// through the built CLI (dist/cli.js), no network and no authentication. It -// mirrors invoices/temp/regen.sh so the two cannot drift: the same variants, -// the same flags, only against anonymous fixtures instead of real invoices. +// through the built CLI (dist/cli.js), no network and no authentication. +// Anonymous fixtures throughout: nothing here needs a real invoice. // // The assertions are deliberately shallow — a file appears, and it is a // structurally complete PDF. Layout is judged by eye, not here; asserting on @@ -141,34 +140,30 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { const ACCENT_SHORT = '#b04'; const SUPPLIED_CODE_I = `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`; - /** The mixed-rate document with the flags every totals variant shares. */ - const mixedVat = () => [fx('e2e-vat-multi.xml'), '--ksef-number', KSEF_NUMBER, ...LOGO()]; - /** * The preview set, laid out as a covering design rather than one variant per * feature. Nine dimensions are in play — document, locale, which QR codes, - * links, logo, KSeF number, totals mode, accent colour, and where Code I - * comes from — and a - * row per combination would be hundreds of PDFs nobody looks at. Instead each - * row varies several at once so that every value of every dimension appears, - * and the pairs that actually interact are covered. + * links, logo, KSeF number, totals mode, accent colour, and where Code I comes + * from — and a row per combination would be hundreds of PDFs nobody looks at. + * Instead each row varies several at once so that every value of every + * dimension appears, and the pairs that actually interact are covered. * - * Rows 01–05 are that covering design. Rows 06–10 are deliberately NOT: the - * totals modes only mean anything compared side by side, so those five hold - * every other flag identical and vary one thing. + * Between them these five rows already spend every totals mode — buckets, + * summary, both, none — so the modes need no pages of their own. * * # document locale QR links logo KSeF nr totals accent * 01 services-np pl I no yes yes buckets #5AB595 * 02 fa3 en I yes no yes summary no (Code I supplied) * 03 buyer-no-id uk II yes yes no both no * 04 vat-multi en+pl II no no no none no - * 05 vat-multi pl+uk both yes yes no both no (+ notes) + * 05 vat-multi pl+uk both yes yes no both no (+ notes, template file) * * What each row is there to show, beyond its share of the grid: 01 the * everyday online invoice, and the accent against the default palette; 02 an * invoice whose Code I URL was handed over ready-made; 03 a foreign buyer * with no NIP, issued offline; 04 and 05 the layout cases — one code absent, - * then both present — where the codes must stay against the right margin. + * then both present — where the codes must stay against the right margin, and + * 05 additionally the only page built from a template file. */ const variants: Array<[name: string, args: () => string[]]> = [ [`${PREFIX}-01-invoice-pl-code-i-accent`, () => [ @@ -187,24 +182,18 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { fx('e2e-vat-multi.xml'), '--locale', 'en+pl', '--env', 'demo', '--qr-cert-url', certificateQrUrl, '--totals', 'none', ]], - [`${PREFIX}-05-invoice-pl-uk-offline-both-codes-links-notes`, () => [ + // The only page drawn from a template file rather than a built-in, which is + // what keeps --template-file wired. Its totals deliberately read the + // standard-rate bucket alone instead of summing every bucket, so on this + // multi-rate invoice the net and VAT lines are narrower than the line items + // above them — that is the template choosing, not the renderer erring. + // What the summing itself computes is pinned exactly in totals-sum.test.ts; + // nothing here reads a figure off the page. + [`${PREFIX}-05-invoice-pl-uk-custom-template-file`, () => [ fx('e2e-vat-multi.xml'), ...LOGO(), '--locale', 'pl+uk', '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', - '--notes', notesFile, + '--notes', notesFile, '--template-file', oldTotalsTemplate, ]], - // Every totals mode on one mixed-rate document (23% + 8% + exempt), so the - // four can be compared page by page. Only --totals differs between them — - // and the accent on 06, which is safe here because it reaches the title and - // the section headings, never the totals rows the group exists to compare. - // The amount due must appear in all of them. - [`${PREFIX}-06-totals-none-accent`, () => [...mixedVat(), '--totals', 'none', '--accent', ACCENT]], - [`${PREFIX}-07-totals-buckets`, () => [...mixedVat(), '--totals', 'buckets']], - [`${PREFIX}-08-totals-summary`, () => [...mixedVat(), '--totals', 'summary']], - [`${PREFIX}-09-totals-both`, () => [...mixedVat(), '--totals', 'both']], - // The A/B against 08: identical document and flags, but the totals read the - // standard-rate bucket alone instead of summing all of them. It needs - // --totals summary, since that is the group those rows belong to. - [`${PREFIX}-10-totals-summary-single-bucket-template`, () => [...mixedVat(), '--totals', 'summary', '--template-file', oldTotalsTemplate]], // Not part of the grid: `fa3-showcase` is a built-in whose point is to // exercise the DSL — palette, letter spacing, highlighted text, colour bars // drawn as data-URI images. Rendered with everything switched on, so a DSL @@ -212,14 +201,14 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // It also carries the accent, in its short hex form: this template sets its // own heading colours, so it is the page that shows whether an accent wins // over a template's palette. - [`${PREFIX}-11-showcase-template-accent`, () => [ + [`${PREFIX}-06-showcase-template-accent`, () => [ fx('e2e-vat-multi.xml'), '--template', 'fa3-showcase', ...LOGO(), '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'summary', '--notes', notesFile, '--accent', ACCENT_SHORT, ]], // Receipts last: they are a different document and read as their own group. - [`${PREFIX}-12-upo-pl`, () => [fx('upo-4_3.xml')]], - [`${PREFIX}-13-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], + [`${PREFIX}-07-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-08-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], ]; /** @@ -273,8 +262,8 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { it('renders every variant of the set', () => { // Guards against a variant being silently dropped from the table above: - // regen.sh and this spec are meant to cover the same ground. - expect(variants).toHaveLength(13); + // the count is stated here so removing a row has to be deliberate. + expect(variants).toHaveLength(8); for (const [name] of variants) { expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); } From ab031ee8583d93845b94a5fba1e1a0484ffade27 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 01:19:30 +0200 Subject: [PATCH 45/67] fix(pdf): anchor the UPO version scan to the document's first element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: narrowing the scan to a tag named Potwierdzenie was not narrow enough. The match was by name, so a commented-out root before the real one decided the version — a comment carrying KSeF/v4-2 ahead of a v4-3 root made the renderer pick the wrong UPO template. Reproduced with exactly that document. Strip comments and anchor the match to the first element in the source, whatever it is named, then require that element to be Potwierdzenie. The bot's own suggestion — read the namespace off `parsed.Potwierdzenie` — cannot work here: the parser runs with `removeNSPrefix`, which drops xmlns declarations entirely, so a parsed UPO root is `{"X":"1"}` with no version anywhere on it. That is why this reads the source at all, and the comment now says so. Co-Authored-By: Claude Code --- packages/ksef-client-ts/src/pdf/parse.ts | 19 ++++++++++++++----- .../tests/unit/pdf/parse.test.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/parse.ts b/packages/ksef-client-ts/src/pdf/parse.ts index a3b5915e..903ffcdc 100644 --- a/packages/ksef-client-ts/src/pdf/parse.ts +++ b/packages/ksef-client-ts/src/pdf/parse.ts @@ -76,11 +76,20 @@ export function detectUpoVersion(xml: string): UpoVersion | null { const parsed = parseXmlForPdf(xml); if (!('Potwierdzenie' in parsed)) return null; - // Read the marker from the root element's own tag. A UPO declares its version - // in the default xmlns on ``, and scanning the whole source - // instead let any Potwierdzenie that merely mentions the string — in a note, - // in an embedded document — pass as that version. - const rootTag = /<(?:[\w.-]+:)?Potwierdzenie\b[^>]*>/.exec(xml)?.[0] ?? ''; + // Read the marker from the root element's own tag, which is why this reads + // the source rather than `parsed`: `removeNSPrefix` drops the xmlns + // declarations, so the version is gone by the time the document is an object. + // + // Comments come out first and the match is anchored to the document's *first* + // element, not to the first thing that looks like a Potwierdzenie. Scanning by + // name alone let a commented-out root — or any mention of the string in a note + // or an embedded document — decide the version instead. + const withoutComments = xml.replace(//g, ''); + const firstElement = /<(?![?!])([\w.:-]+)[^>]*>/.exec(withoutComments); + const rootTag = firstElement?.[0] ?? ''; + const rootName = (firstElement?.[1] ?? '').replace(/^[\w.-]+:/, ''); + if (rootName !== 'Potwierdzenie') return null; + if (/KSeF\/v4-3\b/.test(rootTag)) return 'UPO(4.3)'; if (/KSeF\/v4-2\b/.test(rootTag)) return 'UPO(4.2)'; return null; diff --git a/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts index ea7e5c43..f0f30105 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts @@ -188,6 +188,23 @@ describe('detectUpoVersion', () => { expect(detectUpoVersion(xml)).toBeNull(); }); + // The scan is anchored to the document's first element, so a commented-out + // root cannot decide the version — it matched by name alone before. + it('ignores a commented-out root tag before the real one', () => { + const xml = + '\n' + + '\n' + + '1'; + expect(detectUpoVersion(xml)).toBe('UPO(4.3)'); + }); + + it('survives a comment that mentions no version at all', () => { + const xml = + '' + + '1'; + expect(detectUpoVersion(xml)).toBe('UPO(4.2)'); + }); + it('reads the marker from a namespace-prefixed root tag', () => { const xml = '' + From 24aa6045eb1331e9394014e41b71e03d6e4489c4 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 01:20:47 +0200 Subject: [PATCH 46/67] docs(pdf): say what a notes block's headingStyle actually styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: the exported DSL type still promised that NotesBlock.headingStyle applies to each note's heading. It stopped doing that in 9f7b830, which gave the section a heading of its own and put the notes a level below it — headingStyle styles the section, and a note's title is a fixed h2. The code is the intent here, so the contract is what needed correcting. The prose in pdf-export.md already described it correctly; its list of blocks carrying the option was missing `notes`, which it now names. Co-Authored-By: Claude Code --- packages/ksef-client-ts/docs/pdf-export.md | 2 +- packages/ksef-client-ts/src/pdf/template/dsl.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 9f5d84c4..d089241e 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -215,7 +215,7 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it - **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`. A `divider` takes it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. -- **`headingStyle`** (`parties`, `payment`, `annotations`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. +- **`headingStyle`** (`parties`, `payment`, `annotations`, `notes`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. - **`style`** (party panels) names a style for a panel's value lines. A labelled group inherits it unless it declares its own, so the built-in templates set `partyIdentity` on the panel — the counterparty's name and tax number — and let the address and contact groups drop to the smaller `partyDetails`. Headings keep the panel's heading style either way. - **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. - **`style`** names a style for one row: a totals row (covering both its label and its figure — they are one line to a reader) or a payment/annotation field. The built-in templates use it to set the amount due and its currency in bold, in the totals and again under the payment terms. diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 77cfcbea..186f041b 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -261,7 +261,13 @@ export interface PaymentBlock { */ export interface NotesBlock { type: 'notes'; - /** See {@link HEADING_STYLE_DOC}. Applies to each note's heading. */ + /** + * See {@link HEADING_STYLE_DOC}. Styles the section's own heading. Each + * note's title sits one level below that and is not configurable — a note is + * a sub-heading inside the section, the way `Adres` sits under `Sprzedawca`, + * and letting a template raise it would put the notes above the section that + * holds them. + */ headingStyle?: string; style?: string; } From d8cae76f397b242a04d91d91adc68bc66288de19 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 01:22:06 +0200 Subject: [PATCH 47/67] fix(qr): catch an impossible issue date in any form it arrives in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: the calendar check only looked at bare `YYYY-MM-DD` strings, so "2026-02-30T00:00:00Z" walked past it and was read as 2026-03-02 — the same defect the check was added for, wearing a timestamp. The written calendar fields are now checked on their own terms, for a bare date and a timestamp alike. Deliberately not the bot's suggestion of comparing the written prefix against the parsed UTC date: with an offset the two legitimately differ, so that test refuses real dates — "2026-01-01T00:30:00+01:00" is 2025-12-31 in UTC and is a perfectly good issue date. Both of those now have a test proving they still pass. Co-Authored-By: Claude Code --- .../src/qr/verification-link-service.ts | 30 +++++++++++++------ .../unit/qr/verification-link-service.test.ts | 21 +++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/ksef-client-ts/src/qr/verification-link-service.ts b/packages/ksef-client-ts/src/qr/verification-link-service.ts index 6e51bf99..d3483fa3 100644 --- a/packages/ksef-client-ts/src/qr/verification-link-service.ts +++ b/packages/ksef-client-ts/src/qr/verification-link-service.ts @@ -18,18 +18,30 @@ export class VerificationLinkService { `Invalid issueDate for verification URL: ${JSON.stringify(issueDate)} (expected a parseable date, e.g. "2026-06-08").`, ); } - // A date that does not exist does not fail to parse — it rolls forward, so + // A day that does not exist does not fail to parse — it rolls forward, so // "2026-02-30" becomes 2026-03-02 and the code would verify a different // issue date than the invoice carries, visible only to whoever scans it. - // Date-only strings parse as UTC, so the canonical form round-trips exactly; - // a string carrying a time, or a Date the caller built, is left alone. + // + // The written calendar fields are checked on their own terms rather than + // against the parsed UTC date: with an offset the two legitimately differ + // ("2026-01-01T00:30:00+01:00" is 2025-12-31 in UTC), so comparing them + // would refuse real dates. This holds for a bare date and a timestamp + // alike. A Date the caller built has no written form to check. if (typeof issueDate === 'string') { - const dateOnly = issueDate.trim(); - if (/^\d{4}-\d{2}-\d{2}$/.test(dateOnly) && date.toISOString().slice(0, 10) !== dateOnly) { - throw new Error( - `Invalid issueDate for verification URL: ${JSON.stringify(issueDate)} is not a real calendar date ` + - `(it would be read as ${date.toISOString().slice(0, 10)}).`, - ); + const written = /^(\d{4})-(\d{2})-(\d{2})/.exec(issueDate.trim()); + if (written) { + const [year, month, day] = written.slice(1).map(Number) as [number, number, number]; + const asUtc = new Date(Date.UTC(year, month - 1, day)); + const real = + asUtc.getUTCFullYear() === year && + asUtc.getUTCMonth() === month - 1 && + asUtc.getUTCDate() === day; + if (!real) { + throw new Error( + `Invalid issueDate for verification URL: ${JSON.stringify(issueDate)} is not a real calendar date ` + + `(there is no ${String(day).padStart(2, '0')}.${String(month).padStart(2, '0')}.${year}).`, + ); + } } } const dd = String(date.getUTCDate()).padStart(2, '0'); diff --git a/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts b/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts index 94e82a48..723cb638 100644 --- a/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts +++ b/packages/ksef-client-ts/tests/unit/qr/verification-link-service.test.ts @@ -55,6 +55,27 @@ describe('VerificationLinkService', () => { expect(service.buildInvoiceVerificationUrl(nip, '2024-02-29', hash)).toMatch(/\/29-02-2024\//); }); + // The check reads the written calendar fields, so the form the date arrives + // in does not matter — a timestamp hides the same impossible day. + it('rejects an impossible day carrying a time as well', () => { + for (const value of ['2026-02-30T00:00:00Z', '2026-02-30T12:34:56+01:00', '2026-04-31T09:00:00Z']) { + expect(() => service.buildInvoiceVerificationUrl(nip, value, hash), value).toThrow( + /not a real calendar date/, + ); + } + }); + + // Comparing the written date against the parsed UTC one would refuse these: + // with an offset the two legitimately fall on different days. + it('accepts a real date whose UTC day differs from the written one', () => { + expect(service.buildInvoiceVerificationUrl(nip, '2026-01-01T00:30:00+01:00', hash)).toMatch( + /\/31-12-2025\//, + ); + expect(service.buildInvoiceVerificationUrl(nip, '2026-12-31T23:30:00-05:00', hash)).toMatch( + /\/01-01-2027\//, + ); + }); + it('leaves a date carrying a time, and a caller-built Date, alone', () => { expect(service.buildInvoiceVerificationUrl(nip, '2024-01-01T00:00:00Z', hash)).toMatch( /\/01-01-2024\//, From bead30cdde6b78c2fbf077b51cae9c9ca1b29a0d Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 01:43:20 +0200 Subject: [PATCH 48/67] fix(pdf): print the order rows an advance invoice carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Codex review of version/v0.12.0 against main: an advance invoice (`RodzajFaktury` `ZAL` or `KOR_ZAL`) may carry no `Fa.FaWiersz` at all — the goods and services it covers are recorded under `Fa.Zamowienie`, which both default templates ignored. Rendering one produced a line-item table holding nothing but its header row and dropped the document's actual content, so the only figures on the page were the totals. A `lines` repeater now takes `when`, as almost every other block already does, and the two default templates use it twice: the item table disappears on a document that has no items, and an order table appears under its own heading when the document has one, with the order value beside the rate buckets. New pl/en/uk labels name both. The template lint now reads each built-in against a *set* of fixtures rather than one, because no single document exercises both branches; a misspelled path still resolves against none of them, so it stays a lint. Reproduced against the interpreted document definition: the advance invoice rendered one table whose body was `[header]` and no order rows. Afterwards it renders the order rows, no header-only table, and the ordinary fixtures are untouched — verified on the page as well as in the tree, via the new preview page. Full unit and e2e suites pass. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 6 +- packages/ksef-client-ts/src/pdf/i18n/en.ts | 3 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 3 + packages/ksef-client-ts/src/pdf/i18n/uk.ts | 3 + .../src/pdf/template/builtin/fa2-default.json | 35 +++++++ .../src/pdf/template/builtin/fa3-default.json | 35 +++++++ .../ksef-client-ts/src/pdf/template/dsl.ts | 9 ++ .../tests/e2e/35-invoice-pdf-cli.test.ts | 26 ++++- .../tests/fixtures/pdf/fa2-zal.xml | 96 +++++++++++++++++++ .../tests/fixtures/pdf/fa3-zal.xml | 96 +++++++++++++++++++ .../tests/unit/pdf/advance-invoice.test.ts | 84 ++++++++++++++++ .../unit/pdf/builtin-template-lint.test.ts | 60 +++++++----- 12 files changed, 427 insertions(+), 29 deletions(-) create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal.xml create mode 100644 packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index d089241e..c24e4ca6 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -186,7 +186,7 @@ The `schema` field binds a template to a single document kind. If you render an |-------|---------| | `header` | Title and optional logo on the left; invoice number, issue date and KSeF number stacked on the right. With `offlineStyle` set, the OFFLINE marker takes the KSeF number's place when the document carries none | | `parties` | Seller / buyer two-column panel; a line that resolves empty is skipped | -| `lines` | Invoice line-item table | +| `lines` | Invoice line-item table. Takes `when`, because an invoice does not always carry its items in the same place — see below | | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | | `payment` | Payment details (amount paid, date, method) | | `annotations` | Miscellaneous labelled fields | @@ -208,11 +208,13 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it `each` repeats a group of blocks once per entry of a collection, with the entry as the binding root, so its children use item-relative paths. Use it where a table cannot fit a record on one row — the built-in UPO templates lay out each confirmed document this way, because a 35-character KSeF number beside a 44-character hash will not share a page-wide row. +An advance invoice (`RodzajFaktury` `ZAL` or `KOR_ZAL`) records the goods and services it covers under `Fa.Zamowienie`, and may carry no `Fa.FaWiersz` at all. A repeater with no entries still draws its header row, so a template that binds both gives each one a `when` — this is what the built-in templates do, and it is why an advance invoice shows its order rows under their own heading instead of an empty item table. + ### Bindings, labels, conditions, and formats - **Binding paths** are dot-paths into the document body — e.g. `Fa.P_2` (invoice number), `Podmiot1.DaneIdentyfikacyjne.Nazwa` (seller name). Paths are relative to the body element, not the document wrapper. - **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. -- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`. A `divider` takes it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. +- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`. A `divider` and a `lines` table take it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. - **`headingStyle`** (`parties`, `payment`, `annotations`, `notes`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index de98690b..fdb0b951 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -26,6 +26,9 @@ export const en: LabelBundle = { cn: 'CN', pkob: 'PKOB', gross: 'Gross amount', + // advance-invoice order lines (Fa.Zamowienie) + orderLines: 'Order or contract items', + orderValue: 'Order value', // per-rate buckets (P_13_* / P_14_*) net23: 'Net 23%', vat23: 'VAT 23%', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 823afa34..e36a073f 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -27,6 +27,9 @@ export const pl: LabelBundle = { cn: 'CN', pkob: 'PKOB', gross: 'Wartość brutto', + // advance-invoice order lines (Fa.Zamowienie) + orderLines: 'Pozycje zamówienia lub umowy', + orderValue: 'Wartość zamówienia', // totals // per-rate buckets (P_13_* / P_14_*) net23: 'Netto 23%', diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index 030bd3cf..eff804d0 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -34,6 +34,9 @@ export const uk: LabelBundle = { cn: 'CN', pkob: 'PKOB', gross: 'Сума брутто', + // advance-invoice order lines (Fa.Zamowienie) + orderLines: 'Позиції замовлення або договору', + orderValue: 'Вартість замовлення', // per-rate buckets (P_13_* / P_14_*) net23: 'Нетто 23%', vat23: 'ПДВ 23%', diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 4dd86fea..f3e32c40 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -87,6 +87,7 @@ { "type": "spacer", "height": 23 }, { "type": "lines", + "when": "Fa.FaWiersz", "from": "Fa.FaWiersz", "columns": [ { "label": "lp", "path": "NrWierszaFa", "width": 24 }, @@ -111,10 +112,44 @@ { "label": "net", "path": "P_11", "format": "money", "width": 70, "optional": true } ] }, + { + "type": "text", + "label": "orderLines", + "style": "h1", + "when": "Fa.Zamowienie" + }, + { + "type": "lines", + "when": "Fa.Zamowienie", + "from": "Fa.Zamowienie.ZamowienieWiersz", + "columns": [ + { "label": "lp", "path": "NrWierszaZam", "width": 24 }, + { + "label": "name", + "path": "P_7Z", + "width": "*", + "optional": true, + "subStyle": "lineMeta", + "sub": [ + { "label": "pkwiu", "path": "PKWiUZ", "optional": true }, + { "label": "indeks", "path": "IndeksZ", "optional": true }, + { "label": "gtin", "path": "GTINZ", "optional": true }, + { "label": "cn", "path": "CNZ", "optional": true }, + { "label": "pkob", "path": "PKOBZ", "optional": true } + ] + }, + { "label": "unit", "path": "P_8AZ", "width": 36, "optional": true }, + { "label": "qty", "path": "P_8BZ", "format": "number", "width": 24, "optional": true }, + { "label": "unitPrice", "path": "P_9AZ", "format": "money", "width": 64, "optional": true }, + { "label": "vatRate", "path": "P_12Z", "width": 50, "optional": true }, + { "label": "net", "path": "P_11NettoZ", "format": "money", "width": 70, "optional": true } + ] + }, { "type": "spacer", "height": 21 }, { "type": "totals", "rows": [ + { "label": "orderValue", "path": "Fa.Zamowienie.WartoscZamowienia", "when": "Fa.Zamowienie", "format": "money", "optional": true }, { "label": "net23", "path": "Fa.P_13_1", "when": "totalsBuckets", "format": "money", "optional": true }, { "label": "vat23", "path": "Fa.P_14_1", "when": "totalsBuckets", "format": "money", "optional": true }, { "label": "net8", "path": "Fa.P_13_2", "when": "totalsBuckets", "format": "money", "optional": true }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 907eccfa..465aa01f 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -87,6 +87,7 @@ { "type": "spacer", "height": 9 }, { "type": "lines", + "when": "Fa.FaWiersz", "from": "Fa.FaWiersz", "columns": [ { "label": "lp", "path": "NrWierszaFa", "width": 24 }, @@ -111,10 +112,44 @@ { "label": "net", "path": "P_11", "format": "money", "width": 60, "optional": true } ] }, + { + "type": "text", + "label": "orderLines", + "style": "h1", + "when": "Fa.Zamowienie" + }, + { + "type": "lines", + "when": "Fa.Zamowienie", + "from": "Fa.Zamowienie.ZamowienieWiersz", + "columns": [ + { "label": "lp", "path": "NrWierszaZam", "width": 24 }, + { + "label": "name", + "path": "P_7Z", + "width": "*", + "optional": true, + "subStyle": "lineMeta", + "sub": [ + { "label": "pkwiu", "path": "PKWiUZ", "optional": true }, + { "label": "indeks", "path": "IndeksZ", "optional": true }, + { "label": "gtin", "path": "GTINZ", "optional": true }, + { "label": "cn", "path": "CNZ", "optional": true }, + { "label": "pkob", "path": "PKOBZ", "optional": true } + ] + }, + { "label": "unit", "path": "P_8AZ", "width": 24, "optional": true }, + { "label": "qty", "path": "P_8BZ", "format": "number", "width": 24, "optional": true }, + { "label": "unitPrice", "path": "P_9AZ", "format": "money", "width": 50, "optional": true }, + { "label": "vatRate", "path": "P_12Z", "width": 50, "optional": true }, + { "label": "net", "path": "P_11NettoZ", "format": "money", "width": 60, "optional": true } + ] + }, { "type": "spacer", "height": 9 }, { "type": "totals", "rows": [ + { "label": "orderValue", "path": "Fa.Zamowienie.WartoscZamowienia", "when": "Fa.Zamowienie", "format": "money", "optional": true }, { "label": "net23", "path": "Fa.P_13_1", "when": "totalsBuckets", "format": "money", "optional": true }, { "label": "vat23", "path": "Fa.P_14_1", "when": "totalsBuckets", "format": "money", "optional": true }, { "label": "net8", "path": "Fa.P_13_2", "when": "totalsBuckets", "format": "money", "optional": true }, diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 186f041b..80156fe3 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -196,6 +196,14 @@ export interface PartiesBlock { export interface LinesBlock { type: 'lines'; from: string; + /** + * An invoice does not always carry its items under `Fa.FaWiersz`: an advance + * invoice (`ZAL`/`KOR_ZAL`) leaves it empty and records the goods under + * `Fa.Zamowienie.ZamowienieWiersz` instead. A repeater with no entries still + * draws its header row, so a template that binds both needs each one to + * disappear when the document does not use it. + */ + when?: string; columns: ColumnDef[]; style?: string; } @@ -512,6 +520,7 @@ const blockSchema: z.ZodType = z.lazy(() => z.object({ type: z.literal('lines'), from: z.string(), + when: z.string().optional(), columns: z.array(columnDef), style: z.string().optional(), }).strict(), diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index edead9aa..d1d15c2c 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -194,6 +194,17 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', '--notes', notesFile, '--template-file', oldTotalsTemplate, ]], + // Not part of the grid either, and for the opposite reason to the showcase: + // this is the same default template on a different *document shape*. An + // advance invoice (`ZAL`) carries no `Fa.FaWiersz` — the goods it covers sit + // under `Fa.Zamowienie` — so the page a reader should see here is the order + // table under its own heading, with no empty item table above it. Flags are + // kept to a minimum precisely so nothing else on the page competes for the + // eye. + [`${PREFIX}-06-invoice-advance-order-lines`, () => [ + fx('fa3-zal.xml'), '--ksef-number', KSEF_NUMBER, + '--env', 'demo', '--qr', '--totals', 'buckets', + ]], // Not part of the grid: `fa3-showcase` is a built-in whose point is to // exercise the DSL — palette, letter spacing, highlighted text, colour bars // drawn as data-URI images. Rendered with everything switched on, so a DSL @@ -201,14 +212,14 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // It also carries the accent, in its short hex form: this template sets its // own heading colours, so it is the page that shows whether an accent wins // over a template's palette. - [`${PREFIX}-06-showcase-template-accent`, () => [ + [`${PREFIX}-07-showcase-template-accent`, () => [ fx('e2e-vat-multi.xml'), '--template', 'fa3-showcase', ...LOGO(), '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'summary', '--notes', notesFile, '--accent', ACCENT_SHORT, ]], // Receipts last: they are a different document and read as their own group. - [`${PREFIX}-07-upo-pl`, () => [fx('upo-4_3.xml')]], - [`${PREFIX}-08-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], + [`${PREFIX}-08-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-09-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], ]; /** @@ -220,7 +231,12 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { const args = variants.map(([, build]) => build().join(' ')); const covered = (needle: string) => args.some((a) => a.includes(needle)); - for (const doc of ['e2e-services-np.xml', 'fa3.xml', 'e2e-buyer-no-id.xml', 'e2e-vat-multi.xml', 'upo-4_3.xml']) { + for (const doc of [ + 'e2e-services-np.xml', 'fa3.xml', 'e2e-buyer-no-id.xml', 'e2e-vat-multi.xml', + // An advance invoice reaches a branch of the template no other document + // does, so it is pinned here rather than left to be dropped by accident. + 'fa3-zal.xml', 'upo-4_3.xml', + ]) { expect(covered(doc), `no variant renders ${doc}`).toBe(true); } for (const locale of ['en', 'uk', 'en+pl', 'pl+uk']) { @@ -263,7 +279,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { it('renders every variant of the set', () => { // Guards against a variant being silently dropped from the table above: // the count is stated here so removing a row has to be deliberate. - expect(variants).toHaveLength(8); + expect(variants).toHaveLength(9); for (const [name] of variants) { expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); } diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal.xml new file mode 100644 index 00000000..e5b58b33 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal.xml @@ -0,0 +1,96 @@ + + + + + FA + 2 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ZAL + + 1 + + 2025-02-01 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + 615.00 + + 1 + Usługa przykładowa — zaliczka + IDX-0001 + szt. + 5 + 100.00 + 500.00 + 115.00 + 23 + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal.xml new file mode 100644 index 00000000..96dfbc5d --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal.xml @@ -0,0 +1,96 @@ + + + + + FA + 3 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ZAL + + 1 + + 2025-02-01 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + 615.00 + + 1 + Usługa przykładowa — zaliczka + IDX-0001 + szt. + 5 + 100.00 + 500.00 + 115.00 + 23 + + + + diff --git a/packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts b/packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts new file mode 100644 index 00000000..9fad6d94 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { getBuiltinTemplate, renderInvoicePdf } from '../../../src/pdf/index.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; +import { interpretTemplate, type RenderContext } from '../../../src/pdf/template/interpret.js'; +import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; + +/** + * An advance invoice (`ZAL`/`KOR_ZAL`) may carry no `Fa.FaWiersz` at all: the + * goods and services it covers are recorded under `Fa.Zamowienie` instead. A + * template bound only to `Fa.FaWiersz` therefore printed a header-only line + * table and dropped the document's actual content. + */ + +const fx = (p: string) => readFileSync(new URL(`../../fixtures/${p}`, import.meta.url), 'utf8'); + +function docFor(templateName: string, xml: string): Record { + const template = getBuiltinTemplate(templateName)!; + const parsed = parseXmlForPdf(xml) as Record; + const ctx: RenderContext = { + root: parsed.Faktura, + strict: false, + label: makeLabelResolver('pl', {}), + bindings: { 'opts.logo': '', 'opts.ksefNumber': '', 'opts.accent': '', qrUrl: '', certificateQrUrl: '' }, + flags: { totalsBuckets: true }, + }; + return interpretTemplate(template, ctx, blockRegistry); +} + +/** Every table in the document, as its body rows. */ +function tables(doc: Record): unknown[][][] { + const found: unknown[][][] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) return value.forEach(walk); + if (value === null || typeof value !== 'object') return; + const node = value as Record; + const table = node.table as { body?: unknown[][] } | undefined; + if (table?.body) found.push(table.body); + Object.values(node).forEach(walk); + }; + walk(doc.content); + return found; +} + +const CASES = [ + { template: 'fa2-default', plain: 'pdf/fa2.xml', advance: 'pdf/fa2-zal.xml' }, + { template: 'fa3-default', plain: 'pdf/fa3.xml', advance: 'pdf/fa3-zal.xml' }, +] as const; + +describe.each(CASES)('$template on an advance invoice', ({ template, plain, advance }) => { + it('prints the order rows the document actually carries', () => { + const tree = JSON.stringify(docFor(template, fx(advance))); + expect(tree).toContain('Usługa przykładowa — zaliczka'); + // The sub-line metadata rides along with the row, as it does for `FaWiersz`. + expect(tree).toContain('IDX-0001'); + // …under its own heading, so the table is not mistaken for the item table. + expect(tree).toContain('Pozycje zamówienia lub umowy'); + expect(tree).toContain('Wartość zamówienia'); + }); + + it('draws no line-item table when the invoice has no line items', () => { + // A repeater with no entries still emits its header row, so the empty item + // table shows up as a table whose only row is its header. + const headerOnly = tables(docFor(template, fx(advance))).filter((body) => body.length === 1); + expect(headerOnly.map((body) => JSON.stringify(body[0]))).toEqual([]); + }); + + it('leaves an ordinary invoice with its item table and no order section', () => { + const tree = JSON.stringify(docFor(template, fx(plain))); + expect(tree).toContain('Usługa przykładowa'); + expect(tree).not.toContain('Pozycje zamówienia lub umowy'); + expect(tree).not.toContain('Wartość zamówienia'); + }); + + it('still renders a PDF', async () => { + const bytes = await renderInvoicePdf(fx(advance), template); + expect(Buffer.from(bytes.subarray(0, 5)).toString('latin1')).toBe('%PDF-'); + }); + + it('is strict-clean — every order path the template names resolves', async () => { + await expect(renderInvoicePdf(fx(advance), template, { strict: true })).resolves.toBeInstanceOf(Uint8Array); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index aa1182d3..7a691f1b 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -20,12 +20,20 @@ import type { Block, PartyField, TotalsBlock } from '../../../src/pdf/template/d * templates against fixtures that populate every path they reference. */ -const FIXTURE_BY_TEMPLATE: Record = { - 'fa2-default': 'pdf/fa2.xml', - 'fa3-default': 'pdf/fa3.xml', - 'fa3-showcase': 'pdf/fa3.xml', - 'upo-4_2': 'pdf/upo-4_2.xml', - 'upo-4_3': 'pdf/upo-4_3.xml', +/** + * A template may legitimately bind paths that no single document carries: an + * advance invoice records its goods under `Fa.Zamowienie` and leaves + * `Fa.FaWiersz` empty, an ordinary one the other way round. So a template is + * linted against every fixture that exercises it, and a path counts as resolved + * when *one* of them has it — which still catches a typo, because a misspelling + * resolves against none. + */ +const FIXTURES_BY_TEMPLATE: Record = { + 'fa2-default': ['pdf/fa2.xml', 'pdf/fa2-zal.xml'], + 'fa3-default': ['pdf/fa3.xml', 'pdf/fa3-zal.xml'], + 'fa3-showcase': ['pdf/fa3.xml'], + 'upo-4_2': ['pdf/upo-4_2.xml'], + 'upo-4_3': ['pdf/upo-4_3.xml'], }; /** `when` values resolved from the render context, not from the XML. */ @@ -85,40 +93,47 @@ function collect( return acc; } -function bodyOf(templateName: string): unknown { +function bodiesOf(templateName: string): unknown[] { const template = getBuiltinTemplate(templateName)!; - const xml = readFileSync(new URL(`../../fixtures/${FIXTURE_BY_TEMPLATE[templateName]}`, import.meta.url), 'utf8'); - const parsed = parseXmlForPdf(xml) as Record; - return parsed[template.schema.startsWith('UPO') ? 'Potwierdzenie' : 'Faktura']; + return FIXTURES_BY_TEMPLATE[templateName]!.map((fixture) => { + const xml = readFileSync(new URL(`../../fixtures/${fixture}`, import.meta.url), 'utf8'); + const parsed = parseXmlForPdf(xml) as Record; + return parsed[template.schema.startsWith('UPO') ? 'Potwierdzenie' : 'Faktura']; + }); } +/** The first fixture is the template's ordinary document. */ +const bodyOf = (templateName: string): unknown => bodiesOf(templateName)[0]; + describe('built-in template lint', () => { it('covers every built-in template', () => { - expect(builtinTemplateNames().sort()).toEqual(Object.keys(FIXTURE_BY_TEMPLATE).sort()); + expect(builtinTemplateNames().sort()).toEqual(Object.keys(FIXTURES_BY_TEMPLATE).sort()); }); - it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: every `when` path resolves against its fixture', (name) => { - const root = bodyOf(name); + it.each(Object.keys(FIXTURES_BY_TEMPLATE))('%s: every `when` path resolves against its fixture', (name) => { + const roots = bodiesOf(name); const { conditions } = collect(getBuiltinTemplate(name)!.blocks); - const unresolved = conditions.filter((path) => !has(root, path)); + const unresolved = conditions.filter((path) => !roots.some((root) => has(root, path))); expect(unresolved).toEqual([]); }); - it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: every repeater `from` path resolves against its fixture', (name) => { - const root = bodyOf(name); + it.each(Object.keys(FIXTURES_BY_TEMPLATE))('%s: every repeater `from` path resolves against its fixture', (name) => { + const roots = bodiesOf(name); const { repeaters } = collect(getBuiltinTemplate(name)!.blocks); - const empty = repeaters.filter((path) => list(root, path).length === 0); + const empty = repeaters.filter((path) => !roots.some((root) => list(root, path).length > 0)); expect(empty).toEqual([]); }); - it.each(Object.keys(FIXTURE_BY_TEMPLATE))( + it.each(Object.keys(FIXTURES_BY_TEMPLATE))( '%s: every `firstOf` set has at least one path that resolves', (name) => { - const root = bodyOf(name); + const roots = bodiesOf(name); const { alternatives } = collect(getBuiltinTemplate(name)!.blocks); // Individual alternatives are absent by design (a buyer carries one // identifier), but a set where *none* resolves means every path is wrong. - const dead = alternatives.filter((paths) => !paths.some((p) => has(root, p))); + const dead = alternatives.filter( + (paths) => !paths.some((p) => roots.some((root) => has(root, p))), + ); expect(dead).toEqual([]); }, ); @@ -141,6 +156,7 @@ describe('built-in template lint', () => { const fa3 = collect(getBuiltinTemplate('fa3-default')!.blocks); expect(fa3.conditions).toContain('Fa.Platnosc'); expect(fa3.repeaters).toContain('Fa.FaWiersz'); + expect(fa3.repeaters).toContain('Fa.Zamowienie.ZamowienieWiersz'); expect(fa3.repeaters).toContain('Fa.Platnosc.RachunekBankowy'); expect(fa3.repeaters).toContain('Podmiot2.DaneKontaktowe'); expect(collect(getBuiltinTemplate('upo-4_3')!.blocks).repeaters).toContain('Dokument'); @@ -164,7 +180,7 @@ describe('built-in template lint', () => { * `headingStyle`, `linkStyle` and `offlineStyle` — so the walk takes any key * that ends in `Style` and a new one is covered the day it is added. */ - it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: every style it references is defined', (name) => { + it.each(Object.keys(FIXTURES_BY_TEMPLATE))('%s: every style it references is defined', (name) => { const template = getBuiltinTemplate(name)!; const defined = Object.keys(template.styles ?? {}); const referenced = new Set(); @@ -190,7 +206,7 @@ describe('built-in template lint', () => { } }); - it.each(Object.keys(FIXTURE_BY_TEMPLATE))('%s: defines the heading styles its blocks will use', (name) => { + it.each(Object.keys(FIXTURES_BY_TEMPLATE))('%s: defines the heading styles its blocks will use', (name) => { // A heading belongs to its block, not to the template, so the block reaches // for a style name instead of being handed one — `h2` unless `headingStyle` // says otherwise, and `title` for the header. Nothing in the JSON refers to From b74ab95242fc18357f5f1535ec5546b6df4bfcc6 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 01:45:58 +0200 Subject: [PATCH 49/67] fix(cli): refuse a PDF environment that has no QR host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Codex review of version/v0.12.0 against main: `--env` was cast to the three environments rather than checked against them, and the host lookup treats anything it does not recognize as production. So `--env staging`, or any typo, printed a production verification code on a test invoice and the command reported success — the one way the page can be wrong that a reader cannot see, because nothing on it names the registry. Validate the flag where `--locale` and `--totals` are already validated. Reproduced through the command with `--env staging`: the render options came out `{…,"env":"staging"}` and the file was written. Now the command exits non-zero naming the three valid values and writes nothing, checked both at the seam and through the built CLI. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/commands/invoice.ts | 16 ++++++++++++++-- .../tests/e2e/35-invoice-pdf-cli.test.ts | 11 +++++++++++ .../unit/cli/commands/invoice-pdf.test.ts | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 6976ded3..e8333763 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -576,6 +576,15 @@ type PdfLocale = (typeof VALID_PDF_LOCALES)[number]; const VALID_PDF_TOTALS = ['none', 'buckets', 'summary', 'both'] as const; type PdfTotals = (typeof VALID_PDF_TOTALS)[number]; +/** + * The environments a QR verification host can be derived for. An unrecognized + * value falls through to production when the URL is built, so a typo would + * print a production host on a test invoice and the command would still report + * success — the flag has to be the place that catches it. + */ +const VALID_PDF_ENVS = ['prod', 'test', 'demo'] as const; +type PdfEnv = (typeof VALID_PDF_ENVS)[number]; + /** * A CSS hex colour, the one unambiguous way to name a brand colour. pdfmake * silently ignores a value it does not recognize — the document comes out @@ -703,7 +712,10 @@ const pdf = defineCommand({ throw new Error(`Invalid --accent "${accent}". Expected a hex colour such as #5AB595 or #b04.`); } - const env = args.env as 'prod' | 'test' | 'demo' | undefined; + const env = args.env as string | undefined; + if (env !== undefined && !VALID_PDF_ENVS.includes(env as PdfEnv)) { + throw new Error(`Invalid --env "${env}". Valid: ${VALID_PDF_ENVS.join(', ')}`); + } const logo = args.logo ? readImageAsDataUri(args.logo as string) : undefined; const notes = args.notes ? readNotesFile(args.notes as string) : undefined; const renderOpts = { @@ -714,7 +726,7 @@ const pdf = defineCommand({ ...(args.qrUrl ? { qrUrl: args.qrUrl as string } : {}), ...(args.qrCertUrl ? { certificateQrUrl: args.qrCertUrl as string } : {}), ...(args.ksefNumber ? { ksefNumber: args.ksefNumber as string } : {}), - ...(env ? { env } : {}), + ...(env ? { env: env as PdfEnv } : {}), ...(logo ? { logo } : {}), ...(accent ? { theme: { accent } } : {}), ...(notes ? { notes } : {}), diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index d1d15c2c..09d536a3 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -337,6 +337,17 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // pdfmake silently ignores a colour it cannot parse, so an unrecognized accent // renders a document identical to an unthemed one. A misspelled colour name // has to fail at the flag, or it becomes a PDF that is quietly wrong. + // A typo here used to resolve to the production QR host and the command still + // reported success, so the invoice came out carrying a code that points at the + // wrong registry — nothing on the page says which one it is. + it('refuses an environment it cannot resolve a QR host for', () => { + const out = join(outDir, 'should-not-exist-5.pdf'); + const res = run(['invoice', 'pdf', fx('fa3.xml'), '--qr', '--env', 'staging', '--out', out]); + expect(res.status).not.toBe(0); + expect(`${res.stdout}${res.stderr}`).toMatch(/Invalid --env/); + expect(existsSync(out)).toBe(false); + }); + it('refuses an accent colour that is not hex', () => { const out = join(outDir, 'should-not-exist-4.pdf'); const res = run(['invoice', 'pdf', fx('fa3.xml'), '--accent', 'crimsonn', '--out', out]); diff --git a/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts b/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts index 669d95d3..1437eb03 100644 --- a/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts +++ b/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts @@ -101,6 +101,25 @@ describe('invoice pdf — CLI wiring', () => { await expect(runPdf({ file: 'invoice.xml', locale: 'de' })).rejects.toThrow(/Invalid --locale/); }); + // An unrecognized environment resolves to the production QR host, so without + // this the command would print a production code on a test invoice and report + // success — the one failure mode a reader cannot see on the page. + it('rejects an unknown --env instead of falling back to production', async () => { + await expect(runPdf({ file: 'invoice.xml', qr: true, env: 'staging' })).rejects.toThrow( + /Invalid --env "staging"\. Valid: prod, test, demo/, + ); + expect(mockedPdf.renderInvoicePdf).not.toHaveBeenCalled(); + }); + + it.each(['prod', 'test', 'demo'])('accepts --env %s', async (env) => { + await runPdf({ file: 'invoice.xml', qr: true, env }); + expect(mockedPdf.renderInvoicePdf).toHaveBeenCalledWith( + expect.any(Uint8Array), + 'fa3-default', + expect.objectContaining({ env }), + ); + }); + it('defaults to fa3-default for an FA(3) document and writes next to the source', async () => { await runPdf({ file: 'dir/invoice.xml' }); expect(mockedPdf.renderInvoicePdf).toHaveBeenCalledWith( From 1ab31b0c0fc8de897fe7374cb1b4e5e6ea1b4732 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 01:50:08 +0200 Subject: [PATCH 50/67] fix(pdf): draw a rule across the page it is actually on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Codex review of version/v0.12.0 against main: a divider was a canvas line of a constant 515pt — portrait A4 with 40pt margins, and nothing else. A canvas needs its length in points and the interpreter has no page to measure, but the DSL lets a template choose its size, its orientation and its margins, so the constant was wrong everywhere else: measured out of the PDF, the old rule fell 247pt short on landscape A4 and hung 175pt past the margin on A5. Draw it as a single-cell table sized `'*'` with a border on the cell's bottom edge instead. pdfmake measures that against the page it is drawn on, and an empty canvas in the cell keeps the height at zero, so 300 rules still fit on one page — the property the canvas was chosen for. The new test reads the stroke back out of an uncompressed PDF and checks it against four geometries; against the previous renderer all four fail, including portrait A4, which was short by the 0.28pt the constant had rounded away. Full unit and e2e suites pass, and the preview pages are unchanged apart from rules now ending flush with the right margin. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 2 +- .../src/pdf/template/interpret.ts | 21 ++++- .../tests/unit/pdf/advance-invoice.test.ts | 10 ++- .../tests/unit/pdf/divider-width.test.ts | 84 +++++++++++++++++++ .../tests/unit/pdf/interpret.test.ts | 22 ++++- .../tests/unit/pdf/notes.test.ts | 6 +- .../tests/unit/pdf/upo-multi-document.test.ts | 5 +- 7 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/divider-width.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index c24e4ca6..181691bb 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -204,7 +204,7 @@ It prints the localized attribution on the left and a `Page 1 of 3` indicator on A `qr` block's `fit` is the printed side in points, quiet zone included, and it is exact — give two blocks the same `fit` and they come out the same size on the page, however much data each code carries. What differs instead is the module width, which is what a scanner cares about: Code I is 41 modules while Code II carries a signature and runs 57 over an EC key or 85 over RSA, so the same box makes Code II's modules roughly half as wide as Code I's. The renderer refuses a `fit` that would leave less than a point per module rather than printing a code nothing can read. The built-in templates use 104 for both. -**Primitive blocks** are layout building blocks: `text`, `columns`, `stack`, `each`, `table`, `image`, `divider`, `spacer`. +**Primitive blocks** are layout building blocks: `text`, `columns`, `stack`, `each`, `table`, `image`, `divider`, `spacer`. A `divider` draws a hairline across the content width — whatever `page.size`, `page.orientation` and `page.margins` make that — and a `spacer` adds its `height` and nothing else; neither costs a line of leading. `each` repeats a group of blocks once per entry of a collection, with the entry as the binding root, so its children use item-relative paths. Use it where a table cannot fit a record on one row — the built-in UPO templates lay out each confirmed document this way, because a 35-character KSeF number beside a 44-character hash will not share a page-wide row. diff --git a/packages/ksef-client-ts/src/pdf/template/interpret.ts b/packages/ksef-client-ts/src/pdf/template/interpret.ts index aca8b3c5..99b1aa32 100644 --- a/packages/ksef-client-ts/src/pdf/template/interpret.ts +++ b/packages/ksef-client-ts/src/pdf/template/interpret.ts @@ -133,10 +133,29 @@ const coreRegistry: BlockRegistry = { return withStyle({ columns }, b.style); }, + // A canvas line has to be given its length in points, and the interpreter has + // no page to measure: `page.size`, `page.orientation` and `page.margins` are + // all the template's to choose, so any constant is right for one geometry and + // wrong for the rest — 515pt fits portrait A4 with 40pt margins and overhangs + // A5 by 176pt. A single-cell table sized `'*'` is measured by pdfmake against + // the page it is actually drawn on, and a bottom border on a cell holding an + // empty canvas costs no height, so the rule stays a hairline that spans + // exactly the content width. divider: (block) => { const b = block as import('./dsl.js').DividerBlock; return withStyle( - { canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: '#cccccc' }] }, + { + table: { widths: ['*'], body: [[{ canvas: [] }]] }, + layout: { + hLineWidth: (i: number) => (i === 1 ? 0.5 : 0), + vLineWidth: () => 0, + hLineColor: () => '#cccccc', + paddingTop: () => 0, + paddingBottom: () => 0, + paddingLeft: () => 0, + paddingRight: () => 0, + }, + }, b.style, ); }, diff --git a/packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts b/packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts index 9fad6d94..78f7978a 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts @@ -28,15 +28,19 @@ function docFor(templateName: string, xml: string): Record { return interpretTemplate(template, ctx, blockRegistry); } -/** Every table in the document, as its body rows. */ +/** + * Every *column* table in the document, as its body rows. Only the blocks that + * print columns declare `headerRows`, which is what keeps the horizontal rules + * out: those are one-cell tables too, and every one of them has a single row. + */ function tables(doc: Record): unknown[][][] { const found: unknown[][][] = []; const walk = (value: unknown): void => { if (Array.isArray(value)) return value.forEach(walk); if (value === null || typeof value !== 'object') return; const node = value as Record; - const table = node.table as { body?: unknown[][] } | undefined; - if (table?.body) found.push(table.body); + const table = node.table as { body?: unknown[][]; headerRows?: number } | undefined; + if (table?.body && typeof table.headerRows === 'number') found.push(table.body); Object.values(node).forEach(walk); }; walk(doc.content); diff --git a/packages/ksef-client-ts/tests/unit/pdf/divider-width.test.ts b/packages/ksef-client-ts/tests/unit/pdf/divider-width.test.ts new file mode 100644 index 00000000..c70a2364 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/divider-width.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { loadPdfMake, createPdfBuffer } from '../../../src/pdf/fonts.js'; +import { renderInvoicePdfFromTemplate } from '../../../src/pdf/index.js'; +import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; +import { interpretTemplate, type RenderContext } from '../../../src/pdf/template/interpret.js'; +import type { InvoiceTemplate, PageConfig } from '../../../src/pdf/template/dsl.js'; + +/** + * A divider used to be a canvas line of a constant 515pt — portrait A4 with + * 40pt margins, and nothing else. The DSL lets a template pick its page size, + * its orientation and its margins, so that constant was short on some pages and + * hung past the margin on others. These read the line back out of the PDF and + * check it against the geometry the template asked for. + */ + +const ctx: RenderContext = { + root: {}, + strict: false, + label: (k) => k, + bindings: {}, + flags: {}, +}; + +const templateWith = (page: PageConfig): InvoiceTemplate => ({ + schema: 'FA(3)', + page, + blocks: [{ type: 'divider' }], +}); + +/** + * The drawn line, as `[x1, x2]` in PDF user space. `compress: false` keeps the + * content stream readable; pdfmake writes a stroke as `x y m` / `x y l`. + */ +async function ruleExtent(page: PageConfig): Promise<[number, number]> { + const doc = interpretTemplate(templateWith(page), ctx, blockRegistry); + const bytes = await createPdfBuffer(await loadPdfMake(), { ...doc, compress: false }); + const stream = Buffer.from(bytes).toString('latin1'); + const strokes = stream.match(/([-\d.]+) [-\d.]+ m\n([-\d.]+) [-\d.]+ l/g) ?? []; + expect(strokes, 'the divider drew no line').toHaveLength(1); + const [, x1, x2] = /([-\d.]+) [-\d.]+ m\n([-\d.]+) [-\d.]+ l/.exec(strokes[0]!)!; + return [Number(x1), Number(x2)]; +} + +/** A4 is 595.28 × 841.89pt; A5 is 419.53 × 595.28. */ +const CASES: Array<{ name: string; page: PageConfig; left: number; right: number }> = [ + { name: 'portrait A4', page: { size: 'A4', margins: [40, 40, 40, 50] }, left: 40, right: 555.28 }, + { name: 'landscape A4', page: { size: 'A4', orientation: 'landscape', margins: [40, 40, 40, 50] }, left: 40, right: 801.89 }, + { name: 'portrait A5', page: { size: 'A5', margins: [40, 40, 40, 50] }, left: 40, right: 379.53 }, + { name: 'A4 with narrow margins', page: { size: 'A4', margins: [20, 40, 20, 50] }, left: 20, right: 575.28 }, +]; + +describe('a divider spans the content width of the page it is drawn on', () => { + it.each(CASES)('$name', async ({ page, left, right }) => { + const [x1, x2] = await ruleExtent(page); + expect(x1).toBeCloseTo(left, 1); + expect(x2).toBeCloseTo(right, 1); + }, 30000); + + it('costs no vertical space, so a page of rules is still one page', async () => { + // The rule replaced a zero-height canvas, and a template that separates + // many short sections must not pay a line of leading for each one. + const template: InvoiceTemplate = { + schema: 'FA(3)', + page: { size: 'A4', margins: [40, 40, 40, 50] }, + blocks: Array.from({ length: 300 }, () => ({ type: 'divider' as const })), + }; + const doc = interpretTemplate(template, ctx, blockRegistry); + const bytes = await createPdfBuffer(await loadPdfMake(), doc); + const pages = (Buffer.from(bytes).toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length; + expect(pages).toBe(1); + }, 30000); + + it('still renders through the public entry point', async () => { + const xml = ` + + FA3 +`; + const bytes = await renderInvoicePdfFromTemplate( + xml, + templateWith({ size: 'A4', orientation: 'landscape' }) as never, + ); + expect(Buffer.from(bytes.subarray(0, 5)).toString('latin1')).toBe('%PDF-'); + }, 30000); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts b/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts index 95e8687b..7c1aba09 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts @@ -186,11 +186,25 @@ describe('interpretBlock core primitives', () => { expect(node).toEqual({ columns: [{ text: 'left' }, { text: 'right' }], style: 'row' }); }); - it('renders a divider canvas line', () => { + // The rule is a one-cell table sized `'*'` rather than a canvas line, because + // a canvas needs its length in points and the page it will be drawn on is the + // template's choice — see the note on the renderer. + it('renders a divider as a rule that fills the content width', () => { const node = asRecord(interpretBlock({ type: 'divider' }, makeCtx(ROOT), coreRegistry, 0)); - expect(node).toEqual({ - canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: '#cccccc' }], - }); + expect(node.table).toEqual({ widths: ['*'], body: [[{ canvas: [] }]] }); + const layout = node.layout as { + hLineWidth: (i: number) => number; + vLineWidth: () => number; + hLineColor: () => string; + paddingTop: () => number; + paddingBottom: () => number; + }; + // Only the cell's bottom edge is drawn, and it costs no vertical space. + expect([0, 1, 2].map(layout.hLineWidth)).toEqual([0, 0.5, 0]); + expect(layout.vLineWidth()).toBe(0); + expect(layout.hLineColor()).toBe('#cccccc'); + expect(layout.paddingTop()).toBe(0); + expect(layout.paddingBottom()).toBe(0); }); it('attaches a style to a divider', () => { diff --git a/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts b/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts index 5c19c1d0..f35aeb4f 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts @@ -157,7 +157,11 @@ describe('the notes option reaches the block', () => { ...(notes ? { notes } : {}), }; const doc = interpretTemplate(template, ctx, blockRegistry); - return (doc.content as Array>).filter((n) => Array.isArray(n.canvas)).length; + // A rule is a one-cell table sized `'*'`; a spacer is an empty canvas. + return (doc.content as Array>).filter((n) => { + const table = n.table as { widths?: unknown[]; body?: unknown[][] } | undefined; + return table?.widths?.length === 1 && table.widths[0] === '*' && table.body?.length === 1; + }).length; }; expect(render([{ head: 'Uwaga', body: 'Treść' }])).toBe(render() + 1); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts b/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts index 5643c248..79c1249a 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts @@ -74,8 +74,9 @@ describe.each([ const group = (doc.content as Array>).find( (n) => Array.isArray(n.stack) && JSON.stringify(n).includes('KSeF document number'), ) as { stack: unknown[] }; - // Count dividers by their drawn line — a spacer is an *empty* canvas. - expect(JSON.stringify(group).split('"type":"line"')).toHaveLength(4); + // Count dividers by their shape — a one-cell rule sized to the content + // width. A spacer is a bare empty canvas, with no table around it. + expect(JSON.stringify(group).split('"body":[[{"canvas":[]}]]')).toHaveLength(4); }); it('keeps every field on the page instead of clipping wide records', () => { From 61e2475666a857879f4020064dfdaf684ae9eb91 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 15:29:58 +0200 Subject: [PATCH 51/67] fix(pdf): name P_15 for what it is on this document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in templates printed `Fa.P_15` under a flat `Do zapłaty`, but the FA schemas give that field three readings. On an advance invoice (`ZAL`/`KOR_ZAL`) it is the payment the document records as already received, so the PDF told the reader to pay it a second time. And when the document carries `Fa.Rozliczenie.DoZaplaty` — P_15 plus surcharges minus deductions — that is the figure actually owed, so the page named a number nobody should pay. The reading is derived once from `Fa.RodzajFaktury` and the presence of the settlement, and the templates list one row per reading plus the settled payable when the document states one. Payment rows take `when` for the same reason totals rows do. Verified with a new fixture pair carrying `Rozliczenie` and a test that renders all three built-ins against an ordinary, an advance and a settled invoice: before, the advance invoice printed `Do zapłaty: 615,00` and the settled one printed `Do zapłaty: 615,00` instead of the 625,00 it owes; now the first says `Kwota zapłaty` and the second prints 625,00 under `Do zapłaty`. Full suite green: 2771 unit + 153 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 13 +- .../ksef-client-ts/src/pdf/document-flags.ts | 41 +++++++ packages/ksef-client-ts/src/pdf/i18n/en.ts | 2 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 2 + packages/ksef-client-ts/src/pdf/i18n/uk.ts | 2 + packages/ksef-client-ts/src/pdf/index.ts | 2 + .../src/pdf/template/blocks/payment.ts | 8 +- .../src/pdf/template/builtin/fa2-default.json | 44 ++++++- .../src/pdf/template/builtin/fa3-default.json | 44 ++++++- .../pdf/template/builtin/fa3-showcase.json | 44 ++++++- .../ksef-client-ts/src/pdf/template/dsl.ts | 15 ++- .../tests/fixtures/pdf/fa2-rozliczenie.xml | 101 ++++++++++++++++ .../tests/fixtures/pdf/fa3-rozliczenie.xml | 101 ++++++++++++++++ .../tests/unit/pdf/amount-due-label.test.ts | 112 ++++++++++++++++++ .../tests/unit/pdf/blocks-semantic.test.ts | 15 ++- .../unit/pdf/builtin-template-lint.test.ts | 13 +- .../tests/unit/pdf/totals-sum.test.ts | 10 +- 17 files changed, 547 insertions(+), 22 deletions(-) create mode 100644 packages/ksef-client-ts/src/pdf/document-flags.ts create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa2-rozliczenie.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa3-rozliczenie.xml create mode 100644 packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 181691bb..9a3f38fa 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -188,7 +188,7 @@ The `schema` field binds a template to a single document kind. If you render an | `parties` | Seller / buyer two-column panel; a line that resolves empty is skipped | | `lines` | Invoice line-item table. Takes `when`, because an invoice does not always carry its items in the same place — see below | | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | -| `payment` | Payment details (amount paid, date, method) | +| `payment` | Payment details (amount paid, date, method). A row takes `when`, so one figure can be listed once per reading and only the applicable label prints | | `annotations` | Miscellaneous labelled fields | | `notes` | The caller's own sections, from `notes` — a heading over a body, each | | `qr` | One KSeF verification QR — `code: "invoice"` (Code I, the default) or `code: "certificate"` (Code II) | @@ -208,13 +208,22 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it `each` repeats a group of blocks once per entry of a collection, with the entry as the binding root, so its children use item-relative paths. Use it where a table cannot fit a record on one row — the built-in UPO templates lay out each confirmed document this way, because a 35-character KSeF number beside a 44-character hash will not share a page-wide row. +### What `P_15` is called + +`P_15` does not mean the same thing on every document, so a template cannot give it one fixed label. The FA schemas define it as the total receivable, with two exceptions: + +- on an **advance invoice** (`RodzajFaktury` `ZAL` or `KOR_ZAL`) it is the payment the document records as *already received* — labelling it `Do zapłaty` tells the reader to pay it a second time; +- when the document carries **`Fa.Rozliczenie.DoZaplaty`** — `P_15` plus surcharges minus deductions — that is the figure actually payable, and `P_15` is only the total. + +Exactly one of the `p15IsAmountDue`, `p15IsAdvancePaid` and `p15IsAmountTotal` context flags is true for a given document. The built-in templates list one row per reading and print the settled payable alongside it when the document states one; a custom template that binds `Fa.P_15` unconditionally should gate it the same way. + An advance invoice (`RodzajFaktury` `ZAL` or `KOR_ZAL`) records the goods and services it covers under `Fa.Zamowienie`, and may carry no `Fa.FaWiersz` at all. A repeater with no entries still draws its header row, so a template that binds both gives each one a `when` — this is what the built-in templates do, and it is why an advance invoice shows its order rows under their own heading instead of an empty item table. ### Bindings, labels, conditions, and formats - **Binding paths** are dot-paths into the document body — e.g. `Fa.P_2` (invoice number), `Podmiot1.DaneIdentyfikacyjne.Nazwa` (seller name). Paths are relative to the body element, not the document wrapper. - **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. -- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`. A `divider` and a `lines` table take it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. +- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`, `p15IsAmountDue`, `p15IsAdvancePaid`, `p15IsAmountTotal`. A `divider` and a `lines` table take it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. - **`headingStyle`** (`parties`, `payment`, `annotations`, `notes`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. diff --git a/packages/ksef-client-ts/src/pdf/document-flags.ts b/packages/ksef-client-ts/src/pdf/document-flags.ts new file mode 100644 index 00000000..f0c1af80 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/document-flags.ts @@ -0,0 +1,41 @@ +/** + * Flags derived from the document itself, for templates to gate rows on. + * + * They live here rather than inline in the renderer because the reasoning is + * about the FA schema, not about layout, and because a test can then check the + * reading without going through a PDF. + */ +import { get, has } from './accessor.js'; + +/** + * Invoice kinds whose `P_15` is a payment already received rather than an + * amount still owed (`TRodzajFaktury`): an advance invoice documents the + * receipt of a payment made before the sale. + */ +const ADVANCE_INVOICE_TYPES = new Set(['ZAL', 'KOR_ZAL']); + +/** + * Which of `P_15`'s three readings this document supports. `P_15` does not mean + * the same thing on every invoice, so a template cannot name it with one fixed + * label: + * + * - on an advance invoice it is the payment the document records as received, + * and telling that reader to pay it again is the worst thing an invoice PDF + * can do; + * - when the document carries `Rozliczenie.DoZaplaty` — `P_15` plus surcharges + * minus deductions — the payable figure is that one, so `P_15` is only the + * total receivable; + * - otherwise it is both, and reads as the amount due. + * + * Exactly one flag is true, so a template lists one row per reading and the + * right one prints. + */ +export function p15Flags(root: unknown): Record { + const advance = ADVANCE_INVOICE_TYPES.has(get(root, 'Fa.RodzajFaktury')); + const settled = has(root, 'Fa.Rozliczenie.DoZaplaty'); + return { + p15IsAdvancePaid: advance, + p15IsAmountTotal: !advance && settled, + p15IsAmountDue: !advance && !settled, + }; +} diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index fdb0b951..6214ae13 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -51,6 +51,8 @@ export const en: LabelBundle = { totalNet: 'Total net', totalVat: 'Total VAT', totalDue: 'Amount due', + advancePaid: 'Payment received', + amountTotal: 'Total amount', currency: 'Currency', payment: 'Payment', paid: 'Paid', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index e36a073f..fe2de00d 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -53,6 +53,8 @@ export const pl: LabelBundle = { totalNet: 'Razem netto', totalVat: 'Razem VAT', totalDue: 'Do zapłaty', + advancePaid: 'Kwota zapłaty', + amountTotal: 'Kwota należności ogółem', currency: 'Waluta', // payment payment: 'Płatność', diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index eff804d0..a9c5c899 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -59,6 +59,8 @@ export const uk: LabelBundle = { totalNet: 'Разом нетто', totalVat: 'Разом ПДВ', totalDue: 'До сплати', + advancePaid: 'Сума оплати', + amountTotal: 'Загальна сума', currency: 'Валюта', payment: 'Оплата', paid: 'Сплачено', diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index 692381ba..18f17e69 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -25,6 +25,7 @@ import { blockRegistry } from './template/blocks/index.js'; import { getBuiltinTemplate as loadBuiltinTemplate, builtinTemplateNames } from './template/builtin/index.js'; import { loadPdfMake, createPdfBuffer } from './fonts.js'; import { deriveInvoiceQrUrl } from './qr.js'; +import { p15Flags } from './document-flags.js'; export type { Locale } from './i18n/types.js'; export type { InvoiceTemplate } from './template/dsl.js'; @@ -161,6 +162,7 @@ function buildContext( const totals = opts.totals ?? 'buckets'; const flags: Record = { + ...p15Flags(root), hasKsefNumber: Boolean(opts.ksefNumber), offline: !opts.ksefNumber, // Either code is enough to keep the QR area on the page: an offline invoice diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts index ac1d3d27..db300964 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -1,7 +1,7 @@ import { get, list } from '../../accessor.js'; import type { PaymentBlock } from '../dsl.js'; import { readField } from './field.js'; -import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; +import { evalWhen, resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; /** * Payment details: a `payment` heading, one `label: value` line per @@ -9,6 +9,9 @@ import { resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.j * section ({@link PaymentBlock.accounts}) — one `label: value` line per field, * for each account in the collection. * + * A row may carry `when` and is dropped when it does not apply, so a template + * can list every reading of a figure and print the one this document supports. + * * A row/field whose value resolves empty is skipped, so absent optional fields * (KSeF invoices carry many) don't print a dangling "Label:" with no value. * Strict mode still surfaces dot-path typos: a *missing* binding throws before @@ -26,6 +29,9 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { const stack: PdfNode[] = [{ text: ctx.label('payment'), style: heading }]; for (const row of block.rows) { + // A row may be one of several readings of the same figure, only one of + // which applies to this document. + if (!evalWhen(row.when, ctx)) continue; const value = readField(row, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx)); if (value === '') continue; stack.push({ text: `${ctx.label(row.label)}: ${value}`, ...(row.style ? { style: row.style } : {}) }); diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index f3e32c40..facd52de 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -218,7 +218,16 @@ "when": "totalsSummary", "format": "money" }, - { "label": "totalDue", "path": "Fa.P_15", "format": "money", "style": "strong" }, + { "label": "totalDue", "path": "Fa.P_15", "when": "p15IsAmountDue", "format": "money", "style": "strong" }, + { "label": "advancePaid", "path": "Fa.P_15", "when": "p15IsAdvancePaid", "format": "money", "style": "strong" }, + { "label": "amountTotal", "path": "Fa.P_15", "when": "p15IsAmountTotal", "format": "money", "style": "strong" }, + { + "label": "totalDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "Fa.Rozliczenie.DoZaplaty", + "format": "money", + "style": "strong" + }, { "label": "currency", "path": "Fa.KodWaluty", "style": "strong" } ] }, @@ -232,7 +241,38 @@ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, - { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty", "style": "strong" } + { + "label": "amountDueTotal", + "path": "Fa.P_15", + "when": "p15IsAmountDue", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "advancePaid", + "path": "Fa.P_15", + "when": "p15IsAdvancePaid", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "amountTotal", + "path": "Fa.P_15", + "when": "p15IsAmountTotal", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "totalDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "Fa.Rozliczenie.DoZaplaty", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 465aa01f..947d588a 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -218,7 +218,16 @@ "when": "totalsSummary", "format": "money" }, - { "label": "totalDue", "path": "Fa.P_15", "format": "money", "style": "strong" }, + { "label": "totalDue", "path": "Fa.P_15", "when": "p15IsAmountDue", "format": "money", "style": "strong" }, + { "label": "advancePaid", "path": "Fa.P_15", "when": "p15IsAdvancePaid", "format": "money", "style": "strong" }, + { "label": "amountTotal", "path": "Fa.P_15", "when": "p15IsAmountTotal", "format": "money", "style": "strong" }, + { + "label": "totalDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "Fa.Rozliczenie.DoZaplaty", + "format": "money", + "style": "strong" + }, { "label": "currency", "path": "Fa.KodWaluty", "style": "strong" } ] }, @@ -231,7 +240,38 @@ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, - { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty", "style": "strong" } + { + "label": "amountDueTotal", + "path": "Fa.P_15", + "when": "p15IsAmountDue", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "advancePaid", + "path": "Fa.P_15", + "when": "p15IsAdvancePaid", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "amountTotal", + "path": "Fa.P_15", + "when": "p15IsAmountTotal", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "totalDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "Fa.Rozliczenie.DoZaplaty", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 640fd64b..774a1d50 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -156,7 +156,16 @@ "when": "totalsSummary", "format": "money" }, - { "label": "totalDue", "path": "Fa.P_15", "format": "money", "style": "strong" }, + { "label": "totalDue", "path": "Fa.P_15", "when": "p15IsAmountDue", "format": "money", "style": "strong" }, + { "label": "advancePaid", "path": "Fa.P_15", "when": "p15IsAdvancePaid", "format": "money", "style": "strong" }, + { "label": "amountTotal", "path": "Fa.P_15", "when": "p15IsAmountTotal", "format": "money", "style": "strong" }, + { + "label": "totalDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "Fa.Rozliczenie.DoZaplaty", + "format": "money", + "style": "strong" + }, { "label": "currency", "path": "Fa.KodWaluty", "style": "strong" } ] }, @@ -169,7 +178,38 @@ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, - { "label": "amountDueTotal", "path": "Fa.P_15", "format": "money", "suffixPath": "Fa.KodWaluty", "style": "strong" } + { + "label": "amountDueTotal", + "path": "Fa.P_15", + "when": "p15IsAmountDue", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "advancePaid", + "path": "Fa.P_15", + "when": "p15IsAdvancePaid", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "amountTotal", + "path": "Fa.P_15", + "when": "p15IsAmountTotal", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "totalDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "Fa.Rozliczenie.DoZaplaty", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + } ], "accounts": { "from": "Fa.Platnosc.RachunekBankowy", diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 80156fe3..61b39d0e 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -250,10 +250,21 @@ export interface PaymentAccounts { fields: FieldDef[]; } +/** + * A payment line: a field, plus the same `when` gate a totals row carries. The + * gate exists because one figure can have several readings — `P_15` is an + * amount owed on an ordinary invoice and an amount already received on an + * advance one — and the template picks the right label by listing one row per + * reading. + */ +export interface PaymentRow extends FieldDef { + when?: string; +} + export interface PaymentBlock { type: 'payment'; when?: string; - rows: FieldDef[]; + rows: PaymentRow[]; accounts?: PaymentAccounts; /** See {@link HEADING_STYLE_DOC}. The block label only, not `accounts.heading`. */ headingStyle?: string; @@ -528,7 +539,7 @@ const blockSchema: z.ZodType = z.lazy(() => z.object({ type: z.literal('payment'), when: z.string().optional(), - rows: z.array(fieldDef), + rows: z.array(fieldDef.extend({ when: z.string().optional() })), accounts: z .object({ from: z.string(), diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-rozliczenie.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-rozliczenie.xml new file mode 100644 index 00000000..c0303f4c --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-rozliczenie.xml @@ -0,0 +1,101 @@ + + + + + FA + 2 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + + 10.00 + Koszt dostawy + + 10.00 + 625.00 + + + 1 + + 2025-02-01 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-rozliczenie.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-rozliczenie.xml new file mode 100644 index 00000000..85f0911b --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-rozliczenie.xml @@ -0,0 +1,101 @@ + + + + + FA + 3 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + + 10.00 + Koszt dostawy + + 10.00 + 625.00 + + + 1 + + 2025-02-01 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts new file mode 100644 index 00000000..fbb96d40 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts @@ -0,0 +1,112 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { p15Flags } from '../../../src/pdf/document-flags.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; +import { interpretTemplate, type RenderContext } from '../../../src/pdf/template/interpret.js'; +import { makeLabelResolver } from '../../../src/pdf/i18n/index.js'; + +/** + * `P_15` is not one figure with one name. The FA schemas define it as the total + * receivable, except on an advance invoice (`ZAL`/`KOR_ZAL`) where it is the + * payment the document records as *already received*; and when the document + * carries `Rozliczenie.DoZaplaty` — `P_15` plus surcharges minus deductions — + * that is the figure the buyer actually pays. + * + * Printing `P_15` under a flat `Do zapłaty` therefore told the reader of an + * advance invoice to pay the amount they had already paid, and the reader of a + * settled invoice to pay a figure that was not the one owed. + */ + +const fx = (p: string) => readFileSync(new URL(`../../fixtures/pdf/${p}`, import.meta.url), 'utf8'); + +/** Every rendered text run in the document, flattened. */ +function texts(doc: Record): string[] { + const out: string[] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) return value.forEach(walk); + if (value === null || typeof value !== 'object') return; + const node = value as Record; + if (typeof node.text === 'string') out.push(node.text); + Object.values(node).forEach(walk); + }; + walk(doc.content); + return out; +} + +/** Render through the same flag derivation the public entry point uses. */ +function render(templateName: string, xml: string): string[] { + const template = getBuiltinTemplate(templateName)!; + const root = (parseXmlForPdf(xml) as Record).Faktura; + const ctx: RenderContext = { + root, + strict: false, + label: makeLabelResolver('pl', {}), + bindings: { 'opts.logo': '', 'opts.ksefNumber': '', 'opts.accent': '', qrUrl: '', certificateQrUrl: '' }, + flags: { ...p15Flags(root), totalsBuckets: true }, + }; + return texts(interpretTemplate(template, ctx, blockRegistry)); +} + +describe('which reading of P_15 a document supports', () => { + it('an ordinary invoice: P_15 is the amount due', () => { + expect(p15Flags((parseXmlForPdf(fx('fa3.xml')) as Record).Faktura)).toEqual({ + p15IsAmountDue: true, + p15IsAdvancePaid: false, + p15IsAmountTotal: false, + }); + }); + + it('an advance invoice: P_15 is a payment already received', () => { + expect(p15Flags((parseXmlForPdf(fx('fa3-zal.xml')) as Record).Faktura)).toEqual({ + p15IsAmountDue: false, + p15IsAdvancePaid: true, + p15IsAmountTotal: false, + }); + }); + + it('a settled invoice: P_15 is only the total, DoZaplaty is the payable', () => { + expect(p15Flags((parseXmlForPdf(fx('fa3-rozliczenie.xml')) as Record).Faktura)).toEqual({ + p15IsAmountDue: false, + p15IsAdvancePaid: false, + p15IsAmountTotal: true, + }); + }); + + it('an advance invoice that also settles stays an advance invoice', () => { + const xml = fx('fa3-rozliczenie.xml').replace( + 'VAT', + 'ZAL', + ); + expect(p15Flags((parseXmlForPdf(xml) as Record).Faktura).p15IsAdvancePaid).toBe(true); + }); +}); + +describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s names the figure it prints', (name) => { + const fa = name.startsWith('fa2') ? 'fa2' : 'fa3'; + + it('calls P_15 the amount due on an ordinary invoice', () => { + const out = render(name, fx(`${fa}.xml`)); + expect(out.some((t) => /Do zap[łl]aty/i.test(t))).toBe(true); + expect(out.some((t) => t.includes('Kwota zapłaty'))).toBe(false); + }); + + it('never demands payment of an advance already received', () => { + const out = render(name, fx(`${fa}-zal.xml`)); + // The advance is labelled as a payment made, and nothing on the page says + // the reader still owes it. + expect(out.some((t) => t.includes('Kwota zapłaty'))).toBe(true); + expect(out.some((t) => /Do zap[łl]aty/i.test(t))).toBe(false); + }); + + it('prints the settled payable, not P_15, when the document states one', () => { + const out = render(name, fx(`${fa}-rozliczenie.xml`)); + const due = out.findIndex((t) => /Do zap[łl]aty/i.test(t)); + expect(due, 'the settled payable must be labelled').toBeGreaterThanOrEqual(0); + // 625,00 is DoZaplaty (P_15 615,00 + 10,00 of surcharges), and it is the + // figure printed under `Do zapłaty` — P_15 keeps its own name. + expect(out).toContain('625,00'); + expect(out.some((t) => t.includes('Kwota należności ogółem'))).toBe(true); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index 5490a71c..d72d5800 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -1146,11 +1146,16 @@ describe('the built-in templates restate the amount due with its currency', () = const payment = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'payment') as { rows: Array<{ label: string; path: string; suffixPath?: string }>; }; - const row = payment.rows.find((r) => r.label === 'amountDueTotal')!; - expect(row.path).toBe('Fa.P_15'); - expect(row.suffixPath).toBe('Fa.KodWaluty'); - // It restates the total, so it belongs after the terms it settles. - expect(payment.rows.at(-1)).toBe(row); + // One row per reading of `P_15`, then the settled payable — the figures + // restate the total, so they belong after the terms they settle. + const amounts = payment.rows.slice(-4); + expect(amounts.map((r) => r.path)).toEqual([ + 'Fa.P_15', + 'Fa.P_15', + 'Fa.P_15', + 'Fa.Rozliczenie.DoZaplaty', + ]); + expect(amounts.map((r) => r.suffixPath)).toEqual(Array(4).fill('Fa.KodWaluty')); }); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index 7a691f1b..61a44c07 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -29,9 +29,9 @@ import type { Block, PartyField, TotalsBlock } from '../../../src/pdf/template/d * resolves against none. */ const FIXTURES_BY_TEMPLATE: Record = { - 'fa2-default': ['pdf/fa2.xml', 'pdf/fa2-zal.xml'], - 'fa3-default': ['pdf/fa3.xml', 'pdf/fa3-zal.xml'], - 'fa3-showcase': ['pdf/fa3.xml'], + 'fa2-default': ['pdf/fa2.xml', 'pdf/fa2-zal.xml', 'pdf/fa2-rozliczenie.xml'], + 'fa3-default': ['pdf/fa3.xml', 'pdf/fa3-zal.xml', 'pdf/fa3-rozliczenie.xml'], + 'fa3-showcase': ['pdf/fa3.xml', 'pdf/fa3-rozliczenie.xml'], 'upo-4_2': ['pdf/upo-4_2.xml'], 'upo-4_3': ['pdf/upo-4_3.xml'], }; @@ -40,6 +40,8 @@ const FIXTURES_BY_TEMPLATE: Record = { const CONTEXT_CONDITIONS = new Set([ 'qr', 'offline', 'hasKsefNumber', 'totalsBuckets', 'totalsSummary', 'notes', 'opts.logo', 'opts.ksefNumber', 'opts.accent', 'qrUrl', + // Which of `P_15`'s three readings this document supports. + 'p15IsAmountDue', 'p15IsAdvancePaid', 'p15IsAmountTotal', ]); interface CollectedPaths { @@ -57,7 +59,7 @@ function collect( const when = (block as { when?: string }).when; if (when !== undefined && !CONTEXT_CONDITIONS.has(when)) acc.conditions.push(when); - if (block.type === 'totals') { + if (block.type === 'totals' || block.type === 'payment') { for (const row of block.rows) { if (row.when !== undefined && !CONTEXT_CONDITIONS.has(row.when)) acc.conditions.push(row.when); } @@ -145,7 +147,7 @@ describe('built-in template lint', () => { // `strict` cannot police them. This is what catches a typo instead. const root = bodyOf(name); const totals = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'totals') as TotalsBlock; - const due = totals.rows.find((r) => r.label === 'totalDue')!; + const due = totals.rows.find((r) => r.path === 'Fa.P_15')!; expect(has(root, due.path!), 'the amount due must resolve').toBe(true); const buckets = totals.rows.filter((r) => r.when === 'totalsBuckets' && r.path); expect(buckets.some((r) => has(root, r.path!)), 'no rate bucket resolves').toBe(true); @@ -159,6 +161,7 @@ describe('built-in template lint', () => { expect(fa3.repeaters).toContain('Fa.Zamowienie.ZamowienieWiersz'); expect(fa3.repeaters).toContain('Fa.Platnosc.RachunekBankowy'); expect(fa3.repeaters).toContain('Podmiot2.DaneKontaktowe'); + expect(fa3.conditions).toContain('Fa.Rozliczenie.DoZaplaty'); expect(collect(getBuiltinTemplate('upo-4_3')!.blocks).repeaters).toContain('Dokument'); expect(collect(getBuiltinTemplate('upo-4_2')!.blocks).repeaters).toContain('Dokument'); expect(fa3.alternatives).toHaveLength(1); diff --git a/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts b/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts index a1b9d1a2..61569099 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts @@ -47,6 +47,9 @@ function totalsRows(xml: string, templateName: string, mode: TotalsMode = 'summa flags: { totalsBuckets: mode === 'buckets' || mode === 'both', totalsSummary: mode === 'summary' || mode === 'both', + // Every fixture here is an ordinary invoice with no `Rozliczenie`, so + // `P_15` is the amount due and the templates label it that way. + p15IsAmountDue: true, }, }; const doc = interpretTemplate(template, ctx, blockRegistry); @@ -98,7 +101,12 @@ describe('built-in totals aggregate every VAT bucket', () => { const byLabel = Object.fromEntries(totals.rows.map((r) => [r.label, r])); expect(byLabel.totalNet?.sum).toEqual(NET_BUCKETS); expect(byLabel.totalVat?.sum).toEqual(VAT_BUCKETS); - expect(byLabel.totalDue?.path).toBe('Fa.P_15'); + // Two rows print under `Do zapłaty` and never together: `P_15` on a plain + // invoice, and the settled payable when the document states one. + expect(totals.rows.filter((r) => r.label === 'totalDue').map((r) => r.path)).toEqual([ + 'Fa.P_15', + 'Fa.Rozliczenie.DoZaplaty', + ]); }); it('prints real totals for a reduced-rate-only invoice', () => { From 61d0d860fed3f10aa82c02ef01fe5585551885c8 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 15:32:53 +0200 Subject: [PATCH 52/67] fix(pdf): print the buyer's identifier with the country it belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TPodmiot2` states the counterparty identifier as a choice, and two of its branches are pairs: `KodUE` is mandatory alongside `NrVatUE`, and `NrID` may be qualified by `KodKraju`. The templates bound only the number, so an EU buyer's VAT number lost the country prefix that makes it that country's number, and a foreign identifier lost the country it was issued in. A `firstOf` alternative can now name the qualifier the schema pairs it with. The qualifier is read leniently and dropped when absent, so an unqualified `NrID` — which the schema allows — still prints on its own. Verified by rendering the buyer panel of all three built-ins with each branch of the choice swapped into the fixture: `DE 123456789` and `UA ID-999` now print whole, where before the page showed `123456789` and `ID-999`. A bare `NrID` and a domestic `NIP` are unchanged. Full suite green: 2783 unit + 153 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 10 ++- .../src/pdf/template/blocks/parties.ts | 18 ++++-- .../src/pdf/template/builtin/fa2-default.json | 10 ++- .../src/pdf/template/builtin/fa3-default.json | 10 ++- .../pdf/template/builtin/fa3-showcase.json | 10 ++- .../ksef-client-ts/src/pdf/template/dsl.ts | 23 ++++++- .../unit/pdf/builtin-template-lint.test.ts | 6 +- .../tests/unit/pdf/party-identifier.test.ts | 64 +++++++++++++++++++ 8 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/party-identifier.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 9a3f38fa..45787210 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -228,7 +228,7 @@ An advance invoice (`RodzajFaktury` `ZAL` or `KOR_ZAL`) records the goods and se - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. - **`headingStyle`** (`parties`, `payment`, `annotations`, `notes`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. - **`style`** (party panels) names a style for a panel's value lines. A labelled group inherits it unless it declares its own, so the built-in templates set `partyIdentity` on the panel — the counterparty's name and tax number — and let the address and contact groups drop to the smaller `partyDetails`. Headings keep the panel's heading style either way. -- **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. +- **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. An alternative may be written as `{ "path": …, "prefixPath": … }` to keep the qualifier the schema pairs it with — `KodUE` before `NrVatUE`, `KodKraju` before `NrID` — so `DE 123456789` prints as one identifier. The prefix is read leniently and dropped when the document omits it. - **`style`** names a style for one row: a totals row (covering both its label and its figure — they are one line to a reader) or a payment/annotation field. The built-in templates use it to set the amount due and its currency in bold, in the totals and again under the payment terms. - **`suffixPath`** appends a second binding after the value, separated by a space — an amount and its currency are one fact and print as `800,00 EUR` rather than as a number in one row and a code in another. It is dropped when it resolves empty, never read when the value itself is absent, and read at the same strictness as the value it follows. - **`sub`** (table columns only) prints a second, smaller line under the cell's value, joining `label value` pairs and dropping every entry the row leaves empty. It exists for the line-item classifiers — `Indeks`, `GTIN`, `PKWiU`, `CN`, `PKOB` — which are all optional and of which a real invoice carries one or two: a column's width is fixed for the whole table, so giving each its own column would leave most invoices with several empty ones. `subStyle` names the style for that line and `subSeparator` replaces the default `' · '`. @@ -255,7 +255,13 @@ A trimmed `FA(3)` template with a header, a seller/buyer panel, a line table, a "left": { "label": "seller", "fields": ["Podmiot1.DaneIdentyfikacyjne.Nazwa", "Podmiot1.DaneIdentyfikacyjne.NIP"] }, "right": { "label": "buyer", "fields": [ "Podmiot2.DaneIdentyfikacyjne.Nazwa", - { "firstOf": ["Podmiot2.DaneIdentyfikacyjne.NIP", "Podmiot2.DaneIdentyfikacyjne.NrVatUE", "Podmiot2.DaneIdentyfikacyjne.NrID"] } + { + "firstOf": [ + "Podmiot2.DaneIdentyfikacyjne.NIP", + { "path": "Podmiot2.DaneIdentyfikacyjne.NrVatUE", "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodUE" }, + { "path": "Podmiot2.DaneIdentyfikacyjne.NrID", "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodKraju" } + ] + } ] } }, { diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts index 3a6b5df2..1b54759a 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts @@ -1,5 +1,5 @@ import { list } from '../../accessor.js'; -import type { PartiesBlock, PartyColumn, PartyField, PartyGroup } from '../dsl.js'; +import type { PartiesBlock, PartyAlternative, PartyColumn, PartyField, PartyGroup } from '../dsl.js'; import { resolveBinding, type BlockRenderer, type PdfNode, type RenderContext } from '../interpret.js'; /** @@ -31,7 +31,9 @@ function isGroup(field: PartyField): field is PartyGroup { * An entry may instead list alternatives (`firstOf`) and print the first that * resolves — which is how the counterparty identifier is bound, since KSeF * supplies exactly one of NIP / NrVatUE / NrID. Those are read leniently: the - * alternatives that do not apply are absent by design. + * alternatives that do not apply are absent by design. An alternative may name + * a `prefixPath` for the qualifier the schema pairs it with — `KodUE` before + * `NrVatUE`, `KodKraju` before `NrID` — so the identifier prints whole. * * An entry may also be a labelled group — the address, the contact details — * rendered as a sub-heading over its own lines, and repeated per entry when it @@ -47,15 +49,21 @@ export const partiesRenderer: BlockRenderer = (block, ctx) => { const at = (root: unknown, strict = ctx.strict): RenderContext => ({ ...ctx, root, strict }); const resolveValue = ( - field: string | { path: string; optional?: boolean } | { firstOf: string[] }, + field: string | { path: string; optional?: boolean } | { firstOf: PartyAlternative[] }, root: unknown, strict: boolean, ): string => { if (typeof field === 'string') return resolveBinding(field, at(root, strict)); if ('path' in field) return resolveBinding(field.path, at(root, field.optional ? false : strict)); - for (const path of field.firstOf) { + for (const alternative of field.firstOf) { + const { path, prefixPath } = typeof alternative === 'string' ? { path: alternative, prefixPath: undefined } : alternative; const value = resolveBinding(path, at(root, false)); - if (value) return value; + if (!value) continue; + // The qualifier is part of the identifier, not a second fact: `DE` and + // `123456789` are one VAT number and print as one. An absent qualifier + // leaves the number to stand alone rather than dropping the line. + const prefix = prefixPath ? resolveBinding(prefixPath, at(root, false)) : ''; + return prefix ? `${prefix} ${value}` : value; } return ''; }; diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index facd52de..7384d896 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -62,8 +62,14 @@ { "firstOf": [ "Podmiot2.DaneIdentyfikacyjne.NIP", - "Podmiot2.DaneIdentyfikacyjne.NrVatUE", - "Podmiot2.DaneIdentyfikacyjne.NrID" + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrVatUE", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodUE" + }, + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrID", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodKraju" + } ] }, { diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 947d588a..fa86d749 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -62,8 +62,14 @@ { "firstOf": [ "Podmiot2.DaneIdentyfikacyjne.NIP", - "Podmiot2.DaneIdentyfikacyjne.NrVatUE", - "Podmiot2.DaneIdentyfikacyjne.NrID" + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrVatUE", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodUE" + }, + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrID", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodKraju" + } ] }, { diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 774a1d50..0d79c951 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -78,8 +78,14 @@ { "firstOf": [ "Podmiot2.DaneIdentyfikacyjne.NIP", - "Podmiot2.DaneIdentyfikacyjne.NrVatUE", - "Podmiot2.DaneIdentyfikacyjne.NrID" + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrVatUE", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodUE" + }, + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrID", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodKraju" + } ] }, { diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 61b39d0e..43e25019 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -149,9 +149,20 @@ export interface HeaderBlock { export type PartyField = | string | { path: string; optional?: boolean } - | { firstOf: string[] } + | { firstOf: PartyAlternative[] } | PartyGroup; +/** + * One alternative in a `firstOf` set: a path, or a path with a qualifier + * printed in front of it. The qualifier exists because a tax identifier is not + * always the whole identifier — the FA schemas pair `NrVatUE` with the + * mandatory `KodUE` and allow `NrID` to be qualified by `KodKraju`, and a + * number printed without its country reads as a different, ambiguous one. + * The prefix is read leniently and dropped when absent, so an unqualified + * `NrID` still prints. + */ +export type PartyAlternative = string | { path: string; prefixPath?: string }; + /** * A labelled sub-group inside a party panel — the address, say. The label is a * sub-heading in the panel's own heading style; `style` applies to the group's @@ -452,7 +463,15 @@ const partyField: z.ZodType = z.lazy(() => z.union([ z.string(), z.object({ path: z.string(), optional: z.boolean().optional() }).strict(), - z.object({ firstOf: z.array(z.string()).nonempty() }).strict(), + z + .object({ + firstOf: z + .array( + z.union([z.string(), z.object({ path: z.string(), prefixPath: z.string().optional() }).strict()]), + ) + .nonempty(), + }) + .strict(), z .object({ label: z.string(), diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index 61a44c07..f3d8c270 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -47,7 +47,7 @@ const CONTEXT_CONDITIONS = new Set([ interface CollectedPaths { conditions: string[]; repeaters: string[]; - /** `firstOf` alternative sets — at least one member must resolve. */ + /** `firstOf` alternative sets, as paths — at least one member must resolve. */ alternatives: string[][]; } @@ -76,7 +76,9 @@ function collect( if (field.from !== undefined) acc.repeaters.push(field.from); walkFields(field.fields); } else if ('firstOf' in field) { - acc.alternatives.push(field.firstOf); + // An alternative may carry a `prefixPath` qualifier; the path is + // what has to resolve for the alternative to apply at all. + acc.alternatives.push(field.firstOf.map((a) => (typeof a === 'string' ? a : a.path))); } // `{ path, optional }` is a plain binding; strict covers the ones that // are not marked, and an optional one is absent by design. diff --git a/packages/ksef-client-ts/tests/unit/pdf/party-identifier.test.ts b/packages/ksef-client-ts/tests/unit/pdf/party-identifier.test.ts new file mode 100644 index 00000000..0e2c042b --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/party-identifier.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { partiesRenderer } from '../../../src/pdf/template/blocks/parties.js'; +import type { PartiesBlock } from '../../../src/pdf/template/dsl.js'; +import type { PdfNode, RenderChild, RenderContext } from '../../../src/pdf/template/interpret.js'; + +/** + * `TPodmiot2` states the counterparty's identifier as a choice, and two of its + * branches are pairs: `KodUE` + `NrVatUE`, and an optional `KodKraju` before + * `NrID`. Printing only the number dropped the country it belongs to, which + * turns a VAT number into a different — and ambiguous — one. + */ + +const fa3 = readFileSync(new URL('../../fixtures/pdf/fa3.xml', import.meta.url), 'utf8'); + +const noRender: RenderChild = () => null; + +/** The buyer panel's lines, for an invoice whose identifier branch is swapped in. */ +function buyerLines(templateName: string, identifier: string): string[] { + const xml = fa3.replace('2222222222', identifier); + const block = getBuiltinTemplate(templateName)!.blocks.find((b) => b.type === 'parties') as PartiesBlock; + const ctx: RenderContext = { + root: (parseXmlForPdf(xml) as Record).Faktura, + strict: false, + label: (k: string) => k, + bindings: {}, + flags: {}, + }; + const out: string[] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) return value.forEach(walk); + if (value === null || typeof value !== 'object') return; + const node = value as Record; + if (typeof node.text === 'string') out.push(node.text); + Object.values(node).forEach(walk); + }; + walk(partiesRenderer(block, ctx, noRender) as PdfNode); + return out; +} + +describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s prints a whole buyer identifier', (name) => { + it('keeps the country code an EU VAT number is stated with', () => { + const lines = buyerLines(name, 'DE123456789'); + expect(lines).toContain('DE 123456789'); + expect(lines).not.toContain('123456789'); + }); + + it('keeps the country a foreign identifier is qualified by', () => { + const lines = buyerLines(name, 'UAID-999'); + expect(lines).toContain('UA ID-999'); + }); + + it('still prints an unqualified NrID, since KodKraju is optional there', () => { + const lines = buyerLines(name, 'ID-999'); + expect(lines).toContain('ID-999'); + }); + + it('leaves a domestic NIP exactly as it was', () => { + const lines = buyerLines(name, '2222222222'); + expect(lines).toContain('2222222222'); + }); +}); From 6720af14c20212f2c380477a29940c7ffac1580b Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 15:35:05 +0200 Subject: [PATCH 53/67] fix(pdf): let a strict render accept an invoice with no buyer name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Podmiot2.DaneIdentyfikacyjne.Nazwa` sits in an optional sequence in `TPodmiot2` — art. 106e ust. 5 pkt 3 lets an invoice leave the buyer unnamed — but the templates bound it unmarked, so `strict` policed it. A schema-valid document therefore failed to render with `Missing binding`, which is exactly the outcome the mode's rule exists to prevent: mark what the schema declares optional, police the rest. Verified by stripping `` from the FA(3) fixture and rendering strict: before, `Missing binding: "Podmiot2.DaneIdentyfikacyjne.Nazwa"`; now a PDF. A second test pins the marker in all three built-ins. Full suite green: 2787 unit + 153 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pdf/template/builtin/fa2-default.json | 2 +- .../src/pdf/template/builtin/fa3-default.json | 2 +- .../pdf/template/builtin/fa3-showcase.json | 2 +- .../tests/unit/pdf/strict-mode.test.ts | 24 +++++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 7384d896..a2f6023d 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -58,7 +58,7 @@ "label": "buyer", "style": "partyIdentity", "fields": [ - "Podmiot2.DaneIdentyfikacyjne.Nazwa", + { "path": "Podmiot2.DaneIdentyfikacyjne.Nazwa", "optional": true }, { "firstOf": [ "Podmiot2.DaneIdentyfikacyjne.NIP", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index fa86d749..ce9dd671 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -58,7 +58,7 @@ "label": "buyer", "style": "partyIdentity", "fields": [ - "Podmiot2.DaneIdentyfikacyjne.Nazwa", + { "path": "Podmiot2.DaneIdentyfikacyjne.Nazwa", "optional": true }, { "firstOf": [ "Podmiot2.DaneIdentyfikacyjne.NIP", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 0d79c951..ac5e3aee 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -74,7 +74,7 @@ "label": "buyer", "style": "partyName", "fields": [ - "Podmiot2.DaneIdentyfikacyjne.Nazwa", + { "path": "Podmiot2.DaneIdentyfikacyjne.Nazwa", "optional": true }, { "firstOf": [ "Podmiot2.DaneIdentyfikacyjne.NIP", diff --git a/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts index 55fddae4..6062d281 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts @@ -45,6 +45,17 @@ describe('strict mode survives real documents', () => { ).resolves.toBeInstanceOf(Uint8Array); }); + it('an invoice that does not name its buyer renders strict', async () => { + // `Nazwa` sits in an optional sequence inside `TPodmiot2` — art. 106e ust. 5 + // pkt 3 lets an invoice omit the buyer's name — so its absence is a valid + // document, not a template typo. + const unnamed = fx('fa3.xml').replace(/\s*Nabywca[^<]*<\/Nazwa>/, ''); + expect(unnamed).not.toContain('Nabywca Przykładowy'); + await expect( + renderInvoicePdfFromTemplate(unnamed, fa3Default(), { strict: true }), + ).resolves.toBeInstanceOf(Uint8Array); + }); + it('an invoice without the optional second address line renders strict', async () => { const noAddressL2 = fx('fa3.xml').replace(/\s*[^<]*<\/AdresL2>/g, ''); expect(noAddressL2).not.toContain('AdresL2'); @@ -54,6 +65,19 @@ describe('strict mode survives real documents', () => { }); }); +describe('every built-in marks what the FA schemas let a buyer omit', () => { + it.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s marks the buyer name optional', (name) => { + const parties = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'parties') as { + right: { fields: unknown[] }; + }; + const nazwa = parties.right.fields.find( + (f): f is { path: string; optional?: boolean } => + typeof f === 'object' && f !== null && (f as { path?: string }).path === 'Podmiot2.DaneIdentyfikacyjne.Nazwa', + ); + expect(nazwa?.optional, 'the buyer name is optional in FA(2) and FA(3)').toBe(true); + }); +}); + describe('strict mode still catches a typo in a required binding', () => { it('throws when the amount due path is misspelled', async () => { const template = fa3Default(); From d52777de8f82266497494ccee282523b48fa8c21 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 15:38:12 +0200 Subject: [PATCH 54/67] fix(pdf): read the buyer address through the element that may be absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Podmiot2.Adres` is minOccurs="0" — art. 106e ust. 5 pkt 3 lets an invoice omit the buyer's address — while `AdresL1` and `KodKraju` are mandatory only within an address that exists. The templates bound the children directly, so a strict render of a schema-valid document without a buyer address failed with `Missing binding: "Podmiot2.Adres.AdresL1"`. The group now reads `from` the optional parent, the way the equally optional `DaneKontaktowe` group already did, and disappears heading and all when the document carries no address. The seller's address stays bound directly, since FA declares that one mandatory. Verified by rendering the buyer panel of all three built-ins with and without ``: the address still prints in full, an invoice without one now renders (strict included) and drops only that group while the seller's address stays, and removing the seller's address still throws. Full suite green: 2801 unit + 153 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 2 +- .../src/pdf/template/builtin/fa2-default.json | 7 +- .../src/pdf/template/builtin/fa3-default.json | 7 +- .../pdf/template/builtin/fa3-showcase.json | 7 +- .../tests/unit/pdf/buyer-address.test.ts | 66 +++++++++++++++++++ .../tests/unit/pdf/strict-mode.test.ts | 40 +++++++++++ 6 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/buyer-address.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 45787210..b973dd4c 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -185,7 +185,7 @@ The `schema` field binds a template to a single document kind. If you render an | Block | Renders | |-------|---------| | `header` | Title and optional logo on the left; invoice number, issue date and KSeF number stacked on the right. With `offlineStyle` set, the OFFLINE marker takes the KSeF number's place when the document carries none | -| `parties` | Seller / buyer two-column panel; a line that resolves empty is skipped | +| `parties` | Seller / buyer two-column panel; a line that resolves empty is skipped. A labelled group reads `from` an optional parent element — the buyer's address, say — and is dropped whole when the document carries none | | `lines` | Invoice line-item table. Takes `when`, because an invoice does not always carry its items in the same place — see below | | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | | `payment` | Payment details (amount paid, date, method). A row takes `when`, so one figure can be listed once per reading and only the applicable label prints | diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index a2f6023d..45bb6a2d 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -74,12 +74,9 @@ }, { "label": "address", + "from": "Podmiot2.Adres", "style": "partyDetails", - "fields": [ - "Podmiot2.Adres.AdresL1", - { "path": "Podmiot2.Adres.AdresL2", "optional": true }, - "Podmiot2.Adres.KodKraju" - ] + "fields": ["AdresL1", { "path": "AdresL2", "optional": true }, "KodKraju"] }, { "label": "contact", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index ce9dd671..7ff279cc 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -74,12 +74,9 @@ }, { "label": "address", + "from": "Podmiot2.Adres", "style": "partyDetails", - "fields": [ - "Podmiot2.Adres.AdresL1", - { "path": "Podmiot2.Adres.AdresL2", "optional": true }, - "Podmiot2.Adres.KodKraju" - ] + "fields": ["AdresL1", { "path": "AdresL2", "optional": true }, "KodKraju"] }, { "label": "contact", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index ac5e3aee..88843888 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -90,12 +90,9 @@ }, { "label": "address", + "from": "Podmiot2.Adres", "style": "partyMeta", - "fields": [ - "Podmiot2.Adres.AdresL1", - { "path": "Podmiot2.Adres.AdresL2", "optional": true }, - "Podmiot2.Adres.KodKraju" - ] + "fields": ["AdresL1", { "path": "AdresL2", "optional": true }, "KodKraju"] }, { "label": "contact", "from": "Podmiot2.DaneKontaktowe", "style": "partyMeta", "fields": ["Email", "Telefon", "NrKlienta"] } ] diff --git a/packages/ksef-client-ts/tests/unit/pdf/buyer-address.test.ts b/packages/ksef-client-ts/tests/unit/pdf/buyer-address.test.ts new file mode 100644 index 00000000..59f0ca34 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/buyer-address.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { partiesRenderer } from '../../../src/pdf/template/blocks/parties.js'; +import type { PartiesBlock } from '../../../src/pdf/template/dsl.js'; +import type { PdfNode, RenderChild, RenderContext } from '../../../src/pdf/template/interpret.js'; + +/** + * `Podmiot2.Adres` is `minOccurs="0"` — art. 106e ust. 5 pkt 3 lets an invoice + * omit the buyer's address entirely — while `AdresL1` and `KodKraju` are + * mandatory *within* an address that exists. Binding the children directly made + * the optional parent look mandatory; reading the group from the parent drops + * it whole instead. + */ + +const fa3 = readFileSync(new URL('../../fixtures/pdf/fa3.xml', import.meta.url), 'utf8'); +const noRender: RenderChild = () => null; + +function partyLines(templateName: string, xml: string, strict = false): string[] { + const block = getBuiltinTemplate(templateName)!.blocks.find((b) => b.type === 'parties') as PartiesBlock; + const ctx: RenderContext = { + root: (parseXmlForPdf(xml) as Record).Faktura, + strict, + label: (k: string) => k, + bindings: {}, + flags: {}, + }; + const out: string[] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) return value.forEach(walk); + if (value === null || typeof value !== 'object') return; + const node = value as Record; + if (typeof node.text === 'string') out.push(node.text); + Object.values(node).forEach(walk); + }; + walk(partiesRenderer(block, ctx, noRender) as PdfNode); + return out; +} + +/** The FA(3) fixture with the buyer's address element removed, seller intact. */ +const withoutBuyerAddress = fa3.replace(/[\s\S]*?<\/Podmiot2>/, (m) => + m.replace(/\s*[\s\S]*?<\/Adres>/, ''), +); + +describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s buyer address', (name) => { + it('prints the address the invoice carries', () => { + const lines = partyLines(name, fa3); + expect(lines).toContain('ul. Testowa 2'); + expect(lines).toContain('00-002 Kraków'); + expect(lines).toContain('PL'); + }); + + it('drops the group, heading included, when the invoice carries none', () => { + const lines = partyLines(name, withoutBuyerAddress); + expect(lines).not.toContain('ul. Testowa 2'); + // The seller's address is mandatory and still there, so the panel is not + // simply empty — only the buyer's group went. + expect(lines).toContain('ul. Przykładowa 1'); + expect(lines.filter((t) => t === 'address')).toHaveLength(1); + }); + + it('does not throw under strict when the buyer states no address', () => { + expect(() => partyLines(name, withoutBuyerAddress, true)).not.toThrow(); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts index 6062d281..91020a41 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts @@ -56,6 +56,19 @@ describe('strict mode survives real documents', () => { ).resolves.toBeInstanceOf(Uint8Array); }); + it('an invoice that states no buyer address renders strict', async () => { + // The whole `Podmiot2.Adres` element is minOccurs="0"; `AdresL1` and + // `KodKraju` are only mandatory *within* an address that exists. + const noAddress = fx('fa3.xml').replace(/[\s\S]*?<\/Podmiot2>/, (m) => + m.replace(/\s*[\s\S]*?<\/Adres>/, ''), + ); + expect(noAddress).toContain(''); + expect(noAddress).not.toContain('ul. Testowa 2'); + await expect( + renderInvoicePdfFromTemplate(noAddress, fa3Default(), { strict: true }), + ).resolves.toBeInstanceOf(Uint8Array); + }); + it('an invoice without the optional second address line renders strict', async () => { const noAddressL2 = fx('fa3.xml').replace(/\s*[^<]*<\/AdresL2>/g, ''); expect(noAddressL2).not.toContain('AdresL2'); @@ -76,6 +89,33 @@ describe('every built-in marks what the FA schemas let a buyer omit', () => { ); expect(nazwa?.optional, 'the buyer name is optional in FA(2) and FA(3)').toBe(true); }); + + it.each(['fa2-default', 'fa3-default', 'fa3-showcase'])( + '%s reads the buyer address through its optional parent', + (name) => { + // Binding `Podmiot2.Adres.AdresL1` directly makes a mandatory child of an + // optional parent look mandatory. Reading the group `from` the parent + // drops it whole when the document carries no address. + const parties = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'parties') as { + right: { fields: unknown[] }; + }; + const address = parties.right.fields.find( + (f): f is { from?: string; fields: unknown[] } => + typeof f === 'object' && f !== null && (f as { label?: string }).label === 'address', + ); + expect(address?.from).toBe('Podmiot2.Adres'); + expect(JSON.stringify(address)).not.toContain('Podmiot2.Adres.'); + }, + ); + + it('the seller address stays policed, because FA declares it mandatory', async () => { + const noSellerAddress = fx('fa3.xml').replace(/[\s\S]*?<\/Podmiot1>/, (m) => + m.replace(/\s*[\s\S]*?<\/Adres>/, ''), + ); + await expect( + renderInvoicePdfFromTemplate(noSellerAddress, fa3Default(), { strict: true }), + ).rejects.toThrow(/Podmiot1\.Adres/); + }); }); describe('strict mode still catches a typo in a required binding', () => { From eeaa2289c9efe2f9d53cb7acdecbb0bf6e7a591f Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 15:41:18 +0200 Subject: [PATCH 55/67] fix(pdf): print every payment term, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TerminPlatnosci` is maxOccurs="100" — an invoice paid in instalments states a term per instalment — but the templates read it through a scalar path, and a path walk that meets an array follows its head. Every date after the first vanished from the page with nothing to show it had ever been there. A payment row can now repeat over a collection, printing one line per entry with the entry as its binding root, which is what the bank-account section already did a level down. Verified against the FA(3) fixture given a three-date schedule: before, one date; now all three, in order. A single-term invoice prints exactly what it did, an invoice with no terms prints no term line, and a term stated only as a description is skipped rather than printing an empty label. Full suite green: 2813 unit + 153 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 2 +- .../src/pdf/template/blocks/payment.ts | 18 ++++- .../src/pdf/template/builtin/fa2-default.json | 8 +- .../src/pdf/template/builtin/fa3-default.json | 8 +- .../pdf/template/builtin/fa3-showcase.json | 8 +- .../ksef-client-ts/src/pdf/template/dsl.ts | 11 ++- .../unit/pdf/builtin-template-lint.test.ts | 6 +- .../tests/unit/pdf/payment-terms.test.ts | 76 +++++++++++++++++++ 8 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 packages/ksef-client-ts/tests/unit/pdf/payment-terms.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index b973dd4c..53696a1c 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -188,7 +188,7 @@ The `schema` field binds a template to a single document kind. If you render an | `parties` | Seller / buyer two-column panel; a line that resolves empty is skipped. A labelled group reads `from` an optional parent element — the buyer's address, say — and is dropped whole when the document carries none | | `lines` | Invoice line-item table. Takes `when`, because an invoice does not always carry its items in the same place — see below | | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | -| `payment` | Payment details (amount paid, date, method). A row takes `when`, so one figure can be listed once per reading and only the applicable label prints | +| `payment` | Payment details (amount paid, date, method). A row takes `when`, so one figure can be listed once per reading and only the applicable label prints, and `from`, so a repeated element — the payment terms of an instalment schedule — prints one line per entry | | `annotations` | Miscellaneous labelled fields | | `notes` | The caller's own sections, from `notes` — a heading over a body, each | | `qr` | One KSeF verification QR — `code: "invoice"` (Code I, the default) or `code: "certificate"` (Code II) | diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts index db300964..289ca86b 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -11,6 +11,9 @@ import { evalWhen, resolveBinding, type BlockRenderer, type PdfNode } from '../i * * A row may carry `when` and is dropped when it does not apply, so a template * can list every reading of a figure and print the one this document supports. + * A row may also carry `from` and then prints once per entry of that + * collection, with the entry as its binding root — which is how an invoice paid + * in instalments shows every payment term instead of only the first. * * A row/field whose value resolves empty is skipped, so absent optional fields * (KSeF invoices carry many) don't print a dangling "Label:" with no value. @@ -32,9 +35,18 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { // A row may be one of several readings of the same figure, only one of // which applies to this document. if (!evalWhen(row.when, ctx)) continue; - const value = readField(row, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx)); - if (value === '') continue; - stack.push({ text: `${ctx.label(row.label)}: ${value}`, ...(row.style ? { style: row.style } : {}) }); + // A row with `from` prints one line per entry of a collection: an invoice + // paid in instalments states a payment term per instalment, and printing + // only the first hides the rest of the schedule. + const entries = row.from ? list(ctx.root, row.from) : [undefined]; + for (const entry of entries) { + const value = + entry === undefined + ? readField(row, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx)) + : readField(row, (path, optional) => get(entry, path, optional ? false : ctx.strict)); + if (value === '') continue; + stack.push({ text: `${ctx.label(row.label)}: ${value}`, ...(row.style ? { style: row.style } : {}) }); + } } if (block.accounts) { diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 45bb6a2d..09958f62 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -242,7 +242,13 @@ "when": "Fa.Platnosc", "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, - { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, + { + "label": "paymentDate", + "from": "Fa.Platnosc.TerminPlatnosci", + "path": "Termin", + "format": "date", + "optional": true + }, { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, { "label": "amountDueTotal", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 7ff279cc..dbac9b4d 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -241,7 +241,13 @@ "when": "Fa.Platnosc", "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, - { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, + { + "label": "paymentDate", + "from": "Fa.Platnosc.TerminPlatnosci", + "path": "Termin", + "format": "date", + "optional": true + }, { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, { "label": "amountDueTotal", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 88843888..80e81f9b 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -179,7 +179,13 @@ "when": "Fa.Platnosc", "rows": [ { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, - { "label": "paymentDate", "path": "Fa.Platnosc.TerminPlatnosci.Termin", "format": "date", "optional": true }, + { + "label": "paymentDate", + "from": "Fa.Platnosc.TerminPlatnosci", + "path": "Termin", + "format": "date", + "optional": true + }, { "label": "paymentMethod", "path": "Fa.Platnosc.FormaPlatnosci", "format": "paymentForm", "optional": true }, { "label": "amountDueTotal", diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 43e25019..686a516f 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -270,6 +270,15 @@ export interface PaymentAccounts { */ export interface PaymentRow extends FieldDef { when?: string; + /** + * Repeat this line once per entry of a collection, with the entry as the + * binding root (so `path` and `suffixPath` are item-relative). KSeF allows up + * to 100 `TerminPlatnosci` blocks — an invoice paid in instalments states one + * per instalment — and a scalar path silently prints only the first, because + * a walk that meets an array follows its head. Entries are read leniently: + * every field of a payment term is optional, so an absent one is by design. + */ + from?: string; } export interface PaymentBlock { @@ -558,7 +567,7 @@ const blockSchema: z.ZodType = z.lazy(() => z.object({ type: z.literal('payment'), when: z.string().optional(), - rows: z.array(fieldDef.extend({ when: z.string().optional() })), + rows: z.array(fieldDef.extend({ when: z.string().optional(), from: z.string().optional() })), accounts: z .object({ from: z.string(), diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index f3d8c270..783b0bfe 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -67,7 +67,10 @@ function collect( if (block.type === 'lines') acc.repeaters.push(block.from); if (block.type === 'table' && block.from !== undefined) acc.repeaters.push(block.from); if (block.type === 'each') acc.repeaters.push(block.from); - if (block.type === 'payment' && block.accounts) acc.repeaters.push(block.accounts.from); + if (block.type === 'payment') { + if (block.accounts) acc.repeaters.push(block.accounts.from); + for (const row of block.rows) if (row.from !== undefined) acc.repeaters.push(row.from); + } if (block.type === 'parties') { const walkFields = (fields: PartyField[]): void => { for (const field of fields) { @@ -162,6 +165,7 @@ describe('built-in template lint', () => { expect(fa3.repeaters).toContain('Fa.FaWiersz'); expect(fa3.repeaters).toContain('Fa.Zamowienie.ZamowienieWiersz'); expect(fa3.repeaters).toContain('Fa.Platnosc.RachunekBankowy'); + expect(fa3.repeaters).toContain('Fa.Platnosc.TerminPlatnosci'); expect(fa3.repeaters).toContain('Podmiot2.DaneKontaktowe'); expect(fa3.conditions).toContain('Fa.Rozliczenie.DoZaplaty'); expect(collect(getBuiltinTemplate('upo-4_3')!.blocks).repeaters).toContain('Dokument'); diff --git a/packages/ksef-client-ts/tests/unit/pdf/payment-terms.test.ts b/packages/ksef-client-ts/tests/unit/pdf/payment-terms.test.ts new file mode 100644 index 00000000..a5e5d0f1 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/payment-terms.test.ts @@ -0,0 +1,76 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { paymentRenderer } from '../../../src/pdf/template/blocks/payment.js'; +import type { PaymentBlock } from '../../../src/pdf/template/dsl.js'; +import type { PdfNode, RenderChild, RenderContext } from '../../../src/pdf/template/interpret.js'; + +/** + * `TerminPlatnosci` is `maxOccurs="100"`: an invoice paid in instalments states + * one term per instalment. A scalar binding reads only the first, because a + * path walk that meets an array follows its head — so every date after the + * first vanished from the page with nothing to show it had. + */ + +const fa3 = readFileSync(new URL('../../fixtures/pdf/fa3.xml', import.meta.url), 'utf8'); + +const INSTALMENTS = `2025-02-01 + 2025-03-01 + 2025-04-01`; + +const noRender: RenderChild = () => null; + +function paymentLines(templateName: string, xml: string): string[] { + const block = getBuiltinTemplate(templateName)!.blocks.find((b) => b.type === 'payment') as PaymentBlock; + const ctx: RenderContext = { + root: (parseXmlForPdf(xml) as Record).Faktura, + strict: false, + label: (k: string) => k, + bindings: {}, + flags: { p15IsAmountDue: true }, + }; + const out: string[] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) return value.forEach(walk); + if (value === null || typeof value !== 'object') return; + const node = value as Record; + if (typeof node.text === 'string') out.push(node.text); + Object.values(node).forEach(walk); + }; + walk(paymentRenderer(block, ctx, noRender) as PdfNode); + return out; +} + +const instalments = fa3.replace(/[\s\S]*?<\/TerminPlatnosci>/, INSTALMENTS); + +describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s payment terms', (name) => { + it('prints every term an instalment schedule carries', () => { + const dates = paymentLines(name, instalments).filter((t) => t.startsWith('paymentDate: ')); + expect(dates).toEqual(['paymentDate: 01.02.2025', 'paymentDate: 01.03.2025', 'paymentDate: 01.04.2025']); + }); + + it('prints a single term exactly as before', () => { + const dates = paymentLines(name, fa3).filter((t) => t.startsWith('paymentDate: ')); + expect(dates).toEqual(['paymentDate: 01.02.2025']); + }); + + it('prints no term line at all when the invoice states none', () => { + const noTerms = fa3.replace(/\s*[\s\S]*?<\/TerminPlatnosci>/, ''); + expect(noTerms).not.toContain('TerminPlatnosci'); + expect(paymentLines(name, noTerms).some((t) => t.startsWith('paymentDate'))).toBe(false); + }); + + it('skips a term that carries only a description, not a date', () => { + // `Termin` and `TerminOpis` are both optional inside a term, so an entry + // may have no date to print — and a dangling label is worse than nothing. + const described = fa3.replace( + /[\s\S]*?<\/TerminPlatnosci>/, + '2025-02-01' + + '14dni' + + 'od wydania', + ); + const dates = paymentLines(name, described).filter((t) => t.startsWith('paymentDate')); + expect(dates).toEqual(['paymentDate: 01.02.2025']); + }); +}); From 2d21598dbb1297eb7e0dcf7e3efb10321fd9c5b3 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 15:45:30 +0200 Subject: [PATCH 56/67] fix(pdf): read the UPO version from the namespace the root is bound to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version regexes matched anywhere in the root start tag, so any document with a `Potwierdzenie` root that merely quoted `KSeF/v4-3` — in a note, a source URL, or a second namespace it does not use — was detected as a UPO and routed to the UPO renderer instead of being rejected. Detection now takes the declaration bound to the root element's own prefix (`xmlns` when it has none) and reads the marker from that value alone. Verified: `` returned `UPO(4.3)` before and returns null now, as does a root bound to an unrelated namespace while declaring the UPO one under a prefix. A prefixed root still resolves through its own declaration rather than a sibling's, and the real fixtures are unchanged. Full suite green: 2817 unit + 153 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/src/pdf/parse.ts | 21 ++++++++++++--- .../tests/unit/pdf/parse.test.ts | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/parse.ts b/packages/ksef-client-ts/src/pdf/parse.ts index 903ffcdc..a6a1fec5 100644 --- a/packages/ksef-client-ts/src/pdf/parse.ts +++ b/packages/ksef-client-ts/src/pdf/parse.ts @@ -67,7 +67,7 @@ export function detectInvoiceVersion(xml: string): InvoiceVersion | null { /** * Detect the UPO version. Requires a `Potwierdzenie` root, then reads the - * version from the namespace marker in the raw XML (`.../KSeF/v4-3` → + * version from the namespace that root element is bound to (`.../KSeF/v4-3` → * `UPO(4.3)`, `v4-2` → `UPO(4.2)`). The default `xmlns` declaration is not * surfaced as an attribute by the compact parser, so we scan the source * directly. Returns `null` for non-UPO documents. @@ -87,10 +87,23 @@ export function detectUpoVersion(xml: string): UpoVersion | null { const withoutComments = xml.replace(//g, ''); const firstElement = /<(?![?!])([\w.:-]+)[^>]*>/.exec(withoutComments); const rootTag = firstElement?.[0] ?? ''; - const rootName = (firstElement?.[1] ?? '').replace(/^[\w.-]+:/, ''); + const qualifiedName = firstElement?.[1] ?? ''; + const colon = qualifiedName.indexOf(':'); + const prefix = colon === -1 ? '' : qualifiedName.slice(0, colon); + const rootName = colon === -1 ? qualifiedName : qualifiedName.slice(colon + 1); if (rootName !== 'Potwierdzenie') return null; - if (/KSeF\/v4-3\b/.test(rootTag)) return 'UPO(4.3)'; - if (/KSeF\/v4-2\b/.test(rootTag)) return 'UPO(4.2)'; + // The version has to come from the namespace the root is *in*, not from + // anywhere in its start tag: matching the whole tag let an unrelated document + // that merely quotes the string in some other attribute — a note, a source + // URL, a second namespace it does not use — be routed to the UPO renderer. + const wanted = prefix === '' ? 'xmlns' : `xmlns:${prefix}`; + let namespace = ''; + for (const match of rootTag.matchAll(/\s(xmlns(?::[\w.-]+)?)\s*=\s*(["'])([^"']*)\2/g)) { + if (match[1] === wanted) namespace = match[3] ?? ''; + } + + if (/KSeF\/v4-3\b/.test(namespace)) return 'UPO(4.3)'; + if (/KSeF\/v4-2\b/.test(namespace)) return 'UPO(4.2)'; return null; } diff --git a/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts index f0f30105..c8851118 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts @@ -212,6 +212,32 @@ describe('detectUpoVersion', () => { expect(detectUpoVersion(xml)).toBe('UPO(4.3)'); }); + it('ignores the marker in an attribute that is not a namespace declaration', () => { + // The root element carries the string, but not as the namespace it is in — + // an arbitrary Potwierdzenie is not a UPO because it quotes a URL. + const xml = '1'; + expect(detectUpoVersion(xml)).toBeNull(); + }); + + it('ignores a namespace the root element is not bound to', () => { + const xml = + '1'; + expect(detectUpoVersion(xml)).toBeNull(); + }); + + it('reads the declaration bound to the root prefix, not another one', () => { + const xml = + '1'; + expect(detectUpoVersion(xml)).toBe('UPO(4.3)'); + }); + + it('accepts single-quoted and loosely spaced declarations', () => { + const xml = "1"; + expect(detectUpoVersion(xml)).toBe('UPO(4.2)'); + }); + it('returns null for unrecognized XML', () => { expect(detectUpoVersion('')).toBeNull(); }); From 42ce6f8ed23099aa8192802f7cd7ad6a54df1731 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 16:19:40 +0200 Subject: [PATCH 57/67] feat(pdf): show what an invoice settled in instalments has paid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Fa.Platnosc` states how much has been paid through a choice: either `Zaplacono` — a bare 1 meaning settled in full, with `DataZaplaty` — or `ZnacznikZaplatyCzesciowej` (1 in part, 2 in full) with up to 100 `ZaplataCzesciowa` entries, each an amount, a date and a form. The templates bound only the first branch, so an invoice paid in instalments printed nothing at all about its payments: not that part of the money had arrived, not how much, not when. Neither branch's `DataZaplaty` was printed either. The payment block's one-off `accounts` section becomes a list of repeating groups, because the part payments need the same shape — several fields per entry, kept together — and one repeating row per field would have split them into three parallel lists. A group's paths are entry-relative, with a leading `/` to reach the document root, which is how a part payment keeps the currency the invoice states once. The paid/part-paid status is a flag-gated label rather than a printed value: the schema's `1` tells a reader nothing the label does not. Verified against a new fixture pair taking the partial branch, and by eye on the rendered page — the preview set gains cli-10-invoice-partial-payments. Full suite green: 2839 unit + 154 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 17 ++- .../ksef-client-ts/src/pdf/document-flags.ts | 21 +++ packages/ksef-client-ts/src/pdf/i18n/en.ts | 5 + packages/ksef-client-ts/src/pdf/i18n/pl.ts | 5 + packages/ksef-client-ts/src/pdf/i18n/uk.ts | 5 + packages/ksef-client-ts/src/pdf/index.ts | 3 +- .../src/pdf/template/blocks/payment.ts | 53 ++++--- .../src/pdf/template/builtin/fa2-default.json | 39 ++++-- .../src/pdf/template/builtin/fa3-default.json | 39 ++++-- .../pdf/template/builtin/fa3-showcase.json | 39 ++++-- .../ksef-client-ts/src/pdf/template/dsl.ts | 56 +++++--- .../tests/e2e/35-invoice-pdf-cli.test.ts | 16 ++- .../tests/fixtures/pdf/fa2-czesciowa.xml | 103 ++++++++++++++ .../tests/fixtures/pdf/fa3-czesciowa.xml | 103 ++++++++++++++ .../tests/unit/pdf/blocks-semantic.test.ts | 64 +++++---- .../unit/pdf/builtin-template-lint.test.ts | 11 +- .../tests/unit/pdf/partial-payments.test.ts | 131 ++++++++++++++++++ .../tests/unit/pdf/strict-mode.test.ts | 3 +- 18 files changed, 614 insertions(+), 99 deletions(-) create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa2-czesciowa.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa3-czesciowa.xml create mode 100644 packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 53696a1c..3e2d9d72 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -188,7 +188,7 @@ The `schema` field binds a template to a single document kind. If you render an | `parties` | Seller / buyer two-column panel; a line that resolves empty is skipped. A labelled group reads `from` an optional parent element — the buyer's address, say — and is dropped whole when the document carries none | | `lines` | Invoice line-item table. Takes `when`, because an invoice does not always carry its items in the same place — see below | | `totals` | Net / VAT / gross summary rows (a row reads one path or sums several) | -| `payment` | Payment details (amount paid, date, method). A row takes `when`, so one figure can be listed once per reading and only the applicable label prints, and `from`, so a repeated element — the payment terms of an instalment schedule — prints one line per entry | +| `payment` | Payment details (status, dates, method, amounts), then the repeating sections of `groups` — the part payments and the bank accounts. A row takes `when`, so one figure can be listed once per reading and only the applicable label prints, and `from`, so a repeated element prints one line per entry | | `annotations` | Miscellaneous labelled fields | | `notes` | The caller's own sections, from `notes` — a heading over a body, each | | `qr` | One KSeF verification QR — `code: "invoice"` (Code I, the default) or `code: "certificate"` (Code II) | @@ -208,6 +208,17 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it `each` repeats a group of blocks once per entry of a collection, with the entry as the binding root, so its children use item-relative paths. Use it where a table cannot fit a record on one row — the built-in UPO templates lay out each confirmed document this way, because a 35-character KSeF number beside a 44-character hash will not share a page-wide row. +### How much has been paid + +`Fa.Platnosc` states this through a choice, and a template has to bind both branches or it will show nothing for half of all invoices: + +- **`Zaplacono`** — a bare `1` meaning settled in full, alongside `DataZaplaty`; +- **`ZnacznikZaplatyCzesciowej`** (`1` paid in part, `2` paid in full) alongside up to 100 **`ZaplataCzesciowa`** entries, each carrying an amount, a date and a payment form. + +An invoice settled in instalments takes the second branch and so carries no `Zaplacono` at all. The `paidInFull` and `paidInPart` context flags cover both branches, and the status prints as a label on its own — the schema's `1` says nothing to a reader that the label does not already say. + +The part payments themselves are a `groups` entry, so each one's amount, date and form stay together instead of being split into three separate lists. A group's field paths are entry-relative; write a leading `/` to reach the document root instead, which is how a part payment's amount keeps the currency the invoice states once at the top. + ### What `P_15` is called `P_15` does not mean the same thing on every document, so a template cannot give it one fixed label. The FA schemas define it as the total receivable, with two exceptions: @@ -217,13 +228,15 @@ A `qr` block's `fit` is the printed side in points, quiet zone included, and it Exactly one of the `p15IsAmountDue`, `p15IsAdvancePaid` and `p15IsAmountTotal` context flags is true for a given document. The built-in templates list one row per reading and print the settled payable alongside it when the document states one; a custom template that binds `Fa.P_15` unconditionally should gate it the same way. +> **Not yet covered:** the schema gives `P_15` a fourth reading on the correcting types (`KOR`, `KOR_ZAL`, `KOR_ROZ`), where it is a *correction of* the amount on the invoice being corrected rather than an absolute — possibly negative. The built-in templates currently label a correction's `P_15` as though it were an absolute figure. A template that renders corrections should say so in its own labels until this is handled. + An advance invoice (`RodzajFaktury` `ZAL` or `KOR_ZAL`) records the goods and services it covers under `Fa.Zamowienie`, and may carry no `Fa.FaWiersz` at all. A repeater with no entries still draws its header row, so a template that binds both gives each one a `when` — this is what the built-in templates do, and it is why an advance invoice shows its order rows under their own heading instead of an empty item table. ### Bindings, labels, conditions, and formats - **Binding paths** are dot-paths into the document body — e.g. `Fa.P_2` (invoice number), `Podmiot1.DaneIdentyfikacyjne.Nazwa` (seller name). Paths are relative to the body element, not the document wrapper. - **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. -- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`, `p15IsAmountDue`, `p15IsAdvancePaid`, `p15IsAmountTotal`. A `divider` and a `lines` table take it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. +- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`, `p15IsAmountDue`, `p15IsAdvancePaid`, `p15IsAmountTotal`, `paidInFull`, `paidInPart`. A `divider` and a `lines` table take it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. - **`headingStyle`** (`parties`, `payment`, `annotations`, `notes`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. diff --git a/packages/ksef-client-ts/src/pdf/document-flags.ts b/packages/ksef-client-ts/src/pdf/document-flags.ts index f0c1af80..69345430 100644 --- a/packages/ksef-client-ts/src/pdf/document-flags.ts +++ b/packages/ksef-client-ts/src/pdf/document-flags.ts @@ -39,3 +39,24 @@ export function p15Flags(root: unknown): Record { p15IsAmountDue: !advance && !settled, }; } + +/** + * How much of the invoice the document says has been paid. + * + * `Platnosc` states this through a choice: either `Zaplacono` (a bare `1` + * meaning settled in full, alongside `DataZaplaty`), or + * `ZnacznikZaplatyCzesciowej` — `1` paid in part, `2` paid in full — alongside + * up to 100 `ZaplataCzesciowa` entries. An invoice settled in instalments + * therefore carries no `Zaplacono` at all, which is why a template bound to + * that field alone showed nothing for it. + * + * The status is a flag rather than a printed value because `1` on the page + * says nothing to a reader; the label is the fact. + */ +export function paymentFlags(root: unknown): Record { + const mark = get(root, 'Fa.Platnosc.ZnacznikZaplatyCzesciowej'); + return { + paidInFull: get(root, 'Fa.Platnosc.Zaplacono') === '1' || mark === '2', + paidInPart: mark === '1', + }; +} diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 6214ae13..6068bcef 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -56,6 +56,11 @@ export const en: LabelBundle = { currency: 'Currency', payment: 'Payment', paid: 'Paid', + paidInPart: 'Partially paid', + paidDate: 'Payment date', + partialPayments: 'Partial payments', + partialAmount: 'Partial payment amount', + partialDate: 'Partial payment date', paymentDate: 'Payment due', paymentMethod: 'Payment method', amountDueTotal: 'Total amount due', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index fe2de00d..4727afd2 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -59,6 +59,11 @@ export const pl: LabelBundle = { // payment payment: 'Płatność', paid: 'Zapłacono', + paidInPart: 'Zapłacono w części', + paidDate: 'Data zapłaty', + partialPayments: 'Zapłaty częściowe', + partialAmount: 'Kwota zapłaty częściowej', + partialDate: 'Data zapłaty częściowej', paymentDate: 'Termin płatności', paymentMethod: 'Forma płatności', amountDueTotal: 'Kwota należności ogółem', diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index a9c5c899..4b297416 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -64,6 +64,11 @@ export const uk: LabelBundle = { currency: 'Валюта', payment: 'Оплата', paid: 'Сплачено', + paidInPart: 'Сплачено частково', + paidDate: 'Дата оплати', + partialPayments: 'Часткові оплати', + partialAmount: 'Сума часткової оплати', + partialDate: 'Дата часткової оплати', paymentDate: 'Термін оплати', paymentMethod: 'Спосіб оплати', amountDueTotal: 'Загальна сума до сплати', diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index 18f17e69..eb6b0c00 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -25,7 +25,7 @@ import { blockRegistry } from './template/blocks/index.js'; import { getBuiltinTemplate as loadBuiltinTemplate, builtinTemplateNames } from './template/builtin/index.js'; import { loadPdfMake, createPdfBuffer } from './fonts.js'; import { deriveInvoiceQrUrl } from './qr.js'; -import { p15Flags } from './document-flags.js'; +import { p15Flags, paymentFlags } from './document-flags.js'; export type { Locale } from './i18n/types.js'; export type { InvoiceTemplate } from './template/dsl.js'; @@ -163,6 +163,7 @@ function buildContext( const totals = opts.totals ?? 'buckets'; const flags: Record = { ...p15Flags(root), + ...paymentFlags(root), hasKsefNumber: Boolean(opts.ksefNumber), offline: !opts.ksefNumber, // Either code is enough to keep the QR area on the page: an offline invoice diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts index 289ca86b..1849da04 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -5,9 +5,11 @@ import { evalWhen, resolveBinding, type BlockRenderer, type PdfNode } from '../i /** * Payment details: a `payment` heading, one `label: value` line per - * {@link PaymentBlock.rows} entry, then an optional repeating bank-account - * section ({@link PaymentBlock.accounts}) — one `label: value` line per field, - * for each account in the collection. + * {@link PaymentBlock.rows} entry, then the repeating sections of + * {@link PaymentBlock.groups} — a sub-heading over one `label: value` line per + * field, for each entry in the collection. `Platnosc` has two such sections: + * the bank accounts, and the partial payments an invoice settled in instalments + * records with an amount, a date and a form each. * * A row may carry `when` and is dropped when it does not apply, so a template * can list every reading of a figure and print the one this document supports. @@ -31,6 +33,21 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { const subheading = 'h2'; const stack: PdfNode[] = [{ text: ctx.label('payment'), style: heading }]; + /** + * Read a binding against one entry of a repeater, or against the document + * root when there is no entry. A path written with a leading `/` always + * resolves from the document root: an amount inside a repeater still needs + * the currency the document states once at the top, and `300,00` on its own + * is exactly the ambiguity a currency suffix exists to remove. + */ + const readAt = + (entry: unknown) => + (path: string, optional: boolean): string => { + if (path.startsWith('/')) return resolveBinding(path.slice(1), optional ? lenientCtx : ctx); + if (entry === undefined) return resolveBinding(path, optional ? lenientCtx : ctx); + return get(entry, path, optional ? false : ctx.strict); + }; + for (const row of block.rows) { // A row may be one of several readings of the same figure, only one of // which applies to this document. @@ -38,30 +55,34 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { // A row with `from` prints one line per entry of a collection: an invoice // paid in instalments states a payment term per instalment, and printing // only the first hides the rest of the schedule. + const style = row.style ? { style: row.style } : {}; + // A row with no binding is the label itself — `Zapłacono` states the fact, + // and the schema's `1` after it would state nothing. + if (row.path === undefined) { + stack.push({ text: ctx.label(row.label), ...style }); + continue; + } + const field = { ...row, path: row.path }; const entries = row.from ? list(ctx.root, row.from) : [undefined]; for (const entry of entries) { - const value = - entry === undefined - ? readField(row, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx)) - : readField(row, (path, optional) => get(entry, path, optional ? false : ctx.strict)); + const value = readField(field, readAt(entry)); if (value === '') continue; - stack.push({ text: `${ctx.label(row.label)}: ${value}`, ...(row.style ? { style: row.style } : {}) }); + stack.push({ text: `${ctx.label(row.label)}: ${value}`, ...style }); } } - if (block.accounts) { + for (const group of block.groups ?? []) { const lines: PdfNode[] = []; - for (const account of list(ctx.root, block.accounts.from)) { - for (const field of block.accounts.fields) { - const value = readField(field, (path, optional) => get(account, path, optional ? false : ctx.strict)); + for (const entry of list(ctx.root, group.from)) { + for (const field of group.fields) { + const value = readField(field, readAt(entry)); if (value === '') continue; lines.push({ text: `${ctx.label(field.label)}: ${value}`, ...(field.style ? { style: field.style } : {}) }); } } - if (lines.length > 0) { - if (block.accounts.heading) stack.push({ text: ctx.label(block.accounts.heading), style: subheading }); - stack.push(...lines); - } + if (lines.length === 0) continue; // no sub-heading over nothing + if (group.heading) stack.push({ text: ctx.label(group.heading), style: subheading }); + stack.push(...lines); } return { diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 09958f62..7a7b608a 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -241,7 +241,9 @@ "headingStyle": "h1", "when": "Fa.Platnosc", "rows": [ - { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, + { "label": "paid", "when": "paidInFull" }, + { "label": "paidInPart", "when": "paidInPart" }, + { "label": "paidDate", "path": "Fa.Platnosc.DataZaplaty", "format": "date", "optional": true }, { "label": "paymentDate", "from": "Fa.Platnosc.TerminPlatnosci", @@ -283,15 +285,32 @@ "style": "strong" } ], - "accounts": { - "from": "Fa.Platnosc.RachunekBankowy", - "heading": "bankAccounts", - "fields": [ - { "label": "bankAccount", "path": "NrRB" }, - { "label": "swift", "path": "SWIFT", "optional": true }, - { "label": "bankName", "path": "NazwaBanku", "optional": true } - ] - } + "groups": [ + { + "from": "Fa.Platnosc.ZaplataCzesciowa", + "heading": "partialPayments", + "fields": [ + { + "label": "partialAmount", + "path": "KwotaZaplatyCzesciowej", + "format": "money", + "suffixPath": "/Fa.KodWaluty" + }, + { "label": "partialDate", "path": "DataZaplatyCzesciowej", "format": "date" }, + { "label": "paymentMethod", "path": "FormaPlatnosci", "format": "paymentForm", "optional": true }, + { "label": "paymentMethod", "path": "OpisPlatnosci", "optional": true } + ] + }, + { + "from": "Fa.Platnosc.RachunekBankowy", + "heading": "bankAccounts", + "fields": [ + { "label": "bankAccount", "path": "NrRB" }, + { "label": "swift", "path": "SWIFT", "optional": true }, + { "label": "bankName", "path": "NazwaBanku", "optional": true } + ] + } + ] }, { "type": "divider" }, { "type": "notes", "headingStyle": "h1" }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index dbac9b4d..737c8ef0 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -240,7 +240,9 @@ "headingStyle": "h1", "when": "Fa.Platnosc", "rows": [ - { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, + { "label": "paid", "when": "paidInFull" }, + { "label": "paidInPart", "when": "paidInPart" }, + { "label": "paidDate", "path": "Fa.Platnosc.DataZaplaty", "format": "date", "optional": true }, { "label": "paymentDate", "from": "Fa.Platnosc.TerminPlatnosci", @@ -282,15 +284,32 @@ "style": "strong" } ], - "accounts": { - "from": "Fa.Platnosc.RachunekBankowy", - "heading": "bankAccounts", - "fields": [ - { "label": "bankAccount", "path": "NrRB" }, - { "label": "swift", "path": "SWIFT", "optional": true }, - { "label": "bankName", "path": "NazwaBanku", "optional": true } - ] - } + "groups": [ + { + "from": "Fa.Platnosc.ZaplataCzesciowa", + "heading": "partialPayments", + "fields": [ + { + "label": "partialAmount", + "path": "KwotaZaplatyCzesciowej", + "format": "money", + "suffixPath": "/Fa.KodWaluty" + }, + { "label": "partialDate", "path": "DataZaplatyCzesciowej", "format": "date" }, + { "label": "paymentMethod", "path": "FormaPlatnosci", "format": "paymentForm", "optional": true }, + { "label": "paymentMethod", "path": "OpisPlatnosci", "optional": true } + ] + }, + { + "from": "Fa.Platnosc.RachunekBankowy", + "heading": "bankAccounts", + "fields": [ + { "label": "bankAccount", "path": "NrRB" }, + { "label": "swift", "path": "SWIFT", "optional": true }, + { "label": "bankName", "path": "NazwaBanku", "optional": true } + ] + } + ] }, { "type": "divider" }, { "type": "notes", "headingStyle": "h1" }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 80e81f9b..957461e7 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -178,7 +178,9 @@ "headingStyle": "h1", "when": "Fa.Platnosc", "rows": [ - { "label": "paid", "path": "Fa.Platnosc.Zaplacono", "optional": true }, + { "label": "paid", "when": "paidInFull" }, + { "label": "paidInPart", "when": "paidInPart" }, + { "label": "paidDate", "path": "Fa.Platnosc.DataZaplaty", "format": "date", "optional": true }, { "label": "paymentDate", "from": "Fa.Platnosc.TerminPlatnosci", @@ -220,15 +222,32 @@ "style": "strong" } ], - "accounts": { - "from": "Fa.Platnosc.RachunekBankowy", - "heading": "bankAccounts", - "fields": [ - { "label": "bankAccount", "path": "NrRB" }, - { "label": "swift", "path": "SWIFT", "optional": true }, - { "label": "bankName", "path": "NazwaBanku", "optional": true } - ] - } + "groups": [ + { + "from": "Fa.Platnosc.ZaplataCzesciowa", + "heading": "partialPayments", + "fields": [ + { + "label": "partialAmount", + "path": "KwotaZaplatyCzesciowej", + "format": "money", + "suffixPath": "/Fa.KodWaluty" + }, + { "label": "partialDate", "path": "DataZaplatyCzesciowej", "format": "date" }, + { "label": "paymentMethod", "path": "FormaPlatnosci", "format": "paymentForm", "optional": true }, + { "label": "paymentMethod", "path": "OpisPlatnosci", "optional": true } + ] + }, + { + "from": "Fa.Platnosc.RachunekBankowy", + "heading": "bankAccounts", + "fields": [ + { "label": "bankAccount", "path": "NrRB" }, + { "label": "swift", "path": "SWIFT", "optional": true }, + { "label": "bankName", "path": "NazwaBanku", "optional": true } + ] + } + ] }, { "type": "notes", "headingStyle": "h1", "style": "notes" }, { "type": "spacer", "height": 2 }, diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 686a516f..f326a205 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -250,12 +250,20 @@ export interface TotalsBlock { } /** - * A repeating group of bank-account fields under a payment block. `from` names - * the account collection (read as an always-array), `fields` are the per-account - * label:value lines, and `heading` is an optional i18n sub-heading printed once - * when at least one account resolves. + * A repeating group under a payment block: `from` names the collection (read as + * an always-array), `fields` are the per-entry label:value lines, and `heading` + * is an optional i18n sub-heading printed once when at least one entry resolves. + * + * `Platnosc` has two of these — the bank accounts, and the partial payments an + * invoice settled in instalments records with an amount, a date and a form each + * — so a group holds several fields per entry and keeps each entry's lines + * together, which one repeating row per field could not do. + * + * Field paths are entry-relative, except one written with a leading `/`, which + * resolves from the document root: an amount inside a group still needs the + * currency the document states once, at the top. */ -export interface PaymentAccounts { +export interface PaymentGroup { from: string; heading?: string; fields: FieldDef[]; @@ -268,7 +276,14 @@ export interface PaymentAccounts { * advance one — and the template picks the right label by listing one row per * reading. */ -export interface PaymentRow extends FieldDef { +export interface PaymentRow extends Omit { + /** + * A row with no `path` prints its label alone, and is worth having because + * some facts are the label: `Zapłacono` says everything there is to say, and + * printing the schema's `1` after it says nothing. Such a row is normally + * paired with `when`. + */ + path?: string; when?: string; /** * Repeat this line once per entry of a collection, with the entry as the @@ -285,8 +300,8 @@ export interface PaymentBlock { type: 'payment'; when?: string; rows: PaymentRow[]; - accounts?: PaymentAccounts; - /** See {@link HEADING_STYLE_DOC}. The block label only, not `accounts.heading`. */ + groups?: PaymentGroup[]; + /** See {@link HEADING_STYLE_DOC}. The block label only, not a group's heading. */ headingStyle?: string; style?: string; } @@ -567,14 +582,23 @@ const blockSchema: z.ZodType = z.lazy(() => z.object({ type: z.literal('payment'), when: z.string().optional(), - rows: z.array(fieldDef.extend({ when: z.string().optional(), from: z.string().optional() })), - accounts: z - .object({ - from: z.string(), - heading: z.string().optional(), - fields: z.array(fieldDef), - }) - .strict() + rows: z.array( + fieldDef.extend({ + path: z.string().optional(), + when: z.string().optional(), + from: z.string().optional(), + }), + ), + groups: z + .array( + z + .object({ + from: z.string(), + heading: z.string().optional(), + fields: z.array(fieldDef), + }) + .strict(), + ) .optional(), headingStyle: z.string().optional(), style: z.string().optional(), diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 09d536a3..cefb5100 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -217,6 +217,17 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'summary', '--notes', notesFile, '--accent', ACCENT_SHORT, ]], + // Not part of the grid: another document shape rather than another set of + // flags. `Platnosc` states what has been paid through a choice, and this + // document takes the branch the other pages never reach — no `Zaplacono`, + // a partial marker, and a `ZaplataCzesciowa` per instalment. What a reader + // should see is the payment section saying the invoice is part-paid, then + // each part payment's amount, date and form kept together above the bank + // accounts. + [`${PREFIX}-10-invoice-partial-payments`, () => [ + fx('fa3-czesciowa.xml'), '--ksef-number', KSEF_NUMBER, + '--env', 'demo', '--qr', '--totals', 'buckets', + ]], // Receipts last: they are a different document and read as their own group. [`${PREFIX}-08-upo-pl`, () => [fx('upo-4_3.xml')]], [`${PREFIX}-09-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], @@ -235,7 +246,8 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { 'e2e-services-np.xml', 'fa3.xml', 'e2e-buyer-no-id.xml', 'e2e-vat-multi.xml', // An advance invoice reaches a branch of the template no other document // does, so it is pinned here rather than left to be dropped by accident. - 'fa3-zal.xml', 'upo-4_3.xml', + // So does an invoice settled in instalments. + 'fa3-zal.xml', 'fa3-czesciowa.xml', 'upo-4_3.xml', ]) { expect(covered(doc), `no variant renders ${doc}`).toBe(true); } @@ -279,7 +291,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { it('renders every variant of the set', () => { // Guards against a variant being silently dropped from the table above: // the count is stated here so removing a row has to be deliberate. - expect(variants).toHaveLength(9); + expect(variants).toHaveLength(10); for (const [name] of variants) { expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); } diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-czesciowa.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-czesciowa.xml new file mode 100644 index 00000000..f1e349a5 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-czesciowa.xml @@ -0,0 +1,103 @@ + + + + + FA + 2 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + 1 + + 300.00 + 2025-01-20 + 6 + + + 150.00 + 2025-02-05 + 2 + + + 2025-02-01 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-czesciowa.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-czesciowa.xml new file mode 100644 index 00000000..a2a2a9b3 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-czesciowa.xml @@ -0,0 +1,103 @@ + + + + + FA + 3 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + 1 + + 300.00 + 2025-01-20 + 6 + + + 150.00 + 2025-02-05 + 2 + + + 2025-02-01 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index d72d5800..0b7fc8e0 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -787,15 +787,17 @@ describe('paymentRenderer', () => { { type: 'payment', rows: [], - accounts: { - from: 'Fa.Platnosc.RachunekBankowy', - heading: 'bankAccounts', - fields: [ - { label: 'bankAccount', path: 'NrRB' }, - { label: 'swift', path: 'SWIFT' }, - { label: 'bankName', path: 'NazwaBanku' }, - ], - }, + groups: [ + { + from: 'Fa.Platnosc.RachunekBankowy', + heading: 'bankAccounts', + fields: [ + { label: 'bankAccount', path: 'NrRB' }, + { label: 'swift', path: 'SWIFT' }, + { label: 'bankName', path: 'NazwaBanku' }, + ], + }, + ], }, ctx, noRender, @@ -819,11 +821,13 @@ describe('paymentRenderer', () => { { type: 'payment', rows: [{ label: 'paymentMethod', path: 'Fa.Platnosc.FormaPlatnosci', format: 'paymentForm' }], - accounts: { - from: 'Fa.Platnosc.RachunekBankowy', - heading: 'bankAccounts', - fields: [{ label: 'bankAccount', path: 'NrRB' }], - }, + groups: [ + { + from: 'Fa.Platnosc.RachunekBankowy', + heading: 'bankAccounts', + fields: [{ label: 'bankAccount', path: 'NrRB' }], + }, + ], }, makeCtx({ Fa: { Platnosc: { FormaPlatnosci: '6' } } }), noRender, @@ -843,10 +847,12 @@ describe('paymentRenderer', () => { { type: 'payment', rows: [], - accounts: { - from: 'Fa.Platnosc.RachunekBankowy', - fields: [{ label: 'swift', path: 'SWIFT' }], // absent in the account → strict throws - }, + groups: [ + { + from: 'Fa.Platnosc.RachunekBankowy', + fields: [{ label: 'swift', path: 'SWIFT' }], // absent in the account → strict throws + }, + ], }, ctx, noRender, @@ -946,11 +952,13 @@ describe('headingStyle', () => { type: 'payment', ...(headingStyle ? { headingStyle } : {}), rows: [{ label: 'paid', path: 'Fa.Platnosc.Zaplacono' }], - accounts: { - from: 'Fa.Platnosc.RachunekBankowy', - heading: 'bankAccounts', - fields: [{ label: 'bankAccount', path: 'NrRB' }], - }, + groups: [ + { + from: 'Fa.Platnosc.RachunekBankowy', + heading: 'bankAccounts', + fields: [{ label: 'bankAccount', path: 'NrRB' }], + }, + ], }, makeCtx({ Fa: { Platnosc: { Zaplacono: '1', RachunekBankowy: { NrRB: 'PL01' } } } }), noRender, @@ -1197,10 +1205,12 @@ describe('row style', () => { { label: 'paymentMethod', path: 'Fa.Platnosc.FormaPlatnosci' }, { label: 'amountDueTotal', path: 'Fa.P_15', style: 'strong' }, ], - accounts: { - from: 'Fa.Platnosc.RachunekBankowy', - fields: [{ label: 'bankAccount', path: 'NrRB', style: 'strong' }], - }, + groups: [ + { + from: 'Fa.Platnosc.RachunekBankowy', + fields: [{ label: 'bankAccount', path: 'NrRB', style: 'strong' }], + }, + ], }, makeCtx({ Fa: { P_15: '123.00', Platnosc: { FormaPlatnosci: '6', RachunekBankowy: { NrRB: 'PL01' } } } }), noRender, diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index 783b0bfe..93b0bfdf 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -29,9 +29,9 @@ import type { Block, PartyField, TotalsBlock } from '../../../src/pdf/template/d * resolves against none. */ const FIXTURES_BY_TEMPLATE: Record = { - 'fa2-default': ['pdf/fa2.xml', 'pdf/fa2-zal.xml', 'pdf/fa2-rozliczenie.xml'], - 'fa3-default': ['pdf/fa3.xml', 'pdf/fa3-zal.xml', 'pdf/fa3-rozliczenie.xml'], - 'fa3-showcase': ['pdf/fa3.xml', 'pdf/fa3-rozliczenie.xml'], + 'fa2-default': ['pdf/fa2.xml', 'pdf/fa2-zal.xml', 'pdf/fa2-rozliczenie.xml', 'pdf/fa2-czesciowa.xml'], + 'fa3-default': ['pdf/fa3.xml', 'pdf/fa3-zal.xml', 'pdf/fa3-rozliczenie.xml', 'pdf/fa3-czesciowa.xml'], + 'fa3-showcase': ['pdf/fa3.xml', 'pdf/fa3-rozliczenie.xml', 'pdf/fa3-czesciowa.xml'], 'upo-4_2': ['pdf/upo-4_2.xml'], 'upo-4_3': ['pdf/upo-4_3.xml'], }; @@ -42,6 +42,8 @@ const CONTEXT_CONDITIONS = new Set([ 'opts.logo', 'opts.ksefNumber', 'opts.accent', 'qrUrl', // Which of `P_15`'s three readings this document supports. 'p15IsAmountDue', 'p15IsAdvancePaid', 'p15IsAmountTotal', + // How much of the invoice `Platnosc` says has been paid. + 'paidInFull', 'paidInPart', ]); interface CollectedPaths { @@ -68,7 +70,7 @@ function collect( if (block.type === 'table' && block.from !== undefined) acc.repeaters.push(block.from); if (block.type === 'each') acc.repeaters.push(block.from); if (block.type === 'payment') { - if (block.accounts) acc.repeaters.push(block.accounts.from); + for (const group of block.groups ?? []) acc.repeaters.push(group.from); for (const row of block.rows) if (row.from !== undefined) acc.repeaters.push(row.from); } if (block.type === 'parties') { @@ -166,6 +168,7 @@ describe('built-in template lint', () => { expect(fa3.repeaters).toContain('Fa.Zamowienie.ZamowienieWiersz'); expect(fa3.repeaters).toContain('Fa.Platnosc.RachunekBankowy'); expect(fa3.repeaters).toContain('Fa.Platnosc.TerminPlatnosci'); + expect(fa3.repeaters).toContain('Fa.Platnosc.ZaplataCzesciowa'); expect(fa3.repeaters).toContain('Podmiot2.DaneKontaktowe'); expect(fa3.conditions).toContain('Fa.Rozliczenie.DoZaplaty'); expect(collect(getBuiltinTemplate('upo-4_3')!.blocks).repeaters).toContain('Dokument'); diff --git a/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts b/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts new file mode 100644 index 00000000..ef8c0730 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts @@ -0,0 +1,131 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; +import { paymentFlags } from '../../../src/pdf/document-flags.js'; +import { parseXmlForPdf } from '../../../src/pdf/parse.js'; +import { paymentRenderer } from '../../../src/pdf/template/blocks/payment.js'; +import type { PaymentBlock } from '../../../src/pdf/template/dsl.js'; +import type { PdfNode, RenderChild, RenderContext } from '../../../src/pdf/template/interpret.js'; + +/** + * `Platnosc` states how much has been paid through a choice: either `Zaplacono` + * — a bare `1` meaning settled in full, with `DataZaplaty` — or + * `ZnacznikZaplatyCzesciowej` (1 in part, 2 in full) with up to 100 + * `ZaplataCzesciowa` entries, each an amount, a date and a form. + * + * An invoice settled in instalments therefore carries no `Zaplacono` at all, so + * templates bound to that field alone printed nothing for it: neither that part + * of the money had arrived, nor how much, nor when. + */ + +const fx = (n: string) => readFileSync(new URL(`../../fixtures/pdf/${n}`, import.meta.url), 'utf8'); +const noRender: RenderChild = () => null; +const bodyOf = (xml: string) => (parseXmlForPdf(xml) as Record).Faktura; + +function paymentLines(templateName: string, xml: string, locale?: (k: string) => string): string[] { + const block = getBuiltinTemplate(templateName)!.blocks.find((b) => b.type === 'payment') as PaymentBlock; + const root = bodyOf(xml); + const ctx: RenderContext = { + root, + strict: false, + label: locale ?? ((k: string) => k), + bindings: {}, + flags: { p15IsAmountDue: true, ...paymentFlags(root) }, + }; + const out: string[] = []; + const walk = (value: unknown): void => { + if (Array.isArray(value)) return value.forEach(walk); + if (value === null || typeof value !== 'object') return; + const node = value as Record; + if (typeof node.text === 'string') out.push(node.text); + Object.values(node).forEach(walk); + }; + walk(paymentRenderer(block, ctx, noRender) as PdfNode); + return out; +} + +describe('how much of the invoice has been paid', () => { + it('reads the full-payment branch', () => { + expect(paymentFlags(bodyOf(fx('fa3.xml')))).toEqual({ paidInFull: true, paidInPart: false }); + }); + + it('reads the partial branch, which carries no Zaplacono at all', () => { + const xml = fx('fa3-czesciowa.xml'); + expect(xml).not.toContain(''); + expect(paymentFlags(bodyOf(xml))).toEqual({ paidInFull: false, paidInPart: true }); + }); + + it('treats the marker value 2 as paid in full', () => { + const xml = fx('fa3-czesciowa.xml').replace( + '1<', + '2<', + ); + expect(paymentFlags(bodyOf(xml))).toEqual({ paidInFull: true, paidInPart: false }); + }); + + it('says nothing either way when the invoice states no payment status', () => { + const xml = fx('fa3.xml').replace(/\s*1<\/Zaplacono>/, ''); + expect(paymentFlags(bodyOf(xml))).toEqual({ paidInFull: false, paidInPart: false }); + }); +}); + +describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s partial payments', (name) => { + const fa = name.startsWith('fa2') ? 'fa2' : 'fa3'; + + it('prints every part payment with its amount, date and form', () => { + const lines = paymentLines(name, fx(`${fa}-czesciowa.xml`)); + expect(lines).toEqual( + expect.arrayContaining([ + 'paidInPart', + 'partialPayments', + 'partialAmount: 300,00 PLN', + 'partialDate: 20.01.2025', + 'paymentMethod: Przelew', + 'partialAmount: 150,00 PLN', + 'partialDate: 05.02.2025', + 'paymentMethod: Karta', + ]), + ); + }); + + it('keeps each part payment’s lines together, in document order', () => { + const lines = paymentLines(name, fx(`${fa}-czesciowa.xml`)); + const group = lines.slice(lines.indexOf('partialPayments') + 1); + expect(group.slice(0, 6)).toEqual([ + 'partialAmount: 300,00 PLN', + 'partialDate: 20.01.2025', + 'paymentMethod: Przelew', + 'partialAmount: 150,00 PLN', + 'partialDate: 05.02.2025', + 'paymentMethod: Karta', + ]); + }); + + it('states the status as a fact, not as the schema’s 1', () => { + const lines = paymentLines(name, fx(`${fa}.xml`)); + expect(lines).toContain('paid'); + expect(lines.some((t) => t.startsWith('paid: '))).toBe(false); + expect(lines).not.toContain('paidInPart'); + }); + + it('prints no partial section for an invoice that has none', () => { + const lines = paymentLines(name, fx(`${fa}.xml`)); + expect(lines).not.toContain('partialPayments'); + expect(lines.some((t) => t.startsWith('partialAmount'))).toBe(false); + }); + + it('qualifies a part payment with the currency the document states once', () => { + // The amount lives inside the repeater and the currency does not, so a + // bare `300,00` is the ambiguity the suffix exists to remove — on a EUR + // invoice it reads as the wrong money entirely. + const eur = fx(`${fa}-czesciowa.xml`).replace('PLN', 'EUR'); + const lines = paymentLines(name, eur); + expect(lines).toContain('partialAmount: 300,00 EUR'); + }); + + it('still prints the bank accounts after the part payments', () => { + const lines = paymentLines(name, fx(`${fa}-czesciowa.xml`)); + expect(lines.indexOf('bankAccounts')).toBeGreaterThan(lines.indexOf('partialPayments')); + expect(lines).toContain('bankAccount: 11109000880000000100000001'); + }); +}); diff --git a/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts index 91020a41..02ea7f89 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts @@ -236,7 +236,8 @@ describe('the built-in templates mark the right bindings', () => { it.each(['fa2-default', 'fa3-default'])('%s keeps the bank account number policed', (name) => { const payment = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'payment') as PaymentBlock; - const fields = Object.fromEntries((payment.accounts!.fields as FieldDef[]).map((f) => [f.label, f])); + const accounts = payment.groups!.find((g) => g.from === 'Fa.Platnosc.RachunekBankowy')!; + const fields = Object.fromEntries((accounts.fields as FieldDef[]).map((f) => [f.label, f])); expect(fields.bankAccount?.optional, 'NrRB is required inside RachunekBankowy').toBeUndefined(); expect(fields.swift?.optional).toBe(true); expect(fields.bankName?.optional).toBe(true); From b806305e219e8a3375f6ad5aefa59632e503aeaf Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 17:27:46 +0200 Subject: [PATCH 58/67] feat(pdf): render the whole advance-and-settlement story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invoice PDF has to say what a reader owes, and the FA schemas make that harder than it looks: `P_15` means four different things depending on the document, and the money that has already changed hands lives in three unrelated elements. The built-in templates knew about none of it, so whole classes of document printed a figure that was wrong, absent, or unexplained. What the pages now cover: - Payments received by an advance invoice (`Fa.ZaliczkaCzesciowa`), which add up to `P_15` exactly, and the advance invoices a settlement is issued against (`Fa.FakturaZaliczkowa`, numbers only — no amount lives there). - Settlements against the receivable (`Fa.Platnosc.ZaplataCzesciowa`), which do not add up while the invoice is only part-paid, plus a paid/part-paid status that reads as a fact rather than as the schema's `1`. - A settlement invoice in both of the shapes the schema allows: the remainder stated in `P_15`, or `P_15` as the whole amount with the remainder defined as the difference from the payments received. Nothing carries that difference, so it is computed. - An overpayment (`Fa.Rozliczenie.DoRozliczenia`), where the page must not ask for money at all. Two DSL additions carry the computed figures: `sumFrom` takes the sum of one binding over a repeater, and `less` subtracts such a sum. Both yield blank rather than a wrong number when anything they read is unparseable. The payment block's one-off bank-account section became a list of repeating groups, since the part payments need the same shape, and a group may reach the document root with a leading `/` so an amount inside a repeater keeps its currency. The preview set is reorganized around two independent two-document chains — different buyers, amounts, invoice numbers and forward-moving dates — so one deal can be followed from page to page, with the standalone cases after them. Verified by eye on all fourteen rendered pages as well as by test. Full suite green: 2864 unit + 158 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 33 +++++- .../ksef-client-ts/src/pdf/document-flags.ts | 43 ++++++- packages/ksef-client-ts/src/pdf/i18n/en.ts | 7 ++ packages/ksef-client-ts/src/pdf/i18n/pl.ts | 7 ++ packages/ksef-client-ts/src/pdf/i18n/uk.ts | 7 ++ .../src/pdf/template/blocks/field.ts | 40 ++++++- .../src/pdf/template/blocks/payment.ts | 24 +++- .../src/pdf/template/blocks/totals.ts | 9 +- .../src/pdf/template/builtin/fa2-default.json | 88 ++++++++++++++ .../src/pdf/template/builtin/fa3-default.json | 88 ++++++++++++++ .../pdf/template/builtin/fa3-showcase.json | 88 ++++++++++++++ .../ksef-client-ts/src/pdf/template/dsl.ts | 44 ++++++- .../tests/e2e/35-invoice-pdf-cli.test.ts | 111 +++++++++++++----- .../tests/fixtures/pdf/fa2-nadplata.xml | 101 ++++++++++++++++ .../tests/fixtures/pdf/fa2-roz-b.xml | 104 ++++++++++++++++ .../tests/fixtures/pdf/fa2-roz.xml | 96 +++++++++++++++ .../tests/fixtures/pdf/fa2-zal-b.xml | 105 +++++++++++++++++ .../tests/fixtures/pdf/fa2-zal.xml | 25 ++-- .../tests/fixtures/pdf/fa3-nadplata.xml | 101 ++++++++++++++++ .../tests/fixtures/pdf/fa3-roz-b.xml | 104 ++++++++++++++++ .../tests/fixtures/pdf/fa3-roz.xml | 96 +++++++++++++++ .../tests/fixtures/pdf/fa3-zal-b.xml | 105 +++++++++++++++++ .../tests/fixtures/pdf/fa3-zal.xml | 25 ++-- .../tests/unit/pdf/amount-due-label.test.ts | 95 ++++++++++++++- .../tests/unit/pdf/blocks-semantic.test.ts | 18 ++- .../unit/pdf/builtin-template-lint.test.ts | 27 ++++- .../tests/unit/pdf/partial-payments.test.ts | 40 +++++++ 27 files changed, 1549 insertions(+), 82 deletions(-) create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa2-nadplata.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz-b.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal-b.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa3-nadplata.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz-b.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz.xml create mode 100644 packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal-b.xml diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 3e2d9d72..d858cc20 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -219,14 +219,38 @@ An invoice settled in instalments takes the second branch and so carries no `Zap The part payments themselves are a `groups` entry, so each one's amount, date and form stay together instead of being split into three separate lists. A group's field paths are entry-relative; write a leading `/` to reach the document root instead, which is how a part payment's amount keeps the currency the invoice states once at the top. +Two more repeating sections belong to the same story, and neither lives under `Platnosc`: + +| Element | What it holds | Does it add up to `P_15`? | +|---------|---------------|---------------------------| +| `Fa.ZaliczkaCzesciowa` (≤ 31) | The payments an advance invoice documents having received — each `P_15Z` is a part *of* `P_15` | **Yes**, exactly | +| `Fa.Platnosc.ZaplataCzesciowa` (≤ 100) | Settlements against the receivable | No, not while `ZnacznikZaplatyCzesciowej` is `1` | +| `Fa.FakturaZaliczkowa` (≤ 100) | The advance invoices a settlement invoice is issued against, by KSeF number or by their own | — | + +The two are easy to confuse and read very differently on the page, so the built-in templates print them under separate headings — `Otrzymane płatności` and `Zapłaty częściowe`. + ### What `P_15` is called -`P_15` does not mean the same thing on every document, so a template cannot give it one fixed label. The FA schemas define it as the total receivable, with two exceptions: +`P_15` does not mean the same thing on every document, so a template cannot give it one fixed label. The FA schemas define it as the total receivable, with three exceptions: - on an **advance invoice** (`RodzajFaktury` `ZAL` or `KOR_ZAL`) it is the payment the document records as *already received* — labelling it `Do zapłaty` tells the reader to pay it a second time; -- when the document carries **`Fa.Rozliczenie.DoZaplaty`** — `P_15` plus surcharges minus deductions — that is the figure actually payable, and `P_15` is only the total. +- on a **settlement invoice** (`ROZ`, art. 106f ust. 3) it is what remains to be paid after the advances. Its lines and VAT buckets state the *whole* order, so this is the page where a flat `Do zapłaty` reads as a contradiction: line items of 615,00 above a demand for 165,00. Such an invoice comes in two shapes — see below; +- when the document states the figure itself — **`Fa.Rozliczenie.DoZaplaty`** (`P_15` plus surcharges minus deductions), **`Fa.Rozliczenie.DoRozliczenia`** (an overpayment to refund or carry forward), or a **part-payment marker** — that figure is what the reader acts on and `P_15` is only the total. + +Exactly one of the `p15IsAmountDue`, `p15IsAdvancePaid`, `p15IsRemainder` and `p15IsAmountTotal` context flags is true for a given document. + +#### The two shapes of a settlement invoice + +An issuer may state what is left in either of two ways, and both have to render correctly: + +| | `P_15` | What is owed | +|---|---|---| +| Leaves the advances on the invoice it references | The remainder | `P_15`, stated | +| Restates the payments received (`Fa.ZaliczkaCzesciowa`) | The whole amount | `P_15` less the sum of the `P_15Z` fields | + +The schema defines the second outright: *«różnica kwoty w polu P_15 i sumy poszczególnych pól P_15Z stanowi kwotę pozostałą»*. No field carries that number, so a page that will not compute it cannot show it — which is what `less` is for. The `settlementRemainder` flag gates the computed row. -Exactly one of the `p15IsAmountDue`, `p15IsAdvancePaid` and `p15IsAmountTotal` context flags is true for a given document. The built-in templates list one row per reading and print the settled payable alongside it when the document states one; a custom template that binds `Fa.P_15` unconditionally should gate it the same way. +The same applies to an invoice being paid down: nothing states what has been paid or what is left, so the built-in templates compute both with `sumFrom` and `less`, gated on `paidInPart`. The built-in templates list one row per reading and print the settled payable alongside it when the document states one; a custom template that binds `Fa.P_15` unconditionally should gate it the same way. > **Not yet covered:** the schema gives `P_15` a fourth reading on the correcting types (`KOR`, `KOR_ZAL`, `KOR_ROZ`), where it is a *correction of* the amount on the invoice being corrected rather than an absolute — possibly negative. The built-in templates currently label a correction's `P_15` as though it were an absolute figure. A template that renders corrections should say so in its own labels until this is handled. @@ -236,7 +260,8 @@ An advance invoice (`RodzajFaktury` `ZAL` or `KOR_ZAL`) records the goods and se - **Binding paths** are dot-paths into the document body — e.g. `Fa.P_2` (invoice number), `Podmiot1.DaneIdentyfikacyjne.Nazwa` (seller name). Paths are relative to the body element, not the document wrapper. - **`label`** references an i18n label key resolved per locale; **`text`** is a literal string printed as-is. -- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`, `p15IsAmountDue`, `p15IsAdvancePaid`, `p15IsAmountTotal`, `paidInFull`, `paidInPart`. A `divider` and a `lines` table take it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. +- **`when`** conditionally renders a block against a presence test. It accepts a binding path (e.g. `Fa.Platnosc`) or a context flag: `qr`, `offline`, `hasKsefNumber`, `notes`, `totalsBuckets`, `totalsSummary`, `p15IsAmountDue`, `p15IsAdvancePaid`, `p15IsRemainder`, `p15IsAmountTotal`, `paidInFull`, `paidInPart`. A `divider` and a `lines` table take it too, so a rule can disappear with whatever it separates — the built-in templates close their `notes` block with `{ "type": "divider", "when": "notes" }`, which leaves no stray line on an invoice that carries none. +- **`less`** and **`sumFrom`** (totals and payment rows) compute a figure the document does not state: `sumFrom` takes the sum of one binding over every entry of a collection, and `less` subtracts such a sum from the row's own value. `sum` cannot do this — it adds a fixed list of paths, and the entries of a repeater are not known to the template. Both print blank rather than a wrong number when anything they read is unparseable. Like every computed figure here, they are only as sound as the document. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. - **`headingStyle`** (`parties`, `payment`, `annotations`, `notes`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. diff --git a/packages/ksef-client-ts/src/pdf/document-flags.ts b/packages/ksef-client-ts/src/pdf/document-flags.ts index 69345430..c7009874 100644 --- a/packages/ksef-client-ts/src/pdf/document-flags.ts +++ b/packages/ksef-client-ts/src/pdf/document-flags.ts @@ -14,6 +14,15 @@ import { get, has } from './accessor.js'; */ const ADVANCE_INVOICE_TYPES = new Set(['ZAL', 'KOR_ZAL']); +/** + * The settlement invoice of art. 106f ust. 3 — the one issued after the goods + * are delivered, against advances already invoiced. Its lines state the *full* + * order value while `P_15` states only what is still owed, so a page that calls + * `P_15` "the amount due" leaves a reader comparing it against line items many + * times larger and doubting both. + */ +const SETTLEMENT_INVOICE_TYPES = new Set(['ROZ']); + /** * Which of `P_15`'s three readings this document supports. `P_15` does not mean * the same thing on every invoice, so a template cannot name it with one fixed @@ -22,6 +31,8 @@ const ADVANCE_INVOICE_TYPES = new Set(['ZAL', 'KOR_ZAL']); * - on an advance invoice it is the payment the document records as received, * and telling that reader to pay it again is the worst thing an invoice PDF * can do; + * - on a settlement invoice (`ROZ`) it is what remains to be paid after the + * advances, next to lines that state the whole order; * - when the document carries `Rozliczenie.DoZaplaty` — `P_15` plus surcharges * minus deductions — the payable figure is that one, so `P_15` is only the * total receivable; @@ -31,12 +42,36 @@ const ADVANCE_INVOICE_TYPES = new Set(['ZAL', 'KOR_ZAL']); * right one prints. */ export function p15Flags(root: unknown): Record { - const advance = ADVANCE_INVOICE_TYPES.has(get(root, 'Fa.RodzajFaktury')); - const settled = has(root, 'Fa.Rozliczenie.DoZaplaty'); + const kind = get(root, 'Fa.RodzajFaktury'); + const advance = ADVANCE_INVOICE_TYPES.has(kind); + // `Rozliczenie` states the payable — or the overpayment — outright, so it + // outranks the invoice type: whatever `P_15` means here, it is not the figure + // the reader acts on. + // `P_15` stops being the figure to act on as soon as the document states one + // itself — the payable or the overpayment under `Rozliczenie` — or records + // that part of it has already been paid, which leaves the remainder owed. + const settled = + !advance && + (has(root, 'Fa.Rozliczenie.DoZaplaty') || + has(root, 'Fa.Rozliczenie.DoRozliczenia') || + get(root, 'Fa.Platnosc.ZnacznikZaplatyCzesciowej') === '1'); + + // A settlement invoice comes in two shapes. Plain, `P_15` *is* what is left + // to pay. But when it also documents payments received before delivery, the + // schema defines the remainder as `P_15` minus the sum of those `P_15Z` + // fields — so `P_15` is then the whole amount, and the figure the reader owes + // has to be computed. + const settlement = !advance && !settled && SETTLEMENT_INVOICE_TYPES.has(kind); + const documentsPayments = settlement && has(root, 'Fa.ZaliczkaCzesciowa'); + return { p15IsAdvancePaid: advance, - p15IsAmountTotal: !advance && settled, - p15IsAmountDue: !advance && !settled, + p15IsAmountTotal: settled || documentsPayments, + p15IsRemainder: settlement && !documentsPayments, + p15IsAmountDue: !advance && !settled && !settlement, + // Gates the computed row: the remainder exists only where the schema + // defines it as a difference. + settlementRemainder: documentsPayments, }; } diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 6068bcef..9e78b355 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -51,6 +51,9 @@ export const en: LabelBundle = { totalNet: 'Total net', totalVat: 'Total VAT', totalDue: 'Amount due', + remainingDue: 'Remaining to pay', + paidTotal: 'Paid to date', + overpaid: 'Overpayment to settle', advancePaid: 'Payment received', amountTotal: 'Total amount', currency: 'Currency', @@ -58,6 +61,10 @@ export const en: LabelBundle = { paid: 'Paid', paidInPart: 'Partially paid', paidDate: 'Payment date', + advanceInvoices: 'Advance invoices', + advancePayments: 'Payments received', + advancePaymentAmount: 'Payment amount', + advancePaymentDate: 'Date received', partialPayments: 'Partial payments', partialAmount: 'Partial payment amount', partialDate: 'Partial payment date', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index 4727afd2..aaf065b8 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -53,6 +53,9 @@ export const pl: LabelBundle = { totalNet: 'Razem netto', totalVat: 'Razem VAT', totalDue: 'Do zapłaty', + remainingDue: 'Pozostało do zapłaty', + paidTotal: 'Zapłacono razem', + overpaid: 'Nadpłata do rozliczenia', advancePaid: 'Kwota zapłaty', amountTotal: 'Kwota należności ogółem', currency: 'Waluta', @@ -61,6 +64,10 @@ export const pl: LabelBundle = { paid: 'Zapłacono', paidInPart: 'Zapłacono w części', paidDate: 'Data zapłaty', + advanceInvoices: 'Faktury zaliczkowe', + advancePayments: 'Otrzymane płatności', + advancePaymentAmount: 'Kwota płatności', + advancePaymentDate: 'Data otrzymania', partialPayments: 'Zapłaty częściowe', partialAmount: 'Kwota zapłaty częściowej', partialDate: 'Data zapłaty częściowej', diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index 4b297416..7c081162 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -59,6 +59,9 @@ export const uk: LabelBundle = { totalNet: 'Разом нетто', totalVat: 'Разом ПДВ', totalDue: 'До сплати', + remainingDue: 'Залишок до сплати', + paidTotal: 'Сплачено разом', + overpaid: 'Переплата до врегулювання', advancePaid: 'Сума оплати', amountTotal: 'Загальна сума', currency: 'Валюта', @@ -66,6 +69,10 @@ export const uk: LabelBundle = { paid: 'Сплачено', paidInPart: 'Сплачено частково', paidDate: 'Дата оплати', + advanceInvoices: 'Авансові фактури', + advancePayments: 'Отримані платежі', + advancePaymentAmount: 'Сума платежу', + advancePaymentDate: 'Дата отримання', partialPayments: 'Часткові оплати', partialAmount: 'Сума часткової оплати', partialDate: 'Дата часткової оплати', diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/field.ts b/packages/ksef-client-ts/src/pdf/template/blocks/field.ts index 95b5fc69..aa2b7968 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/field.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/field.ts @@ -1,5 +1,6 @@ -import { applyFormat } from '../../format.js'; -import type { FieldDef } from '../dsl.js'; +import { get, list } from '../../accessor.js'; +import { applyFormat, sumDecimal } from '../../format.js'; +import type { FieldDef, RepeatedSum } from '../dsl.js'; /** * Read one labelled field into its printable value: the binding, formatted, and @@ -25,3 +26,38 @@ export function readField( const suffix = read(field.suffixPath, optional); return suffix === '' ? value : `${value} ${suffix}`; } + +/** + * `value` less the sum of one binding taken over every entry of a collection. + * + * This exists for a figure the FA schemas define as a difference rather than + * state outright: on a settlement invoice that also documents payments received + * before delivery, the schema says the difference between `P_15` and the sum of + * the individual `P_15Z` fields is what remains to be paid. Nothing carries that + * number, so a page that will not compute it cannot show it at all. + * + * Returns `''` when the base value is absent — a difference from nothing is not + * zero, it is unknown — and `sumDecimal` already yields `''` if any operand is + * unparseable, so a broken document prints a blank rather than a wrong figure. + */ +export function lessRepeatedSum(value: string, less: RepeatedSum, root: unknown): string { + if (value.trim() === '') return ''; + const negated = list(root, less.from).map((entry) => { + const raw = get(entry, less.path).trim(); + if (raw === '') return ''; + return raw.startsWith('-') ? raw.slice(1) : `-${raw}`; + }); + return sumDecimal([value, ...negated]); +} + +/** + * The sum of one binding taken over every entry of a collection. + * + * `sum` adds up a fixed list of paths, which cannot express "every part payment + * this invoice records" — the entries are not known to the template. Absent and + * blank entries are skipped; an unparseable one makes the whole sum `''`, as it + * does everywhere else here, because a blank is safer than a wrong total. + */ +export function repeatedSum(sum: RepeatedSum, root: unknown): string { + return sumDecimal(list(root, sum.from).map((entry) => get(entry, sum.path))); +} diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts index 1849da04..69bc9cf8 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -1,6 +1,7 @@ import { get, list } from '../../accessor.js'; +import { applyFormat } from '../../format.js'; import type { PaymentBlock } from '../dsl.js'; -import { readField } from './field.js'; +import { lessRepeatedSum, readField, repeatedSum } from './field.js'; import { evalWhen, resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; /** @@ -56,6 +57,17 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { // paid in instalments states a payment term per instalment, and printing // only the first hides the rest of the schedule. const style = row.style ? { style: row.style } : {}; + // A computed row states a figure the document does not: what has been paid + // so far, or what is left after it. It carries no `path`, so it is settled + // before the label-only case below. + if (row.sumFrom) { + const computed = applyFormat(repeatedSum(row.sumFrom, ctx.root), row.format); + if (computed !== '') { + const suffix = row.suffixPath ? resolveBinding(row.suffixPath, lenientCtx) : ''; + stack.push({ text: `${ctx.label(row.label)}: ${suffix ? `${computed} ${suffix}` : computed}`, ...style }); + } + continue; + } // A row with no binding is the label itself — `Zapłacono` states the fact, // and the schema's `1` after it would state nothing. if (row.path === undefined) { @@ -65,7 +77,15 @@ export const paymentRenderer: BlockRenderer = (block, ctx) => { const field = { ...row, path: row.path }; const entries = row.from ? list(ctx.root, row.from) : [undefined]; for (const entry of entries) { - const value = readField(field, readAt(entry)); + let value: string; + if (row.less) { + const base = lessRepeatedSum(readAt(entry)(field.path, field.optional === true), row.less, ctx.root); + const formatted = applyFormat(base, row.format); + const suffix = formatted && row.suffixPath ? resolveBinding(row.suffixPath, lenientCtx) : ''; + value = suffix ? `${formatted} ${suffix}` : formatted; + } else { + value = readField(field, readAt(entry)); + } if (value === '') continue; stack.push({ text: `${ctx.label(row.label)}: ${value}`, ...style }); } diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts index 209cfec5..e8407e6b 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts @@ -1,4 +1,5 @@ import { applyFormat, sumDecimal } from '../../format.js'; +import { lessRepeatedSum, repeatedSum } from './field.js'; import type { TotalsBlock } from '../dsl.js'; import { evalWhen, resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; @@ -24,9 +25,11 @@ export const totalsRenderer: BlockRenderer = (block, ctx) => { const body: PdfNode[][] = []; for (const row of block.rows) { if (!evalWhen(row.when, ctx)) continue; - const raw = row.sum - ? sumDecimal(row.sum.map((p) => resolveBinding(p, lenient))) - : resolveBinding(row.path ?? '', row.optional ? lenient : ctx); + let base: string; + if (row.sum) base = sumDecimal(row.sum.map((p) => resolveBinding(p, lenient))); + else if (row.sumFrom) base = repeatedSum(row.sumFrom, ctx.root); + else base = resolveBinding(row.path ?? '', row.optional ? lenient : ctx); + const raw = row.less ? lessRepeatedSum(base, row.less, ctx.root) : base; const value = applyFormat(raw, row.format); // A row that resolves empty is skipped, as in `payment` and `parties`: a // template listing every rate bucket must not print a dangling label for diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 7a7b608a..eeafe3b3 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -224,6 +224,7 @@ { "label": "totalDue", "path": "Fa.P_15", "when": "p15IsAmountDue", "format": "money", "style": "strong" }, { "label": "advancePaid", "path": "Fa.P_15", "when": "p15IsAdvancePaid", "format": "money", "style": "strong" }, { "label": "amountTotal", "path": "Fa.P_15", "when": "p15IsAmountTotal", "format": "money", "style": "strong" }, + { "label": "remainingDue", "path": "Fa.P_15", "when": "p15IsRemainder", "format": "money", "style": "strong" }, { "label": "totalDue", "path": "Fa.Rozliczenie.DoZaplaty", @@ -231,6 +232,30 @@ "format": "money", "style": "strong" }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "settlementRemainder", + "less": { "from": "Fa.ZaliczkaCzesciowa", "path": "P_15Z" }, + "format": "money", + "style": "strong" + }, + { "label": "paidTotal", "when": "paidInPart", "sumFrom": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money" }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "paidInPart", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "style": "strong" + }, + { + "label": "overpaid", + "path": "Fa.Rozliczenie.DoRozliczenia", + "when": "Fa.Rozliczenie.DoRozliczenia", + "format": "money", + "style": "strong" + }, { "label": "currency", "path": "Fa.KodWaluty", "style": "strong" } ] }, @@ -276,6 +301,48 @@ "suffixPath": "Fa.KodWaluty", "style": "strong" }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "p15IsRemainder", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "settlementRemainder", + "less": { "from": "Fa.ZaliczkaCzesciowa", "path": "P_15Z" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "paidTotal", + "when": "paidInPart", + "sumFrom": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "paidInPart", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "overpaid", + "path": "Fa.Rozliczenie.DoRozliczenia", + "when": "Fa.Rozliczenie.DoRozliczenia", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, { "label": "totalDue", "path": "Fa.Rozliczenie.DoZaplaty", @@ -286,6 +353,27 @@ } ], "groups": [ + { + "from": "Fa.FakturaZaliczkowa", + "heading": "advanceInvoices", + "fields": [ + { "label": "ksefNumber", "path": "NrKSeFFaZaliczkowej", "optional": true }, + { "label": "invoiceNumber", "path": "NrFaZaliczkowej", "optional": true } + ] + }, + { + "from": "Fa.ZaliczkaCzesciowa", + "heading": "advancePayments", + "fields": [ + { + "label": "advancePaymentAmount", + "path": "P_15Z", + "format": "money", + "suffixPath": "/Fa.KodWaluty" + }, + { "label": "advancePaymentDate", "path": "P_6Z", "format": "date" } + ] + }, { "from": "Fa.Platnosc.ZaplataCzesciowa", "heading": "partialPayments", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 737c8ef0..688b259b 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -224,6 +224,7 @@ { "label": "totalDue", "path": "Fa.P_15", "when": "p15IsAmountDue", "format": "money", "style": "strong" }, { "label": "advancePaid", "path": "Fa.P_15", "when": "p15IsAdvancePaid", "format": "money", "style": "strong" }, { "label": "amountTotal", "path": "Fa.P_15", "when": "p15IsAmountTotal", "format": "money", "style": "strong" }, + { "label": "remainingDue", "path": "Fa.P_15", "when": "p15IsRemainder", "format": "money", "style": "strong" }, { "label": "totalDue", "path": "Fa.Rozliczenie.DoZaplaty", @@ -231,6 +232,30 @@ "format": "money", "style": "strong" }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "settlementRemainder", + "less": { "from": "Fa.ZaliczkaCzesciowa", "path": "P_15Z" }, + "format": "money", + "style": "strong" + }, + { "label": "paidTotal", "when": "paidInPart", "sumFrom": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money" }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "paidInPart", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "style": "strong" + }, + { + "label": "overpaid", + "path": "Fa.Rozliczenie.DoRozliczenia", + "when": "Fa.Rozliczenie.DoRozliczenia", + "format": "money", + "style": "strong" + }, { "label": "currency", "path": "Fa.KodWaluty", "style": "strong" } ] }, @@ -275,6 +300,48 @@ "suffixPath": "Fa.KodWaluty", "style": "strong" }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "p15IsRemainder", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "settlementRemainder", + "less": { "from": "Fa.ZaliczkaCzesciowa", "path": "P_15Z" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "paidTotal", + "when": "paidInPart", + "sumFrom": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "paidInPart", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "overpaid", + "path": "Fa.Rozliczenie.DoRozliczenia", + "when": "Fa.Rozliczenie.DoRozliczenia", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, { "label": "totalDue", "path": "Fa.Rozliczenie.DoZaplaty", @@ -285,6 +352,27 @@ } ], "groups": [ + { + "from": "Fa.FakturaZaliczkowa", + "heading": "advanceInvoices", + "fields": [ + { "label": "ksefNumber", "path": "NrKSeFFaZaliczkowej", "optional": true }, + { "label": "invoiceNumber", "path": "NrFaZaliczkowej", "optional": true } + ] + }, + { + "from": "Fa.ZaliczkaCzesciowa", + "heading": "advancePayments", + "fields": [ + { + "label": "advancePaymentAmount", + "path": "P_15Z", + "format": "money", + "suffixPath": "/Fa.KodWaluty" + }, + { "label": "advancePaymentDate", "path": "P_6Z", "format": "date" } + ] + }, { "from": "Fa.Platnosc.ZaplataCzesciowa", "heading": "partialPayments", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 957461e7..9c4f2910 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -162,6 +162,7 @@ { "label": "totalDue", "path": "Fa.P_15", "when": "p15IsAmountDue", "format": "money", "style": "strong" }, { "label": "advancePaid", "path": "Fa.P_15", "when": "p15IsAdvancePaid", "format": "money", "style": "strong" }, { "label": "amountTotal", "path": "Fa.P_15", "when": "p15IsAmountTotal", "format": "money", "style": "strong" }, + { "label": "remainingDue", "path": "Fa.P_15", "when": "p15IsRemainder", "format": "money", "style": "strong" }, { "label": "totalDue", "path": "Fa.Rozliczenie.DoZaplaty", @@ -169,6 +170,30 @@ "format": "money", "style": "strong" }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "settlementRemainder", + "less": { "from": "Fa.ZaliczkaCzesciowa", "path": "P_15Z" }, + "format": "money", + "style": "strong" + }, + { "label": "paidTotal", "when": "paidInPart", "sumFrom": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money" }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "paidInPart", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "style": "strong" + }, + { + "label": "overpaid", + "path": "Fa.Rozliczenie.DoRozliczenia", + "when": "Fa.Rozliczenie.DoRozliczenia", + "format": "money", + "style": "strong" + }, { "label": "currency", "path": "Fa.KodWaluty", "style": "strong" } ] }, @@ -213,6 +238,48 @@ "suffixPath": "Fa.KodWaluty", "style": "strong" }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "p15IsRemainder", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "settlementRemainder", + "less": { "from": "Fa.ZaliczkaCzesciowa", "path": "P_15Z" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "paidTotal", + "when": "paidInPart", + "sumFrom": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "paidInPart", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, + { + "label": "overpaid", + "path": "Fa.Rozliczenie.DoRozliczenia", + "when": "Fa.Rozliczenie.DoRozliczenia", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, { "label": "totalDue", "path": "Fa.Rozliczenie.DoZaplaty", @@ -223,6 +290,27 @@ } ], "groups": [ + { + "from": "Fa.FakturaZaliczkowa", + "heading": "advanceInvoices", + "fields": [ + { "label": "ksefNumber", "path": "NrKSeFFaZaliczkowej", "optional": true }, + { "label": "invoiceNumber", "path": "NrFaZaliczkowej", "optional": true } + ] + }, + { + "from": "Fa.ZaliczkaCzesciowa", + "heading": "advancePayments", + "fields": [ + { + "label": "advancePaymentAmount", + "path": "P_15Z", + "format": "money", + "suffixPath": "/Fa.KodWaluty" + }, + { "label": "advancePaymentDate", "path": "P_6Z", "format": "date" } + ] + }, { "from": "Fa.Platnosc.ZaplataCzesciowa", "heading": "partialPayments", diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index f326a205..92b6a9fa 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -232,6 +232,27 @@ export interface TotalsRow { optional?: boolean; /** Binding paths to add up; absent buckets are skipped. */ sum?: string[]; + /** + * Subtract from this row's value the sum of one binding taken over every + * entry of a collection. + * + * It exists for a figure the FA schemas define as a difference instead of + * stating it: on a settlement invoice that also documents payments received + * before delivery, the schema says the difference between `P_15` and the sum + * of the individual `P_15Z` fields is what remains to be paid. No field + * carries that number, so a page that will not compute it cannot show it. + * + * Like every computed figure here, it is only as sound as the document — see + * the warning on the totals summary. The row prints blank rather than a wrong + * number when anything it reads is unparseable. + */ + less?: RepeatedSum; + /** + * Take this row's value as the sum of one binding over every entry of a + * collection — what an invoice has been paid so far, say, which `sum` cannot + * express because the entries are not known to the template. + */ + sumFrom?: RepeatedSum; /** * Visibility condition, evaluated like any other `when`. The built-in * templates gate their per-bucket rows on `totalsBuckets` and their computed @@ -276,6 +297,16 @@ export interface PaymentGroup { * advance one — and the template picks the right label by listing one row per * reading. */ +/** + * One binding read over every entry of a collection, to be summed. Used by + * `less`, where a figure is defined as a difference the document does not + * state — see {@link TotalsRow.less}. + */ +export interface RepeatedSum { + from: string; + path: string; +} + export interface PaymentRow extends Omit { /** * A row with no `path` prints its label alone, and is worth having because @@ -285,6 +316,10 @@ export interface PaymentRow extends Omit { */ path?: string; when?: string; + /** See {@link TotalsRow.less}. */ + less?: RepeatedSum; + /** See {@link TotalsRow.sumFrom}. */ + sumFrom?: RepeatedSum; /** * Repeat this line once per entry of a collection, with the entry as the * binding root (so `path` and `suffixPath` are item-relative). KSeF allows up @@ -535,19 +570,22 @@ const columnDef = z }) .strict(); +const repeatedSum = z.object({ from: z.string(), path: z.string() }).strict(); const totalsRow = z .object({ label: z.string(), path: z.string().optional(), optional: z.boolean().optional(), sum: z.array(z.string()).nonempty().optional(), + less: repeatedSum.optional(), + sumFrom: repeatedSum.optional(), when: z.string().optional(), format: formatEnum.optional(), style: z.string().optional(), }) .strict() - .refine((r) => (r.path === undefined) !== (r.sum === undefined), { - message: 'a totals row needs exactly one of "path" or "sum"', + .refine((r) => [r.path, r.sum, r.sumFrom].filter((v) => v !== undefined).length === 1, { + message: 'a totals row needs exactly one of "path", "sum" or "sumFrom"', }); // Recursive block schema (containers embed blocks). z.lazy breaks the cycle. @@ -587,6 +625,8 @@ const blockSchema: z.ZodType = z.lazy(() => path: z.string().optional(), when: z.string().optional(), from: z.string().optional(), + less: repeatedSum.optional(), + sumFrom: repeatedSum.optional(), }), ), groups: z diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index cefb5100..67de87c8 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -35,6 +35,16 @@ const fx = (name: string) => join(fixtures, name); /** A KSeF number shaped like the real thing; this one identifies nobody. */ const KSEF_NUMBER = '1111111111-20260115-010000000000-00'; +/** + * The chain pages carry their own numbers: 07 names 06's in + * `FakturaZaliczkowa`, and 09 names 08's, so the link between two pages of one + * deal is visible on paper rather than asserted only in a fixture comment. + */ +const KSEF_ZAL_A = '1111111111-20250115-010000000000-A1'; +const KSEF_ROZ_A = '1111111111-20250210-010000000000-A2'; +const KSEF_ZAL_B = '1111111111-20250312-020000000000-B2'; +const KSEF_ROZ_B = '1111111111-20250408-020000000000-B3'; + /** * The QR group renders against DEMO. The documents are invented, so no verifier * will resolve them anywhere — but a demo link is the one a reader can safely @@ -194,43 +204,79 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', '--notes', notesFile, '--template-file', oldTotalsTemplate, ]], - // Not part of the grid either, and for the opposite reason to the showcase: - // this is the same default template on a different *document shape*. An - // advance invoice (`ZAL`) carries no `Fa.FaWiersz` — the goods it covers sit - // under `Fa.Zamowienie` — so the page a reader should see here is the order - // table under its own heading, with no empty item table above it. Flags are - // kept to a minimum precisely so nothing else on the page competes for the - // eye. - [`${PREFIX}-06-invoice-advance-order-lines`, () => [ - fx('fa3-zal.xml'), '--ksef-number', KSEF_NUMBER, + // Beyond the grid the pages come in *chains*, numbered so one deal runs from + // page to page. Each chain is two documents and no more: an advance invoice + // and the settlement that closes it, for a different buyer and a different + // amount each time, with dates that only ever move forward. + // + // 06 → 07 Nabywca Przykładowy S.A. order 615,00 remainder STATED + // 08 → 09 Odbiorca Handlowy Sp. z o.o. order 1 230,00 remainder COMPUTED + // + // The two chains exist because the FA schemas let a settlement invoice + // state what is left in either of two ways, and the pages have to be right + // for both. 10 and 11 then stand alone: an ordinary invoice being paid down, + // and one that has been overpaid. + + // 06 — chain A, the advance invoice (ZAL). No `Fa.FaWiersz`: the goods sit + // under `Fa.Zamowienie`, so the page shows the order table under its own + // heading with no empty item table above it. `P_15` is 450,00 — money + // *received*, not owed — and the two payments that make it up add to it + // exactly. Its KSeF number is the one page 07 points back at. + [`${PREFIX}-06-chain-a-advance`, () => [ + fx('fa3-zal.xml'), '--ksef-number', KSEF_ZAL_A, '--env', 'demo', '--qr', '--totals', 'buckets', ]], - // Not part of the grid: `fa3-showcase` is a built-in whose point is to - // exercise the DSL — palette, letter spacing, highlighted text, colour bars - // drawn as data-URI images. Rendered with everything switched on, so a DSL - // change that breaks it is visible rather than discovered by a reader. - // It also carries the accent, in its short hex form: this template sets its - // own heading colours, so it is the page that shows whether an accent wins - // over a template's palette. - [`${PREFIX}-07-showcase-template-accent`, () => [ - fx('e2e-vat-multi.xml'), '--template', 'fa3-showcase', ...LOGO(), - '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', - '--totals', 'summary', '--notes', notesFile, '--accent', ACCENT_SHORT, + // 07 — chain A, the settlement (ROZ) that closes 06. The lines and VAT + // buckets show the whole 615,00 order while `P_15` is only the 165,00 still + // owed: read as a flat "amount due", those figures contradict each other. + [`${PREFIX}-07-chain-a-settlement-stated`, () => [ + fx('fa3-roz.xml'), '--ksef-number', KSEF_ROZ_A, + '--env', 'demo', '--qr', '--totals', 'buckets', + ]], + // 08 — chain B, a different buyer and a different deal: order 1 230,00, + // advance 800,00 received in March. + [`${PREFIX}-08-chain-b-advance`, () => [ + fx('fa3-zal-b.xml'), '--ksef-number', KSEF_ZAL_B, + '--env', 'demo', '--qr', '--totals', 'buckets', + ]], + // 09 — chain B's settlement, which states the payments it received instead + // of leaving them on 08. So `P_15` is the whole 1 230,00 and what is owed is + // the difference the schema defines: `P_15` less the sum of the `P_15Z` + // fields, 430,00. No field carries that number. + [`${PREFIX}-09-chain-b-settlement-computed`, () => [ + fx('fa3-roz-b.xml'), '--ksef-number', KSEF_ROZ_B, + '--env', 'demo', '--qr', '--totals', 'buckets', ]], - // Not part of the grid: another document shape rather than another set of - // flags. `Platnosc` states what has been paid through a choice, and this - // document takes the branch the other pages never reach — no `Zaplacono`, - // a partial marker, and a `ZaplataCzesciowa` per instalment. What a reader - // should see is the payment section saying the invoice is part-paid, then - // each part payment's amount, date and form kept together above the bank - // accounts. - [`${PREFIX}-10-invoice-partial-payments`, () => [ + // 10 — standalone: an ordinary invoice being paid down, which is a + // different thing from an advance and reads differently. `Platnosc` takes + // the branch no other page reaches (no `Zaplacono`, a partial marker, one + // `ZaplataCzesciowa` per instalment), and the parts deliberately do *not* + // add up to the total — that is what "paid in part" means. + [`${PREFIX}-10-partial-payments`, () => [ fx('fa3-czesciowa.xml'), '--ksef-number', KSEF_NUMBER, '--env', 'demo', '--qr', '--totals', 'buckets', ]], + // 11 — standalone, the opposite end of the same branch: `Rozliczenie` + // states a `DoRozliczenia` overpayment rather than a `DoZaplaty`. Nothing + // is owed, so nothing on the page may ask for payment. + [`${PREFIX}-11-overpayment`, () => [ + fx('fa3-nadplata.xml'), '--ksef-number', KSEF_NUMBER, + '--env', 'demo', '--qr', '--totals', 'buckets', + ]], + // 12 — not a document shape but a template: `fa3-showcase` exists to + // exercise the DSL (palette, letter spacing, highlighted text, colour bars + // drawn as data-URI images), rendered with everything switched on so a DSL + // change that breaks it is visible rather than discovered by a reader. It + // carries the accent in its short hex form, and so is the page that shows + // whether an accent wins over a template's own palette. + [`${PREFIX}-12-showcase-template-accent`, () => [ + fx('e2e-vat-multi.xml'), '--template', 'fa3-showcase', ...LOGO(), + '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', + '--totals', 'summary', '--notes', notesFile, '--accent', ACCENT_SHORT, + ]], // Receipts last: they are a different document and read as their own group. - [`${PREFIX}-08-upo-pl`, () => [fx('upo-4_3.xml')]], - [`${PREFIX}-09-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], + [`${PREFIX}-13-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-14-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], ]; /** @@ -247,7 +293,8 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // An advance invoice reaches a branch of the template no other document // does, so it is pinned here rather than left to be dropped by accident. // So does an invoice settled in instalments. - 'fa3-zal.xml', 'fa3-czesciowa.xml', 'upo-4_3.xml', + 'fa3-zal.xml', 'fa3-roz.xml', 'fa3-zal-b.xml', 'fa3-roz-b.xml', + 'fa3-czesciowa.xml', 'fa3-nadplata.xml', 'upo-4_3.xml', ]) { expect(covered(doc), `no variant renders ${doc}`).toBe(true); } @@ -291,7 +338,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { it('renders every variant of the set', () => { // Guards against a variant being silently dropped from the table above: // the count is stated here so removing a row has to be deliberate. - expect(variants).toHaveLength(10); + expect(variants).toHaveLength(14); for (const [name] of variants) { expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); } diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-nadplata.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-nadplata.xml new file mode 100644 index 00000000..8cc55924 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-nadplata.xml @@ -0,0 +1,101 @@ + + + + + FA + 2 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + + 700.00 + Wpłata ponad należność + + 700.00 + 85.00 + + + 1 + + 2025-02-01 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz-b.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz-b.xml new file mode 100644 index 00000000..a8114f75 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz-b.xml @@ -0,0 +1,104 @@ + + + + + FA + 2 + 2025-04-08T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 5555555555 + Odbiorca Handlowy Sp. z o.o. + + + PL + ul. Morska 17 + 81-001 Gdynia + + + zakupy@odbiorca.example + +48000000005 + KL-0042 + + + + PLN + 2025-04-08 + Warszawa + ROZ/2025/04/007 + 2025-04-02 + 1000.00 + 230.00 + 1230.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ROZ + + 2025-03-04 + 500.00 + + + 2025-03-11 + 300.00 + + + 1111111111-20250312-020000000000-B2 + + + 1 + Dostawa towaru + szt. + 10 + 100.00 + 1000.00 + 23 + + + + 2025-04-22 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz.xml new file mode 100644 index 00000000..2a36d7ba --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz.xml @@ -0,0 +1,96 @@ + + + + + FA + 2 + 2025-02-10T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-02-10 + Warszawa + ROZ/2025/02/001 + 2025-02-05 + 500.00 + 115.00 + 165.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ROZ + + 1111111111-20250115-010000000000-A1 + + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + + 2025-02-24 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal-b.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal-b.xml new file mode 100644 index 00000000..b1f55efb --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal-b.xml @@ -0,0 +1,105 @@ + + + + + FA + 2 + 2025-03-12T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 5555555555 + Odbiorca Handlowy Sp. z o.o. + + + PL + ul. Morska 17 + 81-001 Gdynia + + + zakupy@odbiorca.example + +48000000005 + KL-0042 + + + + PLN + 2025-03-12 + Warszawa + ZAL/2025/03/007 + 2025-03-12 + 650.41 + 149.59 + 800.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ZAL + + 2025-03-04 + 500.00 + + + 2025-03-11 + 300.00 + + + 1 + 2025-03-11 + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + 1230.00 + + 1 + Dostawa towaru — zaliczka + IDX-0042 + szt. + 10 + 100.00 + 1000.00 + 230.00 + 23 + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal.xml index e5b58b33..5f896ff6 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal.xml @@ -1,5 +1,8 @@ - + @@ -44,11 +47,11 @@ PLN 2025-01-15 Warszawa - FA/2025/01/001 + ZAL/2025/01/001 2025-01-15 - 500.00 - 115.00 - 615.00 + 365.85 + 84.15 + 450.00 2 2 @@ -66,11 +69,17 @@ ZAL + + 2025-01-10 + 300.00 + + + 2025-01-14 + 150.00 + 1 - - 2025-02-01 - + 2025-01-14 6 11109000880000000100000001 diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-nadplata.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-nadplata.xml new file mode 100644 index 00000000..4c365cd3 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-nadplata.xml @@ -0,0 +1,101 @@ + + + + + FA + 3 + 2025-01-15T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-01-15 + Warszawa + FA/2025/01/001 + 2025-01-15 + 500.00 + 115.00 + 615.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + + 700.00 + Wpłata ponad należność + + 700.00 + 85.00 + + + 1 + + 2025-02-01 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz-b.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz-b.xml new file mode 100644 index 00000000..2078222a --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz-b.xml @@ -0,0 +1,104 @@ + + + + + FA + 3 + 2025-04-08T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 5555555555 + Odbiorca Handlowy Sp. z o.o. + + + PL + ul. Morska 17 + 81-001 Gdynia + + + zakupy@odbiorca.example + +48000000005 + KL-0042 + + + + PLN + 2025-04-08 + Warszawa + ROZ/2025/04/007 + 2025-04-02 + 1000.00 + 230.00 + 1230.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ROZ + + 2025-03-04 + 500.00 + + + 2025-03-11 + 300.00 + + + 1111111111-20250312-020000000000-B2 + + + 1 + Dostawa towaru + szt. + 10 + 100.00 + 1000.00 + 23 + + + + 2025-04-22 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz.xml new file mode 100644 index 00000000..2ba8601c --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz.xml @@ -0,0 +1,96 @@ + + + + + FA + 3 + 2025-02-10T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 2222222222 + Nabywca Przykładowy S.A. + + + PL + ul. Testowa 2 + 00-002 Kraków + + + kontakt@nabywca.example + +48000000002 + KL-0001 + + + + PLN + 2025-02-10 + Warszawa + ROZ/2025/02/001 + 2025-02-05 + 500.00 + 115.00 + 165.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ROZ + + 1111111111-20250115-010000000000-A1 + + + 1 + Usługa przykładowa + szt. + 5 + 100.00 + 500.00 + 23 + + + + 2025-02-24 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal-b.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal-b.xml new file mode 100644 index 00000000..d458c4ed --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal-b.xml @@ -0,0 +1,105 @@ + + + + + FA + 3 + 2025-03-12T10:00:00Z + anonymous test fixture + + + + 1111111111 + Sprzedawca Przykładowy Sp. z o.o. + + + PL + ul. Przykładowa 1 + 00-001 Warszawa + + + kontakt@sprzedawca.example + +48000000001 + + + + + 5555555555 + Odbiorca Handlowy Sp. z o.o. + + + PL + ul. Morska 17 + 81-001 Gdynia + + + zakupy@odbiorca.example + +48000000005 + KL-0042 + + + + PLN + 2025-03-12 + Warszawa + ZAL/2025/03/007 + 2025-03-12 + 650.41 + 149.59 + 800.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ZAL + + 2025-03-04 + 500.00 + + + 2025-03-11 + 300.00 + + + 1 + 2025-03-11 + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + 1230.00 + + 1 + Dostawa towaru — zaliczka + IDX-0042 + szt. + 10 + 100.00 + 1000.00 + 230.00 + 23 + + + + diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal.xml index 96dfbc5d..f512699d 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal.xml @@ -1,5 +1,8 @@ - + @@ -44,11 +47,11 @@ PLN 2025-01-15 Warszawa - FA/2025/01/001 + ZAL/2025/01/001 2025-01-15 - 500.00 - 115.00 - 615.00 + 365.85 + 84.15 + 450.00 2 2 @@ -66,11 +69,17 @@ ZAL + + 2025-01-10 + 300.00 + + + 2025-01-14 + 150.00 + 1 - - 2025-02-01 - + 2025-01-14 6 11109000880000000100000001 diff --git a/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts index fbb96d40..af8084de 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; -import { p15Flags } from '../../../src/pdf/document-flags.js'; +import { p15Flags, paymentFlags } from '../../../src/pdf/document-flags.js'; import { parseXmlForPdf } from '../../../src/pdf/parse.js'; import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; import { interpretTemplate, type RenderContext } from '../../../src/pdf/template/interpret.js'; @@ -44,7 +44,7 @@ function render(templateName: string, xml: string): string[] { strict: false, label: makeLabelResolver('pl', {}), bindings: { 'opts.logo': '', 'opts.ksefNumber': '', 'opts.accent': '', qrUrl: '', certificateQrUrl: '' }, - flags: { ...p15Flags(root), totalsBuckets: true }, + flags: { ...p15Flags(root), ...paymentFlags(root), totalsBuckets: true }, }; return texts(interpretTemplate(template, ctx, blockRegistry)); } @@ -55,6 +55,8 @@ describe('which reading of P_15 a document supports', () => { p15IsAmountDue: true, p15IsAdvancePaid: false, p15IsAmountTotal: false, + p15IsRemainder: false, + settlementRemainder: false, }); }); @@ -63,6 +65,8 @@ describe('which reading of P_15 a document supports', () => { p15IsAmountDue: false, p15IsAdvancePaid: true, p15IsAmountTotal: false, + p15IsRemainder: false, + settlementRemainder: false, }); }); @@ -71,6 +75,53 @@ describe('which reading of P_15 a document supports', () => { p15IsAmountDue: false, p15IsAdvancePaid: false, p15IsAmountTotal: true, + p15IsRemainder: false, + settlementRemainder: false, + }); + }); + + it('a settlement invoice: P_15 is what is left after the advances', () => { + // Its lines state the whole order (500,00 + 115,00) while P_15 states 165,00 + // — the case where a flat `Do zapłaty` reads as a contradiction. + expect(p15Flags((parseXmlForPdf(fx('fa3-roz.xml')) as Record).Faktura)).toEqual({ + p15IsAmountDue: false, + p15IsAdvancePaid: false, + p15IsAmountTotal: false, + p15IsRemainder: true, + settlementRemainder: false, + }); + }); + + it('a part-paid invoice: P_15 is the total, the remainder is what is owed', () => { + expect(p15Flags((parseXmlForPdf(fx('fa3-czesciowa.xml')) as Record).Faktura)).toEqual({ + p15IsAmountDue: false, + p15IsAdvancePaid: false, + p15IsAmountTotal: true, + p15IsRemainder: false, + settlementRemainder: false, + }); + }); + + it('an overpaid invoice asks for nothing', () => { + expect(p15Flags((parseXmlForPdf(fx('fa3-nadplata.xml')) as Record).Faktura)).toEqual({ + p15IsAmountDue: false, + p15IsAdvancePaid: false, + p15IsAmountTotal: true, + p15IsRemainder: false, + settlementRemainder: false, + }); + }); + + it('a settlement invoice that also states the payments it received', () => { + // Chain B's settlement restates the payments it received, so its P_15 is + // the whole 1 230,00 and the remainder is the difference the schema + // defines — P_15 must not be labelled as what is left. + expect(p15Flags((parseXmlForPdf(fx('fa3-roz-b.xml')) as Record).Faktura)).toEqual({ + p15IsAmountDue: false, + p15IsAdvancePaid: false, + p15IsAmountTotal: true, + p15IsRemainder: false, + settlementRemainder: true, }); }); @@ -100,6 +151,46 @@ describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s names the figu expect(out.some((t) => /Do zap[łl]aty/i.test(t))).toBe(false); }); + it('calls the remainder a remainder on a settlement invoice', () => { + const out = render(name, fx(`${fa}-roz.xml`)); + expect(out).toContain('165,00'); + expect(out.some((t) => t.includes('Pozostało do zapłaty'))).toBe(true); + // The lines above it still state the full order, so naming this one + // plainly `Do zapłaty` is what made the page look self-contradictory. + expect(out).toContain('500,00'); + expect(out.some((t) => /^Do zap[łl]aty$/i.test(t))).toBe(false); + }); + + it('computes the remainder the schema defines as a difference', () => { + // Chain B: P_15 is the whole 1 230,00 and the payments received are + // stated, so what is left — 430,00 — exists only as `P_15` minus the sum of + // the `P_15Z` fields. No field carries it. + const out = render(name, fx(`${fa}-roz-b.xml`)); + // `money` groups thousands with a non-breaking space. + expect(out.some((t) => t.includes('1\u00a0230,00'))).toBe(true); + expect(out.some((t) => t.includes('Pozostało do zapłaty: 430,00 PLN'))).toBe(true); + expect(out.some((t) => t.includes('Kwota należności ogółem'))).toBe(true); + }); + + it('states what a part-paid invoice has paid and what is left', () => { + const out = render(name, fx(`${fa}-czesciowa.xml`)); + expect(out.some((t) => t.includes('Zapłacono razem: 450,00 PLN'))).toBe(true); + expect(out.some((t) => t.includes('Pozostało do zapłaty: 165,00 PLN'))).toBe(true); + }); + + it('names an overpayment instead of demanding money', () => { + const out = render(name, fx(`${fa}-nadplata.xml`)); + expect(out.some((t) => t.includes('Nadpłata do rozliczenia: 85,00 PLN'))).toBe(true); + // Nothing is owed, so nothing on the page asks for payment. + expect(out.some((t) => /^Do zap[łl]aty/i.test(t))).toBe(false); + }); + + it('names the advance invoice it settles', () => { + const out = render(name, fx(`${fa}-roz.xml`)); + expect(out.some((t) => t.includes('Faktury zaliczkowe'))).toBe(true); + expect(out.some((t) => t.includes('1111111111-20250115-010000000000-A1'))).toBe(true); + }); + it('prints the settled payable, not P_15, when the document states one', () => { const out = render(name, fx(`${fa}-rozliczenie.xml`)); const due = out.findIndex((t) => /Do zap[łl]aty/i.test(t)); diff --git a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts index 0b7fc8e0..52fd8029 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -1152,18 +1152,14 @@ describe('suffixPath', () => { describe('the built-in templates restate the amount due with its currency', () => { it.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s does it in the payment block', (name) => { const payment = getBuiltinTemplate(name)!.blocks.find((b) => b.type === 'payment') as { - rows: Array<{ label: string; path: string; suffixPath?: string }>; + rows: Array<{ label: string; path?: string; format?: string; suffixPath?: string }>; }; - // One row per reading of `P_15`, then the settled payable — the figures - // restate the total, so they belong after the terms they settle. - const amounts = payment.rows.slice(-4); - expect(amounts.map((r) => r.path)).toEqual([ - 'Fa.P_15', - 'Fa.P_15', - 'Fa.P_15', - 'Fa.Rozliczenie.DoZaplaty', - ]); - expect(amounts.map((r) => r.suffixPath)).toEqual(Array(4).fill('Fa.KodWaluty')); + // Every money figure the block prints names its currency — an amount and + // its currency are one fact — and they all sit after the terms they settle. + const money = payment.rows.filter((r) => r.format === 'money'); + expect(money.length).toBeGreaterThan(1); + expect(money.every((r) => r.suffixPath === 'Fa.KodWaluty')).toBe(true); + expect(payment.rows.slice(-money.length)).toEqual(money); }); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index 93b0bfdf..ca8dd40c 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -29,9 +29,18 @@ import type { Block, PartyField, TotalsBlock } from '../../../src/pdf/template/d * resolves against none. */ const FIXTURES_BY_TEMPLATE: Record = { - 'fa2-default': ['pdf/fa2.xml', 'pdf/fa2-zal.xml', 'pdf/fa2-rozliczenie.xml', 'pdf/fa2-czesciowa.xml'], - 'fa3-default': ['pdf/fa3.xml', 'pdf/fa3-zal.xml', 'pdf/fa3-rozliczenie.xml', 'pdf/fa3-czesciowa.xml'], - 'fa3-showcase': ['pdf/fa3.xml', 'pdf/fa3-rozliczenie.xml', 'pdf/fa3-czesciowa.xml'], + 'fa2-default': [ + 'pdf/fa2.xml', 'pdf/fa2-zal.xml', 'pdf/fa2-rozliczenie.xml', 'pdf/fa2-czesciowa.xml', + 'pdf/fa2-roz.xml', 'pdf/fa2-zal-b.xml', 'pdf/fa2-roz-b.xml', 'pdf/fa2-nadplata.xml', + ], + 'fa3-default': [ + 'pdf/fa3.xml', 'pdf/fa3-zal.xml', 'pdf/fa3-rozliczenie.xml', 'pdf/fa3-czesciowa.xml', + 'pdf/fa3-roz.xml', 'pdf/fa3-zal-b.xml', 'pdf/fa3-roz-b.xml', 'pdf/fa3-nadplata.xml', + ], + 'fa3-showcase': [ + 'pdf/fa3.xml', 'pdf/fa3-rozliczenie.xml', 'pdf/fa3-czesciowa.xml', 'pdf/fa3-zal.xml', + 'pdf/fa3-roz.xml', 'pdf/fa3-zal-b.xml', 'pdf/fa3-roz-b.xml', 'pdf/fa3-nadplata.xml', + ], 'upo-4_2': ['pdf/upo-4_2.xml'], 'upo-4_3': ['pdf/upo-4_3.xml'], }; @@ -41,7 +50,9 @@ const CONTEXT_CONDITIONS = new Set([ 'qr', 'offline', 'hasKsefNumber', 'totalsBuckets', 'totalsSummary', 'notes', 'opts.logo', 'opts.ksefNumber', 'opts.accent', 'qrUrl', // Which of `P_15`'s three readings this document supports. - 'p15IsAmountDue', 'p15IsAdvancePaid', 'p15IsAmountTotal', + 'p15IsAmountDue', 'p15IsAdvancePaid', 'p15IsAmountTotal', 'p15IsRemainder', + // Whether the remainder is a figure the schema defines as a difference. + 'settlementRemainder', // How much of the invoice `Platnosc` says has been paid. 'paidInFull', 'paidInPart', ]); @@ -64,6 +75,12 @@ function collect( if (block.type === 'totals' || block.type === 'payment') { for (const row of block.rows) { if (row.when !== undefined && !CONTEXT_CONDITIONS.has(row.when)) acc.conditions.push(row.when); + // A computed row reads a collection too, and a typo in that path + // silently makes the figure wrong rather than absent — worse than a + // blank line, so it is linted like any other repeater. + for (const computed of [row.less, row.sumFrom]) { + if (computed) acc.repeaters.push(computed.from); + } } } if (block.type === 'lines') acc.repeaters.push(block.from); @@ -169,6 +186,8 @@ describe('built-in template lint', () => { expect(fa3.repeaters).toContain('Fa.Platnosc.RachunekBankowy'); expect(fa3.repeaters).toContain('Fa.Platnosc.TerminPlatnosci'); expect(fa3.repeaters).toContain('Fa.Platnosc.ZaplataCzesciowa'); + expect(fa3.repeaters).toContain('Fa.ZaliczkaCzesciowa'); + expect(fa3.repeaters).toContain('Fa.FakturaZaliczkowa'); expect(fa3.repeaters).toContain('Podmiot2.DaneKontaktowe'); expect(fa3.conditions).toContain('Fa.Rozliczenie.DoZaplaty'); expect(collect(getBuiltinTemplate('upo-4_3')!.blocks).repeaters).toContain('Dokument'); diff --git a/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts b/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts index ef8c0730..4a85ff5d 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts @@ -69,6 +69,46 @@ describe('how much of the invoice has been paid', () => { }); }); +/** + * FA models two different things that both look like "part payments", and only + * one of them adds up: + * + * - `Fa.ZaliczkaCzesciowa` — the payments an advance invoice documents having + * received, each `P_15Z` "składająca się na kwotę w polu P_15". These sum to + * `P_15` exactly. + * - `Fa.Platnosc.ZaplataCzesciowa` — settlements against the receivable. These + * sum to less than the total for as long as the invoice is only part-paid; + * that is what `ZnacznikZaplatyCzesciowej = 1` means. + * + * Reading a page of the second as though it were the first is what makes the + * figures look broken, so both are rendered and each is named for what it is. + */ +describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s payments that make up P_15', (name) => { + const fa = name.startsWith('fa2') ? 'fa2' : 'fa3'; + + it('prints each received payment, and they add up to P_15', () => { + const xml = fx(`${fa}-zal.xml`); + const amounts = [...xml.matchAll(/([\d.]+)<\/P_15Z>/g)].map((m) => Number(m[1])); + const total = Number(/([\d.]+)<\/P_15>/.exec(xml)![1]); + expect(amounts.reduce((a, b) => a + b, 0)).toBe(total); + + const lines = paymentLines(name, xml); + expect(lines).toEqual( + expect.arrayContaining([ + 'advancePayments', + 'advancePaymentAmount: 300,00 PLN', + 'advancePaymentDate: 10.01.2025', + 'advancePaymentAmount: 150,00 PLN', + 'advancePaymentDate: 14.01.2025', + ]), + ); + }); + + it('prints no such section for an invoice that documents a single payment', () => { + expect(paymentLines(name, fx(`${fa}.xml`))).not.toContain('advancePayments'); + }); +}); + describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s partial payments', (name) => { const fa = name.startsWith('fa2') ? 'fa2' : 'fa3'; From e87aa55fb904e24031e9d62973de2c3f338eacf2 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 17:34:31 +0200 Subject: [PATCH 59/67] fix(pdf): tax a settlement invoice on the remainder, not the whole order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settlement fixtures carried the full order in `P_13_1`/`P_14_1` while `P_15` held only the remainder, so the page said 500,00 net plus 115,00 VAT and then asked for 165,00 — figures that cannot all be true of one document. Worse, the advance invoice had already declared the tax on its own share: taxing the whole order again on the settlement would put 199,15 of VAT on a deal that carries 115,00. The tax summary now covers what is left, which is the share the advance did not: 134,15 net and 30,85 VAT against a stated `P_15` of 165,00. The line items still state the whole order, as the schema requires. Chain B is rebuilt the same way, and it no longer restates payments its own advance invoice had already invoiced — it documents one further payment of its own, so `P_15` covers that payment plus the rest and the remainder is the difference the schema defines. Each document now reconciles internally, and the tax declared across a chain adds up to the tax on the deal: 84,15 + 30,85 = 115,00, and 149,59 + 80,41 = 230,00. Alongside it, two things the pages were leaving to the reader to work out: a document now heads itself `Faktura zaliczkowa` or `Faktura rozliczająca` when it is one — a correction of an advance invoice is not one, and stays plain — and the order total says `brutto`, so it is not read as a net figure beside the net buckets under it. Verified on the rendered pages as well as by test: every money column now sums to the figure below it. Full suite green: 2873 unit + 158 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 3 ++ .../ksef-client-ts/src/pdf/document-flags.ts | 21 ++++++++ packages/ksef-client-ts/src/pdf/i18n/en.ts | 4 +- packages/ksef-client-ts/src/pdf/i18n/pl.ts | 4 +- packages/ksef-client-ts/src/pdf/i18n/uk.ts | 4 +- packages/ksef-client-ts/src/pdf/index.ts | 5 +- .../src/pdf/template/blocks/header.ts | 11 +++- .../src/pdf/template/builtin/fa2-default.json | 1 - .../src/pdf/template/builtin/fa3-default.json | 1 - .../pdf/template/builtin/fa3-showcase.json | 1 - .../tests/fixtures/pdf/fa2-roz-b.xml | 22 ++++---- .../tests/fixtures/pdf/fa2-roz.xml | 11 ++-- .../tests/fixtures/pdf/fa3-roz-b.xml | 22 ++++---- .../tests/fixtures/pdf/fa3-roz.xml | 11 ++-- .../tests/unit/pdf/amount-due-label.test.ts | 50 ++++++++++++++----- .../tests/unit/pdf/partial-payments.test.ts | 4 +- 16 files changed, 118 insertions(+), 57 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index d858cc20..71fc2948 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -239,6 +239,8 @@ The two are easy to confuse and read very differently on the page, so the built- Exactly one of the `p15IsAmountDue`, `p15IsAdvancePaid`, `p15IsRemainder` and `p15IsAmountTotal` context flags is true for a given document. +A settlement invoice is where this matters most on the page. Its **line items state the whole order**, but its **tax summary and `P_15` cover only what is left**: the advance invoice already declared the tax on its own share, and declaring the full amount again would tax the deal twice. So an FA(3) settlement of a 615,00 order against a 450,00 advance carries line items worth 500,00 net while `P_13_1` is 134,15, `P_14_1` is 30,85 and `P_15` is 165,00 — figures that look inconsistent until you know which base each one uses. + #### The two shapes of a settlement invoice An issuer may state what is left in either of two ways, and both have to render correctly: @@ -264,6 +266,7 @@ An advance invoice (`RodzajFaktury` `ZAL` or `KOR_ZAL`) records the goods and se - **`less`** and **`sumFrom`** (totals and payment rows) compute a figure the document does not state: `sumFrom` takes the sum of one binding over every entry of a collection, and `less` subtracts such a sum from the row's own value. `sum` cannot do this — it adds a fixed list of paths, and the entries of a repeater are not known to the template. Both print blank rather than a wrong number when anything they read is unparseable. Like every computed figure here, they are only as sound as the document. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. +- **The document titles itself.** A `header` block with no `title` heads the page by what the document *is* — `Faktura zaliczkowa` for an advance invoice (`ZAL`), `Faktura rozliczająca` for a settlement (`ROZ`), plain `Faktura` otherwise, corrections included: `KOR_ZAL` corrects an advance invoice, it is not one. A template that names its own `title` keeps it. The built-ins name none. - **`headingStyle`** (`parties`, `payment`, `annotations`, `notes`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. - **`style`** (party panels) names a style for a panel's value lines. A labelled group inherits it unless it declares its own, so the built-in templates set `partyIdentity` on the panel — the counterparty's name and tax number — and let the address and contact groups drop to the smaller `partyDetails`. Headings keep the panel's heading style either way. - **`firstOf`** (party fields only) prints the first of several paths that resolves. KSeF identifies a counterparty by exactly one of `NIP`, `NrVatUE` or `NrID` depending on where they are established, so the built-in templates bind the buyer's identifier this way; a panel bound to `NIP` alone has nothing to print for a foreign buyer. An alternative may be written as `{ "path": …, "prefixPath": … }` to keep the qualifier the schema pairs it with — `KodUE` before `NrVatUE`, `KodKraju` before `NrID` — so `DE 123456789` prints as one identifier. The prefix is read leniently and dropped when the document omits it. diff --git a/packages/ksef-client-ts/src/pdf/document-flags.ts b/packages/ksef-client-ts/src/pdf/document-flags.ts index c7009874..a06ccb67 100644 --- a/packages/ksef-client-ts/src/pdf/document-flags.ts +++ b/packages/ksef-client-ts/src/pdf/document-flags.ts @@ -95,3 +95,24 @@ export function paymentFlags(root: unknown): Record { paidInPart: mark === '1', }; } + +/** + * What kind of document this is, for the parts of the page that name it rather + * than compute from it — the title above all. `ZAL` and `ROZ` are the two an + * ordinary reader must not confuse: one records money taken before the sale, + * the other closes the sale against it, and both would otherwise be headed + * simply "Faktura". A correction of either keeps the plain heading: `KOR_ZAL` + * is not an advance invoice, it is a correction of one. + */ +export function kindFlags(root: unknown): Record { + const kind = get(root, 'Fa.RodzajFaktury'); + return { + isAdvanceInvoice: kind === 'ZAL', + isSettlementInvoice: kind === 'ROZ', + }; +} + +/** Every document-derived flag a template may gate on. */ +export function documentFlags(root: unknown): Record { + return { ...p15Flags(root), ...paymentFlags(root), ...kindFlags(root) }; +} diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 9e78b355..3dd4c42c 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -3,6 +3,8 @@ import type { LabelBundle } from './types.js'; /** English label bundle (mirrors the Polish key set). */ export const en: LabelBundle = { invoice: 'Invoice', + invoiceAdvance: 'Advance invoice', + invoiceSettlement: 'Settlement invoice', duplicate: 'Duplicate', seller: 'Seller', buyer: 'Buyer', @@ -28,7 +30,7 @@ export const en: LabelBundle = { gross: 'Gross amount', // advance-invoice order lines (Fa.Zamowienie) orderLines: 'Order or contract items', - orderValue: 'Order value', + orderValue: 'Order value, gross', // per-rate buckets (P_13_* / P_14_*) net23: 'Net 23%', vat23: 'VAT 23%', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index aaf065b8..cfb646f3 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -3,6 +3,8 @@ import type { LabelBundle } from './types.js'; /** Polish label bundle (canonical key set). */ export const pl: LabelBundle = { invoice: 'Faktura', + invoiceAdvance: 'Faktura zaliczkowa', + invoiceSettlement: 'Faktura rozliczająca', duplicate: 'Duplikat', seller: 'Sprzedawca', buyer: 'Nabywca', @@ -29,7 +31,7 @@ export const pl: LabelBundle = { gross: 'Wartość brutto', // advance-invoice order lines (Fa.Zamowienie) orderLines: 'Pozycje zamówienia lub umowy', - orderValue: 'Wartość zamówienia', + orderValue: 'Wartość zamówienia brutto', // totals // per-rate buckets (P_13_* / P_14_*) net23: 'Netto 23%', diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index 7c081162..3a92285d 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -11,6 +11,8 @@ import type { LabelBundle } from './types.js'; */ export const uk: LabelBundle = { invoice: 'Фактура', + invoiceAdvance: 'Авансова фактура', + invoiceSettlement: 'Розрахункова фактура', duplicate: 'Дублікат', seller: 'Продавець', buyer: 'Покупець', @@ -36,7 +38,7 @@ export const uk: LabelBundle = { gross: 'Сума брутто', // advance-invoice order lines (Fa.Zamowienie) orderLines: 'Позиції замовлення або договору', - orderValue: 'Вартість замовлення', + orderValue: 'Вартість замовлення, брутто', // per-rate buckets (P_13_* / P_14_*) net23: 'Нетто 23%', vat23: 'ПДВ 23%', diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index eb6b0c00..93f7f951 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -25,7 +25,7 @@ import { blockRegistry } from './template/blocks/index.js'; import { getBuiltinTemplate as loadBuiltinTemplate, builtinTemplateNames } from './template/builtin/index.js'; import { loadPdfMake, createPdfBuffer } from './fonts.js'; import { deriveInvoiceQrUrl } from './qr.js'; -import { p15Flags, paymentFlags } from './document-flags.js'; +import { documentFlags } from './document-flags.js'; export type { Locale } from './i18n/types.js'; export type { InvoiceTemplate } from './template/dsl.js'; @@ -162,8 +162,7 @@ function buildContext( const totals = opts.totals ?? 'buckets'; const flags: Record = { - ...p15Flags(root), - ...paymentFlags(root), + ...documentFlags(root), hasKsefNumber: Boolean(opts.ksefNumber), offline: !opts.ksefNumber, // Either code is enough to keep the QR area on the page: an offline invoice diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/header.ts b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts index 914b5661..963cf62e 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/header.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts @@ -11,7 +11,16 @@ import { resolveBinding, resolveText, type BlockRenderer, type PdfNode } from '. * of drifting to the left margin under the title. */ export const headerRenderer: BlockRenderer = (block, ctx) => { - const title = resolveText(block.title, ctx) || ctx.label('invoice'); + // A template may name its own title; otherwise the document names itself. + // An advance invoice and the one that settles it are the two a reader must + // not mistake for an ordinary invoice, and the heading is where that is + // cheapest to say. + const kind = ctx.flags.isAdvanceInvoice + ? 'invoiceAdvance' + : ctx.flags.isSettlementInvoice + ? 'invoiceSettlement' + : 'invoice'; + const title = resolveText(block.title, ctx) || ctx.label(kind); const left: PdfNode[] = [{ text: title, style: block.style ?? 'title' }]; if (block.logo) { const logo = resolveBinding(block.logo, ctx); diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index eeafe3b3..7c527fc5 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -20,7 +20,6 @@ "type": "header", "logo": "opts.logo", "logoWidth": 48, - "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1", "ksefNumber": "opts.ksefNumber", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 688b259b..06031cf0 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -20,7 +20,6 @@ "type": "header", "logo": "opts.logo", "logoWidth": 48, - "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1", "ksefNumber": "opts.ksefNumber", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 9c4f2910..44dfd129 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -37,7 +37,6 @@ "type": "header", "logo": "opts.logo", "logoWidth": 40, - "title": { "label": "invoice" }, "number": "Fa.P_2", "date": "Fa.P_1", "ksefNumber": "opts.ksefNumber", diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz-b.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz-b.xml index a8114f75..58ba60f9 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz-b.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz-b.xml @@ -1,8 +1,10 @@ + closing fa2-zal-b.xml, which had invoiced 800,00 of a 1 230,00 order. + This one covers the remaining 430,00 (349,59 net + 80,41 VAT) and *itself* documents one + further payment received before delivery — 250,00 on 01.04. So what is still owed is the + difference the schema defines, P_15 less the sum of the P_15Z fields: 180,00. + Remainder COMPUTED. No real taxpayer data. --> @@ -49,9 +51,9 @@ Warszawa ROZ/2025/04/007 2025-04-02 - 1000.00 - 230.00 - 1230.00 + 349.59 + 80.41 + 430.00 2 2 @@ -70,12 +72,8 @@ ROZ - 2025-03-04 - 500.00 - - - 2025-03-11 - 300.00 + 2025-04-01 + 250.00 1111111111-20250312-020000000000-B2 diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz.xml index 2a36d7ba..8535a284 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz.xml @@ -1,7 +1,10 @@ Warszawa ROZ/2025/02/001 2025-02-05 - 500.00 - 115.00 + 134.15 + 30.85 165.00 2 diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz-b.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz-b.xml index 2078222a..baa3396d 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz-b.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz-b.xml @@ -1,8 +1,10 @@ + closing fa3-zal-b.xml, which had invoiced 800,00 of a 1 230,00 order. + This one covers the remaining 430,00 (349,59 net + 80,41 VAT) and *itself* documents one + further payment received before delivery — 250,00 on 01.04. So what is still owed is the + difference the schema defines, P_15 less the sum of the P_15Z fields: 180,00. + Remainder COMPUTED. No real taxpayer data. --> @@ -49,9 +51,9 @@ Warszawa ROZ/2025/04/007 2025-04-02 - 1000.00 - 230.00 - 1230.00 + 349.59 + 80.41 + 430.00 2 2 @@ -70,12 +72,8 @@ ROZ - 2025-03-04 - 500.00 - - - 2025-03-11 - 300.00 + 2025-04-01 + 250.00 1111111111-20250312-020000000000-B2 diff --git a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz.xml index 2ba8601c..f92affa3 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz.xml +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz.xml @@ -1,7 +1,10 @@ Warszawa ROZ/2025/02/001 2025-02-05 - 500.00 - 115.00 + 134.15 + 30.85 165.00 2 diff --git a/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts index af8084de..8d4554aa 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; -import { p15Flags, paymentFlags } from '../../../src/pdf/document-flags.js'; +import { documentFlags, p15Flags } from '../../../src/pdf/document-flags.js'; import { parseXmlForPdf } from '../../../src/pdf/parse.js'; import { blockRegistry } from '../../../src/pdf/template/blocks/index.js'; import { interpretTemplate, type RenderContext } from '../../../src/pdf/template/interpret.js'; @@ -44,7 +44,7 @@ function render(templateName: string, xml: string): string[] { strict: false, label: makeLabelResolver('pl', {}), bindings: { 'opts.logo': '', 'opts.ksefNumber': '', 'opts.accent': '', qrUrl: '', certificateQrUrl: '' }, - flags: { ...p15Flags(root), ...paymentFlags(root), totalsBuckets: true }, + flags: { ...documentFlags(root), totalsBuckets: true }, }; return texts(interpretTemplate(template, ctx, blockRegistry)); } @@ -81,8 +81,9 @@ describe('which reading of P_15 a document supports', () => { }); it('a settlement invoice: P_15 is what is left after the advances', () => { - // Its lines state the whole order (500,00 + 115,00) while P_15 states 165,00 - // — the case where a flat `Do zapłaty` reads as a contradiction. + // Its line items state the whole 615,00 order while the tax summary and + // `P_15` cover only the 165,00 remainder — the case where a flat + // `Do zapłaty` reads as a contradiction. expect(p15Flags((parseXmlForPdf(fx('fa3-roz.xml')) as Record).Faktura)).toEqual({ p15IsAmountDue: false, p15IsAdvancePaid: false, @@ -113,9 +114,10 @@ describe('which reading of P_15 a document supports', () => { }); it('a settlement invoice that also states the payments it received', () => { - // Chain B's settlement restates the payments it received, so its P_15 is - // the whole 1 230,00 and the remainder is the difference the schema - // defines — P_15 must not be labelled as what is left. + // Chain B's settlement documents a further payment it received, so its + // `P_15` covers that payment plus the rest and the remainder is the + // difference the schema defines — `P_15` must not be labelled as what is + // left. expect(p15Flags((parseXmlForPdf(fx('fa3-roz-b.xml')) as Record).Faktura)).toEqual({ p15IsAmountDue: false, p15IsAdvancePaid: false, @@ -134,6 +136,28 @@ describe('which reading of P_15 a document supports', () => { }); }); +describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s names the document itself', (name) => { + const fa = name.startsWith('fa2') ? 'fa2' : 'fa3'; + + it('heads an advance invoice as one', () => { + expect(render(name, fx(`${fa}-zal.xml`))).toContain('Faktura zaliczkowa'); + }); + + it('heads a settlement invoice as one, in both of its shapes', () => { + expect(render(name, fx(`${fa}-roz.xml`))).toContain('Faktura rozliczająca'); + expect(render(name, fx(`${fa}-roz-b.xml`))).toContain('Faktura rozliczająca'); + }); + + it('leaves an ordinary invoice, and a correction of an advance, plainly headed', () => { + expect(render(name, fx(`${fa}.xml`))).toContain('Faktura'); + // KOR_ZAL corrects an advance invoice; it is not one. + const korZal = fx(`${fa}-zal.xml`).replace('ZAL<', 'KOR_ZAL<'); + const out = render(name, korZal); + expect(out).toContain('Faktura'); + expect(out).not.toContain('Faktura zaliczkowa'); + }); +}); + describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s names the figure it prints', (name) => { const fa = name.startsWith('fa2') ? 'fa2' : 'fa3'; @@ -162,13 +186,13 @@ describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s names the figu }); it('computes the remainder the schema defines as a difference', () => { - // Chain B: P_15 is the whole 1 230,00 and the payments received are - // stated, so what is left — 430,00 — exists only as `P_15` minus the sum of - // the `P_15Z` fields. No field carries it. + // Chain B: `P_15` is the 430,00 this invoice covers, of which 250,00 was + // received before delivery and stated here — so what is still owed, 180,00, + // exists only as `P_15` minus the sum of the `P_15Z` fields. No field + // carries it. const out = render(name, fx(`${fa}-roz-b.xml`)); - // `money` groups thousands with a non-breaking space. - expect(out.some((t) => t.includes('1\u00a0230,00'))).toBe(true); - expect(out.some((t) => t.includes('Pozostało do zapłaty: 430,00 PLN'))).toBe(true); + expect(out).toContain('430,00'); + expect(out.some((t) => t.includes('Pozostało do zapłaty: 180,00 PLN'))).toBe(true); expect(out.some((t) => t.includes('Kwota należności ogółem'))).toBe(true); }); diff --git a/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts b/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts index 4a85ff5d..0b3bd978 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; -import { paymentFlags } from '../../../src/pdf/document-flags.js'; +import { documentFlags, paymentFlags } from '../../../src/pdf/document-flags.js'; import { parseXmlForPdf } from '../../../src/pdf/parse.js'; import { paymentRenderer } from '../../../src/pdf/template/blocks/payment.js'; import type { PaymentBlock } from '../../../src/pdf/template/dsl.js'; @@ -30,7 +30,7 @@ function paymentLines(templateName: string, xml: string, locale?: (k: string) => strict: false, label: locale ?? ((k: string) => k), bindings: {}, - flags: { p15IsAmountDue: true, ...paymentFlags(root) }, + flags: { ...documentFlags(root) }, }; const out: string[] = []; const walk = (value: unknown): void => { From 7649f91898f57c7ae905868eb1e1feb965c42c4e Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 17:47:58 +0200 Subject: [PATCH 60/67] feat(pdf): bridge the order to the remainder on a settlement invoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A settlement invoice states the whole order in its line items but taxes only what is left, so the page shows 500,00 of line values above a demand for 165,00 with nothing to connect them. The bridge is now printed: the order's net, and what the advances covered. Both are derived, so they appear only under `totals: 'summary'` or `'both'` — `'buckets'` promises that every number on the page traces to a field, and this keeps that promise. Neither figure invents tax: the order's net is a sum of stated line values and the advances' share is that sum less the stated remainder. It stops at net deliberately, because a settlement invoice carries no VAT or gross figure for the whole order at all — `Fa.Zamowienie` belongs to advance invoices, and `P_11Vat` is a special case rather than a per-line tax — so stating those would mean re-deriving tax from the rate and risking a figure the issuer never declared. The arithmetic closes against the other end of the chain: on the second chain the bridge computes 650,41, which is exactly the net its advance invoice declared. Also here, two smaller things: a computed figure may now be a single binding or a fixed list as well as a sum over a collection, which is what lets the bridge subtract a multi-rate remainder; and a caller may reword any label for one render, outranking both the template and the bundle, so an issuer who says "faktura końcowa" need fork neither. Full suite green: 2884 unit + 158 e2e. Co-Authored-By: Claude Opus 5 (1M context) --- packages/ksef-client-ts/docs/pdf-export.md | 5 ++++ packages/ksef-client-ts/src/pdf/i18n/en.ts | 2 ++ packages/ksef-client-ts/src/pdf/i18n/pl.ts | 2 ++ packages/ksef-client-ts/src/pdf/i18n/uk.ts | 2 ++ packages/ksef-client-ts/src/pdf/index.ts | 20 +++++++++++-- .../src/pdf/template/blocks/field.ts | 16 +++++------ .../src/pdf/template/builtin/fa2-default.json | 13 +++++++++ .../src/pdf/template/builtin/fa3-default.json | 13 +++++++++ .../pdf/template/builtin/fa3-showcase.json | 13 +++++++++ .../ksef-client-ts/src/pdf/template/dsl.ts | 23 +++++++++++---- .../tests/e2e/35-invoice-pdf-cli.test.ts | 12 +++++--- .../tests/unit/pdf/amount-due-label.test.ts | 28 +++++++++++++++++-- .../unit/pdf/builtin-template-lint.test.ts | 7 ++++- .../tests/unit/pdf/i18n.test.ts | 19 +++++++++++++ 14 files changed, 152 insertions(+), 23 deletions(-) diff --git a/packages/ksef-client-ts/docs/pdf-export.md b/packages/ksef-client-ts/docs/pdf-export.md index 71fc2948..75763e07 100644 --- a/packages/ksef-client-ts/docs/pdf-export.md +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -241,6 +241,10 @@ Exactly one of the `p15IsAmountDue`, `p15IsAdvancePaid`, `p15IsRemainder` and `p A settlement invoice is where this matters most on the page. Its **line items state the whole order**, but its **tax summary and `P_15` cover only what is left**: the advance invoice already declared the tax on its own share, and declaring the full amount again would tax the deal twice. So an FA(3) settlement of a 615,00 order against a 450,00 advance carries line items worth 500,00 net while `P_13_1` is 134,15, `P_14_1` is 30,85 and `P_15` is 165,00 — figures that look inconsistent until you know which base each one uses. +A reader given only that cannot see how 500,00 and 165,00 relate, so with `totals: 'summary'` or `'both'` the built-in templates add the bridge — the order's net (a sum of the stated line values) and what the advances covered (that sum less the stated remainder). Both are derived, which is why they appear only where the caller has accepted derived figures; `'buckets'` keeps its promise that every number on the page traces to a field. + +The bridge deliberately stops at net. A settlement invoice carries **no VAT or gross figure for the whole order** — `Fa.Zamowienie` belongs to advance invoices, and `P_11Vat` is a special case of art. 106e ust. 10, not a per-line tax — so stating them would mean re-deriving tax from the rate, which can disagree with what the issuer declared. + #### The two shapes of a settlement invoice An issuer may state what is left in either of two ways, and both have to render correctly: @@ -266,6 +270,7 @@ An advance invoice (`RodzajFaktury` `ZAL` or `KOR_ZAL`) records the goods and se - **`less`** and **`sumFrom`** (totals and payment rows) compute a figure the document does not state: `sumFrom` takes the sum of one binding over every entry of a collection, and `less` subtracts such a sum from the row's own value. `sum` cannot do this — it adds a fixed list of paths, and the entries of a repeater are not known to the template. Both print blank rather than a wrong number when anything they read is unparseable. Like every computed figure here, they are only as sound as the document. - **`format`** names a value formatter: `money`, `date`, `number`, or `nip`. - **`optional`** marks a binding the document may legitimately omit, exempting it from `strict`. Mark exactly what the schema declares optional — everything left unmarked is a field the document must carry. +- **Label overrides.** `RenderOptions.labels` rewords any label for one render — `{ invoiceSettlement: 'Faktura końcowa' }`. It outranks a template's own `labels`, which outrank the locale bundle, so neither has to be forked to change a word. - **The document titles itself.** A `header` block with no `title` heads the page by what the document *is* — `Faktura zaliczkowa` for an advance invoice (`ZAL`), `Faktura rozliczająca` for a settlement (`ROZ`), plain `Faktura` otherwise, corrections included: `KOR_ZAL` corrects an advance invoice, it is not one. A template that names its own `title` keeps it. The built-ins name none. - **`headingStyle`** (`parties`, `payment`, `annotations`, `notes`) names the style for the heading those blocks print themselves — `Sprzedawca`, `Płatność`. It reaches that first line only: labels nested inside a block (`Adres`, `Dane kontaktowe`, `Rachunek bankowy`) are a level down and stay on `h2`, so section headings can be lifted without dragging every label along. Both default to `h2`; the built-in templates name `h1` for the block headings and leave the nested ones on `h2`. The `header` block's title works the same way through plain `style`, defaulting to `title`. A `styles` map that omits `h2` or `title` loses those headings with nothing in the JSON to point at. - **`style`** (party panels) names a style for a panel's value lines. A labelled group inherits it unless it declares its own, so the built-in templates set `partyIdentity` on the panel — the counterparty's name and tax number — and let the address and contact groups drop to the smaller `partyDetails`. Headings keep the panel's heading style either way. diff --git a/packages/ksef-client-ts/src/pdf/i18n/en.ts b/packages/ksef-client-ts/src/pdf/i18n/en.ts index 3dd4c42c..e6a64b5a 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/en.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -51,6 +51,8 @@ export const en: LabelBundle = { netReverseCharge: 'Net reverse charge', netMargin: 'Net margin scheme', totalNet: 'Total net', + orderNet: 'Order value, net', + settledByAdvances: 'Settled by advances, net', totalVat: 'Total VAT', totalDue: 'Amount due', remainingDue: 'Remaining to pay', diff --git a/packages/ksef-client-ts/src/pdf/i18n/pl.ts b/packages/ksef-client-ts/src/pdf/i18n/pl.ts index cfb646f3..24690f3e 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/pl.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -53,6 +53,8 @@ export const pl: LabelBundle = { netReverseCharge: 'Netto odwrotne obciążenie', netMargin: 'Netto procedura marży', totalNet: 'Razem netto', + orderNet: 'Wartość zamówienia netto', + settledByAdvances: 'Rozliczono zaliczkami (netto)', totalVat: 'Razem VAT', totalDue: 'Do zapłaty', remainingDue: 'Pozostało do zapłaty', diff --git a/packages/ksef-client-ts/src/pdf/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts index 3a92285d..ba529dd4 100644 --- a/packages/ksef-client-ts/src/pdf/i18n/uk.ts +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -59,6 +59,8 @@ export const uk: LabelBundle = { netReverseCharge: 'Нетто зворотне нарахування', netMargin: 'Нетто маржинальна схема', totalNet: 'Разом нетто', + orderNet: 'Вартість замовлення, нетто', + settledByAdvances: 'Закрито авансами, нетто', totalVat: 'Разом ПДВ', totalDue: 'До сплати', remainingDue: 'Залишок до сплати', diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index 93f7f951..91abb288 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -71,6 +71,13 @@ export interface RenderOptions { locale?: Locale; /** Which totals to print above the amount due. Default `'buckets'`. */ totals?: TotalsMode; + /** + * Label overrides for this render, by key — `{ invoiceSettlement: 'Faktura + * końcowa' }`. They outrank a template's own `labels`, which in turn outrank + * the locale bundle, so a caller can reword any label without forking the + * template or the bundle. Unknown keys are ignored. + */ + labels?: Record; /** KSeF number printed on the visualization; absent → marked OFFLINE. */ ksefNumber?: string; /** Embed the KSeF Code I QR derived from the invoice XML. */ @@ -147,7 +154,9 @@ function buildContext( ): RenderContext { const label = makeLabelResolver(opts.locale ?? 'pl', { bilingualSeparator: opts.bilingualSeparator, - overrides: template.labels, + // The caller is more specific to this render than the template is, so it + // wins: a template's own wording is a default, not a lock. + overrides: { ...template.labels, ...opts.labels }, }); const bindings: Record = { @@ -161,8 +170,9 @@ function buildContext( const notes = (opts.notes ?? []).filter((n) => (n?.head ?? '').trim() !== '' || (n?.body ?? '').trim() !== ''); const totals = opts.totals ?? 'buckets'; + const derived = documentFlags(root); const flags: Record = { - ...documentFlags(root), + ...derived, hasKsefNumber: Boolean(opts.ksefNumber), offline: !opts.ksefNumber, // Either code is enough to keep the QR area on the page: an offline invoice @@ -171,6 +181,12 @@ function buildContext( qrLinks: Boolean(opts.qrLinks), totalsBuckets: totals === 'buckets' || totals === 'both', totalsSummary: totals === 'summary' || totals === 'both', + // A settlement invoice states the whole order in its lines but taxes only + // the remainder, so the two figures a reader tries to reconcile sit far + // apart. The bridge between them is derived, which is why it appears only + // where the caller has accepted derived figures. + settlementBreakdown: + derived.isSettlementInvoice === true && (totals === 'summary' || totals === 'both'), // So a template can gate other things on notes being present — a divider // around them, say — without the block itself needing a condition. notes: notes.length > 0, diff --git a/packages/ksef-client-ts/src/pdf/template/blocks/field.ts b/packages/ksef-client-ts/src/pdf/template/blocks/field.ts index aa2b7968..f6264364 100644 --- a/packages/ksef-client-ts/src/pdf/template/blocks/field.ts +++ b/packages/ksef-client-ts/src/pdf/template/blocks/field.ts @@ -42,12 +42,9 @@ export function readField( */ export function lessRepeatedSum(value: string, less: RepeatedSum, root: unknown): string { if (value.trim() === '') return ''; - const negated = list(root, less.from).map((entry) => { - const raw = get(entry, less.path).trim(); - if (raw === '') return ''; - return raw.startsWith('-') ? raw.slice(1) : `-${raw}`; - }); - return sumDecimal([value, ...negated]); + const raw = repeatedSum(less, root).trim(); + if (raw === '') return value; + return sumDecimal([value, raw.startsWith('-') ? raw.slice(1) : `-${raw}`]); } /** @@ -58,6 +55,9 @@ export function lessRepeatedSum(value: string, less: RepeatedSum, root: unknown) * blank entries are skipped; an unparseable one makes the whole sum `''`, as it * does everywhere else here, because a blank is safer than a wrong total. */ -export function repeatedSum(sum: RepeatedSum, root: unknown): string { - return sumDecimal(list(root, sum.from).map((entry) => get(entry, sum.path))); +export function repeatedSum(spec: RepeatedSum, root: unknown): string { + if (spec.sum) return sumDecimal(spec.sum.map((path) => get(root, path))); + const path = spec.path ?? ''; + if (spec.from === undefined) return get(root, path); + return sumDecimal(list(root, spec.from).map((entry) => get(entry, path))); } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index 7c527fc5..a0fa0bc6 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -220,6 +220,19 @@ "when": "totalsSummary", "format": "money" }, + { + "label": "orderNet", + "when": "settlementBreakdown", + "sumFrom": { "from": "Fa.FaWiersz", "path": "P_11" }, + "format": "money" + }, + { + "label": "settledByAdvances", + "when": "settlementBreakdown", + "sumFrom": { "from": "Fa.FaWiersz", "path": "P_11" }, + "less": { "sum": ["Fa.P_13_1", "Fa.P_13_2", "Fa.P_13_3", "Fa.P_13_4", "Fa.P_13_5", "Fa.P_13_6_1", "Fa.P_13_6_2", "Fa.P_13_6_3", "Fa.P_13_7", "Fa.P_13_8", "Fa.P_13_9", "Fa.P_13_10", "Fa.P_13_11"] }, + "format": "money" + }, { "label": "totalDue", "path": "Fa.P_15", "when": "p15IsAmountDue", "format": "money", "style": "strong" }, { "label": "advancePaid", "path": "Fa.P_15", "when": "p15IsAdvancePaid", "format": "money", "style": "strong" }, { "label": "amountTotal", "path": "Fa.P_15", "when": "p15IsAmountTotal", "format": "money", "style": "strong" }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 06031cf0..87f977a4 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -220,6 +220,19 @@ "when": "totalsSummary", "format": "money" }, + { + "label": "orderNet", + "when": "settlementBreakdown", + "sumFrom": { "from": "Fa.FaWiersz", "path": "P_11" }, + "format": "money" + }, + { + "label": "settledByAdvances", + "when": "settlementBreakdown", + "sumFrom": { "from": "Fa.FaWiersz", "path": "P_11" }, + "less": { "sum": ["Fa.P_13_1", "Fa.P_13_2", "Fa.P_13_3", "Fa.P_13_4", "Fa.P_13_5", "Fa.P_13_6_1", "Fa.P_13_6_2", "Fa.P_13_6_3", "Fa.P_13_7", "Fa.P_13_8", "Fa.P_13_9", "Fa.P_13_10", "Fa.P_13_11"] }, + "format": "money" + }, { "label": "totalDue", "path": "Fa.P_15", "when": "p15IsAmountDue", "format": "money", "style": "strong" }, { "label": "advancePaid", "path": "Fa.P_15", "when": "p15IsAdvancePaid", "format": "money", "style": "strong" }, { "label": "amountTotal", "path": "Fa.P_15", "when": "p15IsAmountTotal", "format": "money", "style": "strong" }, diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index 44dfd129..d1f746b0 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -158,6 +158,19 @@ "when": "totalsSummary", "format": "money" }, + { + "label": "orderNet", + "when": "settlementBreakdown", + "sumFrom": { "from": "Fa.FaWiersz", "path": "P_11" }, + "format": "money" + }, + { + "label": "settledByAdvances", + "when": "settlementBreakdown", + "sumFrom": { "from": "Fa.FaWiersz", "path": "P_11" }, + "less": { "sum": ["Fa.P_13_1", "Fa.P_13_2", "Fa.P_13_3", "Fa.P_13_4", "Fa.P_13_5", "Fa.P_13_6_1", "Fa.P_13_6_2", "Fa.P_13_6_3", "Fa.P_13_7", "Fa.P_13_8", "Fa.P_13_9", "Fa.P_13_10", "Fa.P_13_11"] }, + "format": "money" + }, { "label": "totalDue", "path": "Fa.P_15", "when": "p15IsAmountDue", "format": "money", "style": "strong" }, { "label": "advancePaid", "path": "Fa.P_15", "when": "p15IsAdvancePaid", "format": "money", "style": "strong" }, { "label": "amountTotal", "path": "Fa.P_15", "when": "p15IsAmountTotal", "format": "money", "style": "strong" }, diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 92b6a9fa..7f30bfcc 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -298,13 +298,16 @@ export interface PaymentGroup { * reading. */ /** - * One binding read over every entry of a collection, to be summed. Used by - * `less`, where a figure is defined as a difference the document does not - * state — see {@link TotalsRow.less}. + * A figure to read, in one of three shapes: `{ from, path }` sums one binding + * over every entry of a collection, `{ path }` reads a single binding, and + * `{ sum }` adds a fixed list of them. Used by `sumFrom` and `less`, where a + * figure is defined in terms of others the document does not state — see + * {@link TotalsRow.less}. */ export interface RepeatedSum { - from: string; - path: string; + from?: string; + path?: string; + sum?: string[]; } export interface PaymentRow extends Omit { @@ -570,7 +573,15 @@ const columnDef = z }) .strict(); -const repeatedSum = z.object({ from: z.string(), path: z.string() }).strict(); +const repeatedSum = z + .object({ from: z.string().optional(), path: z.string().optional(), sum: z.array(z.string()).nonempty().optional() }) + .strict() + .refine((v) => (v.path !== undefined) !== (v.sum !== undefined), { + message: 'a computed figure needs exactly one of "path" (optionally with "from") or "sum"', + }) + .refine((v) => v.from === undefined || v.path !== undefined, { + message: '"from" names a collection to read "path" over, so it needs "path"', + }); const totalsRow = z .object({ label: z.string(), diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 67de87c8..cf86beb4 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -227,11 +227,15 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { '--env', 'demo', '--qr', '--totals', 'buckets', ]], // 07 — chain A, the settlement (ROZ) that closes 06. The lines and VAT - // buckets show the whole 615,00 order while `P_15` is only the 165,00 still - // owed: read as a flat "amount due", those figures contradict each other. + // items show the whole 615,00 order while the tax summary and `P_15` cover + // only the 165,00 still owed — the advance invoice already declared the tax + // on its own share. Rendered with `--totals both`, so the derived bridge + // between the two (`Wartość zamówienia netto` and `Rozliczono zaliczkami`) + // is on the page: that reconciliation is computed, so it appears only where + // the caller has accepted computed figures. [`${PREFIX}-07-chain-a-settlement-stated`, () => [ fx('fa3-roz.xml'), '--ksef-number', KSEF_ROZ_A, - '--env', 'demo', '--qr', '--totals', 'buckets', + '--env', 'demo', '--qr', '--totals', 'both', ]], // 08 — chain B, a different buyer and a different deal: order 1 230,00, // advance 800,00 received in March. @@ -245,7 +249,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // fields, 430,00. No field carries that number. [`${PREFIX}-09-chain-b-settlement-computed`, () => [ fx('fa3-roz-b.xml'), '--ksef-number', KSEF_ROZ_B, - '--env', 'demo', '--qr', '--totals', 'buckets', + '--env', 'demo', '--qr', '--totals', 'both', ]], // 10 — standalone: an ordinary invoice being paid down, which is a // different thing from an advance and reads differently. `Platnosc` takes diff --git a/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts index 8d4554aa..cc8c75dd 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts @@ -36,7 +36,7 @@ function texts(doc: Record): string[] { } /** Render through the same flag derivation the public entry point uses. */ -function render(templateName: string, xml: string): string[] { +function render(templateName: string, xml: string, extraFlags: Record = {}): string[] { const template = getBuiltinTemplate(templateName)!; const root = (parseXmlForPdf(xml) as Record).Faktura; const ctx: RenderContext = { @@ -44,7 +44,7 @@ function render(templateName: string, xml: string): string[] { strict: false, label: makeLabelResolver('pl', {}), bindings: { 'opts.logo': '', 'opts.ksefNumber': '', 'opts.accent': '', qrUrl: '', certificateQrUrl: '' }, - flags: { ...documentFlags(root), totalsBuckets: true }, + flags: { ...documentFlags(root), totalsBuckets: true, ...extraFlags }, }; return texts(interpretTemplate(template, ctx, blockRegistry)); } @@ -209,6 +209,30 @@ describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s names the figu expect(out.some((t) => /^Do zap[łl]aty/i.test(t))).toBe(false); }); + it('bridges the whole order to the remainder, when computed figures are allowed', () => { + // A settlement invoice states the whole order in its lines but taxes only + // what is left, so the two figures a reader tries to reconcile sit far + // apart. The bridge is derived — hence gated on the totals mode — and it + // uses no invented tax: the order's net is a sum of stated line values, and + // what the advances covered is that sum less the stated remainder. + const out = render(name, fx(`${fa}-roz.xml`), { totalsSummary: true, settlementBreakdown: true }); + expect(out.some((t) => t.includes('Wartość zamówienia netto'))).toBe(true); + expect(out).toContain('500,00'); + expect(out.some((t) => t.includes('Rozliczono zaliczkami'))).toBe(true); + expect(out).toContain('365,85'); // 500,00 − 134,15, the advance's own net + }); + + it('closes chain B against its advance invoice exactly', () => { + // 1 000,00 − 349,59 = 650,41, which is the net the advance invoice declared. + const out = render(name, fx(`${fa}-roz-b.xml`), { totalsSummary: true, settlementBreakdown: true }); + expect(out).toContain('650,41'); + }); + + it('prints no bridge when the caller asked for stated figures only', () => { + const out = render(name, fx(`${fa}-roz.xml`)); + expect(out.some((t) => t.includes('Rozliczono zaliczkami'))).toBe(false); + }); + it('names the advance invoice it settles', () => { const out = render(name, fx(`${fa}-roz.xml`)); expect(out.some((t) => t.includes('Faktury zaliczkowe'))).toBe(true); diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index ca8dd40c..2c1d0d64 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -53,6 +53,9 @@ const CONTEXT_CONDITIONS = new Set([ 'p15IsAmountDue', 'p15IsAdvancePaid', 'p15IsAmountTotal', 'p15IsRemainder', // Whether the remainder is a figure the schema defines as a difference. 'settlementRemainder', + // The settlement reconciliation, which is derived and so is gated on the + // totals mode as well as on the document. + 'settlementBreakdown', // How much of the invoice `Platnosc` says has been paid. 'paidInFull', 'paidInPart', ]); @@ -79,7 +82,9 @@ function collect( // silently makes the figure wrong rather than absent — worse than a // blank line, so it is linted like any other repeater. for (const computed of [row.less, row.sumFrom]) { - if (computed) acc.repeaters.push(computed.from); + // Only the collection form names a repeater; `{ path }` and `{ sum }` + // read the document root and are covered by the scalar lints. + if (computed?.from !== undefined) acc.repeaters.push(computed.from); } } } diff --git a/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts b/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts index 1343f45b..0aeb25ac 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts @@ -138,3 +138,22 @@ describe('the bundles stay key-complete', () => { } }); }); + +describe('a caller may reword any label for one render', () => { + it('outranks the template, which outranks the bundle', () => { + // Three sources, most specific first. The document titles are the reason + // this exists — an issuer who calls a settlement invoice `faktura końcowa` + // should not have to fork a template to say so. + expect(resolveLabel('invoiceSettlement', 'pl')).toBe('Faktura rozliczająca'); + expect(resolveLabel('invoiceSettlement', 'pl', { overrides: { invoiceSettlement: 'Faktura końcowa' } })) + .toBe('Faktura końcowa'); + }); + + it('names an advance invoice and a settlement invoice in every bundle', () => { + for (const locale of ['pl', 'en', 'uk'] as const) { + for (const key of ['invoice', 'invoiceAdvance', 'invoiceSettlement']) { + expect(resolveLabel(key, locale), `${key} missing from ${locale}`).not.toBe(key); + } + } + }); +}); From f948e63e8a70fe2adbae52067143e4b40d310bff Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 17:55:48 +0200 Subject: [PATCH 61/67] test(pdf): render the second chain in English MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview set had no advance or settlement page in anything but Polish, so every label this story added — the document titles, the payments received, the order-to-remainder bridge — was only ever seen in one language. Chain B now renders with `--locale en`, which puts each of them on a page in English without adding a page. The document data stays Polish, as it must: a locale switches labels, not the invoice. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/e2e/35-invoice-pdf-cli.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index cf86beb4..1901a690 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -209,9 +209,13 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // and the settlement that closes it, for a different buyer and a different // amount each time, with dates that only ever move forward. // - // 06 → 07 Nabywca Przykładowy S.A. order 615,00 remainder STATED + // 06 → 07 Nabywca Przykładowy S.A. order 615,00 remainder STATED // 08 → 09 Odbiorca Handlowy Sp. z o.o. order 1 230,00 remainder COMPUTED // + // Chain B renders in English. The document data stays Polish — labels are + // what a locale switches — so the pair doubles as proof that every label + // this story added has an English word behind it, not only a Polish one. + // // The two chains exist because the FA schemas let a settlement invoice // state what is left in either of two ways, and the pages have to be right // for both. 10 and 11 then stand alone: an ordinary invoice being paid down, @@ -240,7 +244,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // 08 — chain B, a different buyer and a different deal: order 1 230,00, // advance 800,00 received in March. [`${PREFIX}-08-chain-b-advance`, () => [ - fx('fa3-zal-b.xml'), '--ksef-number', KSEF_ZAL_B, + fx('fa3-zal-b.xml'), '--ksef-number', KSEF_ZAL_B, '--locale', 'en', '--env', 'demo', '--qr', '--totals', 'buckets', ]], // 09 — chain B's settlement, which states the payments it received instead @@ -248,7 +252,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // the difference the schema defines: `P_15` less the sum of the `P_15Z` // fields, 430,00. No field carries that number. [`${PREFIX}-09-chain-b-settlement-computed`, () => [ - fx('fa3-roz-b.xml'), '--ksef-number', KSEF_ROZ_B, + fx('fa3-roz-b.xml'), '--ksef-number', KSEF_ROZ_B, '--locale', 'en', '--env', 'demo', '--qr', '--totals', 'both', ]], // 10 — standalone: an ordinary invoice being paid down, which is a From e4e5acba3724e126b27012d7a8578562f6736988 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 19:51:35 +0200 Subject: [PATCH 62/67] fix(pdf): find the UPO root tag by scanning, not by pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: the root-element match refused `` rather than to the next `>` — had that fake tag matched instead of the real root, and its namespace decided the version while the parsed object's `Potwierdzenie` root passed the check unchanged. The same pattern ended the tag at the first `>`, which XML allows unescaped inside an attribute value, truncating the tag and losing an `xmlns` declared after it. The prolog is now skipped node by node, each to its own terminator, and a start tag ends at the first `>` outside quotes. Co-Authored-By: Claude Code --- packages/ksef-client-ts/src/pdf/parse.ts | 89 +++++++++++++++++-- .../tests/unit/pdf/parse.test.ts | 32 +++++++ 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/parse.ts b/packages/ksef-client-ts/src/pdf/parse.ts index a6a1fec5..e2cb7b7f 100644 --- a/packages/ksef-client-ts/src/pdf/parse.ts +++ b/packages/ksef-client-ts/src/pdf/parse.ts @@ -65,6 +65,82 @@ export function detectInvoiceVersion(xml: string): InvoiceVersion | null { return byKod ?? byVariant; } +/** + * Skip one `` node of the prolog — in practice a DOCTYPE — and return the + * offset just past it. Its internal subset is bracketed and may itself contain + * `>`, so the terminator is the first unquoted `>` at bracket depth zero. + */ +function skipMarkupDeclaration(xml: string, at: number): number { + let depth = 0; + let quote = ''; + for (let i = at + 2; i < xml.length; i += 1) { + const ch = xml[i]; + if (quote !== '') { + if (ch === quote) quote = ''; + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '[') { + depth += 1; + } else if (ch === ']') { + depth -= 1; + } else if (ch === '>' && depth <= 0) { + return i + 1; + } + } + return xml.length; +} + +/** + * The root element's start tag, verbatim, or `''` for a document without one. + * + * A scanner rather than a regular expression, because both halves of the job + * defeat one. The prolog may carry comments, processing instructions and a + * DOCTYPE, and each ends at its own terminator rather than at the next `>` — + * any of them may contain text that reads like a start tag, and a pattern that + * merely refuses `` + * *outside* a quoted attribute value, which XML permits unescaped; stopping at + * the first `>` truncates the tag and loses whatever follows, `xmlns` included. + */ +function rootStartTag(xml: string): string { + let i = 0; + while (i < xml.length) { + const lt = xml.indexOf('<', i); + if (lt === -1) return ''; + + if (xml.startsWith('', lt + 4); + if (end === -1) return ''; + i = end + 3; + continue; + } + if (xml.startsWith('', lt + 2); + if (end === -1) return ''; + i = end + 2; + continue; + } + if (xml.startsWith('') { + return xml.slice(lt, j + 1); + } + } + return ''; + } + return ''; +} + /** * Detect the UPO version. Requires a `Potwierdzenie` root, then reads the * version from the namespace that root element is bound to (`.../KSeF/v4-3` → @@ -80,14 +156,11 @@ export function detectUpoVersion(xml: string): UpoVersion | null { // the source rather than `parsed`: `removeNSPrefix` drops the xmlns // declarations, so the version is gone by the time the document is an object. // - // Comments come out first and the match is anchored to the document's *first* - // element, not to the first thing that looks like a Potwierdzenie. Scanning by - // name alone let a commented-out root — or any mention of the string in a note - // or an embedded document — decide the version instead. - const withoutComments = xml.replace(//g, ''); - const firstElement = /<(?![?!])([\w.:-]+)[^>]*>/.exec(withoutComments); - const rootTag = firstElement?.[0] ?? ''; - const qualifiedName = firstElement?.[1] ?? ''; + // The tag is found by a scanner rather than by name, so a commented-out root — + // or any mention of the string in a note or an embedded document — cannot + // decide the version. + const rootTag = rootStartTag(xml); + const qualifiedName = /^<([\w.:-]+)/.exec(rootTag)?.[1] ?? ''; const colon = qualifiedName.indexOf(':'); const prefix = colon === -1 ? '' : qualifiedName.slice(0, colon); const rootName = colon === -1 ? qualifiedName : qualifiedName.slice(colon + 1); diff --git a/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts index c8851118..9b069253 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts @@ -198,6 +198,38 @@ describe('detectUpoVersion', () => { expect(detectUpoVersion(xml)).toBe('UPO(4.3)'); }); + // A processing instruction ends at `?>`, not at the next `>`, so its content + // is free to hold anything — including something that reads like a root tag. + // Refusing ` { + const xml = + '\n' + + '"?>\n' + + '1'; + expect(detectUpoVersion(xml)).toBe('UPO(4.3)'); + }); + + // XML forbids a bare `<` in an attribute value but permits `>`, so a start + // tag does not end at the first `>` — it ends at the first one outside the + // quotes. Stopping early truncated the tag and dropped the xmlns after it. + it('reads a namespace declared after an attribute containing a bare >', () => { + const xml = + '' + + '1'; + expect(detectUpoVersion(xml)).toBe('UPO(4.3)'); + }); + + // A DOCTYPE's internal subset is bracketed and may contain `>` of its own, + // so it ends at the first unquoted `>` at bracket depth zero, not before. + it('skips a DOCTYPE whose internal subset contains angle brackets', () => { + const xml = + ' b"> ]>\n' + + '1'; + expect(detectUpoVersion(xml)).toBe('UPO(4.2)'); + }); + it('survives a comment that mentions no version at all', () => { const xml = '' + From 273cb679a94619b563c6a1a49d15637c2355ce80 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 19:53:11 +0200 Subject: [PATCH 63/67] refactor(pdf): make a computed figure's shape a type, not a convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: `RepeatedSum` declared all three of its fields optional, so `{}` and `{ from: 'X' }` type-checked while the schema that validates them refuses both — a caller building a template as an object learned about it at render time rather than at compile time. A union of the two real shapes says the same thing the two refinements do. The runtime rules had no tests of their own, so they get some: the three shapes that are accepted, and the three that are not. Co-Authored-By: Claude Code --- .../ksef-client-ts/src/pdf/template/dsl.ts | 13 +++++--- .../ksef-client-ts/tests/unit/pdf/dsl.test.ts | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 7f30bfcc..2079675c 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -303,12 +303,15 @@ export interface PaymentGroup { * `{ sum }` adds a fixed list of them. Used by `sumFrom` and `less`, where a * figure is defined in terms of others the document does not state — see * {@link TotalsRow.less}. + * + * A union rather than three optional fields, so the shapes the validator + * already refuses — nothing at all, or a `from` with no `path` to read over — + * are refused at compile time too for a caller who builds the template as an + * object instead of parsing it from JSON. */ -export interface RepeatedSum { - from?: string; - path?: string; - sum?: string[]; -} +export type RepeatedSum = + | { path: string; from?: string; sum?: never } + | { sum: string[]; path?: never; from?: never }; export interface PaymentRow extends Omit { /** diff --git a/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts index 011aeda8..e8c6f3c5 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts @@ -148,3 +148,36 @@ describe('a divider can be conditional', () => { expect(() => validateTemplate({ schema: 'FA(3)', blocks: [{ type: 'divider' }] })).not.toThrow(); }); }); + +// The shapes `RepeatedSum` refuses at compile time are the ones the schema has +// always refused at runtime. Pinning them here keeps the two from drifting: the +// type is a mirror of these rules, and a template parsed from JSON never meets +// the type at all. +describe('a computed figure states exactly one source', () => { + const totalsWith = (sumFrom: unknown): unknown => ({ + schema: 'FA(3)', + blocks: [{ type: 'totals', rows: [{ label: 'totalDue', sumFrom }] }], + }); + + it('accepts a path, a path over a collection, and a fixed list', () => { + expect(() => validateTemplate(totalsWith({ path: 'Fa.P_15' }))).not.toThrow(); + expect(() => validateTemplate(totalsWith({ from: 'Fa.ZaliczkaCzesciowa', path: 'P_15Z' }))).not.toThrow(); + expect(() => validateTemplate(totalsWith({ sum: ['Fa.P_13_1', 'Fa.P_14_1'] }))).not.toThrow(); + }); + + it('refuses a figure with no source at all', () => { + expect(() => validateTemplate(totalsWith({}))).toThrow(KSeFValidationError); + }); + + it('refuses both sources at once', () => { + expect(() => validateTemplate(totalsWith({ path: 'Fa.P_15', sum: ['Fa.P_13_1'] }))).toThrow( + KSeFValidationError, + ); + }); + + it('refuses a collection with nothing to read over it', () => { + expect(() => validateTemplate(totalsWith({ from: 'Fa.ZaliczkaCzesciowa' }))).toThrow( + KSeFValidationError, + ); + }); +}); From 87bb4e3921ed2054771028600b930a83a6773711 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 19:54:44 +0200 Subject: [PATCH 64/67] fix(pdf): refuse a payment row that is both read and computed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit PR review on #46: nothing stopped a payment row carrying both `sumFrom` and `path`. The renderer settles the computed case first and returns, so the binding was silently dropped and the sum printed under a label written for the reading — a wrong figure with no error behind it. `from` and `less` are discarded the same way. The sibling `totals` row has carried this refinement all along; the payment row now does too. Not reapplying `.strict()` after `.extend()`, which the review also asked for: the pinned zod 4.4.3 does preserve the unknown-key behaviour of a strict object across `.extend()`, verified against the installed version. Co-Authored-By: Claude Code --- .../ksef-client-ts/src/pdf/template/dsl.ts | 28 +++++++++---- .../ksef-client-ts/tests/unit/pdf/dsl.test.ts | 42 +++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index 2079675c..a6fb9e7d 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -635,13 +635,27 @@ const blockSchema: z.ZodType = z.lazy(() => type: z.literal('payment'), when: z.string().optional(), rows: z.array( - fieldDef.extend({ - path: z.string().optional(), - when: z.string().optional(), - from: z.string().optional(), - less: repeatedSum.optional(), - sumFrom: repeatedSum.optional(), - }), + fieldDef + .extend({ + path: z.string().optional(), + when: z.string().optional(), + from: z.string().optional(), + less: repeatedSum.optional(), + sumFrom: repeatedSum.optional(), + }) + // A computed row is settled before the reading ones, so anything that + // describes a reading is dead weight beside `sumFrom` — and a row + // carrying both prints the computed figure under a label written for + // the other one, which no error would ever announce. + .refine( + (r) => + r.sumFrom === undefined || + (r.path === undefined && r.from === undefined && r.less === undefined), + { + message: + 'a computed payment row states its own figure, so "sumFrom" takes no "path", "from" or "less"', + }, + ), ), groups: z .array( diff --git a/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts index e8c6f3c5..68054f55 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect } from 'vitest'; import { validateTemplate } from '../../../src/pdf/template/dsl.js'; import type { InvoiceTemplate } from '../../../src/pdf/template/dsl.js'; import { KSeFValidationError } from '../../../src/errors/ksef-validation-error.js'; +import { + builtinTemplateNames, + getBuiltinTemplate, +} from '../../../src/pdf/template/builtin/index.js'; /** A small but structurally rich valid template (header + text + nested container). */ const VALID: unknown = { @@ -181,3 +185,41 @@ describe('a computed figure states exactly one source', () => { ); }); }); + +// The payment renderer settles a computed row before it looks at any binding, +// so a row carrying both prints the sum under a label written for the reading — +// silently, since neither shape is wrong on its own. +describe('a payment row is either read or computed, never both', () => { + const paymentWith = (row: Record): unknown => ({ + schema: 'FA(3)', + blocks: [{ type: 'payment', rows: [{ label: 'paid', ...row }] }], + }); + + it('accepts a label alone, a reading, and a computed figure', () => { + expect(() => validateTemplate(paymentWith({}))).not.toThrow(); + expect(() => validateTemplate(paymentWith({ path: 'Fa.Platnosc.Zaplacono' }))).not.toThrow(); + expect(() => + validateTemplate( + paymentWith({ sumFrom: { from: 'Fa.Platnosc.ZaplataCzesciowa', path: 'KwotaZaplatyCzesciowej' } }), + ), + ).not.toThrow(); + }); + + it.each([ + ['path', { path: 'Fa.P_15' }], + ['from', { from: 'Fa.Platnosc.ZaplataCzesciowa' }], + ['less', { less: { path: 'Fa.P_15' } }], + ])('refuses a computed row that also carries %s', (_label, extra) => { + expect(() => validateTemplate(paymentWith({ sumFrom: { sum: ['Fa.P_15'] }, ...extra }))).toThrow( + KSeFValidationError, + ); + }); + + // Every built-in drives this schema, and two of them carry computed payment + // rows — the refinement must not have made them invalid. + it('leaves every built-in template valid', () => { + for (const name of builtinTemplateNames()) { + expect(() => validateTemplate(getBuiltinTemplate(name)), name).not.toThrow(); + } + }); +}); From 844264f515f680efe521828250658a04c19cfe2f Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 22:22:13 +0200 Subject: [PATCH 65/67] refactor(pdf): make a payment row's two shapes a type, not a convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validator already refused a payment row that is both read and computed. The type did not, so a template built as an object compiled with `sumFrom` beside `path`, `from` or `less` — and the renderer settles the computed figure first, so such a page prints it under a label written for the reading. The row is now a union whose computed branch forbids the reading properties, pinned by negative compile assertions in the published-types fixture, which checks the built declarations rather than the source. The preview set takes back its stray page: the one-sided notes render joins the numbered table as 12, with the pages after it shifting up, instead of writing an unnumbered PDF beside them — and it now asserts the file exists, as every other page does. Both PDF specs also name the TEST verification host rather than DEMO. Neither spec touches the network, but a page should not name one environment while the specs beside it authenticate against another. CLAUDE.md becomes AGENTS.md, reworded for any coding agent, with CLAUDE.md and GEMINI.md pointing at it and the two references in the repository repointed. It also gains a short note on which environment the E2E specs drive. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M7sLete4AKPr5oHz2cMFqM --- .claude/skills/bump-version/SKILL.md | 2 +- .coderabbit.yaml | 2 +- AGENTS.md | 196 ++++++++++++++++++ CLAUDE.md | 195 +---------------- GEMINI.md | 1 + .../ksef-client-ts/src/pdf/template/dsl.ts | 75 ++++--- .../tests/e2e/35-invoice-pdf-cli.test.ts | 83 ++++---- .../tests/e2e/36-invoice-pdf-library.test.ts | 12 +- .../tests/fixtures/pdf-types-check.ts | 28 +++ 9 files changed, 323 insertions(+), 271 deletions(-) create mode 100644 AGENTS.md mode change 100644 => 120000 CLAUDE.md create mode 120000 GEMINI.md diff --git a/.claude/skills/bump-version/SKILL.md b/.claude/skills/bump-version/SKILL.md index 9d582999..eb6a9ca2 100644 --- a/.claude/skills/bump-version/SKILL.md +++ b/.claude/skills/bump-version/SKILL.md @@ -39,4 +39,4 @@ Prepare a new version release: create a version branch, bump version in package. **Important** - Do NOT push to remote. -- The branch name MUST use `version/` prefix per project convention (see CLAUDE.md). +- The branch name MUST use `version/` prefix per project convention (see AGENTS.md). diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 1afbda00..b497affe 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -62,7 +62,7 @@ knowledge_base: code_guidelines: enabled: true filePatterns: - - "CLAUDE.md" + - "AGENTS.md" learnings: scope: "auto" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b66cc1eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,196 @@ +# AGENTS.md + +Guidance for any AI coding agent working in this repository: how the project is laid out, which commands to run, and the conventions a change is expected to follow. Read it before making changes — it takes precedence over habits carried in from other repositories. + +## Project + +Yarn 4.x workspace monorepo. The library (`ksef-client-ts`) lives in `packages/ksef-client-ts/`. TypeScript client for the Polish National e-Invoice System (KSeF) API v2. Targets Node.js 18+ with dual ESM/CJS output. Current version and release history are in `packages/ksef-client-ts/CHANGELOG.md`. + +## Commands + +Run from the **repo root** — all commands delegate to the `ksef-client-ts` workspace: + +```bash +yarn build # Build ESM + CJS + DTS via tsup +yarn lint # Type-check only (tsc --noEmit) +yarn test # Run unit tests (vitest run tests/unit) +yarn test:e2e # Run E2E tests (vitest run tests/e2e) +yarn test:watch # Watch mode (all tests) +yarn docs:dev # VitePress dev server +yarn docs:build # Build docs site +yarn check-api # Check OpenAPI coverage +yarn sync-openapi # Download the OpenAPI spec from the live KSeF API +yarn split-openapi # Split open-api.json into per-domain chunks +yarn sync-schemas # Download XSD schemas from CIRFMF/ksef-docs +``` + +Run a single test file: `yarn workspace ksef-client-ts vitest run tests/unit/foo.test.ts` + +Tests live in `packages/ksef-client-ts/tests/**/*.test.ts` (vitest, globals enabled). Unit tests in `tests/unit/`, E2E tests in `tests/e2e/` (relative to the package). + +E2E specs drive the **live KSeF TEST API** (`environment: 'TEST'`, creds from `KSEF_TEST_TOKEN`/`KSEF_TEST_NIP`) — never DEMO or PROD. The PDF specs (35, 36) are the exception: no network at all, they render locally into `.pdf-preview/` (override with `KSEF_PDF_OUT`) and need `yarn build` first; `--env test` there only picks the host printed in the QR link, so keep it on TEST like everything else. + +**Package manager is yarn 4.x** (Corepack). Do not use npm. The `.yarnrc.yml` sets `nodeLinker: node-modules`. + +## Architecture + +### Layered design + +Source paths below are relative to the library package, `packages/ksef-client-ts/`. + +```text +KSeFClient (src/client.ts) + ├── 14 API services + crypto + qr + offline (17 properties total) + ├── each service wraps RestClient for its API domain + ├── crypto is lazy-initialized (user calls client.crypto.init()) + └── offline is lazy-initialized (accessed via client.offline) + +Services (src/services/*.ts) — 14 services + └── use RestClient.execute() with RestRequest builders + Routes constants + +HTTP layer (src/http/) + ├── RestClient — wraps native fetch, handles errors (429/401/403), JSON, auth headers + ├── RestRequest — fluent builder (method, path, body, headers, query) + ├── RouteBuilder — prepends /v2/ version prefix + ├── Routes — all API endpoint paths as const object + ├── RetryPolicy — exponential backoff with jitter, configurable retryable status codes + ├── RateLimitPolicy — token bucket rate limiter (global + per-endpoint) + ├── CircuitBreakerPolicy — opt-in fail-fast above retry: opens after N consecutive network/5xx failures, probes after cooldown (429/401 never trip) + ├── PresignedUrlPolicy — validates presigned download URLs (HTTPS, host allowlist) + └── AuthManager — manages access/refresh tokens, auto-refresh on 401 with dedup + +Crypto layer (src/crypto/) + ├── CertificateFetcher — fetches & caches KSeF public certs + ├── CryptographyService — AES-256-CBC, RSA-OAEP, ECDH+AES-GCM, CSR gen + ├── SignatureService — XAdES-B enveloped XML signatures (static) + └── CertificateService — self-signed cert generation (static) + +QR layer (src/qr/) + ├── VerificationLinkService — builds invoice/certificate verification URLs + └── QrCodeService — generates QR codes (PNG, SVG, SVG+label) + +Offline layer (src/offline/) + ├── types — OfflineMode, OfflineInvoiceStatus, OfflineInvoiceMetadata, OfflineCertificate + ├── deadline — calculateOfflineDeadline(), business day helpers, maintenance cascading + ├── storage — OfflineInvoiceStorage interface + InMemoryOfflineInvoiceStorage + └── file-storage — FileOfflineInvoiceStorage (~/.ksef/offline/) + +XML layer (src/xml/) + ├── upo-parser — parses official KSeF UPO receipt XML into typed objects + ├── invoice-field-extractor — extracts P_1/P_2/P_4B/P_4C from invoice XML + ├── xml-engine — fast-xml-parser wrapper (preserveOrder); parseXml/buildXml/stripBom + ├── order-map — ORDER_MAP per XSD parent + comparePKey natural sort + + │ multi-rate P_13/P_14/P_14W interleave per VAT group + ├── faktura-builder — FA2/FA3 builder; injects xmlns + xmlns:etd on + ├── pef-builder — PEF (Invoice) / PEF_KOR (CreditNote) UBL builder + └── invoice-serializer — polymorphic serializeInvoiceXml(input, options) → Buffer + dispatching on FakturaInput / PefUblDocumentInput / string / Buffer / XmlDocument + +CLI (src/cli/) — 17 command groups via citty + ├── setup, auth, session, invoice, permission, token, cert, lighthouse, limits, + │ collective-identifier, peppol, test-data, qr, config, doctor, completion, + │ offline + ├── requireSession() — auto-recovers via refresh or re-login from stored credentials + └── session-recovery — cascade: refresh token → loginWithToken from credentials → error +``` + +### Key conventions + +- **Imports use `.js` extensions** (ESM resolution convention, even for `.ts` source files). +- **Models** are in `src/models/{domain}/types.ts` with barrel `index.ts` re-exports. Types from `src/models/common.ts` are shared across domains. +- **Builders** in `src/builders/` provide fluent APIs for complex request construction. +- **Static vs instance**: `SignatureService` and `CertificateService` are fully static (no state). `CryptographyService` requires a `CertificateFetcher` instance (injected via `KSeFClient` constructor). +- **No auto-init**: `CryptographyService.init()` must be called explicitly to fetch KSeF public certificates. It is NOT called in the `KSeFClient` constructor. + +### Naming collisions to be aware of + +- `CertificateApiService` (src/services/) — API CRUD for certificate enrollment. Named with "Api" suffix to avoid collision with `CertificateService` (src/crypto/) which handles self-signed cert generation. +- `InvoiceFilterInvoicingMode` (not `InvoicingMode`) — avoids collision with session types. +- `PermissionSubjectIdentifierType` (not `SubjectIdentifierType`) — avoids collision with auth types. Note: both now use `'Nip' | 'Pesel' | 'Fingerprint'` values (aligned with OpenAPI spec). + +### KSeF environments and portals + +| Env | API | Web Portal | +|-----|-----|------------| +| PROD | `https://api.ksef.mf.gov.pl` | `https://ap.ksef.mf.gov.pl/web/` | +| TEST | `https://api-test.ksef.mf.gov.pl` | `https://ap-test.ksef.mf.gov.pl/web/` | +| DEMO | `https://api-demo.ksef.mf.gov.pl` | `https://ap-demo.ksef.mf.gov.pl/web/` | + +The web portal is used for token generation, permission management, and invoice browsing via browser (requires qualified signature or trusted profile). Each environment is fully isolated — accounts, tokens, and certificates created in one env do not exist in others. + +### Environment variables + +`KSEF_NIP`, `KSEF_TOKEN` (for PROD) and `KSEF_TOKEN_DEMO` (for DEMO) are set in the current shell environment. + +- PROD: `ksef auth login --token "$KSEF_TOKEN" --nip "$KSEF_NIP" --env prod` +- DEMO: `ksef auth login --token "$KSEF_TOKEN_DEMO" --nip "$KSEF_NIP" --env demo` + +### Invoice upload flow (CLI) + +```bash +ksef auth login --token "$KSEF_TOKEN" --nip "$KSEF_NIP" +ksef session open # 1. Open online session (required before sending) +ksef invoice build data.json # (optional) Build XML from JSON/YAML; `--template FA3` prints a skeleton. +ksef invoice send file.xml # 2. Send invoice +ksef session invoices # 3. Verify invoice status (check for errors/duplicates) +ksef invoice query --from 2026-01-01 # Query invoices by date range +ksef session close # 4. Close session (optional) +``` + +Invoice number (`P_2` in XML) must be unique — resubmitting gives error 440 (Duplikat faktury). + +### OpenAPI spec + +`packages/ksef-client-ts/docs/open-api.json` is the KSeF API OpenAPI specification (source of truth, KSeF API v2.7.1, build `2.7.1-te`; synced from the live TEST endpoint `https://api-test.ksef.mf.gov.pl/docs/v2/openapi.json`). Note: TEST/DEMO lead while PROD trails, so the vendored spec can be ahead of what PROD serves. Update it with `yarn sync-openapi` (`--env demo|prod` to pull from another environment, `--dry-run` to preview the delta), which writes the served document verbatim. Per-domain chunks in `packages/ksef-client-ts/docs/openapi-chunks/` (10 chunks + manifest; descriptions stripped to save tokens). Regenerate with `yarn split-openapi` after every sync. Validate coverage with `yarn check-api`. + +### XSD schemas + +`packages/ksef-client-ts/docs/schemas/` contains official KSeF invoice XSD schemas from [CIRFMF/ksef-docs](https://github.com/CIRFMF/ksef-docs). Organized by type: `FA/` (standard invoices), `PEF/` (Peppol), `RR/` (farmer invoices), each with `bazowe/` base types. Update with `yarn sync-schemas`. + +### Error hierarchy + +`KSeFError` (base) → `KSeFApiError` (generic HTTP), `KSeFBadRequestError` (400), `KSeFUnauthorizedError` (401), `KSeFForbiddenError` (403), `KSeFGoneError` (410, retention expired), `KSeFRateLimitError` (429), `KSeFBatchTimeoutError` (KSeF code 21208), `KSeFUnknownPublicKeyError` (KSeF code 21470, pre-empts `KSeFBadRequestError`), `KSeFAuthStatusError`, `KSeFSessionExpiredError`, `KSeFValidationError` (builder validation), `KSeFXsdValidationError` (XSD schema validation), `KSeFMetadataPaginationError` (paging cannot advance), `KSeFCircuitOpenError` (circuit breaker fail-fast). + +`RestClient.ensureSuccess` reads body text once, then parses per status code (400→429→401→403→410), falling back to a KSeF-error-code check and then generic `KSeFApiError`. + +### CI/CD + +GitHub Actions workflows in `.github/workflows/` (the `.github/` dir stays at the repo root; build/test steps run root-level `yarn` scripts that delegate to the `ksef-client-ts` workspace): +- `ci.yml` — markdown lint + unit + E2E tests on Node 18/20/22 matrix, coverage badge via gist (coverage JSON read from `packages/ksef-client-ts/coverage/`) +- `release.yml` — on tag `v*`: create GitHub Release (from `packages/ksef-client-ts/CHANGELOG.md`), then publish to npm + GitHub Packages in parallel +- `deploy-docs.yml` — VitePress → GitHub Pages (artifact from `packages/ksef-client-ts/docs/.vitepress/dist`) +- `deno-smoke.yml` — Deno runtime smoke test (`deno task smoke`, run in the package dir) +- `codex-pr-review.yml` — automatic Codex PR review on open/sync (prompt: `.github/codex/prompts/review.md`) + +### Documentation + +VitePress site in `packages/ksef-client-ts/docs/` with Scalar API reference. Config: `packages/ksef-client-ts/docs/.vitepress/config.ts`. +Feature descriptions live in two places that must be kept in sync: the root `README.md` (bullet list — the canonical repo landing page on GitHub) and `packages/ksef-client-ts/docs/index.md` (VitePress homepage cards). The package `packages/ksef-client-ts/README.md` is intentionally a thin npm-only intro (install + quick start + links) and is NOT a mirror of the feature list — do not duplicate the full feature bullets there. + +### Plans + +`plans/` directory (gitignored) contains development plans and roadmaps. Not tracked in git. + +- `plans/references.md` — reference-project comparison: maturity, crypto, feature gaps, KSeF API changelog +- `plans/shipped.md` — feature history by version +- `plans/backlog.md` — pending work, release proposals, deferred items +- `plans/p--.md` — individual feature sub-plans; each is cross-referenced from `backlog.md` + +### Reference implementations + +`ref/` directory (gitignored) contains reference implementations, official docs, and related projects. See `ref/ref-index.md` for the full index. + +### WebCrypto typing quirk + +`crypto.webcrypto.subtle.generateKey()` returns `CryptoKeyPair | CryptoKey`. Cast to `crypto.webcrypto.CryptoKeyPair` when generating key pairs — TypeScript cannot narrow this union. + +## Rules + +- Do not push commits or create files unless explicitly asked. Do not push to remote unless the user explicitly says "push" — committing and pushing are separate actions. Do not assume the user wants additional actions beyond what was requested. +- When writing CHANGELOG entries, describe the feature's purpose and user-facing impact, NOT implementation details. Keep entries concise — one sentence per bullet, no method names, class names, parameter names, option names, header names, CLI flags, internal field names, or error class identifiers. If an API version reference is useful (e.g. "KSeF API v2.2.0"), keep it in parentheses at the end. +- Always run the full test suite (unit + e2e) before committing. Ensure all tests pass before creating commits. +- When debugging issues, investigate root causes before suggesting surface-level fixes. Don't suggest simple retries or config changes without first checking if the value is hardcoded or the real problem is deeper. +- Always merge PRs with `--squash`. Merge commits are disabled on this repository. +- When squash-merging, edit the combined commit message to remove duplicate `Co-Authored-By` lines from individual commits — keep only a single `Co-Authored-By` at the very end. +- Name version branches with `version/` prefix (e.g. `version/v0.6.1`) to avoid conflicts with release tags. +- When writing documentation (README, docs/**, plans/**, CHANGELOG, etc.), always tag fenced code blocks containing ASCII tables, tree diagrams, or other non-code content with ` ```text ` instead of a bare ` ``` `. Reserve language-less fences only for genuinely untyped snippets. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 5296f7f5..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,194 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project - -Yarn 4.x workspace monorepo. The library (`ksef-client-ts`) lives in `packages/ksef-client-ts/`. TypeScript client for the Polish National e-Invoice System (KSeF) API v2. Targets Node.js 18+ with dual ESM/CJS output. Current version and release history are in `packages/ksef-client-ts/CHANGELOG.md`. - -## Commands - -Run from the **repo root** — all commands delegate to the `ksef-client-ts` workspace: - -```bash -yarn build # Build ESM + CJS + DTS via tsup -yarn lint # Type-check only (tsc --noEmit) -yarn test # Run unit tests (vitest run tests/unit) -yarn test:e2e # Run E2E tests (vitest run tests/e2e) -yarn test:watch # Watch mode (all tests) -yarn docs:dev # VitePress dev server -yarn docs:build # Build docs site -yarn check-api # Check OpenAPI coverage -yarn sync-openapi # Download the OpenAPI spec from the live KSeF API -yarn split-openapi # Split open-api.json into per-domain chunks -yarn sync-schemas # Download XSD schemas from CIRFMF/ksef-docs -``` - -Run a single test file: `yarn workspace ksef-client-ts vitest run tests/unit/foo.test.ts` - -Tests live in `packages/ksef-client-ts/tests/**/*.test.ts` (vitest, globals enabled). Unit tests in `tests/unit/`, E2E tests in `tests/e2e/` (relative to the package). - -**Package manager is yarn 4.x** (Corepack). Do not use npm. The `.yarnrc.yml` sets `nodeLinker: node-modules`. - -## Architecture - -### Layered design - -Source paths below are relative to the library package, `packages/ksef-client-ts/`. - -```text -KSeFClient (src/client.ts) - ├── 14 API services + crypto + qr + offline (17 properties total) - ├── each service wraps RestClient for its API domain - ├── crypto is lazy-initialized (user calls client.crypto.init()) - └── offline is lazy-initialized (accessed via client.offline) - -Services (src/services/*.ts) — 14 services - └── use RestClient.execute() with RestRequest builders + Routes constants - -HTTP layer (src/http/) - ├── RestClient — wraps native fetch, handles errors (429/401/403), JSON, auth headers - ├── RestRequest — fluent builder (method, path, body, headers, query) - ├── RouteBuilder — prepends /v2/ version prefix - ├── Routes — all API endpoint paths as const object - ├── RetryPolicy — exponential backoff with jitter, configurable retryable status codes - ├── RateLimitPolicy — token bucket rate limiter (global + per-endpoint) - ├── CircuitBreakerPolicy — opt-in fail-fast above retry: opens after N consecutive network/5xx failures, probes after cooldown (429/401 never trip) - ├── PresignedUrlPolicy — validates presigned download URLs (HTTPS, host allowlist) - └── AuthManager — manages access/refresh tokens, auto-refresh on 401 with dedup - -Crypto layer (src/crypto/) - ├── CertificateFetcher — fetches & caches KSeF public certs - ├── CryptographyService — AES-256-CBC, RSA-OAEP, ECDH+AES-GCM, CSR gen - ├── SignatureService — XAdES-B enveloped XML signatures (static) - └── CertificateService — self-signed cert generation (static) - -QR layer (src/qr/) - ├── VerificationLinkService — builds invoice/certificate verification URLs - └── QrCodeService — generates QR codes (PNG, SVG, SVG+label) - -Offline layer (src/offline/) - ├── types — OfflineMode, OfflineInvoiceStatus, OfflineInvoiceMetadata, OfflineCertificate - ├── deadline — calculateOfflineDeadline(), business day helpers, maintenance cascading - ├── storage — OfflineInvoiceStorage interface + InMemoryOfflineInvoiceStorage - └── file-storage — FileOfflineInvoiceStorage (~/.ksef/offline/) - -XML layer (src/xml/) - ├── upo-parser — parses official KSeF UPO receipt XML into typed objects - ├── invoice-field-extractor — extracts P_1/P_2/P_4B/P_4C from invoice XML - ├── xml-engine — fast-xml-parser wrapper (preserveOrder); parseXml/buildXml/stripBom - ├── order-map — ORDER_MAP per XSD parent + comparePKey natural sort + - │ multi-rate P_13/P_14/P_14W interleave per VAT group - ├── faktura-builder — FA2/FA3 builder; injects xmlns + xmlns:etd on - ├── pef-builder — PEF (Invoice) / PEF_KOR (CreditNote) UBL builder - └── invoice-serializer — polymorphic serializeInvoiceXml(input, options) → Buffer - dispatching on FakturaInput / PefUblDocumentInput / string / Buffer / XmlDocument - -CLI (src/cli/) — 17 command groups via citty - ├── setup, auth, session, invoice, permission, token, cert, lighthouse, limits, - │ collective-identifier, peppol, test-data, qr, config, doctor, completion, - │ offline - ├── requireSession() — auto-recovers via refresh or re-login from stored credentials - └── session-recovery — cascade: refresh token → loginWithToken from credentials → error -``` - -### Key conventions - -- **Imports use `.js` extensions** (ESM resolution convention, even for `.ts` source files). -- **Models** are in `src/models/{domain}/types.ts` with barrel `index.ts` re-exports. Types from `src/models/common.ts` are shared across domains. -- **Builders** in `src/builders/` provide fluent APIs for complex request construction. -- **Static vs instance**: `SignatureService` and `CertificateService` are fully static (no state). `CryptographyService` requires a `CertificateFetcher` instance (injected via `KSeFClient` constructor). -- **No auto-init**: `CryptographyService.init()` must be called explicitly to fetch KSeF public certificates. It is NOT called in the `KSeFClient` constructor. - -### Naming collisions to be aware of - -- `CertificateApiService` (src/services/) — API CRUD for certificate enrollment. Named with "Api" suffix to avoid collision with `CertificateService` (src/crypto/) which handles self-signed cert generation. -- `InvoiceFilterInvoicingMode` (not `InvoicingMode`) — avoids collision with session types. -- `PermissionSubjectIdentifierType` (not `SubjectIdentifierType`) — avoids collision with auth types. Note: both now use `'Nip' | 'Pesel' | 'Fingerprint'` values (aligned with OpenAPI spec). - -### KSeF environments and portals - -| Env | API | Web Portal | -|-----|-----|------------| -| PROD | `https://api.ksef.mf.gov.pl` | `https://ap.ksef.mf.gov.pl/web/` | -| TEST | `https://api-test.ksef.mf.gov.pl` | `https://ap-test.ksef.mf.gov.pl/web/` | -| DEMO | `https://api-demo.ksef.mf.gov.pl` | `https://ap-demo.ksef.mf.gov.pl/web/` | - -The web portal is used for token generation, permission management, and invoice browsing via browser (requires qualified signature or trusted profile). Each environment is fully isolated — accounts, tokens, and certificates created in one env do not exist in others. - -### Environment variables - -`KSEF_NIP`, `KSEF_TOKEN` (for PROD) and `KSEF_TOKEN_DEMO` (for DEMO) are set in the current shell environment. - -- PROD: `ksef auth login --token "$KSEF_TOKEN" --nip "$KSEF_NIP" --env prod` -- DEMO: `ksef auth login --token "$KSEF_TOKEN_DEMO" --nip "$KSEF_NIP" --env demo` - -### Invoice upload flow (CLI) - -```bash -ksef auth login --token "$KSEF_TOKEN" --nip "$KSEF_NIP" -ksef session open # 1. Open online session (required before sending) -ksef invoice build data.json # (optional) Build XML from JSON/YAML; `--template FA3` prints a skeleton. -ksef invoice send file.xml # 2. Send invoice -ksef session invoices # 3. Verify invoice status (check for errors/duplicates) -ksef invoice query --from 2026-01-01 # Query invoices by date range -ksef session close # 4. Close session (optional) -``` - -Invoice number (`P_2` in XML) must be unique — resubmitting gives error 440 (Duplikat faktury). - -### OpenAPI spec - -`packages/ksef-client-ts/docs/open-api.json` is the KSeF API OpenAPI specification (source of truth, KSeF API v2.7.1, build `2.7.1-te`; synced from the live TEST endpoint `https://api-test.ksef.mf.gov.pl/docs/v2/openapi.json`). Note: TEST/DEMO lead while PROD trails, so the vendored spec can be ahead of what PROD serves. Update it with `yarn sync-openapi` (`--env demo|prod` to pull from another environment, `--dry-run` to preview the delta), which writes the served document verbatim. Per-domain chunks in `packages/ksef-client-ts/docs/openapi-chunks/` (10 chunks + manifest; descriptions stripped to save tokens). Regenerate with `yarn split-openapi` after every sync. Validate coverage with `yarn check-api`. - -### XSD schemas - -`packages/ksef-client-ts/docs/schemas/` contains official KSeF invoice XSD schemas from [CIRFMF/ksef-docs](https://github.com/CIRFMF/ksef-docs). Organized by type: `FA/` (standard invoices), `PEF/` (Peppol), `RR/` (farmer invoices), each with `bazowe/` base types. Update with `yarn sync-schemas`. - -### Error hierarchy - -`KSeFError` (base) → `KSeFApiError` (generic HTTP), `KSeFBadRequestError` (400), `KSeFUnauthorizedError` (401), `KSeFForbiddenError` (403), `KSeFGoneError` (410, retention expired), `KSeFRateLimitError` (429), `KSeFBatchTimeoutError` (KSeF code 21208), `KSeFUnknownPublicKeyError` (KSeF code 21470, pre-empts `KSeFBadRequestError`), `KSeFAuthStatusError`, `KSeFSessionExpiredError`, `KSeFValidationError` (builder validation), `KSeFXsdValidationError` (XSD schema validation), `KSeFMetadataPaginationError` (paging cannot advance), `KSeFCircuitOpenError` (circuit breaker fail-fast). - -`RestClient.ensureSuccess` reads body text once, then parses per status code (400→429→401→403→410), falling back to a KSeF-error-code check and then generic `KSeFApiError`. - -### CI/CD - -GitHub Actions workflows in `.github/workflows/` (the `.github/` dir stays at the repo root; build/test steps run root-level `yarn` scripts that delegate to the `ksef-client-ts` workspace): -- `ci.yml` — markdown lint + unit + E2E tests on Node 18/20/22 matrix, coverage badge via gist (coverage JSON read from `packages/ksef-client-ts/coverage/`) -- `release.yml` — on tag `v*`: create GitHub Release (from `packages/ksef-client-ts/CHANGELOG.md`), then publish to npm + GitHub Packages in parallel -- `deploy-docs.yml` — VitePress → GitHub Pages (artifact from `packages/ksef-client-ts/docs/.vitepress/dist`) -- `deno-smoke.yml` — Deno runtime smoke test (`deno task smoke`, run in the package dir) -- `codex-pr-review.yml` — automatic Codex PR review on open/sync (prompt: `.github/codex/prompts/review.md`) - -### Documentation - -VitePress site in `packages/ksef-client-ts/docs/` with Scalar API reference. Config: `packages/ksef-client-ts/docs/.vitepress/config.ts`. -Feature descriptions live in two places that must be kept in sync: the root `README.md` (bullet list — the canonical repo landing page on GitHub) and `packages/ksef-client-ts/docs/index.md` (VitePress homepage cards). The package `packages/ksef-client-ts/README.md` is intentionally a thin npm-only intro (install + quick start + links) and is NOT a mirror of the feature list — do not duplicate the full feature bullets there. - -### Plans - -`plans/` directory (gitignored) contains development plans and roadmaps. Not tracked in git. - -- `plans/references.md` — reference-project comparison: maturity, crypto, feature gaps, KSeF API changelog -- `plans/shipped.md` — feature history by version -- `plans/backlog.md` — pending work, release proposals, deferred items -- `plans/p--.md` — individual feature sub-plans; each is cross-referenced from `backlog.md` - -### Reference implementations - -`ref/` directory (gitignored) contains reference implementations, official docs, and related projects. See `ref/ref-index.md` for the full index. - -### WebCrypto typing quirk - -`crypto.webcrypto.subtle.generateKey()` returns `CryptoKeyPair | CryptoKey`. Cast to `crypto.webcrypto.CryptoKeyPair` when generating key pairs — TypeScript cannot narrow this union. - -## Rules - -- Do not push commits or create files unless explicitly asked. Do not push to remote unless the user explicitly says "push" — committing and pushing are separate actions. Do not assume the user wants additional actions beyond what was requested. -- When writing CHANGELOG entries, describe the feature's purpose and user-facing impact, NOT implementation details. Keep entries concise — one sentence per bullet, no method names, class names, parameter names, option names, header names, CLI flags, internal field names, or error class identifiers. If an API version reference is useful (e.g. "KSeF API v2.2.0"), keep it in parentheses at the end. -- Always run the full test suite (unit + e2e) before committing. Ensure all tests pass before creating commits. -- When debugging issues, investigate root causes before suggesting surface-level fixes. Don't suggest simple retries or config changes without first checking if the value is hardcoded or the real problem is deeper. -- Always merge PRs with `--squash`. Merge commits are disabled on this repository. -- When squash-merging, edit the combined commit message to remove duplicate `Co-Authored-By` lines from individual commits — keep only a single `Co-Authored-By` at the very end. -- Name version branches with `version/` prefix (e.g. `version/v0.6.1`) to avoid conflicts with release tags. -- When writing documentation (README, docs/**, plans/**, CHANGELOG, etc.), always tag fenced code blocks containing ASCII tables, tree diagrams, or other non-code content with ` ```text ` instead of a bare ` ``` `. Reserve language-less fences only for genuinely untyped snippets. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/ksef-client-ts/src/pdf/template/dsl.ts b/packages/ksef-client-ts/src/pdf/template/dsl.ts index a6fb9e7d..f372006b 100644 --- a/packages/ksef-client-ts/src/pdf/template/dsl.ts +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -290,13 +290,6 @@ export interface PaymentGroup { fields: FieldDef[]; } -/** - * A payment line: a field, plus the same `when` gate a totals row carries. The - * gate exists because one figure can have several readings — `P_15` is an - * amount owed on an ordinary invoice and an amount already received on an - * advance one — and the template picks the right label by listing one row per - * reading. - */ /** * A figure to read, in one of three shapes: `{ from, path }` sums one binding * over every entry of a collection, `{ path }` reads a single binding, and @@ -313,29 +306,51 @@ export type RepeatedSum = | { path: string; from?: string; sum?: never } | { sum: string[]; path?: never; from?: never }; -export interface PaymentRow extends Omit { - /** - * A row with no `path` prints its label alone, and is worth having because - * some facts are the label: `Zapłacono` says everything there is to say, and - * printing the schema's `1` after it says nothing. Such a row is normally - * paired with `when`. - */ - path?: string; - when?: string; - /** See {@link TotalsRow.less}. */ - less?: RepeatedSum; - /** See {@link TotalsRow.sumFrom}. */ - sumFrom?: RepeatedSum; - /** - * Repeat this line once per entry of a collection, with the entry as the - * binding root (so `path` and `suffixPath` are item-relative). KSeF allows up - * to 100 `TerminPlatnosci` blocks — an invoice paid in instalments states one - * per instalment — and a scalar path silently prints only the first, because - * a walk that meets an array follows its head. Entries are read leniently: - * every field of a payment term is optional, so an absent one is by design. - */ - from?: string; -} +/** + * A payment line: a field, plus the same `when` gate a totals row carries. The + * gate exists because one figure can have several readings — `P_15` is an + * amount owed on an ordinary invoice and an amount already received on an + * advance one — and the template picks the right label by listing one row per + * reading. + * + * Two shapes rather than one with everything optional, because a row is either + * read from the document or computed from it. The renderer settles a computed + * figure before it looks at any binding, so a row carrying both prints the + * computed number under a label written for the reading — and neither half is + * wrong on its own for an error to announce. The validator refuses the + * combination in a parsed template; the union refuses it in a hand-built one. + */ +export type PaymentRow = Omit & { when?: string } & ( + | { + /** + * A row with no `path` prints its label alone, and is worth having + * because some facts are the label: `Zapłacono` says everything there + * is to say, and printing the schema's `1` after it says nothing. Such + * a row is normally paired with `when`. + */ + path?: string; + /** + * Repeat this line once per entry of a collection, with the entry as + * the binding root (so `path` and `suffixPath` are item-relative). KSeF + * allows up to 100 `TerminPlatnosci` blocks — an invoice paid in + * instalments states one per instalment — and a scalar path silently + * prints only the first, because a walk that meets an array follows its + * head. Entries are read leniently: every field of a payment term is + * optional, so an absent one is by design. + */ + from?: string; + /** See {@link TotalsRow.less}. */ + less?: RepeatedSum; + sumFrom?: never; + } + | { + /** See {@link TotalsRow.sumFrom}. */ + sumFrom: RepeatedSum; + path?: never; + from?: never; + less?: never; + } + ); export interface PaymentBlock { type: 'payment'; diff --git a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts index 1901a690..d5021a47 100644 --- a/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -46,11 +46,14 @@ const KSEF_ZAL_B = '1111111111-20250312-020000000000-B2'; const KSEF_ROZ_B = '1111111111-20250408-020000000000-B3'; /** - * The QR group renders against DEMO. The documents are invented, so no verifier - * will resolve them anywhere — but a demo link is the one a reader can safely - * click, and it keeps every code in the group pointing at the same host. + * The QR group renders against TEST — the environment the rest of this suite + * drives, so a page cannot name one environment while the spec beside it + * authenticates against another. Nothing goes over the wire either way: a + * verification link is computed, never called, and the documents are invented, + * so no verifier resolves them anywhere. What naming a host buys is that every + * code in the group points at the same one. */ -const DEMO_QR_HOST = 'https://qr-demo.ksef.mf.gov.pl'; +const TEST_QR_HOST = 'https://qr-test.ksef.mf.gov.pl'; function run(args: string[]) { const result = spawnSync('node', [cliEntry, ...args], { encoding: 'utf-8', cwd: repoRoot }); @@ -70,6 +73,7 @@ let oldTotalsTemplate: string; let multiDocumentUpo: string; let certificateQrUrl: string; let notesFile: string; +let oneSidedNotes: string; function writeDerivedInputs(): void { // A copy of fa3-default whose totals read a single rate bucket — the shape the @@ -117,7 +121,12 @@ function writeDerivedInputs(): void { ]), ); - certificateQrUrl = new VerificationLinkService(DEMO_QR_HOST).buildCertificateVerificationUrl( + // A note with only a head, and one with only a body — the two shapes page 12 + // is there to show. + oneSidedNotes = join(inputsDir, 'cli-notes-one-sided.json'); + writeFileSync(oneSidedNotes, JSON.stringify([{ head: 'Tylko nagłówek' }, { body: 'Tylko treść.' }])); + + certificateQrUrl = new VerificationLinkService(TEST_QR_HOST).buildCertificateVerificationUrl( 'Nip', '1111111111', '1111111111', @@ -148,7 +157,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { /** The two hex forms the flag accepts, so the preview set exercises both. */ const ACCENT = '#5AB595'; const ACCENT_SHORT = '#b04'; - const SUPPLIED_CODE_I = `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`; + const SUPPLIED_CODE_I = `${TEST_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`; /** * The preview set, laid out as a covering design rather than one variant per @@ -178,7 +187,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { const variants: Array<[name: string, args: () => string[]]> = [ [`${PREFIX}-01-invoice-pl-code-i-accent`, () => [ fx('e2e-services-np.xml'), '--ksef-number', KSEF_NUMBER, ...LOGO(), - '--env', 'demo', '--qr', '--totals', 'buckets', '--accent', ACCENT, + '--env', 'test', '--qr', '--totals', 'buckets', '--accent', ACCENT, ]], [`${PREFIX}-02-invoice-en-supplied-code-i-links`, () => [ fx('fa3.xml'), '--ksef-number', KSEF_NUMBER, '--locale', 'en', @@ -186,11 +195,11 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { ]], [`${PREFIX}-03-invoice-uk-offline-code-ii-links`, () => [ fx('e2e-buyer-no-id.xml'), ...LOGO(), '--locale', 'uk', - '--env', 'demo', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', + '--env', 'test', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', ]], [`${PREFIX}-04-invoice-bilingual-offline-code-ii`, () => [ fx('e2e-vat-multi.xml'), '--locale', 'en+pl', - '--env', 'demo', '--qr-cert-url', certificateQrUrl, '--totals', 'none', + '--env', 'test', '--qr-cert-url', certificateQrUrl, '--totals', 'none', ]], // The only page drawn from a template file rather than a built-in, which is // what keeps --template-file wired. Its totals deliberately read the @@ -201,7 +210,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // nothing here reads a figure off the page. [`${PREFIX}-05-invoice-pl-uk-custom-template-file`, () => [ fx('e2e-vat-multi.xml'), ...LOGO(), '--locale', 'pl+uk', - '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', + '--env', 'test', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'both', '--notes', notesFile, '--template-file', oldTotalsTemplate, ]], // Beyond the grid the pages come in *chains*, numbered so one deal runs from @@ -218,8 +227,9 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // // The two chains exist because the FA schemas let a settlement invoice // state what is left in either of two ways, and the pages have to be right - // for both. 10 and 11 then stand alone: an ordinary invoice being paid down, - // and one that has been overpaid. + // for both. 10, 11 and 12 then stand alone: an ordinary invoice being paid + // down, one that has been overpaid, and one whose notes each carry only + // half of what a note can carry. // 06 — chain A, the advance invoice (ZAL). No `Fa.FaWiersz`: the goods sit // under `Fa.Zamowienie`, so the page shows the order table under its own @@ -228,7 +238,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // exactly. Its KSeF number is the one page 07 points back at. [`${PREFIX}-06-chain-a-advance`, () => [ fx('fa3-zal.xml'), '--ksef-number', KSEF_ZAL_A, - '--env', 'demo', '--qr', '--totals', 'buckets', + '--env', 'test', '--qr', '--totals', 'buckets', ]], // 07 — chain A, the settlement (ROZ) that closes 06. The lines and VAT // items show the whole 615,00 order while the tax summary and `P_15` cover @@ -239,13 +249,13 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // the caller has accepted computed figures. [`${PREFIX}-07-chain-a-settlement-stated`, () => [ fx('fa3-roz.xml'), '--ksef-number', KSEF_ROZ_A, - '--env', 'demo', '--qr', '--totals', 'both', + '--env', 'test', '--qr', '--totals', 'both', ]], // 08 — chain B, a different buyer and a different deal: order 1 230,00, // advance 800,00 received in March. [`${PREFIX}-08-chain-b-advance`, () => [ fx('fa3-zal-b.xml'), '--ksef-number', KSEF_ZAL_B, '--locale', 'en', - '--env', 'demo', '--qr', '--totals', 'buckets', + '--env', 'test', '--qr', '--totals', 'buckets', ]], // 09 — chain B's settlement, which states the payments it received instead // of leaving them on 08. So `P_15` is the whole 1 230,00 and what is owed is @@ -253,7 +263,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // fields, 430,00. No field carries that number. [`${PREFIX}-09-chain-b-settlement-computed`, () => [ fx('fa3-roz-b.xml'), '--ksef-number', KSEF_ROZ_B, '--locale', 'en', - '--env', 'demo', '--qr', '--totals', 'both', + '--env', 'test', '--qr', '--totals', 'both', ]], // 10 — standalone: an ordinary invoice being paid down, which is a // different thing from an advance and reads differently. `Platnosc` takes @@ -262,29 +272,37 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { // add up to the total — that is what "paid in part" means. [`${PREFIX}-10-partial-payments`, () => [ fx('fa3-czesciowa.xml'), '--ksef-number', KSEF_NUMBER, - '--env', 'demo', '--qr', '--totals', 'buckets', + '--env', 'test', '--qr', '--totals', 'buckets', ]], // 11 — standalone, the opposite end of the same branch: `Rozliczenie` // states a `DoRozliczenia` overpayment rather than a `DoZaplaty`. Nothing // is owed, so nothing on the page may ask for payment. [`${PREFIX}-11-overpayment`, () => [ fx('fa3-nadplata.xml'), '--ksef-number', KSEF_NUMBER, - '--env', 'demo', '--qr', '--totals', 'buckets', + '--env', 'test', '--qr', '--totals', 'buckets', ]], - // 12 — not a document shape but a template: `fa3-showcase` exists to + // 12 — the notes flag, given a note with only a head and one with only a + // body. The renderer prints whichever half a note carries, and the docs say + // so, so the flag has to accept the same shape the library does: a CLI + // stricter than the API it fronts rejects input the user was told was + // valid. It is a page rather than an assertion because what a half-note + // looks like — a heading with nothing under it, a paragraph with nothing + // over it — is a layout question, and those are settled by eye here. + [`${PREFIX}-12-notes-one-sided`, () => [fx('fa3.xml'), '--notes', oneSidedNotes]], + // 13 — not a document shape but a template: `fa3-showcase` exists to // exercise the DSL (palette, letter spacing, highlighted text, colour bars // drawn as data-URI images), rendered with everything switched on so a DSL // change that breaks it is visible rather than discovered by a reader. It // carries the accent in its short hex form, and so is the page that shows // whether an accent wins over a template's own palette. - [`${PREFIX}-12-showcase-template-accent`, () => [ + [`${PREFIX}-13-showcase-template-accent`, () => [ fx('e2e-vat-multi.xml'), '--template', 'fa3-showcase', ...LOGO(), - '--env', 'demo', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', + '--env', 'test', '--qr', '--qr-cert-url', certificateQrUrl, '--qr-links', '--totals', 'summary', '--notes', notesFile, '--accent', ACCENT_SHORT, ]], // Receipts last: they are a different document and read as their own group. - [`${PREFIX}-13-upo-pl`, () => [fx('upo-4_3.xml')]], - [`${PREFIX}-14-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], + [`${PREFIX}-14-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-15-upo-five-documents-bilingual`, () => [multiDocumentUpo, '--locale', 'en+pl']], ]; /** @@ -346,7 +364,7 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { it('renders every variant of the set', () => { // Guards against a variant being silently dropped from the table above: // the count is stated here so removing a row has to be deliberate. - expect(variants).toHaveLength(14); + expect(variants).toHaveLength(15); for (const [name] of variants) { expect(existsSync(join(outDir, `${name}.pdf`)), `${name}.pdf missing`).toBe(true); } @@ -366,21 +384,8 @@ describe('35 - `ksef invoice pdf` renders the preview set', () => { expect(existsSync(out)).toBe(false); }); - // The renderer prints whichever half a note carries, and the docs say so, so - // the flag has to accept the same shape the library does — a CLI stricter than - // the API it fronts rejects input the user was told was valid. - it('accepts a note with only a head or only a body', () => { - const oneSided = join(inputsDir, `${PREFIX}-notes-one-sided.json`); - writeFileSync( - oneSided, - JSON.stringify([{ head: 'Tylko nagłówek' }, { body: 'Tylko treść.' }]), - ); - const out = join(outDir, `${PREFIX}-notes-one-sided.pdf`); - const res = run(['invoice', 'pdf', fx('fa3.xml'), '--notes', oneSided, '--out', out]); - expect(res.status, `exit ${res.status}\n${res.stderr}`).toBe(0); - expect(isCompletePdf(out), `${out} is not a complete PDF`).toBe(true); - }); - + // Page 12 renders the note shapes the flag accepts; these two pin what it + // still refuses, which writes no file at all. it('still refuses a note entry that carries neither half', () => { const empty = join(inputsDir, `${PREFIX}-notes-empty-entry.json`); writeFileSync(empty, JSON.stringify([{ note: 'wrong key' }])); diff --git a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts index 4f4f46ec..6e16f584 100644 --- a/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts +++ b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts @@ -47,8 +47,8 @@ const fixtures = join(repoRoot, 'tests', 'fixtures', 'pdf'); const outDir = process.env.KSEF_PDF_OUT ?? join(repoRoot, '.pdf-preview'); const inputsDir = join(outDir, '_inputs'); const PREFIX = 'lib'; -/** Same host as the CLI preview set: a demo link is one a reader can click. */ -const DEMO_QR_HOST = 'https://qr-demo.ksef.mf.gov.pl'; +/** Same host as the CLI preview set: the environment this suite drives. */ +const TEST_QR_HOST = 'https://qr-test.ksef.mf.gov.pl'; const require = createRequire(import.meta.url); @@ -122,7 +122,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => logo: LOGO, ksefNumber: KSEF_NUMBER, qr: true, - env: 'demo', + env: 'test', }), ); }); @@ -185,7 +185,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => format: 'pem', }) as string; const certificateQrUrl = new VerificationLinkService( - DEMO_QR_HOST, + TEST_QR_HOST, ).buildCertificateVerificationUrl( 'Nip', '1111111111', @@ -199,7 +199,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => renderInvoicePdf(raw, 'fa3-default', { qr: true, strict: true, - env: 'demo', + env: 'test', invoiceHash, certificateQrUrl, qrLinks: true, @@ -212,7 +212,7 @@ describe('36 - `ksef-client-ts/pdf` renders beyond what the CLI exposes', () => await save( `${PREFIX}-04-supplied-code-i-url`, renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { - qrUrl: `${DEMO_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`, + qrUrl: `${TEST_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`, qrLinks: true, locale: 'pl+uk', notes: [{ head: 'Delivery terms', body: 'Goods released at the seller warehouse.' }], diff --git a/packages/ksef-client-ts/tests/fixtures/pdf-types-check.ts b/packages/ksef-client-ts/tests/fixtures/pdf-types-check.ts index 42a5e531..8e273e55 100644 --- a/packages/ksef-client-ts/tests/fixtures/pdf-types-check.ts +++ b/packages/ksef-client-ts/tests/fixtures/pdf-types-check.ts @@ -39,3 +39,31 @@ const _loc: Locale = 'en'; const _iv: 'FA(2)' | 'FA(3)' | null = detectInvoiceVersion(xml); const _uv: 'UPO(4.2)' | 'UPO(4.3)' | null = detectUpoVersion(xml); void _loc; void _iv; void _uv; + +/** + * A payment row is either read from the document or computed from it, never + * both — the renderer settles a computed figure first, so a row carrying both + * would print the computed number under a label written for the reading. The + * validator refuses that in a parsed template; these assertions pin that a + * hand-built one is refused at compile time, through the published types. + * + * Each bad row is written on one line so `@ts-expect-error` covers wherever the + * compiler anchors the error inside it. + */ +type PaymentRows = Extract['rows']; + +const paid = { from: 'Fa.Platnosc.ZaplataCzesciowa', path: 'KwotaZaplatyCzesciowej' }; + +const _validRows: PaymentRows = [ + { label: 'paid' }, + { label: 'dueDate', path: 'Fa.Platnosc.TerminPlatnosci.Termin', from: 'Fa.Platnosc.TerminPlatnosci' }, + { label: 'toPay', path: 'Fa.P_15', less: paid }, + { label: 'paidTotal', sumFrom: paid }, +]; +// @ts-expect-error — a computed row states its own figure, so it takes no `path` +const _sumFromWithPath: PaymentRows = [{ label: 'paidTotal', sumFrom: paid, path: 'Fa.P_15' }]; +// @ts-expect-error — ...nor a `from` to repeat itself over +const _sumFromWithFrom: PaymentRows = [{ label: 'paidTotal', sumFrom: paid, from: 'Fa.Platnosc.ZaplataCzesciowa' }]; +// @ts-expect-error — ...nor a `less` to subtract from a value it never read +const _sumFromWithLess: PaymentRows = [{ label: 'paidTotal', sumFrom: paid, less: paid }]; +void _validRows; void _sumFromWithPath; void _sumFromWithFrom; void _sumFromWithLess; From 49955fd4ec00520e0e3b8537477deb37ff488893 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 22:43:03 +0200 Subject: [PATCH 66/67] fix(pdf): pay instalments against the payable, not against P_15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Rozliczenie.DoZaplaty` is, in the schema's words, the amount due "równa polu P_15 powiększonemu o Obciazenia i pomniejszonemu o Odliczenia". Where a document states it, that is what the reader owes — but the remainder row subtracted the instalments from `P_15` regardless, so an invoice carrying a surcharge and settled in part printed a remainder short by that surcharge, directly under its own correct `Do zapłaty` line. Two figures on one page that cannot both be right, and nothing to raise an error: both readings are valid shapes on their own. Nothing in FA stops the two from meeting, so the base is now chosen per document: a new pair of payment flags says whether the instalments come off the stated payable or off `P_15`, and each of the three invoice templates lists one remainder row per base, in totals and in payment alike. Verified with a case built from the part-payment fixture plus a 10,00 surcharge (P_15 615,00, payable 625,00, 450,00 paid): before, both blocks printed 165,00 in all three templates; after, they print 175,00, while an invoice with no `Rozliczenie` still reads 165,00 off `P_15`. Full unit suite 2905 passing, and the two PDF E2E specs 39 passing against a fresh build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M7sLete4AKPr5oHz2cMFqM --- .../ksef-client-ts/src/pdf/document-flags.ts | 13 ++- .../src/pdf/template/builtin/fa2-default.json | 21 +++- .../src/pdf/template/builtin/fa3-default.json | 21 +++- .../pdf/template/builtin/fa3-showcase.json | 21 +++- .../unit/pdf/builtin-template-lint.test.ts | 5 +- .../tests/unit/pdf/partial-payments.test.ts | 99 ++++++++++++++++++- 6 files changed, 166 insertions(+), 14 deletions(-) diff --git a/packages/ksef-client-ts/src/pdf/document-flags.ts b/packages/ksef-client-ts/src/pdf/document-flags.ts index a06ccb67..a9cc00dc 100644 --- a/packages/ksef-client-ts/src/pdf/document-flags.ts +++ b/packages/ksef-client-ts/src/pdf/document-flags.ts @@ -90,9 +90,20 @@ export function p15Flags(root: unknown): Record { */ export function paymentFlags(root: unknown): Record { const mark = get(root, 'Fa.Platnosc.ZnacznikZaplatyCzesciowej'); + const paidInPart = mark === '1'; + // What the instalments are paid against. The schema defines + // `Rozliczenie.DoZaplaty` as `P_15` plus surcharges less deductions, so a + // document that states it states the figure the reader owes — and a + // remainder taken off `P_15` would be short by the surcharge while the page's + // own `Do zapłaty` line, one row above, said otherwise. Nothing in FA stops + // an invoice carrying surcharges from being settled in instalments, so the + // two really do meet. + const payableStated = has(root, 'Fa.Rozliczenie.DoZaplaty'); return { paidInFull: get(root, 'Fa.Platnosc.Zaplacono') === '1' || mark === '2', - paidInPart: mark === '1', + paidInPart, + paidInPartOfPayable: paidInPart && payableStated, + paidInPartOfTotal: paidInPart && !payableStated, }; } diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json index a0fa0bc6..ff015c56 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -253,10 +253,18 @@ "style": "strong" }, { "label": "paidTotal", "when": "paidInPart", "sumFrom": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money" }, + { + "label": "remainingDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "paidInPartOfPayable", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "style": "strong" + }, { "label": "remainingDue", "path": "Fa.P_15", - "when": "paidInPart", + "when": "paidInPartOfTotal", "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money", "style": "strong" @@ -338,10 +346,19 @@ "suffixPath": "Fa.KodWaluty", "style": "strong" }, + { + "label": "remainingDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "paidInPartOfPayable", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, { "label": "remainingDue", "path": "Fa.P_15", - "when": "paidInPart", + "when": "paidInPartOfTotal", "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money", "suffixPath": "Fa.KodWaluty", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json index 87f977a4..9dc01bf6 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -253,10 +253,18 @@ "style": "strong" }, { "label": "paidTotal", "when": "paidInPart", "sumFrom": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money" }, + { + "label": "remainingDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "paidInPartOfPayable", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "style": "strong" + }, { "label": "remainingDue", "path": "Fa.P_15", - "when": "paidInPart", + "when": "paidInPartOfTotal", "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money", "style": "strong" @@ -337,10 +345,19 @@ "suffixPath": "Fa.KodWaluty", "style": "strong" }, + { + "label": "remainingDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "paidInPartOfPayable", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, { "label": "remainingDue", "path": "Fa.P_15", - "when": "paidInPart", + "when": "paidInPartOfTotal", "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money", "suffixPath": "Fa.KodWaluty", diff --git a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json index d1f746b0..a7dedfde 100644 --- a/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -191,10 +191,18 @@ "style": "strong" }, { "label": "paidTotal", "when": "paidInPart", "sumFrom": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money" }, + { + "label": "remainingDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "paidInPartOfPayable", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "style": "strong" + }, { "label": "remainingDue", "path": "Fa.P_15", - "when": "paidInPart", + "when": "paidInPartOfTotal", "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money", "style": "strong" @@ -275,10 +283,19 @@ "suffixPath": "Fa.KodWaluty", "style": "strong" }, + { + "label": "remainingDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "paidInPartOfPayable", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + }, { "label": "remainingDue", "path": "Fa.P_15", - "when": "paidInPart", + "when": "paidInPartOfTotal", "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, "format": "money", "suffixPath": "Fa.KodWaluty", diff --git a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts index 2c1d0d64..de221a88 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -56,8 +56,9 @@ const CONTEXT_CONDITIONS = new Set([ // The settlement reconciliation, which is derived and so is gated on the // totals mode as well as on the document. 'settlementBreakdown', - // How much of the invoice `Platnosc` says has been paid. - 'paidInFull', 'paidInPart', + // How much of the invoice `Platnosc` says has been paid, and — where it is + // part-paid — which figure the instalments come off. + 'paidInFull', 'paidInPart', 'paidInPartOfPayable', 'paidInPartOfTotal', ]); interface CollectedPaths { diff --git a/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts b/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts index 0b3bd978..8370533e 100644 --- a/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts +++ b/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts @@ -4,7 +4,8 @@ import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.js'; import { documentFlags, paymentFlags } from '../../../src/pdf/document-flags.js'; import { parseXmlForPdf } from '../../../src/pdf/parse.js'; import { paymentRenderer } from '../../../src/pdf/template/blocks/payment.js'; -import type { PaymentBlock } from '../../../src/pdf/template/dsl.js'; +import { totalsRenderer } from '../../../src/pdf/template/blocks/totals.js'; +import type { PaymentBlock, TotalsBlock } from '../../../src/pdf/template/dsl.js'; import type { PdfNode, RenderChild, RenderContext } from '../../../src/pdf/template/interpret.js'; /** @@ -44,15 +45,44 @@ function paymentLines(templateName: string, xml: string, locale?: (k: string) => return out; } +/** Rendered totals as `label -> value`, in the order the rows appear. */ +function totalsPairs(templateName: string, xml: string): Array { + const block = getBuiltinTemplate(templateName)!.blocks.find((b) => b.type === 'totals') as TotalsBlock; + const root = bodyOf(xml); + const ctx: RenderContext = { + root, + strict: false, + label: (k: string) => k, + bindings: {}, + flags: { ...documentFlags(root), totalsBuckets: true }, + }; + const node = totalsRenderer(block, ctx, noRender) as { + columns: Array<{ table?: { body: Array> } }>; + }; + const body = node.columns.find((c) => c.table)!.table!.body; + return body.map(([label, value]) => [label!.text, value!.text] as const); +} + describe('how much of the invoice has been paid', () => { it('reads the full-payment branch', () => { - expect(paymentFlags(bodyOf(fx('fa3.xml')))).toEqual({ paidInFull: true, paidInPart: false }); + expect(paymentFlags(bodyOf(fx('fa3.xml')))).toEqual({ + paidInFull: true, + paidInPart: false, + paidInPartOfPayable: false, + paidInPartOfTotal: false, + }); }); it('reads the partial branch, which carries no Zaplacono at all', () => { const xml = fx('fa3-czesciowa.xml'); expect(xml).not.toContain(''); - expect(paymentFlags(bodyOf(xml))).toEqual({ paidInFull: false, paidInPart: true }); + expect(paymentFlags(bodyOf(xml))).toEqual({ + paidInFull: false, + paidInPart: true, + // No `Rozliczenie` on this fixture, so `P_15` is what is being paid down. + paidInPartOfPayable: false, + paidInPartOfTotal: true, + }); }); it('treats the marker value 2 as paid in full', () => { @@ -60,12 +90,22 @@ describe('how much of the invoice has been paid', () => { '1<', '2<', ); - expect(paymentFlags(bodyOf(xml))).toEqual({ paidInFull: true, paidInPart: false }); + expect(paymentFlags(bodyOf(xml))).toEqual({ + paidInFull: true, + paidInPart: false, + paidInPartOfPayable: false, + paidInPartOfTotal: false, + }); }); it('says nothing either way when the invoice states no payment status', () => { const xml = fx('fa3.xml').replace(/\s*1<\/Zaplacono>/, ''); - expect(paymentFlags(bodyOf(xml))).toEqual({ paidInFull: false, paidInPart: false }); + expect(paymentFlags(bodyOf(xml))).toEqual({ + paidInFull: false, + paidInPart: false, + paidInPartOfPayable: false, + paidInPartOfTotal: false, + }); }); }); @@ -169,3 +209,52 @@ describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])('%s partial paymen expect(lines).toContain('bankAccount: 11109000880000000100000001'); }); }); + +/** + * `Rozliczenie.DoZaplaty` is, in the schema's own words, "kwota należności do + * zapłaty równa polu P_15 powiększonemu o Obciazenia i pomniejszonemu o + * Odliczenia" — so on a document that states it, that figure, not `P_15`, is + * what the reader owes and therefore what the instalments are paid against. + * + * The two can appear together: nothing in FA stops an invoice carrying + * surcharges from being settled in instalments. A page that subtracts the + * instalments from `P_15` then prints a remainder short by the surcharge, + * directly under its own correct `Do zapłaty` line — two figures on one page + * that cannot both be right, and no error anywhere. + */ +describe.each(['fa2-default', 'fa3-default', 'fa3-showcase'])( + '%s pays instalments against the payable the document states', + (name) => { + const fa = name.startsWith('fa2') ? 'fa2' : 'fa3'; + /** P_15 615,00 plus a 10,00 surcharge: 625,00 payable, 450,00 of it paid. */ + const withSurcharge = (xml: string) => + xml.replace( + '', + '' + + '10.00Koszt dostawy' + + '10.00' + + '625.00' + + '', + ); + + it('subtracts them from DoZaplaty in the payment block, not from P_15', () => { + const lines = paymentLines(name, withSurcharge(fx(`${fa}-czesciowa.xml`))); + expect(lines).toContain('paidTotal: 450,00 PLN'); + expect(lines).toContain('remainingDue: 175,00 PLN'); + expect(lines).not.toContain('remainingDue: 165,00 PLN'); + }); + + it('subtracts them from DoZaplaty in the totals block too', () => { + const rows = totalsPairs(name, withSurcharge(fx(`${fa}-czesciowa.xml`))); + expect(rows).toContainEqual(['totalDue', '625,00']); + expect(rows).toContainEqual(['paidTotal', '450,00']); + expect(rows).toContainEqual(['remainingDue', '175,00']); + }); + + it('still subtracts them from P_15 when the document states no payable', () => { + const plain = fx(`${fa}-czesciowa.xml`); + expect(paymentLines(name, plain)).toContain('remainingDue: 165,00 PLN'); + expect(totalsPairs(name, plain)).toContainEqual(['remainingDue', '165,00']); + }); + }, +); From 5e04982a5927f39c1f015a53237c328d18f3cae7 Mon Sep 17 00:00:00 2001 From: FlopBut Date: Sun, 30 Aug 2026 22:46:46 +0200 Subject: [PATCH 67/67] fix(errors): keep one catch-all working across the package's entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package ships a bundle per entry point, so a render from `./pdf` threw an error built from that bundle's own copy of the error classes. `instanceof KSeFError` against the root entry was therefore false, and `./pdf` exported no constructor of its own — leaving a consumer of the PDF module with no typed way to catch anything, while the documentation promised that one `instanceof KSeFError` covers every error the library throws. The base class now recognises its own kind by a registered symbol, so it answers for an error from any entry point. Subclasses keep the ordinary prototype test, so one kind of failure is still told apart from another; `./pdf` exports the two classes it throws for exactly that, and the documented contract now says which check is which. Verified against the built package in both module systems: before, an invalid template rejected with `instanceof KSeFError` false under ESM and CJS alike; after, true in both, with the subclass checks still distinguishing a bad template from a missing pdfmake. Pinned in tests/package, which runs against the built artefact in CI. Unit 2905 passing, package 28, PDF E2E 39, and both published-type checks clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01M7sLete4AKPr5oHz2cMFqM --- .../ksef-client-ts/docs/error-handling.md | 2 + .../ksef-client-ts/src/errors/ksef-error.ts | 30 ++++++++ packages/ksef-client-ts/src/pdf/index.ts | 13 ++++ .../tests/package/pdf-subpath.test.ts | 73 +++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 packages/ksef-client-ts/tests/package/pdf-subpath.test.ts diff --git a/packages/ksef-client-ts/docs/error-handling.md b/packages/ksef-client-ts/docs/error-handling.md index 8ee0bdc4..d8c1e850 100644 --- a/packages/ksef-client-ts/docs/error-handling.md +++ b/packages/ksef-client-ts/docs/error-handling.md @@ -18,6 +18,8 @@ The library provides a structured error hierarchy so that callers can react prec All error classes extend a common base `KSeFError`, so a single `instanceof KSeFError` catch covers every library error without catching unrelated exceptions. +This holds across the package's entry points (`ksef-client-ts`, `ksef-client-ts/node`, `ksef-client-ts/pdf`), which are bundled separately and therefore carry their own copies of the classes: `KSeFError` recognises its own kind by a registered symbol rather than by prototype. A *subclass* check is per entry point, so to tell one failure apart from another in the PDF module — an invalid template from a missing `pdfmake`, say — import `KSeFValidationError` and `KSeFPdfError` from `ksef-client-ts/pdf`. + --- ## Error Hierarchy diff --git a/packages/ksef-client-ts/src/errors/ksef-error.ts b/packages/ksef-client-ts/src/errors/ksef-error.ts index 0348a65b..2baa93cc 100644 --- a/packages/ksef-client-ts/src/errors/ksef-error.ts +++ b/packages/ksef-client-ts/src/errors/ksef-error.ts @@ -1,6 +1,36 @@ +/** + * Identifies a KSeF error across copies of this class. + * + * The package ships several entry points — `.`, `./node`, `./pdf` — each + * bundled on its own, so an error thrown from `./pdf` is built from that + * bundle's own copy of this class and fails an ordinary prototype test against + * the root one. That would quietly break the single promise the hierarchy + * makes: that one `instanceof KSeFError` catches everything the library throws. + * A registered symbol is the same value in every copy, so the brand survives + * the split. + */ +const KSEF_ERROR_BRAND: unique symbol = Symbol.for('ksef-client-ts.KSeFError'); + export class KSeFError extends Error { + /** @internal Cross-entry-point brand; see the note on the symbol. */ + readonly [KSEF_ERROR_BRAND] = true; + constructor(message: string) { super(message); this.name = 'KSeFError'; } + + /** + * Answers for an error from any entry point, not just this copy of the class. + * + * Only the base class does so: a subclass falls back to the ordinary + * prototype test, so `instanceof KSeFApiError` still tells one kind of + * failure from another rather than matching every KSeF error alike. Catching + * a specific subclass across entry points therefore means importing it from + * the entry point that threw it. + */ + static [Symbol.hasInstance](value: unknown): boolean { + if (this !== KSeFError) return Function.prototype[Symbol.hasInstance].call(this, value); + return typeof value === 'object' && value !== null && KSEF_ERROR_BRAND in value; + } } diff --git a/packages/ksef-client-ts/src/pdf/index.ts b/packages/ksef-client-ts/src/pdf/index.ts index 91abb288..71ebbd89 100644 --- a/packages/ksef-client-ts/src/pdf/index.ts +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -32,6 +32,19 @@ export type { InvoiceTemplate } from './template/dsl.js'; export type { RenderNote } from './template/interpret.js'; export { detectInvoiceVersion, detectUpoVersion } from './parse.js'; export { builtinTemplateNames } from './template/builtin/index.js'; +/** + * The errors a render throws, from the entry point that throws them. + * + * This subpath is bundled separately from the package root, so its classes are + * a distinct copy: catching `KSeFPdfError` or `KSeFValidationError` imported + * from `ksef-client-ts` would never match an error raised here. `KSeFError` + * itself does match across entry points — it recognises its own kind by a + * registered symbol — so a catch-all written against the root still works; the + * exports here are for telling a bad template apart from a missing pdfmake. + */ +export { KSeFPdfError } from './errors.js'; +export { KSeFError } from '../errors/ksef-error.js'; +export { KSeFValidationError, type ValidationDetail } from '../errors/ksef-validation-error.js'; /** * A built-in template as a plain object, for callers who want to start from one diff --git a/packages/ksef-client-ts/tests/package/pdf-subpath.test.ts b/packages/ksef-client-ts/tests/package/pdf-subpath.test.ts new file mode 100644 index 00000000..1dc042e4 --- /dev/null +++ b/packages/ksef-client-ts/tests/package/pdf-subpath.test.ts @@ -0,0 +1,73 @@ +/** + * `/pdf` subpath error identity. + * + * The package ships one bundle per entry point, so `./pdf` carries its own copy + * of the error classes: an error raised by a render is not the root entry's + * `KSeFValidationError`, and before the brand it was not the root's `KSeFError` + * either. That silently broke the contract `docs/error-handling.md` states — + * "a single `instanceof KSeFError` catch covers every library error" — for + * every consumer of the PDF module. + * + * These run against the built package (both conditions of the exports map), so + * they fail if a bundling change reintroduces the split. + */ +import { createRequire } from 'node:module'; +import { describe, it, expect } from 'vitest'; + +import { KSeFError, KSeFValidationError } from 'ksef-client-ts'; +import { + renderInvoicePdfFromTemplate, + KSeFError as PdfKSeFError, + KSeFPdfError, + KSeFValidationError as PdfValidationError, +} from 'ksef-client-ts/pdf'; + +const require_ = createRequire(import.meta.url); +const cjsRoot = require_('ksef-client-ts') as typeof import('ksef-client-ts'); +const cjsPdf = require_('ksef-client-ts/pdf') as typeof import('ksef-client-ts/pdf'); + +/** Structurally invalid: a `lines` block with no `from`. */ +const badTemplate = { schema: 'FA(3)', blocks: [{ type: 'lines', columns: [] }] }; +const xml = new TextEncoder().encode(''); + +async function renderError(render: typeof renderInvoicePdfFromTemplate): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await render(xml, badTemplate as any); + } catch (e) { + return e; + } + throw new Error('expected the render to reject an invalid template'); +} + +describe('ksef-client-ts/pdf — error identity across entry points', () => { + it('throws a render error the root catch-all recognises (ESM)', async () => { + const error = await renderError(renderInvoicePdfFromTemplate); + expect(error).toBeInstanceOf(KSeFError); + }); + + it('throws a render error the root catch-all recognises (CJS)', async () => { + const error = await renderError(cjsPdf.renderInvoicePdfFromTemplate); + expect(error).toBeInstanceOf(cjsRoot.KSeFError); + }); + + it('exports the exact classes it throws, so the kind can be told apart', async () => { + const error = await renderError(renderInvoicePdfFromTemplate); + expect(error).toBeInstanceOf(PdfValidationError); + expect(error).not.toBeInstanceOf(KSeFPdfError); + // Separate bundles really are separate classes — the brand is what bridges + // them, not a shared module instance. + expect(PdfKSeFError).not.toBe(KSeFError); + }); + + it('keeps subclasses distinct — the base matches across copies, a subclass does not', () => { + const pdfError = new KSeFPdfError('pdfmake is missing'); + expect(pdfError).toBeInstanceOf(KSeFError); + expect(pdfError).toBeInstanceOf(PdfKSeFError); + expect(pdfError).not.toBeInstanceOf(PdfValidationError); + // The root's own subclass still answers only for its own copy, which is why + // `/pdf` exports the classes it throws. + expect(new KSeFValidationError('bad')).toBeInstanceOf(KSeFError); + expect(pdfError).not.toBeInstanceOf(KSeFValidationError); + }); +});