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/.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/.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 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/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/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 d4dc72b5..a56f09b3 100644 --- a/packages/ksef-client-ts/CHANGELOG.md +++ b/packages/ksef-client-ts/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [0.12.0] - Unreleased + +### 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 ### Changed (breaking) 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/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/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..75763e07 --- /dev/null +++ b/packages/ksef-client-ts/docs/pdf-export.md @@ -0,0 +1,506 @@ +# 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` (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. + +::: 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 + +### `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 +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' \| '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**. | +| `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 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. | + +`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. | +| `notes` | `{ head, body }[]` | Extra sections printed where the template puts its `notes` block. | + +--- + +## 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 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. 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 (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) | +| `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. + +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`. 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. + +### 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. + +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 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; +- 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. + +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: + +| | `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. + +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. + +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`, `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. +- **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. +- **`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 `' · '`. +- **`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. + +### 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", "ksefNumber": "opts.ksefNumber" }, + { "type": "divider" }, + { + "type": "parties", + "left": { "label": "seller", "fields": ["Podmiot1.DaneIdentyfikacyjne.Nazwa", "Podmiot1.DaneIdentyfikacyjne.NIP"] }, + "right": { "label": "buyer", "fields": [ + "Podmiot2.DaneIdentyfikacyjne.Nazwa", + { + "firstOf": [ + "Podmiot2.DaneIdentyfikacyjne.NIP", + { "path": "Podmiot2.DaneIdentyfikacyjne.NrVatUE", "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodUE" }, + { "path": "Podmiot2.DaneIdentyfikacyjne.NrID", "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodKraju" } + ] + } + ] } + }, + { + "type": "lines", + "from": "Fa.FaWiersz", + "columns": [ + { "label": "name", "path": "P_7", "width": "*" }, + { "label": "qty", "path": "P_8B", "width": 44, "format": "number" }, + { "label": "net", "path": "P_11", "width": 70, "format": "money" } + ] + }, + { + "type": "totals", + "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" } + ] +} +``` + +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 + +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: + +| Locale | Output | +|--------|--------| +| `pl` (default) | Polish labels | +| `en` | English labels | +| `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 | + +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', { + locale: 'pl+en', + bilingualSeparator: ' | ', +}); +``` + +--- + +## 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. + +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 +``` + +```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. + +**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, // Code I, derived from the document + certificateQrUrl, // Code II, supplied + qrLinks: true, // a clickable link under each code + env: 'test', +}); +``` + +`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. + +--- + +## 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 + +# 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 '#5AB595' +``` + +| 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, 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` | +| `--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": … }]` | +| `--logo ` | Logo image printed in the header — PNG or JPEG | +| `--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) | + +`--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. + +--- + +## 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 0d3e6890..74afdf4d 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, @@ -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/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'], diff --git a/packages/ksef-client-ts/src/cli/commands/invoice.ts b/packages/ksef-client-ts/src/cli/commands/invoice.ts index 2b1a30de..e8333763 100644 --- a/packages/ksef-client-ts/src/cli/commands/invoice.ts +++ b/packages/ksef-client-ts/src/cli/commands/invoice.ts @@ -570,7 +570,207 @@ const validateCmd = defineCommand({ }, }); +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; +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 + * 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 + * "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', +}; + +/** + * `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')}`; +} + +/** + * 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)) { + 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 }; + 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: typeof note.head === 'string' ? note.head : '', + body: typeof note.body === 'string' ? 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: { + 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 | 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' }, + 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) to print in the header' }, + 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)' }, + 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 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 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 #5AB595 or #b04.`); + } + + 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 = { + 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: env as PdfEnv } : {}), + ...(logo ? { logo } : {}), + ...(accent ? { theme: { accent } } : {}), + ...(notes ? { notes } : {}), + }; + + // 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; + // 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 (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'; + 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/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/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/document-flags.ts b/packages/ksef-client-ts/src/pdf/document-flags.ts new file mode 100644 index 00000000..a9cc00dc --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/document-flags.ts @@ -0,0 +1,129 @@ +/** + * 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']); + +/** + * 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 + * 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; + * - 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; + * - 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 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: 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, + }; +} + +/** + * 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'); + 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, + paidInPartOfPayable: paidInPart && payableStated, + paidInPartOfTotal: paidInPart && !payableStated, + }; +} + +/** + * 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/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..ef55d734 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/fonts.ts @@ -0,0 +1,170 @@ +/** + * 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. + * + * `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): { 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}), ` + + `which is not installed. Install it with: ${INSTALL_HINT}`, + ); +} + +/** + * 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+)(?:\+[0-9A-Za-z.-]+)?$/.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. + * + * 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 { + stream = pdfMake.createPdf(docDefinition).getStream(); + } catch (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/src/pdf/format.ts b/packages/ksef-client-ts/src/pdf/format.ts new file mode 100644 index 00000000..87d2a297 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/format.ts @@ -0,0 +1,150 @@ +/** + * Value formatters referenced by DSL bindings (`format: 'money' | 'date' | + * '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' | 'paymentForm'; + +const NBSP = ' '; + +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 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 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}`; +} + +/** 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)}`; +} + +/** + * 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; +} + +/** + * 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, + number: formatNumber, + nip: formatNip, + paymentForm: formatPaymentForm, +}; + +/** 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..e6a64b5a --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/en.ts @@ -0,0 +1,94 @@ +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', + address: 'Address', + contact: 'Contact details', + 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', + indeks: 'Item code', + gtin: 'GTIN', + pkwiu: 'PKWiU', + cn: 'CN', + pkob: 'PKOB', + gross: 'Gross amount', + // advance-invoice order lines (Fa.Zamowienie) + orderLines: 'Order or contract items', + orderValue: 'Order value, gross', + // 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', + orderNet: 'Order value, net', + settledByAdvances: 'Settled by advances, 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', + payment: 'Payment', + 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', + paymentDate: 'Payment due', + paymentMethod: 'Payment method', + amountDueTotal: 'Total amount due', + bankAccounts: 'Bank account', + bankAccount: 'Account number', + swift: 'SWIFT / BIC', + bankName: 'Bank name', + annotations: 'Annotations', + notes: 'Additional information', + upoTitle: 'Official Receipt Confirmation (UPO)', + ksefDocNumber: 'KSeF document number', + sessionRef: 'Session reference number', + 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/index.ts b/packages/ksef-client-ts/src/pdf/i18n/index.ts new file mode 100644 index 00000000..185de3d5 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/index.ts @@ -0,0 +1,67 @@ +/** + * 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, uk }; + +/** + * 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 `' / '`. */ + bilingualSeparator?: string; + /** Per-template label overrides (highest precedence). */ + overrides?: LabelBundle; +} + +function resolveOne(key: string, locale: BaseLocale, 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. 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 { + const pair = bilingualPair(locale); + if (pair) { + const sep = opts.bilingualSeparator ?? ' / '; + return `${resolveOne(key, pair[0], opts.overrides)}${sep}${resolveOne(key, pair[1], opts.overrides)}`; + } + return resolveOne(key, locale as BaseLocale, 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..24690f3e --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/pl.ts @@ -0,0 +1,101 @@ +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', + address: 'Adres', + contact: 'Dane kontaktowe', + 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', + indeks: 'Indeks', + gtin: 'GTIN', + pkwiu: 'PKWiU', + cn: 'CN', + pkob: 'PKOB', + gross: 'Wartość brutto', + // advance-invoice order lines (Fa.Zamowienie) + orderLines: 'Pozycje zamówienia lub umowy', + orderValue: 'Wartość zamówienia 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', + orderNet: 'Wartość zamówienia netto', + settledByAdvances: 'Rozliczono zaliczkami (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', + // payment + payment: 'Płatność', + 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', + 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', + bankName: 'Nazwa banku', + // annotations + annotations: 'Adnotacje', + notes: 'Pozostałe informacje', + // 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', + 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/i18n/types.ts b/packages/ksef-client-ts/src/pdf/i18n/types.ts new file mode 100644 index 00000000..026f7e2e --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/types.ts @@ -0,0 +1,26 @@ +/** + * 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 = + | BaseLocale + | 'pl+en' + | 'en+pl' + | 'pl+uk' + | 'uk+pl' + | 'en+uk' + | 'uk+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 + * 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/i18n/uk.ts b/packages/ksef-client-ts/src/pdf/i18n/uk.ts new file mode 100644 index 00000000..ba529dd4 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/i18n/uk.ts @@ -0,0 +1,102 @@ +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: 'Фактура', + invoiceAdvance: 'Авансова фактура', + invoiceSettlement: 'Розрахункова фактура', + duplicate: 'Дублікат', + seller: 'Продавець', + buyer: 'Покупець', + address: 'Адреса', + contact: 'Контактні дані', + issueDate: 'Дата виставлення', + invoiceNumber: 'Номер фактури', + ksefNumber: 'Номер KSeF', + offline: 'OFFLINE', + lp: '№', + name: 'Найменування', + unit: 'Од.', + qty: 'Кількість', + unitPrice: 'Ціна нетто', + net: 'Сума нетто', + vatRate: 'Ставка ПДВ', + vat: 'Сума ПДВ', + indeks: 'Код позиції', + gtin: 'GTIN', + pkwiu: 'PKWiU', + cn: 'CN', + pkob: 'PKOB', + gross: 'Сума брутто', + // advance-invoice order lines (Fa.Zamowienie) + orderLines: 'Позиції замовлення або договору', + orderValue: 'Вартість замовлення, брутто', + // 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: 'Разом нетто', + orderNet: 'Вартість замовлення, нетто', + settledByAdvances: 'Закрито авансами, нетто', + totalVat: 'Разом ПДВ', + totalDue: 'До сплати', + remainingDue: 'Залишок до сплати', + paidTotal: 'Сплачено разом', + overpaid: 'Переплата до врегулювання', + advancePaid: 'Сума оплати', + amountTotal: 'Загальна сума', + currency: 'Валюта', + payment: 'Оплата', + paid: 'Сплачено', + paidInPart: 'Сплачено частково', + paidDate: 'Дата оплати', + advanceInvoices: 'Авансові фактури', + advancePayments: 'Отримані платежі', + advancePaymentAmount: 'Сума платежу', + advancePaymentDate: 'Дата отримання', + partialPayments: 'Часткові оплати', + partialAmount: 'Сума часткової оплати', + partialDate: 'Дата часткової оплати', + paymentDate: 'Термін оплати', + paymentMethod: 'Спосіб оплати', + amountDueTotal: 'Загальна сума до сплати', + bankAccounts: 'Банківський рахунок', + bankAccount: 'Номер рахунку', + swift: 'SWIFT / BIC', + bankName: 'Назва банку', + annotations: 'Примітки', + notes: 'Додаткова інформація', + 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 new file mode 100644 index 00000000..71ebbd89 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/index.ts @@ -0,0 +1,348 @@ +/** + * `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, type RenderNote } from './template/interpret.js'; +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 { documentFlags } from './document-flags.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'; +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 + * 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 — + * 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; + /** + * 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. */ + 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). */ + 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). 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; + /** 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; + /** + * 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; + +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, + qrUrls: { invoice: string; certificate: string }, +): RenderContext { + const label = makeLabelResolver(opts.locale ?? 'pl', { + bilingualSeparator: opts.bilingualSeparator, + // 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 = { + 'opts.logo': opts.logo ?? '', + 'opts.ksefNumber': opts.ksefNumber ?? '', + 'opts.accent': opts.theme?.accent ?? '', + qrUrl: qrUrls.invoice, + certificateQrUrl: qrUrls.certificate, + }; + + const notes = (opts.notes ?? []).filter((n) => (n?.head ?? '').trim() !== '' || (n?.body ?? '').trim() !== ''); + + const totals = opts.totals ?? 'buckets'; + const derived = documentFlags(root); + const flags: Record = { + ...derived, + hasKsefNumber: Boolean(opts.ksefNumber), + offline: !opts.ksefNumber, + // 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', + // 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, + }; + + return { root, strict: opts.strict ?? false, label, bindings, flags, notes }; +} + +/** + * 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 === schema) return; + if (detected === null) { + throw new KSeFPdfError( + `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).`, + ); +} + +/** + * 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, + 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, 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, + env: opts.env, + baseQrUrl: opts.baseQrUrl, + invoiceHash: opts.invoiceHash, + strict: opts.strict, + }); + } + + const ctx = buildContext(body, template, opts, { + invoice: qrUrl, + certificate: opts.certificateQrUrl ?? '', + }); + const doc = interpretTemplate(applyAccent(template, opts.theme?.accent), 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 = loadBuiltinTemplate(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..e2cb7b7f --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/parse.ts @@ -0,0 +1,182 @@ +/** + * 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. + */ +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); + + // 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; +} + +/** + * 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` → + * `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; + + // 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. + // + // 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); + if (rootName !== 'Potwierdzenie') return null; + + // 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/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..2c865bfd --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/qr.ts @@ -0,0 +1,91 @@ +/** + * 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'; +import { KSeFPdfError } from './errors.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); + // 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( + '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/annotations.ts b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts new file mode 100644 index 00000000..57e50904 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/annotations.ts @@ -0,0 +1,28 @@ +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` + * line per {@link AnnotationsBlock.fields} entry (localized label + formatted + * 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) { + const value = readField(field, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx)); + stack.push({ text: `${ctx.label(field.label)}: ${value}` }); + } + + return { + stack, + margin: [0, 8, 0, 8], + ...(block.style ? { style: block.style } : {}), + }; +}; 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..33758e00 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/cell.ts @@ -0,0 +1,53 @@ +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 = ' · '; + +/** + * 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 = readField(column, read); + + const parts: string[] = []; + for (const sub of column.sub ?? []) { + const text = readField(sub, read); + 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/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/field.ts b/packages/ksef-client-ts/src/pdf/template/blocks/field.ts new file mode 100644 index 00000000..f6264364 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/field.ts @@ -0,0 +1,63 @@ +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 + * — 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}`; +} + +/** + * `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 raw = repeatedSum(less, root).trim(); + if (raw === '') return value; + return sumDecimal([value, raw.startsWith('-') ? raw.slice(1) : `-${raw}`]); +} + +/** + * 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(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/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..963cf62e --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/header.ts @@ -0,0 +1,53 @@ +import { applyFormat } from '../../format.js'; +import type { HeaderBlock } from '../dsl.js'; +import { resolveBinding, resolveText, type BlockRenderer, type PdfNode } from '../interpret.js'; + +/** + * 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. 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) => { + // 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); + if (logo) left.push({ image: logo, width: block.logoWidth ?? 120, margin: [0, 6, 0, 0] }); + } + + 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')}` }); + } + if (block.ksefNumber) { + const value = resolveBinding(block.ksefNumber, ctx); + if (value) { + right.push({ text: `${ctx.label('ksefNumber')}: ${value}` }); + } else if (block.offlineStyle) { + right.push({ text: ctx.label('offline'), style: block.offlineStyle }); + } + } + + 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..ce226de8 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/index.ts @@ -0,0 +1,35 @@ +/** + * 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 { notesRenderer } from './notes.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'; + +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, + notes: notesRenderer 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 new file mode 100644 index 00000000..a2712675 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/lines.ts @@ -0,0 +1,39 @@ +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 + * 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. 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 + * 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) => buildHeaderCell(c, ctx)); + const bodyRows: PdfNode[][] = list(ctx.root, block.from).map((row) => + block.columns.map((c) => + buildCell(c, (path, optional) => get(row, path, optional ? false : ctx.strict), ctx), + ), + ); + + return { + table: { + headerRows: 1, + widths: block.columns.map((c) => c.width ?? '*'), + 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/notes.ts b/packages/ksef-client-ts/src/pdf/template/blocks/notes.ts new file mode 100644 index 00000000..15d9ccd9 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/notes.ts @@ -0,0 +1,50 @@ +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 + * 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. + * + * 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 — 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 notes: PdfNode[] = []; + + for (const note of ctx.notes ?? []) { + const head = (note.head ?? '').trim(); + const body = (note.body ?? '').trim(); + if (head === '' && body === '') continue; + // 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 (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], + ...(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..1b54759a --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/parties.ts @@ -0,0 +1,108 @@ +import { list } from '../../accessor.js'; +import type { PartiesBlock, PartyAlternative, PartyColumn, PartyField, PartyGroup } from '../dsl.js'; +import { resolveBinding, type BlockRenderer, type PdfNode, type RenderContext } from '../interpret.js'; + +/** + * 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; +} + +/** + * 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 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 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 + * 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}. 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 = ( + 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 alternative of field.firstOf) { + const { path, prefixPath } = typeof alternative === 'string' ? { path: alternative, prefixPath: undefined } : alternative; + const value = resolveBinding(path, at(root, false)); + 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 ''; + }; + + // 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, inherited)) + : renderFields(field.fields, root, strict, inherited); + if (inner.length === 0) continue; // no heading without content + out.push({ text: ctx.label(field.label), style: SUBHEADING_STYLE }); + out.push(...inner); + continue; + } + const value = resolveValue(field, root, strict); + if (value === '') continue; + out.push(style ? { text: value, style } : { text: value }); + } + return out; + }; + + const side = (col: PartyColumn): PdfNode => ({ + width: '*', + stack: [ + { text: ctx.label(col.label), style: heading }, + ...renderFields(col.fields, ctx.root, ctx.strict, col.style), + ], + }); + + 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..69bc9cf8 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/payment.ts @@ -0,0 +1,113 @@ +import { get, list } from '../../accessor.js'; +import { applyFormat } from '../../format.js'; +import type { PaymentBlock } from '../dsl.js'; +import { lessRepeatedSum, readField, repeatedSum } from './field.js'; +import { evalWhen, resolveBinding, type BlockRenderer, type PdfNode } from '../interpret.js'; + +/** + * Payment details: a `payment` heading, one `label: value` line per + * {@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. + * 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. + * 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) => { + // Bindings the schema declares optional are read leniently even under strict. + const lenientCtx = { ...ctx, strict: false }; + // 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 }]; + + /** + * 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. + if (!evalWhen(row.when, ctx)) continue; + // 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 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) { + 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) { + 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 }); + } + } + + for (const group of block.groups ?? []) { + const lines: PdfNode[] = []; + 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) continue; // no sub-heading over nothing + if (group.heading) stack.push({ text: ctx.label(group.heading), style: subheading }); + stack.push(...lines); + } + + 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..f539163f --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/qr.ts @@ -0,0 +1,117 @@ +import * as QRCode from 'qrcode'; +import { KSeFPdfError } from '../../errors.js'; +import type { QrBlock } from '../dsl.js'; +import type { BlockRenderer, PdfNode } from '../interpret.js'; + +/** + * 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[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 }; + // 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 } : {}), + }, + ] + : []; + + // 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/blocks/table.ts b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts new file mode 100644 index 00000000..9601f9d7 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/table.ts @@ -0,0 +1,55 @@ +import { get, list } from '../../accessor.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: + * + * - **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`. Each column takes its own `width` (default `'*'`, an even + * 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[][] = []; + + if (showHeaders) { + 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) => buildCell(col, (path, optional) => get(row, path, optional ? false : ctx.strict), ctx))); + } + } else { + body.push( + columns.map((col) => buildCell(col, (path, optional) => resolveBinding(path, optional ? lenientCtx : ctx), 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, + widths: columns.map((col) => col.width ?? '*'), + 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..e8407e6b --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/blocks/totals.ts @@ -0,0 +1,66 @@ +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'; + +/** + * Totals summary: a compact, right-aligned label/value table. Each row of + * {@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 + * 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 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[][] = []; + for (const row of block.rows) { + if (!evalWhen(row.when, ctx)) continue; + 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 + // 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), ...rowStyle }, + { text: value, alignment: 'right', ...rowStyle }, + ]); + } + + // 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: '' }, + { + 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..ff015c56 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa2-default.json @@ -0,0 +1,445 @@ +{ + "schema": "FA(2)", + "page": { "size": "A4", "margins": [40, 40, 40, 50] }, + "pageFooter": { "style": "footerNote" }, + "styles": { + "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 }, + "footerNote": { "fontSize": 7, "color": "#999999" }, + "qrLink": { "fontSize": 8, "color": "#0645ad", "decoration": "underline" }, + "partyIdentity": { "fontSize": 9 }, + "partyDetails": { "fontSize": 8 } + }, + "blocks": [ + { + "type": "header", + "logo": "opts.logo", + "logoWidth": 48, + "number": "Fa.P_2", + "date": "Fa.P_1", + "ksefNumber": "opts.ksefNumber", + "offlineStyle": "offline" + }, + { "type": "divider" }, + { "type": "spacer", "height": 4 }, + { + "type": "parties", + "headingStyle": "h1", + "left": { + "label": "seller", + "style": "partyIdentity", + "fields": [ + "Podmiot1.DaneIdentyfikacyjne.Nazwa", + "Podmiot1.DaneIdentyfikacyjne.NIP", + { + "label": "address", + "style": "partyDetails", + "fields": [ + "Podmiot1.Adres.AdresL1", + { "path": "Podmiot1.Adres.AdresL2", "optional": true }, + "Podmiot1.Adres.KodKraju" + ] + }, + { + "label": "contact", + "from": "Podmiot1.DaneKontaktowe", + "style": "partyDetails", + "fields": ["Email", "Telefon"] + } + ] + }, + "right": { + "label": "buyer", + "style": "partyIdentity", + "fields": [ + { "path": "Podmiot2.DaneIdentyfikacyjne.Nazwa", "optional": true }, + { + "firstOf": [ + "Podmiot2.DaneIdentyfikacyjne.NIP", + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrVatUE", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodUE" + }, + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrID", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodKraju" + } + ] + }, + { + "label": "address", + "from": "Podmiot2.Adres", + "style": "partyDetails", + "fields": ["AdresL1", { "path": "AdresL2", "optional": true }, "KodKraju"] + }, + { + "label": "contact", + "from": "Podmiot2.DaneKontaktowe", + "style": "partyDetails", + "fields": ["Email", "Telefon", "NrKlienta"] + } + ] + } + }, + { "type": "spacer", "height": 23 }, + { + "type": "lines", + "when": "Fa.FaWiersz", + "from": "Fa.FaWiersz", + "columns": [ + { "label": "lp", "path": "NrWierszaFa", "width": 24 }, + { + "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 }, + { "label": "vatRate", "path": "P_12", "width": 50, "optional": true }, + { "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 }, + { "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": [ + "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": "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" }, + { "label": "remainingDue", "path": "Fa.P_15", "when": "p15IsRemainder", "format": "money", "style": "strong" }, + { + "label": "totalDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "Fa.Rozliczenie.DoZaplaty", + "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.Rozliczenie.DoZaplaty", + "when": "paidInPartOfPayable", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "style": "strong" + }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "paidInPartOfTotal", + "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" } + ] + }, + { "type": "spacer", "height": 21 }, + { "type": "divider" }, + { + "type": "payment", + "headingStyle": "h1", + "when": "Fa.Platnosc", + "rows": [ + { "label": "paid", "when": "paidInFull" }, + { "label": "paidInPart", "when": "paidInPart" }, + { "label": "paidDate", "path": "Fa.Platnosc.DataZaplaty", "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", + "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": "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.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": "paidInPartOfTotal", + "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", + "when": "Fa.Rozliczenie.DoZaplaty", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + } + ], + "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", + "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" }, + { "type": "divider", "when": "notes" }, + { + "type": "columns", + "when": "qr", + "columns": [ + { "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 new file mode 100644 index 00000000..9dc01bf6 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-default.json @@ -0,0 +1,444 @@ +{ + "schema": "FA(3)", + "page": { "size": "A4", "margins": [40, 40, 40, 50] }, + "pageFooter": { "style": "footerNote" }, + "styles": { + "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 }, + "footerNote": { "fontSize": 7, "color": "#999999" }, + "qrLink": { "fontSize": 8, "color": "#0645ad", "decoration": "underline" }, + "partyIdentity": { "fontSize": 9 }, + "partyDetails": { "fontSize": 8 } + }, + "blocks": [ + { + "type": "header", + "logo": "opts.logo", + "logoWidth": 48, + "number": "Fa.P_2", + "date": "Fa.P_1", + "ksefNumber": "opts.ksefNumber", + "offlineStyle": "offline" + }, + { "type": "divider" }, + { "type": "spacer", "height": 4 }, + { + "type": "parties", + "headingStyle": "h1", + "left": { + "label": "seller", + "style": "partyIdentity", + "fields": [ + "Podmiot1.DaneIdentyfikacyjne.Nazwa", + "Podmiot1.DaneIdentyfikacyjne.NIP", + { + "label": "address", + "style": "partyDetails", + "fields": [ + "Podmiot1.Adres.AdresL1", + { "path": "Podmiot1.Adres.AdresL2", "optional": true }, + "Podmiot1.Adres.KodKraju" + ] + }, + { + "label": "contact", + "from": "Podmiot1.DaneKontaktowe", + "style": "partyDetails", + "fields": ["Email", "Telefon"] + } + ] + }, + "right": { + "label": "buyer", + "style": "partyIdentity", + "fields": [ + { "path": "Podmiot2.DaneIdentyfikacyjne.Nazwa", "optional": true }, + { + "firstOf": [ + "Podmiot2.DaneIdentyfikacyjne.NIP", + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrVatUE", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodUE" + }, + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrID", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodKraju" + } + ] + }, + { + "label": "address", + "from": "Podmiot2.Adres", + "style": "partyDetails", + "fields": ["AdresL1", { "path": "AdresL2", "optional": true }, "KodKraju"] + }, + { + "label": "contact", + "from": "Podmiot2.DaneKontaktowe", + "style": "partyDetails", + "fields": ["Email", "Telefon", "NrKlienta"] + } + ] + } + }, + { "type": "spacer", "height": 9 }, + { + "type": "lines", + "when": "Fa.FaWiersz", + "from": "Fa.FaWiersz", + "columns": [ + { "label": "lp", "path": "NrWierszaFa", "width": 24 }, + { + "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 }, + { "label": "vatRate", "path": "P_12", "width": 50, "optional": true }, + { "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 }, + { "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": [ + "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": "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" }, + { "label": "remainingDue", "path": "Fa.P_15", "when": "p15IsRemainder", "format": "money", "style": "strong" }, + { + "label": "totalDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "Fa.Rozliczenie.DoZaplaty", + "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.Rozliczenie.DoZaplaty", + "when": "paidInPartOfPayable", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "style": "strong" + }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "paidInPartOfTotal", + "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" } + ] + }, + { "type": "divider" }, + { + "type": "payment", + "headingStyle": "h1", + "when": "Fa.Platnosc", + "rows": [ + { "label": "paid", "when": "paidInFull" }, + { "label": "paidInPart", "when": "paidInPart" }, + { "label": "paidDate", "path": "Fa.Platnosc.DataZaplaty", "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", + "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": "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.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": "paidInPartOfTotal", + "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", + "when": "Fa.Rozliczenie.DoZaplaty", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + } + ], + "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", + "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" }, + { "type": "divider", "when": "notes" }, + { + "type": "columns", + "when": "qr", + "columns": [ + { "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-showcase.json b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json new file mode 100644 index 00000000..a7dedfde --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/fa3-showcase.json @@ -0,0 +1,381 @@ +{ + "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", + "currency": "waluta", + "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] }, + "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 }, + "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, + "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": [ + { "path": "Podmiot2.DaneIdentyfikacyjne.Nazwa", "optional": true }, + { + "firstOf": [ + "Podmiot2.DaneIdentyfikacyjne.NIP", + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrVatUE", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodUE" + }, + { + "path": "Podmiot2.DaneIdentyfikacyjne.NrID", + "prefixPath": "Podmiot2.DaneIdentyfikacyjne.KodKraju" + } + ] + }, + { + "label": "address", + "from": "Podmiot2.Adres", + "style": "partyMeta", + "fields": ["AdresL1", { "path": "AdresL2", "optional": true }, "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, + "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 }, + { "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": "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" }, + { "label": "remainingDue", "path": "Fa.P_15", "when": "p15IsRemainder", "format": "money", "style": "strong" }, + { + "label": "totalDue", + "path": "Fa.Rozliczenie.DoZaplaty", + "when": "Fa.Rozliczenie.DoZaplaty", + "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.Rozliczenie.DoZaplaty", + "when": "paidInPartOfPayable", + "less": { "from": "Fa.Platnosc.ZaplataCzesciowa", "path": "KwotaZaplatyCzesciowej" }, + "format": "money", + "style": "strong" + }, + { + "label": "remainingDue", + "path": "Fa.P_15", + "when": "paidInPartOfTotal", + "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" } + ] + }, + { "type": "spacer", "height": 4 }, + { + "type": "payment", + "headingStyle": "h1", + "when": "Fa.Platnosc", + "rows": [ + { "label": "paid", "when": "paidInFull" }, + { "label": "paidInPart", "when": "paidInPart" }, + { "label": "paidDate", "path": "Fa.Platnosc.DataZaplaty", "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", + "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": "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.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": "paidInPartOfTotal", + "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", + "when": "Fa.Rozliczenie.DoZaplaty", + "format": "money", + "suffixPath": "Fa.KodWaluty", + "style": "strong" + } + ], + "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", + "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 }, + { + "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 new file mode 100644 index 00000000..4c725815 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/index.ts @@ -0,0 +1,28 @@ +/** + * 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 fa3Showcase from './fa3-showcase.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), + 'fa3-showcase': validateTemplate(fa3Showcase), + '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..5aa40db1 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_2.json @@ -0,0 +1,69 @@ +{ + "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" }, + "footerNote": { "fontSize": 7, "color": "#999999" } + }, + "blocks": [ + { "type": "header", "title": { "label": "upoTitle" } }, + { "type": "divider" }, + { "type": "spacer", "height": 21 }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, + { "type": "text", "path": "NumerReferencyjnySesji" } + ] + }, + { "type": "spacer", "height": 21 }, + { "type": "text", "label": "documents", "style": "fieldLabel" }, + { "type": "spacer", "height": 15 }, + { + "type": "each", + "from": "Dokument", + "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": 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 new file mode 100644 index 00000000..cb988482 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/builtin/upo-4_3.json @@ -0,0 +1,69 @@ +{ + "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" }, + "footerNote": { "fontSize": 7, "color": "#999999" } + }, + "blocks": [ + { "type": "header", "title": { "label": "upoTitle" } }, + { "type": "divider" }, + { "type": "spacer", "height": 21 }, + { + "type": "columns", + "columns": [ + { "type": "text", "label": "sessionRef", "style": "fieldLabel" }, + { "type": "text", "path": "NumerReferencyjnySesji" } + ] + }, + { "type": "spacer", "height": 21 }, + { "type": "text", "label": "documents", "style": "fieldLabel" }, + { "type": "spacer", "height": 15 }, + { + "type": "each", + "from": "Dokument", + "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": 15 } + ] + } + ] +} 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..f372006b --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/dsl.ts @@ -0,0 +1,799 @@ +/** + * 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>; + +/** + * 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'; + 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; + /** + * 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; + /** + * 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; +} + +/** + * 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' | '*'; + /** + * 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; +} + +/** + * 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 { + 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; + /** + * 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 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; +} + +/** + * 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 + | { path: string; optional?: boolean } + | { 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 + * 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; + /** + * 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[]; +} + +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; +} + +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; +} + +/** + * 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; + /** See {@link FieldDef.optional}. A `sum` is always read leniently. */ + 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 + * 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; +} + +export interface TotalsBlock { + type: 'totals'; + rows: TotalsRow[]; + style?: string; +} + +/** + * 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 PaymentGroup { + from: string; + heading?: string; + fields: FieldDef[]; +} + +/** + * 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}. + * + * 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 type RepeatedSum = + | { path: string; from?: string; sum?: never } + | { sum: string[]; path?: never; from?: never }; + +/** + * 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'; + when?: string; + rows: PaymentRow[]; + groups?: PaymentGroup[]; + /** See {@link HEADING_STYLE_DOC}. The block label only, not a group's heading. */ + headingStyle?: string; + 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}. 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; +} + +export interface AnnotationsBlock { + type: 'annotations'; + fields: FieldDef[]; + /** See {@link HEADING_STYLE_DOC}. */ + headingStyle?: string; + 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 { + 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; +} + +/** + * 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: ColumnDef[]; + headers?: boolean; + when?: string; + style?: string; +} + +export interface ImageBlock { + type: 'image'; + src?: string; + path?: string; + width?: number; + when?: string; +} + +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; +} + +export interface SpacerBlock { + type: 'spacer'; + height?: number; +} + +export type Block = + | HeaderBlock + | PartiesBlock + | LinesBlock + | TotalsBlock + | PaymentBlock + | AnnotationsBlock + | NotesBlock + | QrBlock + | FooterBlock + | TextBlock + | ColumnsBlock + | StackBlock + | EachBlock + | 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; + pageFooter?: PageFooterConfig; + 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', '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({ path: z.string(), optional: z.boolean().optional() }).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(), + from: z.string().optional(), + fields: z.array(partyField), + style: z.string().optional(), + }) + .strict(), + ]), +); +const partyColumn = z + .object({ label: z.string(), style: z.string().optional(), fields: z.array(partyField) }) + .strict(); +const fieldDef = z + .object({ + label: z.string(), + path: z.string(), + optional: z.boolean().optional(), + format: formatEnum.optional(), + style: z.string().optional(), + suffixPath: z.string().optional(), + }) + .strict(); + +const columnDef = z + .object({ + label: z.string(), + path: z.string(), + 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(), + subSeparator: z.string().optional(), + }) + .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(), + 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, 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. +const blockSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion('type', [ + 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(), + ksefNumber: z.string().optional(), + offlineStyle: z.string().optional(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('parties'), + left: partyColumn, + right: partyColumn, + headingStyle: z.string().optional(), + style: z.string().optional(), + }).strict(), + z.object({ + type: z.literal('lines'), + from: z.string(), + when: z.string().optional(), + columns: z.array(columnDef), + 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(), + rows: z.array( + 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( + z + .object({ + from: z.string(), + heading: z.string().optional(), + fields: z.array(fieldDef), + }) + .strict(), + ) + .optional(), + 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), + headingStyle: z.string().optional(), + style: z.string().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(), + 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('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(columnDef), + 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'), when: z.string().optional(), 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(), + 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(), + 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..99b1aa32 --- /dev/null +++ b/packages/ksef-client-ts/src/pdf/template/interpret.ts @@ -0,0 +1,264 @@ +/** + * 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[]; + +/** + * 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; + +/** 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; + /** + * 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; +} + +/** + * 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 = ( + 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); + }, + + // 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( + { + 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, + ); + }, + + // 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 { canvas: [], margin: [0, 0, 0, b.height ?? 8] }; + }, +}; + +/** + * 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, over) => interpretBlock(child, over ?? ctx, registry, depth + 1); + 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, + * 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.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; + if (template.page.margins) doc.pageMargins = template.page.margins; + } + return doc; +} + +export { coreRegistry }; 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..d3483fa3 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,37 @@ 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").`, + ); + } + // 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. + // + // 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 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'); const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); const yyyy = date.getUTCFullYear(); 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..d5021a47 --- /dev/null +++ b/packages/ksef-client-ts/tests/e2e/35-invoice-pdf-cli.test.ts @@ -0,0 +1,443 @@ +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. +// 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 +// 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. 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); + +/** 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 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 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 }); + 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; +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 + // 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(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. + 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(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; + 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.' }, + ]), + ); + + // 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', + '01F20A5D352AE590', + randomBytes(32).toString('base64'), + key, + ); +} + +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\`.`); + } + mkdirSync(inputsDir, { recursive: true }); + for (const stale of readdirSync(outDir)) { + if (stale.startsWith(`${PREFIX}-`)) rmSync(join(outDir, stale), { force: true }); + } + writeDerivedInputs(); + }); + + afterAll(() => { + // eslint-disable-next-line no-console + console.log(`\n rendered PDFs kept for review in ${outDir}\n`); + }); + + 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 = `${TEST_QR_HOST}/invoice/1111111111/15-01-2026/SUPPLIED-VERBATIM`; + + /** + * 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. + * + * 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, 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, 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`, () => [ + fx('e2e-services-np.xml'), '--ksef-number', KSEF_NUMBER, ...LOGO(), + '--env', 'test', '--qr', '--totals', 'buckets', '--accent', ACCENT, + ]], + [`${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', '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', '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 + // 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', '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 + // 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 + // + // 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, 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 + // 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', '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 + // 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', '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', '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 + // 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, '--locale', 'en', + '--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 + // 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', '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', 'test', '--qr', '--totals', 'buckets', + ]], + // 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}-13-showcase-template-accent`, () => [ + fx('e2e-vat-multi.xml'), '--template', 'fa3-showcase', ...LOGO(), + '--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}-14-upo-pl`, () => [fx('upo-4_3.xml')]], + [`${PREFIX}-15-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', + // 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-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); + } + 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('--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); + } + // 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) => { + 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: + // the count is stated here so removing a row has to be deliberate. + expect(variants).toHaveLength(15); + 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); + }); + + // 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' }])); + 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. + // 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]); + expect(res.status).not.toBe(0); + expect(`${res.stdout}${res.stderr}`).toMatch(/Invalid --accent/); + 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); + }); +}); 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..6e16f584 --- /dev/null +++ b/packages/ksef-client-ts/tests/e2e/36-invoice-pdf-library.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createRequire } from 'node:module'; +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash, generateKeyPairSync } from 'node:crypto'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + renderInvoicePdf, + renderInvoicePdfFromFile, + renderInvoicePdfFromTemplate, + renderUpoPdf, + getBuiltinTemplate, + builtinTemplateNames, + detectInvoiceVersion, + 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 most of RenderOptions but cannot +// pass a template as an object at all. This spec covers what the command line +// cannot reach — baseQrUrl, bilingualSeparator, strict, invoiceHash, and +// renderInvoicePdfFromTemplate — so a regression there is not invisible just +// 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. +// +// 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 = process.env.KSEF_PDF_OUT ?? join(repoRoot, '.pdf-preview'); +const inputsDir = join(outDir, '_inputs'); +const PREFIX = 'lib'; +/** 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); + +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(() => { + mkdirSync(inputsDir, { recursive: true }); + for (const stale of readdirSync(outDir)) { + if (stale.startsWith(`${PREFIX}-`)) rmSync(join(outDir, stale), { force: 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('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`, + renderInvoicePdfFromTemplate(bytes('e2e-vat-multi.xml'), template, { + logo: LOGO, + ksefNumber: KSEF_NUMBER, + qr: true, + env: 'test', + }), + ); + }); + + 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, 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}-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, + totals: 'both', + ksefNumber: KSEF_NUMBER, + }), + ); + }); + + 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. + const raw = bytes('e2e-vat-multi.xml'); + const invoiceHash = createHash('sha256').update(raw).digest('base64'); + const key = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ + type: 'pkcs8', + format: 'pem', + }) as string; + const certificateQrUrl = new VerificationLinkService( + TEST_QR_HOST, + ).buildCertificateVerificationUrl( + 'Nip', + '1111111111', + '1111111111', + '01F20A5D352AE590', + invoiceHash, + key, + ); + await save( + `${PREFIX}-03-precomputed-hash-both-codes-links-strict`, + renderInvoicePdf(raw, 'fa3-default', { + qr: true, + strict: true, + env: 'test', + invoiceHash, + certificateQrUrl, + qrLinks: true, + locale: 'en+uk', + }), + ); + }); + + it('takes a Code I URL verbatim, skipping derivation entirely', async () => { + await save( + `${PREFIX}-04-supplied-code-i-url`, + renderInvoicePdf(bytes('e2e-vat-multi.xml'), 'fa3-default', { + 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.' }], + ksefNumber: KSEF_NUMBER, + }), + ); + }); + + // Receipt last, as in spec 35. + it('renders a UPO through the library entry point', async () => { + await save(`${PREFIX}-05-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([]); + }); + }); + + 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(inputsDir, '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/); + }); + }); +}); 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..8e273e55 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf-types-check.ts @@ -0,0 +1,69 @@ +/** + * 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; + +/** + * 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; 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 00000000..f4589d3f Binary files /dev/null and b/packages/ksef-client-ts/tests/fixtures/pdf/e2e-logo.png differ 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..d484509c --- /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 + + + + + GB-000123 + Example Trading Ltd + + + GB + 12 Example Street, Floor 3 + London EC1A 1BB, United Kingdom + + + accounts@trading.example + + 2 + 2 + + + EUR + 2026-01-15 + Warszawa + FIX/NP/2026/001 + 2026-01-15 + 800.00 + 800.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + Kalibracja urzadzen pomiarowych / Calibration of measuring equipment + PKD 71.20.B + 71.20.19.0 + szt + 20 + 20.00 + 400.00 + np I + + + 2 + Przeglad techniczny sprzetu / Technical inspection of equipment + szt + 20 + 20.00 + 400.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..8e75b079 --- /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 + 10000.00 + 2300.00 + 1000.00 + 80.00 + 1200.00 + 14580.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 + 100.00 + 10000.00 + 23 + + + 2 + Uslugi konserwacji sprzetu komputerowego + h + 10 + 100.00 + 1000.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-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/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..58ba60f9 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz-b.xml @@ -0,0 +1,102 @@ + + + + + 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 + 349.59 + 80.41 + 430.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ROZ + + 2025-04-01 + 250.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..8535a284 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-roz.xml @@ -0,0 +1,99 @@ + + + + + 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 + 134.15 + 30.85 + 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-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/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 new file mode 100644 index 00000000..5f896ff6 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2-zal.xml @@ -0,0 +1,105 @@ + + + + + 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 + ZAL/2025/01/001 + 2025-01-15 + 365.85 + 84.15 + 450.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ZAL + + 2025-01-10 + 300.00 + + + 2025-01-14 + 150.00 + + + 1 + 2025-01-14 + 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/fa2.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml new file mode 100644 index 00000000..a93ff551 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa2.xml @@ -0,0 +1,91 @@ + + + + + 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 + + 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/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..baa3396d --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz-b.xml @@ -0,0 +1,102 @@ + + + + + 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 + 349.59 + 80.41 + 430.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ROZ + + 2025-04-01 + 250.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..f92affa3 --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-roz.xml @@ -0,0 +1,99 @@ + + + + + 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 + 134.15 + 30.85 + 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-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/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 new file mode 100644 index 00000000..f512699d --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3-zal.xml @@ -0,0 +1,105 @@ + + + + + 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 + ZAL/2025/01/001 + 2025-01-15 + 365.85 + 84.15 + 450.00 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + ZAL + + 2025-01-10 + 300.00 + + + 2025-01-14 + 150.00 + + + 1 + 2025-01-14 + 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.xml b/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml new file mode 100644 index 00000000..b3169cfd --- /dev/null +++ b/packages/ksef-client-ts/tests/fixtures/pdf/fa3.xml @@ -0,0 +1,91 @@ + + + + + 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 + + 2025-02-01 + + 6 + + 11109000880000000100000001 + WBKPPLPP + Bank Przykładowy S.A. + + + + 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/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); + }); +}); 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..1437eb03 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/cli/commands/invoice-pdf.test.ts @@ -0,0 +1,230 @@ +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/); + }); + + // 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( + 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('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( + 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/advance-invoice.test.ts b/packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts new file mode 100644 index 00000000..78f7978a --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/advance-invoice.test.ts @@ -0,0 +1,88 @@ +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 *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[][]; headerRows?: number } | undefined; + if (table?.body && typeof table.headerRows === 'number') 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/amount-due-label.test.ts b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts new file mode 100644 index 00000000..cc8c75dd --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/amount-due-label.test.ts @@ -0,0 +1,251 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.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'; +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, extraFlags: Record = {}): 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: { ...documentFlags(root), totalsBuckets: true, ...extraFlags }, + }; + 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, + p15IsRemainder: false, + settlementRemainder: 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, + p15IsRemainder: false, + settlementRemainder: 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, + p15IsRemainder: false, + settlementRemainder: false, + }); + }); + + it('a settlement invoice: P_15 is what is left after the advances', () => { + // 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, + 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 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, + p15IsAmountTotal: true, + p15IsRemainder: false, + settlementRemainder: 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 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'; + + 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('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 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`)); + 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); + }); + + 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('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); + 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)); + 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-primitive.test.ts b/packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts new file mode 100644 index 00000000..75e4af3b --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-primitive.test.ts @@ -0,0 +1,170 @@ +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(); + }); + + // 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)); + 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..52fd8029 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/blocks-semantic.test.ts @@ -0,0 +1,1261 @@ +/** + * 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'; +import { getBuiltinTemplate } from '../../../src/pdf/template/builtin/index.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; + +// ── 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: 'partyDetails', 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('partyDetails'); + expect(stack[4].style).toBe('partyDetails'); + }); + + 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: 'partyDetails', 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: 'partyDetails', 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: 'partyDetails', + 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'); + }); + + // 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) ─────────────────────────────────────────────── + +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; + // 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); + 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('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('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; + // 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', () => { + // 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( + 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'); + // 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'); + }); + + 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); + }); + + 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: [], + groups: [ + { + 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' }], + groups: [ + { + 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: [], + groups: [ + { + from: 'Fa.Platnosc.RachunekBankowy', + fields: [{ label: 'swift', path: 'SWIFT' }], // absent in the account → strict throws + }, + ], + }, + ctx, + noRender, + ), + ).toThrow(/Missing binding/); + }); +}); + +// ── 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'); + }); +}); + +// ── 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' }], + groups: [ + { + 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(); + }); +}); + +// ── 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(); + }); +}); + +// ── 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; format?: string; suffixPath?: string }>; + }; + // 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); + }); +}); + +// ── 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' }, + ], + 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, + ), + ); + 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); + }); +}); 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..de221a88 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/builtin-template-lint.test.ts @@ -0,0 +1,269 @@ +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, PartyField, TotalsBlock } 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. + */ + +/** + * 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', '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'], +}; + +/** `when` values resolved from the render context, not from the XML. */ +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', '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, and — where it is + // part-paid — which figure the instalments come off. + 'paidInFull', 'paidInPart', 'paidInPartOfPayable', 'paidInPartOfTotal', +]); + +interface CollectedPaths { + conditions: string[]; + repeaters: string[]; + /** `firstOf` alternative sets, as paths — at least one member must resolve. */ + alternatives: string[][]; +} + +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); + + 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]) { + // 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); + } + } + } + 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') { + 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') { + 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 if ('firstOf' in field) { + // 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. + } + }; + walkFields(block.left.fields); + walkFields(block.right.fields); + } + + 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; +} + +function bodiesOf(templateName: string): unknown[] { + const template = getBuiltinTemplate(templateName)!; + 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(FIXTURES_BY_TEMPLATE).sort()); + }); + + 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) => !roots.some((root) => has(root, path))); + expect(unresolved).toEqual([]); + }); + + 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) => !roots.some((root) => list(root, path).length > 0)); + expect(empty).toEqual([]); + }); + + it.each(Object.keys(FIXTURES_BY_TEMPLATE))( + '%s: every `firstOf` set has at least one path that resolves', + (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) => roots.some((root) => has(root, p))), + ); + expect(dead).toEqual([]); + }, + ); + + 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.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); + }, + ); + + 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.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('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'); + 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', () => { + 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 + }); + + /** + * 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(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(); + 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 (namesAStyle(key) && typeof inner === 'string') referenced.add(inner); + else walk(inner); + } + }; + walk(template); + expect([...referenced].filter((style) => !defined.includes(style))).toEqual([]); + }); + + 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(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 + // those defaults, so a template that omits them loses its headings silently. + const template = getBuiltinTemplate(name)!; + const defined = Object.keys(template.styles ?? {}); + expect(defined).toContain('title'); + + 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', () => { + const root = bodyOf('fa3-default'); + expect(list(root, 'Fa.FaWiersz').length).toBeGreaterThan(0); + expect(list(root, 'Fa.FaWierzs')).toEqual([]); + }); +}); 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/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/dsl.test.ts b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts new file mode 100644 index 00000000..68054f55 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/dsl.test.ts @@ -0,0 +1,225 @@ +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 = { + 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'); + } + }); +}); + +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(); + }); +}); + +// 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, + ); + }); +}); + +// 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(); + } + }); +}); 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..e7a757f5 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/fonts.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +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', () => { + 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); + }); + + // `^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', () => { + // 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); + }); +}); + +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/format.test.ts b/packages/ksef-client-ts/tests/unit/pdf/format.test.ts new file mode 100644 index 00000000..14b7046c --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/format.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest'; +import { + formatMoney, + formatNumber, + formatDate, + formatNip, + formatPaymentForm, + 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(' '); + }); + + // 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', () => { + 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(''); + }); + + 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', () => { + 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('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'); + }); + + 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'); + }); + + it('routes to the paymentForm formatter', () => { + expect(applyFormat('6', 'paymentForm')).toBe('Przelew'); + }); +}); 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..0aeb25ac --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/i18n.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +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', () => { + 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('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'); + }); + + 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'); + }); +}); + +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); + } + }); +}); + +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); + } + } + }); +}); 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..7c1aba09 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/interpret.test.ts @@ -0,0 +1,357 @@ +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' }); + }); + + // 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.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', () => { + const node = asRecord( + interpretBlock({ type: 'divider', style: 'rule' }, makeCtx(ROOT), coreRegistry, 0), + ); + 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({ 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({ 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', () => { + 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/notes.test.ts b/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts new file mode 100644 index 00000000..f35aeb4f --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/notes.test.ts @@ -0,0 +1,215 @@ +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('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', + 'Prosimy o podanie numeru faktury w tytule przelewu.', + ]); + }); + + 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'); // 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('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[1].style).toBe('h2'); + expect(out.stack[3].style).toBe('h2'); + }); + + 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(['notes', '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(['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', () => { + 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([ + 'notes', + '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([ + 'Pozostałe informacje', + '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); + // 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); + }); + + 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 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'); + }); + + 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(); + }); +}); 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'); + }); +}); 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..9b069253 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/parse.test.ts @@ -0,0 +1,276 @@ +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(); + }); + + // 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', () => { + 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(); + }); + + // 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(); + }); + + // 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)'); + }); + + // 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 = + '' + + '1'; + expect(detectUpoVersion(xml)).toBe('UPO(4.2)'); + }); + + it('reads the marker from a namespace-prefixed root tag', () => { + const xml = + '' + + '1'; + 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(); + }); +}); 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..8370533e --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/partial-payments.test.ts @@ -0,0 +1,260 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +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 { 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'; + +/** + * `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: { ...documentFlags(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; +} + +/** 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, + 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, + // 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', () => { + const xml = fx('fa3-czesciowa.xml').replace( + '1<', + '2<', + ); + 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, + paidInPartOfPayable: false, + paidInPartOfTotal: false, + }); + }); +}); + +/** + * 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'; + + 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'); + }); +}); + +/** + * `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']); + }); + }, +); 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'); + }); +}); 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']); + }); +}); 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 new file mode 100644 index 00000000..b5f47ed2 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/qr.test.ts @@ -0,0 +1,380 @@ +import { describe, it, expect } from 'vitest'; +import crypto from 'node:crypto'; +import { + computeInvoiceHashBase64, + resolveBaseQrUrl, + deriveInvoiceQrUrl, +} 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 { KSeFPdfError } from '../../../src/pdf/errors.js'; +import type { RenderContext } from '../../../src/pdf/template/interpret.js'; +import type { QrBlock } from '../../../src/pdf/template/dsl.js'; + +/** + * 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'; + +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(); + }); + + 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); + }); + + // 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); + expect(() => deriveInvoiceQrUrl({ rawInput: CLEAN, body: noNip })).toThrow(/seller NIP/i); + }); + +}); + +describe('qrRenderer', () => { + function ctxWith(bindings: Record, flags: Record = {}): RenderContext { + return { + root: body, + strict: false, + label: (k: string) => k, + bindings, + 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'; + + /** + * 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('renders nothing when the qrUrl binding is empty', () => { + expect(qrRenderer(block, ctxWith({ qrUrl: '' }), noopRender)).toBeNull(); + }); + + it('renders nothing when the qrUrl binding is absent', () => { + expect(qrRenderer(block, ctxWith({}), noopRender)).toBeNull(); + }); + + 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]).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', () => { + 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]).toMatchObject({ 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(); + }); + }); +}); + +/** + * 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']); + } + }); +}); 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..e2889006 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/render-builtins.test.ts @@ -0,0 +1,160 @@ +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('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/); + }); +}); + +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.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); + }); +}); + +/** + * 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\)/); + }); + + 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\)/); + }); +}); 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..b73db8f1 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/render-smoke.test.ts @@ -0,0 +1,83 @@ +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(); + }); +}); + +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/, + ); + }); +}); 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..02ea7f89 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/strict-mode.test.ts @@ -0,0 +1,245 @@ +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 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 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'); + await expect( + renderInvoicePdfFromTemplate(noAddressL2, fa3Default(), { strict: true }), + ).resolves.toBeInstanceOf(Uint8Array); + }); +}); + +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); + }); + + 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', () => { + 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); + }); +}); + +/** + * `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; + 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 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); + }); +}); 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..67a6d407 --- /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: '#5AB595' } }); + const styles = lastStyles(); + 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: '#5AB595' } }); + const after = lastStyles(); + 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: '#5AB595' } }); + 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'); + }); +}); 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..61569099 --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/totals-sum.test.ts @@ -0,0 +1,245 @@ +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'); +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 + .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). 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']; + +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 = { + root: (parsed as Record).Faktura, + strict: false, + label: makeLabelResolver('en', {}), + bindings: {}, + 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); + const totals = (doc.content as Array>).find( + (n) => Array.isArray(n.columns) && JSON.stringify(n).includes('table'), + )!; + const cols = totals.columns as Array>; + 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'); + 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); + // 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', () => { + const reduced = fa3 + .replace('500.00', '500.00') + .replace('115.00', '40.00') + .replace('615.00', '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 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 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'], + ['Currency', 'PLN'], + ]); + }); + + 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'], + ['Currency', 'PLN'], + ]); + }); + + 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'], + ['Currency', 'PLN'], + ]); + }); + + 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', + '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', 'Currency', + ]); + }); + + 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'); + } + }); + + // 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', () => { + 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/); + }); +}); 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..79c1249a --- /dev/null +++ b/packages/ksef-client-ts/tests/unit/pdf/upo-multi-document.test.ts @@ -0,0 +1,106 @@ +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 field group per document, separated by dividers', () => { + 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 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[] }; + // 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', () => { + const template = getBuiltinTemplate(templateName)!; + const parsed = parseXmlForPdf(single); + const ctx = { + root: (parsed as Record).Potwierdzenie, + strict: false, + label: makeLabelResolver('en', {}), + bindings: {}, + flags: {}, + }; + 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); + } + }); +}); + +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-'); + }); +}); 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..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 @@ -31,6 +31,59 @@ 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/); + }); + + // 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\//); + }); + + // 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\//, + ); + 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); 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"