diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc53dd0..3360f47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,20 +73,25 @@ jobs: cache: npm cache-dependency-path: package-lock.json + - name: Install native build deps for optional libxmljs2 + run: sudo apt-get update && sudo apt-get install -y libxml2-dev + - name: Install dependencies run: npm ci + - name: Install optional libxmljs2 for XSD coverage + run: npm install libxmljs2 --no-save + - name: Build package run: npm run build - name: Run tests with coverage run: > - NODE_OPTIONS=--enable-source-maps npm exec -- c8 --reporter=text-summary --reporter=text --reporter=lcov --reporter=html + NODE_OPTIONS=--enable-source-maps npm exec -- c8 --exclude-after-remap + --check-coverage --lines 100 --branches 100 --functions 100 --statements 100 + --reporter=text-summary --reporter=text --reporter=lcov --reporter=html sh -lc 'node --test $(find test/unit -name "*.test.js" ! -name "cliArgs.test.js" ! -name "cliTokenStore.test.js" -print) test/e2e/*.test.js' - - name: Enforce coverage threshold - run: npm exec -- c8 check-coverage --statements 100 --branches 100 --functions 100 --lines 100 - - name: Upload coverage artifact uses: actions/upload-artifact@v4 with: diff --git a/README.md b/README.md index 308cdf8..c4da45e 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ Biblioteka udostępnia typowane klienty endpointów, gotowe workflowy (auth/sesj ## Kompatybilność -- KSeF API: `v2.4.0` +- KSeF API: `v2.6.0` - Node.js: `>= 20` - Środowiska: `TEST`, `DEMO`, `PRD` -KSeF `2.4.0` rozszerza obsługę błędów o `application/problem+json` oraz doprecyzowuje operacje +KSeF `2.6.0` obejmuje obsługę `application/problem+json`, doprecyzowane operacje na tokenie użytym do bieżącego uwierzytelnienia. SDK zachowuje dotychczasowe metody klientów, a bogatszy format błędów możesz wymusić przez nagłówek `X-Error-Format: problem-details` ustawiony w `KsefClientOptions.headers`. @@ -23,11 +23,12 @@ npm install ksef-client Opcjonalne zależności: ```bash -npm install qrcode node-forge +npm install qrcode node-forge libxmljs2 ``` - `qrcode` jest wymagane dla `client.qr` - `node-forge` jest wymagane dla `XadesKeyPair.fromPkcs12*` +- `libxmljs2` jest wymagane tylko dla runtime walidacji `FA3Draft.toXml({ xsdValidate: true })`; schematy FA(3) są pakowane z biblioteką ## Quick Start diff --git a/docs/README.md b/docs/README.md index 9042e2c..5f2171c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@ Dokumentacja opisuje publiczne API biblioteki `ksef-client-typescript` oraz goto Kontrakt API (OpenAPI), dokumenty procesowe i ograniczenia systemu znajdują się w `ksef-docs/`. -Kompatybilność SDK: **KSeF API `v2.4.0`**. +Kompatybilność SDK: **KSeF API `v2.6.0`**. ## Status parity @@ -16,6 +16,7 @@ Aktualny status zgodności SDK względem `ksef-docs` znajdziesz w raporcie: - Node.js `>= 20` - dostęp do środowiska KSeF (`TEST`, `DEMO`, `PRD`) - dane uwierzytelniające (token KSeF lub certyfikat/XAdES) +- opcjonalnie `qrcode`, `node-forge` i `libxmljs2` dla QR, PKCS#12/XAdES i runtime walidacji XSD FA(3) ## Instalacja (lokalnie) diff --git a/docs/api/tokens.md b/docs/api/tokens.md index 494701a..4b739b3 100644 --- a/docs/api/tokens.md +++ b/docs/api/tokens.md @@ -21,9 +21,9 @@ Token KSeF możesz wykorzystać np. w autoryzacji przez `authenticateWithKsefTok `"InvoiceRead"`, `"InvoiceWrite"`, `"CredentialsRead"`, `"CredentialsManage"`, `"SubunitManage"`, `"EnforcementOperations"`, `"Introspection"`. - Stronicowanie listy tokenów działa przez nagłówek `x-continuation-token` (potwierdzone testami jednostkowymi). -- Od KSeF API `2.4.0` `GET /tokens` może zwrócić także informacje o tokenie użytym do bieżącego +- Od KSeF API `2.6.0` `GET /tokens` może zwrócić także informacje o tokenie użytym do bieżącego uwierzytelnienia, nawet bez `CredentialsManage` / `CredentialsRead`. -- Od KSeF API `2.4.0` `GET /tokens/{referenceNumber}` oraz `DELETE /tokens/{referenceNumber}` +- Od KSeF API `2.6.0` `GET /tokens/{referenceNumber}` oraz `DELETE /tokens/{referenceNumber}` mogą dotyczyć bieżącego tokenu uwierzytelniającego również bez dodatkowego uprawnienia `CredentialsManage`. diff --git a/docs/cli/README.md b/docs/cli/README.md index f23f406..c7b1521 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -36,6 +36,33 @@ Podstawowe uzycie CLI: node dist/cli/index.js --help ``` +## Session checkpoints (resume) + +CLI wspiera zapisywalne checkpointy sesji online/batch: + +```bash +# online +node dist/cli/index.js --json session online open --id demo-online --form-code FA3 +node dist/cli/index.js --json session online send --id demo-online --invoice-file ./invoice.xml --wait-status +node dist/cli/index.js --json session online close --id demo-online + +# batch +node dist/cli/index.js --json session batch open --id demo-batch --dir ./invoices +node dist/cli/index.js --json session batch upload --id demo-batch --parallelism 4 +node dist/cli/index.js --json session batch close --id demo-batch --wait-status --wait-upo +``` + +Operacje na zapisanych checkpointach: + +```bash +node dist/cli/index.js --json session list +node dist/cli/index.js --json session show --id demo-online +node dist/cli/index.js --json session status --id demo-online --invoice-ref INV-1 +node dist/cli/index.js --json session export --id demo-online --out ./artifacts/ +node dist/cli/index.js --json session import --in ./artifacts/session-demo-online.json --id demo-online-copy +node dist/cli/index.js --json session drop --id demo-online +``` + ## Wskazowki - `src/types/openapi.generated.ts` jest plikiem generowanym; nie edytuj go recznie. diff --git a/docs/configuration.md b/docs/configuration.md index eaa4c10..68e9968 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,7 +8,7 @@ Konfiguracja dotyczy zarówno `new KsefClient(...)`, jak i `KsefClient.connect(. - gdy ustawisz `baseUrl` bez sufiksu `/v2`, biblioteka automatycznie doda `/v2` (chyba ze `appendV2: false`), - `KsefClient.connect(...)` rozszerza `KsefClientOptions` o opcje potrzebne do logowania tokenem KSeF, - `headers` są globalne i trafiają do wszystkich requestów klienta, także do requestów na pre-signed URL (upload/download), więc nie należy umieszczać tam `Authorization`. -- dla KSeF `2.4.0` możesz przez `headers` włączyć `X-Error-Format: problem-details`, aby otrzymywać bogatsze odpowiedzi błędów dla `400` i `429`. +- dla KSeF `2.6.0` możesz przez `headers` włączyć `X-Error-Format: problem-details`, aby otrzymywać bogatsze odpowiedzi błędów dla `400` i `429`. ## `KsefClientOptions` diff --git a/docs/errors.md b/docs/errors.md index 57a8db0..832ebd9 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -14,7 +14,7 @@ SDK mapuje błędy HTTP i błędy walidacji na dedykowane klasy wyjątków. | `KsefSessionExpiredError` | Brak tokena dostępowego lub nieudane odświeżenie sesji. | `message` | | `KsefValidationError` | Błąd lokalnej walidacji danych wejściowych. | `message`, `details` | -## `problem` i KSeF API `2.4.0` +## `problem` i KSeF API `2.6.0` Przy odpowiedziach JSON SDK próbuje zmapować `error.problem` do jednego z modeli: @@ -27,7 +27,7 @@ Przy odpowiedziach JSON SDK próbuje zmapować `error.problem` do jednego z mode - fallback `UnknownApiProblem`, gdy JSON nie pasuje do znanego modelu Mapowanie do konkretnego typed modelu następuje tylko wtedy, gdy payload spełnia wymagane pola -kontraktu `ksef-docs 2.4.0`. Przy częściowym albo niepoprawnym `problem+json` SDK zachowuje +kontraktu `ksef-docs 2.6.0`. Przy częściowym albo niepoprawnym `problem+json` SDK zachowuje `responseBody`, a `error.problem` przechodzi do `UnknownApiProblem`. `410 Gone` może pojawić się po wygaśnięciu retencji technicznych statusów operacji asynchronicznych diff --git a/docs/examples/README.md b/docs/examples/README.md index acd9515..327e69f 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -245,3 +245,10 @@ await client.testdata.unblockContext({ - Workflowy i scenariusze: [../workflows/README.md](../workflows/README.md) - API reference: [../api/README.md](../api/README.md) - Błędy i retry: [../errors.md](../errors.md) +- FA(3) typed SDK examples: [./fa3-typed-sdk.md](./fa3-typed-sdk.md) + +## Kontrakt FA(3) typed SDK + +- Publiczne API FA(3) jest utrzymywane jako lokalny kontrakt TypeScript z testami jednostkowymi. +- Runtime walidacji `xsdValidate` korzysta ze schematów pakowanych w `dist`; do samej walidacji XSD potrzebna jest opcjonalna zależność `libxmljs2`. +- Przykłady FA(3) znajdują się w [`fa3-typed-sdk.md`](./fa3-typed-sdk.md). diff --git a/docs/examples/fa3-typed-sdk.md b/docs/examples/fa3-typed-sdk.md new file mode 100644 index 0000000..60d90b2 --- /dev/null +++ b/docs/examples/fa3-typed-sdk.md @@ -0,0 +1,93 @@ +# FA(3) typed SDK examples + +Typed SDK FA(3) działa samodzielnie w repo TypeScript. Schematy XSD są pakowane do `dist`, a runtime +walidacji `toXml({ xsdValidate: true })` wymaga opcjonalnej zależności `libxmljs2`. + +## Single invoice + +```ts +import { FA3Invoice } from "ksef-client"; + +const draft = FA3Invoice.basic("FV/1") + .issueDate("2026-05-18T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addLine({ + description: "Usługa", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 23, + }) + .build(); + +const xml = await draft.toXml({ xsdValidate: true }); +``` + +## Correction before/after + +```ts +import { FA3Invoice } from "ksef-client"; + +const correction = FA3Invoice.correction("KOR/1") + .issueDate("2026-05-18T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .correction({ + reason: "Korekta ceny", + correctedInvoiceNumber: "FV/OLD/1", + correctedInvoiceDate: "2026-05-01", + }) + .addCorrectedLineBeforeAfter({ + before: { description: "Usługa", quantity: 1, unit: "szt", unitNetPrice: 200, vatRate: 23 }, + after: { description: "Usługa", quantity: 1, unit: "szt", unitNetPrice: 100, vatRate: 23 }, + }) + .build(); + +const correctionXml = await correction.toXml(); +``` + +## Settlement invoice + +```ts +import { FA3Invoice } from "ksef-client"; + +const settlement = FA3Invoice.settlement("ROZ/1") + .issueDate("2026-05-18T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .advanceReference({ invoiceNumber: "FV/ZAL/1" }) + .addServiceLine("Usługa", { quantity: 1, unitNetPrice: 100, vatRate: 23 }) + .settlementDetails({ + amountDue: "125.00", + charges: [{ amount: "3.00", reason: "Dopłata" }], + deductions: [{ amount: "1.00", reason: "Rabat" }], + }) + .build(); + +const settlementXml = await settlement.toXml(); +``` + +## JSON roundtrip + +```ts +import { FA3BatchDraft } from "ksef-client"; + +const batch = new FA3BatchDraft([draft]); +const json = batch.toJson(); +const restored = FA3BatchDraft.fromJson(json); + +await restored.drafts[0].toXml(); +``` + +## Batch ZIP flow + +```ts +import { FA3BatchDraft } from "ksef-client"; + +const batch = new FA3BatchDraft([draftA, draftB]); + +await batch.toXmlFiles("./out/fa3"); +await batch.toXmlZip("./out/fa3.zip"); +``` + diff --git a/docs/maintainers.md b/docs/maintainers.md index bf2fa76..8bf13f3 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -29,7 +29,7 @@ Uwaga: CI wymaga 100% coverage (statement/branch/function/line). ## Kontrola zgodności z OpenAPI -Aktualny target kompatybilności repo: **KSeF API `2.4.0`**. +Aktualny target kompatybilności repo: **KSeF API `2.6.0`**. Regeneracja modeli: diff --git a/docs/parity-ksef-docs.md b/docs/parity-ksef-docs.md index 207d989..beae9e6 100644 --- a/docs/parity-ksef-docs.md +++ b/docs/parity-ksef-docs.md @@ -1,11 +1,11 @@ # Raport parity: `ksef-client-typescript` vs `ksef-docs` -Data analizy: **2026-04-13** +Data analizy: **2026-05-26** ## Zakres i źródła -- Kontrakt API: `ksef-docs/open-api.json` (`2.4.0`) -- Changelog: `ksef-docs/api-changelog.md` (wersje do `2.4.0`) +- Kontrakt API: `ksef-docs/open-api.json` (`2.6.0`) +- Changelog: `ksef-docs/api-changelog.md` (wersje do `2.6.0`) - Implementacja TypeScript: `src/api/*`, `src/types/*`, `src/services/*`, `src/client/*`, `docs/*` - Weryfikacja dodatkowa: `temp/openapi-test-v2.json` (`https://api-test.ksef.mf.gov.pl/docs/v2/openapi.json`) @@ -16,10 +16,10 @@ Data analizy: **2026-04-13** - Nadmiarowe endpointy po stronie TS: **0** - Zgodność kontraktu `ksef-docs` vs `api-test`: **zgodna** (po odfiltrowaniu opisów/metadanych) -## Zmiany uwzględnione po stronie SDK (2.4.0) +## Zmiany uwzględnione po stronie SDK (2.6.0) 1. Modele OpenAPI - - odświeżono `src/types/openapi.generated.ts` do `ksef-docs 2.4.0`; + - odświeżono `src/types/openapi.generated.ts` do kontraktu `ksef-docs 2.6.0`; - liczba schematów wzrosła do `287`; - modele obejmują nowe typy `ApiError`, `BadRequestProblemDetails`, `GoneProblemDetails`, `TooManyRequestsProblemDetails` oraz pola `timestamp` w `ForbiddenProblemDetails` @@ -31,12 +31,12 @@ Data analizy: **2026-04-13** - `KsefRateLimitError` udostępnia również `retryAfterSeconds`, obok dotychczasowego `retryAfter`. 3. Tokeny KSeF - - kontrakt i dokumentacja odzwierciedlają semantykę `2.4.0` dla operacji na bieżącym tokenie + - kontrakt i dokumentacja odzwierciedlają semantykę `2.6.0` dla operacji na bieżącym tokenie uwierzytelniającym przy `GET /tokens`, `GET /tokens/{referenceNumber}` i `DELETE /tokens/{referenceNumber}`. 4. Dokumentacja SDK - - deklaracje kompatybilności API w README i `docs/*` wskazują `v2.4.0`; + - deklaracje kompatybilności API w README i `docs/*` wskazują `v2.6.0`; - dokumentacja opisuje `X-Error-Format: problem-details`, `410 Gone` oraz zaktualizowane zachowanie endpointów tokenów. @@ -58,9 +58,9 @@ Data analizy: **2026-04-13** ## Parity dokumentacji -Dokumentacja TS została uaktualniona do spójności z KSeF `2.4.0`: +Dokumentacja TS została uaktualniona do spójności z KSeF `2.6.0`: -- deklaracje kompatybilności API (`v2.4.0`) w README i docs, +- deklaracje kompatybilności API (`v2.6.0`) w README i docs, - opis `problem+json` i `X-Error-Format` w dokumentacji błędów i konfiguracji, - opis self-token semantics w dokumentacji `tokens`, - zaktualizowany raport parity i wyniki walidacji. diff --git a/package-lock.json b/package-lock.json index 9d85685..8ba137a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,15 +7,20 @@ "": { "name": "ksef-client", "version": "0.5.2", + "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.11", "fast-xml-parser": "^4.4.1", + "libxmljs2": "^0.37.0", "undici": "^6.19.8", "xml-crypto": "^6.1.2", "xpath": "^0.0.33", "yauzl": "^2.10.0", "yazl": "^2.5.1" }, + "bin": { + "ksef-ts": "dist/cli/index.js" + }, "devDependencies": { "@types/node": "^20.14.10", "@types/qrcode": "^1.5.5", @@ -26,7 +31,6 @@ "c8": "^10.1.2", "eslint": "^9.11.1", "fast-check": "^3.23.2", - "libxmljs2": "^0.37.0", "prettier": "^3.3.3", "tsup": "^8.3.0", "typescript": "^5.6.3" @@ -35,6 +39,7 @@ "node": ">=20" }, "optionalDependencies": { + "libxmljs2": "^0.37.0", "node-forge": "^1.3.1", "qrcode": "^1.5.3" } @@ -736,7 +741,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -754,8 +759,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^7.0.4" }, @@ -816,8 +821,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", @@ -833,8 +838,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "semver": "^7.3.5" }, @@ -846,7 +851,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -1299,7 +1303,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -1520,8 +1523,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true, "license": "ISC", + "optional": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -1532,7 +1535,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1554,8 +1556,8 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 14" } @@ -1581,7 +1583,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -1624,14 +1626,13 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -1646,14 +1647,15 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -1662,8 +1664,8 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -1674,7 +1676,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1684,7 +1686,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -1700,6 +1701,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -1778,8 +1780,8 @@ "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", @@ -1855,8 +1857,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, "license": "BlueOak-1.0.0", + "optional": true, "engines": { "node": ">=18" } @@ -2004,7 +2006,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2019,7 +2021,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2047,8 +2049,8 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -2063,8 +2065,8 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=4.0.0" } @@ -2080,8 +2082,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } @@ -2097,21 +2099,20 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2122,8 +2123,8 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "once": "^1.4.0" } @@ -2132,8 +2133,8 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=6" } @@ -2142,8 +2143,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/esbuild": { "version": "0.27.2", @@ -2152,7 +2153,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2217,7 +2217,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2430,8 +2429,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, "license": "(MIT OR WTFPL)", + "optional": true, "engines": { "node": ">=6" } @@ -2440,8 +2439,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "optional": true }, "node_modules/fast-check": { "version": "3.23.2", @@ -2518,7 +2517,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2549,8 +2548,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/find-up": { "version": "5.0.0", @@ -2606,7 +2605,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -2623,15 +2622,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/fs-minipass": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^7.0.3" }, @@ -2668,14 +2667,14 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -2722,8 +2721,8 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/has-flag": { "version": "4.0.0", @@ -2746,15 +2745,15 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "optional": true }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -2767,8 +2766,8 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -2781,7 +2780,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2795,7 +2793,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -2810,7 +2807,8 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/ignore": { "version": "7.0.5", @@ -2843,7 +2841,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -2853,22 +2851,22 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 12" } @@ -2910,7 +2908,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -2956,7 +2954,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -3040,9 +3038,9 @@ "version": "0.37.0", "resolved": "https://registry.npmjs.org/libxmljs2/-/libxmljs2-0.37.0.tgz", "integrity": "sha512-Xb78V8GZouoZFrq8cCwx7+G3WYOcJG0xb3YUbweSyE4z2EIrQCZMr3Ye/dHn4mESs6YxUMeQeUZm5IXg+iLHog==", - "dev": true, "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { "bindings": "~1.5.0", "nan": "~2.22.2", @@ -3110,7 +3108,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/magic-string": { @@ -3143,8 +3141,8 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", @@ -3166,8 +3164,8 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=10" }, @@ -3179,7 +3177,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -3195,8 +3193,8 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", + "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3205,7 +3203,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -3215,8 +3213,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^7.0.3" }, @@ -3228,8 +3226,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", @@ -3246,8 +3244,8 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -3259,8 +3257,8 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -3272,15 +3270,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -3292,8 +3290,8 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -3305,15 +3303,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/minipass-sized": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -3325,8 +3323,8 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -3338,15 +3336,15 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "minipass": "^7.1.2" }, @@ -3358,8 +3356,8 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/mlly": { "version": "1.8.0", @@ -3378,7 +3376,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/mz": { @@ -3397,15 +3395,15 @@ "version": "2.22.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", - "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/natural-compare": { "version": "1.4.0", @@ -3418,8 +3416,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 0.6" } @@ -3428,8 +3426,8 @@ "version": "3.85.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "semver": "^7.3.5" }, @@ -3451,8 +3449,8 @@ "version": "11.5.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", @@ -3476,8 +3474,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "dev": true, "license": "ISC", + "optional": true, "engines": { "node": ">=16" } @@ -3486,8 +3484,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "isexe": "^3.1.1" }, @@ -3502,8 +3500,8 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "abbrev": "^3.0.0" }, @@ -3528,8 +3526,8 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "wrappy": "1" } @@ -3588,8 +3586,8 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=18" }, @@ -3611,7 +3609,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -3641,7 +3639,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -3651,7 +3649,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, + "devOptional": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -3688,9 +3686,8 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3777,8 +3774,8 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -3830,8 +3827,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", - "dev": true, "license": "ISC", + "optional": true, "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -3840,8 +3837,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -3854,8 +3851,8 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -4082,8 +4079,8 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -4098,8 +4095,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=0.10.0" } @@ -4108,8 +4105,8 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -4164,8 +4161,8 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 4" } @@ -4219,7 +4216,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -4234,13 +4230,13 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT", "optional": true }, @@ -4248,7 +4244,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4268,7 +4264,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4281,7 +4277,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4291,7 +4287,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -4304,7 +4300,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, "funding": [ { "type": "github", @@ -4319,13 +4314,13 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/simple-get": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, "funding": [ { "type": "github", @@ -4341,6 +4336,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -4351,8 +4347,8 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -4362,8 +4358,8 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" @@ -4377,8 +4373,8 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -4402,8 +4398,8 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^7.0.3" }, @@ -4415,8 +4411,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -4425,7 +4421,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -4444,7 +4440,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4459,7 +4455,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4469,14 +4465,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4489,7 +4485,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -4506,7 +4502,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -4519,7 +4515,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4590,8 +4586,8 @@ "version": "7.5.2", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", - "dev": true, "license": "BlueOak-1.0.0", + "optional": true, "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -4607,8 +4603,8 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -4620,15 +4616,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, "license": "MIT", + "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -4689,7 +4685,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -4799,8 +4795,8 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, "license": "Apache-2.0", + "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -4827,7 +4823,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4863,8 +4858,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "unique-slug": "^5.0.0" }, @@ -4876,8 +4871,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", - "dev": true, "license": "ISC", + "optional": true, "dependencies": { "imurmurhash": "^0.1.4" }, @@ -4899,8 +4894,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -4921,7 +4916,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -4954,7 +4949,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -4973,7 +4968,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -4991,7 +4986,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5001,14 +4996,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -5023,7 +5018,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -5036,7 +5031,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -5049,8 +5044,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/xml-crypto": { "version": "6.1.2", @@ -5089,8 +5084,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, "license": "BlueOak-1.0.0", + "optional": true, "engines": { "node": ">=18" } @@ -5203,4 +5198,3 @@ } } } - diff --git a/package.json b/package.json index 13ed4ba..ee8b99a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,11 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, + "./documents/fa3": { + "types": "./dist/documents/fa3.d.ts", + "import": "./dist/documents/fa3.js", + "require": "./dist/documents/fa3.cjs" + }, "./cli": { "import": "./dist/cli/index.js", "require": "./dist/cli/index.cjs" @@ -45,7 +50,7 @@ "LICENSE" ], "scripts": { - "build": "tsup", + "build": "tsup && node scripts/copy-fa3-schemas.mjs", "typecheck": "tsc --noEmit", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -62,6 +67,23 @@ "node": ">=20" }, "sideEffects": false, + "c8": { + "excludeAfterRemap": true, + "exclude": [ + "coverage/**", + "packages/*/test{,s}/**", + "**/*.d.ts", + "test{,s}/**", + "test{,-*}.{js,cjs,mjs,ts,tsx,jsx}", + "**/*{.,-}test.{js,cjs,mjs,ts,tsx,jsx}", + "**/__tests__/**", + "**/{ava,babel,nyc}.config.{js,cjs,mjs}", + "**/jest.config.{js,cjs,mjs,ts}", + "**/{karma,rollup,webpack}.config.js", + "**/.{eslint,mocha}rc.{js,cjs}", + "**/documents/fa3/index.ts" + ] + }, "dependencies": { "@xmldom/xmldom": "^0.8.11", "fast-xml-parser": "^4.4.1", @@ -72,6 +94,7 @@ "yazl": "^2.5.1" }, "optionalDependencies": { + "libxmljs2": "^0.37.0", "node-forge": "^1.3.1", "qrcode": "^1.5.3" }, @@ -85,7 +108,6 @@ "c8": "^10.1.2", "eslint": "^9.11.1", "fast-check": "^3.23.2", - "libxmljs2": "^0.37.0", "prettier": "^3.3.3", "tsup": "^8.3.0", "typescript": "^5.6.3" diff --git a/scripts/copy-fa3-schemas.mjs b/scripts/copy-fa3-schemas.mjs new file mode 100644 index 0000000..75e8103 --- /dev/null +++ b/scripts/copy-fa3-schemas.mjs @@ -0,0 +1,9 @@ +import { cp, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const root = process.cwd(); +const source = path.join(root, "src", "documents", "fa3", "schemas"); +const target = path.join(root, "dist", "documents", "fa3", "schemas"); + +await mkdir(path.dirname(target), { recursive: true }); +await cp(source, target, { recursive: true, force: true }); diff --git a/src/cli/args.ts b/src/cli/args.ts index 967349d..a48225f 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -32,9 +32,23 @@ const VALUE_OPTIONS = new Set([ "hash-of-corrected-invoice", "upo-output", "session-ref", + "session-id", + "save-session", "invoice-ref", "upo-ref", "out-dir", + "id", + "in", + "out", + "system-code", + "schema-version", + "form-value", + "invoice", + "poll-interval", + "save-upo", + "parallelism", + "zip", + "dir", ]); export function parseArgv(argv: string[]): ParsedArgv { diff --git a/src/cli/index.ts b/src/cli/index.ts index a4d9c56..bb40afb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,7 +1,8 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { + CryptographyService, KsefApiError, KsefClient, KsefError, @@ -14,6 +15,8 @@ import { type KsefLighthouseEnvironment, type InvoiceQueryFilters, type JsonValue, + type SessionStatusResponse, + createZip, } from "../index"; import { getBooleanOption, getNumberOption, getStringOption, parseArgv } from "./args"; import { @@ -26,6 +29,26 @@ import { writeConfig, } from "./configStore"; import { parseFormCode } from "./formCodes"; +import { + type BatchPayloadSource, + type BatchSessionCheckpoint, + type OnlineSessionCheckpoint, + type SessionCheckpoint, + SessionStoreError, + deleteCheckpoint, + deserializeBatchSessionState, + deserializeOnlineSessionState, + exportCheckpoint, + importCheckpoint, + listCheckpoints, + loadCheckpoint, + saveCheckpoint, + serializeBatchSessionState, + serializeOnlineSessionState, + summarizeCheckpoint, + updateCheckpoint, + validateSessionId, +} from "./sessionStore"; import { clearStoredTokens, formatTokenStoreWarning, @@ -140,6 +163,11 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi emit(context, result); return EXIT_SUCCESS; } + case "session": { + const result = await runSession(rest, parsed.options, context); + emit(context, toJsonValue(result)); + return EXIT_SUCCESS; + } case "upo": { const result = await runUpo(rest, parsed.options, context); emit(context, result); @@ -518,14 +546,43 @@ async function runSend( const invoiceXml = await readFile(path.resolve(context.cwd, invoiceFile), "utf8"); const formCode = parseFormCode(getStringOption(options, "form-code")); const waitForUpo = getBooleanOption(options, "wait-upo"); - const pollIntervalMs = getNumberOption(options, "poll-interval-ms"); - const maxAttempts = getNumberOption(options, "max-attempts"); + const pollIntervalMs = waitForUpo ? resolvePollIntervalMs(options, 2000) : 2000; + const maxAttempts = waitForUpo ? resolveMaxAttempts(options, 60) : 60; const hashOfCorrectedInvoice = getStringOption(options, "hash-of-corrected-invoice"); + const sessionId = getStringOption(options, "session-id") ?? getStringOption(options, "save-session"); + const upoOutput = getStringOption(options, "upo-output") ?? getStringOption(options, "save-upo"); + const saveUpoOverwrite = getBooleanOption(options, "save-upo-overwrite"); + if (upoOutput && !waitForUpo) { + throw new CliError("Option --save-upo/--upo-output requires --wait-upo."); + } const session = await client.workflows.sessions.online.open({ formCode, upoV43: getBooleanOption(options, "upo-v43"), }); + let checkpoint: OnlineSessionCheckpoint | null = null; + if (sessionId) { + const normalizedSessionId = validateSessionId(sessionId); + checkpoint = { + schemaVersion: 1, + id: normalizedSessionId, + profile: loaded.profileName, + baseUrl: resolveBaseUrl(loaded.profile), + kind: "online", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + stage: "opened", + sessionState: serializeOnlineSessionState(session.getState()), + lastInvoiceRef: null, + sentInvoiceRefs: [], + }; + try { + await saveCheckpoint(context.cliHome, checkpoint, { overwrite: false }); + } catch (error) { + await session.close().catch(() => undefined); + throw error; + } + } let closed = false; try { const sendResponse = await session.sendInvoice({ @@ -533,21 +590,29 @@ async function runSend( offlineMode: getBooleanOption(options, "offline"), ...(hashOfCorrectedInvoice ? { hashOfCorrectedInvoice } : {}), }); + if (checkpoint) { + checkpoint = (await updateCheckpoint(context.cliHome, checkpoint, { + stage: "invoice_sent", + lastInvoiceRef: sendResponse.referenceNumber, + sentInvoiceRefs: [...checkpoint.sentInvoiceRefs, sendResponse.referenceNumber], + })) as OnlineSessionCheckpoint; + } await session.close(); closed = true; + if (checkpoint) { + checkpoint = (await updateCheckpoint(context.cliHome, checkpoint, { + stage: "closed", + })) as OnlineSessionCheckpoint; + } let upoXml: string | null = null; let upo: JsonValue | null = null; if (waitForUpo) { - upoXml = await session.waitForUpo({ - ...(pollIntervalMs !== undefined ? { pollIntervalMs } : {}), - ...(maxAttempts !== undefined ? { maxAttempts } : {}), - }); + upoXml = await session.waitForUpo({ pollIntervalMs, maxAttempts }); if (upoXml) { upo = toJsonValue(parseUpoXml(upoXml)); - const upoOutput = getStringOption(options, "upo-output"); if (upoOutput) { - await writeOutputFile(upoOutput, upoXml, context.cwd); + await saveOutputFile(upoOutput, upoXml, context.cwd, { overwrite: saveUpoOverwrite }); } } } @@ -558,6 +623,7 @@ async function runSend( sessionReferenceNumber: session.referenceNumber, invoiceReferenceNumber: sendResponse.referenceNumber, waitForUpo, + ...(checkpoint ? { sessionId: checkpoint.id } : {}), ...(upoXml ? { upoXml } : {}), ...(upo ? { upo } : {}), }; @@ -568,6 +634,435 @@ async function runSend( } } +async function runSession( + positionals: string[], + options: Record, + context: CommandContext, +): Promise { + const [subcommand, ...rest] = positionals; + if (!subcommand) { + throw new CliError("session requires subcommand: list | show | status | export | import | drop | online | batch."); + } + + if (subcommand === "online") { + return runSessionOnline(rest, options, context); + } + + if (subcommand === "batch") { + return runSessionBatch(rest, options, context); + } + + const loaded = await loadProfileContext(options, context); + const profileName = loaded.profileName; + + if (subcommand === "list") { + const checkpoints = await listCheckpoints(context.cliHome, profileName); + return { + count: checkpoints.length, + items: checkpoints.map((item) => summarizeCheckpoint(item)), + }; + } + + if (subcommand === "show") { + const sessionId = requireSessionId(options, rest[0]); + const checkpoint = await loadCheckpoint(context.cliHome, profileName, sessionId); + return { + ...summarizeCheckpoint(checkpoint), + checkpoint: toJsonValue(checkpoint), + }; + } + + if (subcommand === "status") { + const sessionId = requireSessionId(options, rest[0]); + const invoiceRef = getStringOption(options, "invoice-ref"); + const checkpoint = await loadCheckpoint(context.cliHome, profileName, sessionId); + const client = await createAuthenticatedClientForBaseUrl( + profileName, + loaded.profile, + checkpoint.baseUrl, + context, + ); + const sessionRef = checkpoint.sessionState.referenceNumber; + const status = await client.sessions.getSessionStatus(sessionRef); + const payload: Record = { + id: checkpoint.id, + kind: checkpoint.kind, + stage: checkpoint.stage, + sessionRef, + status: toJsonValue(status), + }; + if (invoiceRef) { + payload.invoiceStatus = toJsonValue( + await client.sessions.getSessionInvoiceStatus(sessionRef, invoiceRef), + ); + } + return payload; + } + + if (subcommand === "export") { + const sessionId = requireSessionId(options, rest[0]); + const out = getStringOption(options, "out"); + if (!out) { + throw new CliError("session export requires --out."); + } + const exportedPath = await exportCheckpoint(context.cliHome, profileName, sessionId, out); + return { + id: sessionId, + profile: profileName, + path: exportedPath, + }; + } + + if (subcommand === "import") { + const sourcePath = getStringOption(options, "in"); + if (!sourcePath) { + throw new CliError("session import requires --in."); + } + const sessionIdOverride = getStringOption(options, "id"); + const imported = await importCheckpoint(context.cliHome, profileName, sourcePath, { + ...(sessionIdOverride ? { sessionId: sessionIdOverride } : {}), + }); + return summarizeCheckpoint(imported); + } + + if (subcommand === "drop") { + const sessionId = requireSessionId(options, rest[0]); + await deleteCheckpoint(context.cliHome, profileName, sessionId); + return { + id: sessionId, + profile: profileName, + deleted: true, + }; + } + + throw new CliError(`Unknown session subcommand "${subcommand}".`); +} + +async function runSessionOnline( + positionals: string[], + options: Record, + context: CommandContext, +): Promise { + const [subcommand] = positionals; + if (!subcommand) { + throw new CliError("session online requires subcommand: open | send | close."); + } + const loaded = await loadProfileContext(options, context); + const profileName = loaded.profileName; + + if (subcommand === "open") { + const sessionId = requireSessionId(options); + const client = await createAuthenticatedClient(profileName, loaded.profile, context); + const formCode = parseSessionFormCode(options); + const handle = await client.workflows.sessions.online.open({ + formCode: formCode as never, + upoV43: getBooleanOption(options, "upo-v43"), + }); + + const checkpoint: OnlineSessionCheckpoint = { + schemaVersion: 1, + id: sessionId, + profile: profileName, + baseUrl: resolveBaseUrl(loaded.profile), + kind: "online", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + stage: "opened", + sessionState: serializeOnlineSessionState(handle.getState()), + lastInvoiceRef: null, + sentInvoiceRefs: [], + }; + + try { + await saveCheckpoint(context.cliHome, checkpoint, { overwrite: false }); + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } + + return summarizeCheckpoint(checkpoint); + } + + if (subcommand === "send") { + const sessionId = requireSessionId(options); + const invoiceFile = getStringOption(options, "invoice-file") ?? getStringOption(options, "invoice"); + if (!invoiceFile) { + throw new CliError("session online send requires --invoice-file."); + } + const waitStatus = getBooleanOption(options, "wait-status"); + const waitUpo = getBooleanOption(options, "wait-upo"); + const saveUpo = getStringOption(options, "save-upo"); + const saveUpoOverwrite = getBooleanOption(options, "save-upo-overwrite"); + if (saveUpo && !waitUpo) { + throw new CliError("Option --save-upo requires --wait-upo."); + } + const pollIntervalMs = resolvePollIntervalMs(options, 2000); + const maxAttempts = resolveMaxAttempts(options, 60); + + const checkpoint = await requireOnlineCheckpoint(context, profileName, sessionId); + ensureCheckpointNotClosed(checkpoint, "ksef-ts session online send"); + const client = await createAuthenticatedClientForBaseUrl( + profileName, + loaded.profile, + checkpoint.baseUrl, + context, + ); + const handle = client.workflows.sessions.online.resume( + deserializeOnlineSessionState(checkpoint.sessionState), + ); + const invoiceXml = await readFile(path.resolve(context.cwd, invoiceFile), "utf8"); + const sendResponse = await handle.sendInvoice({ invoice: invoiceXml }); + const invoiceRef = sendResponse.referenceNumber; + if (!invoiceRef) { + throw new CliError("Send response does not contain invoice reference number.", EXIT_REMOTE); + } + + const sentInvoiceRefs = checkpoint.sentInvoiceRefs.includes(invoiceRef) + ? [...checkpoint.sentInvoiceRefs] + : [...checkpoint.sentInvoiceRefs, invoiceRef]; + const updated = (await updateCheckpoint(context.cliHome, checkpoint, { + stage: "invoice_sent", + lastInvoiceRef: invoiceRef, + sentInvoiceRefs, + })) as OnlineSessionCheckpoint; + + const result: Record = { + id: updated.id, + sessionRef: updated.sessionState.referenceNumber, + invoiceRef, + stage: updated.stage, + }; + + if (waitStatus || waitUpo) { + const invoiceStatus = await waitForInvoiceStatus(handle, invoiceRef, pollIntervalMs, maxAttempts); + result.invoiceStatus = toJsonValue(invoiceStatus); + result.statusCode = extractStatusCode(invoiceStatus); + result.ksefNumber = extractKsefNumber(invoiceStatus) ?? ""; + } + + if (waitUpo) { + const upoXml = await waitForInvoiceUpo(handle, invoiceRef, pollIntervalMs, maxAttempts); + result.upoBytes = Buffer.byteLength(upoXml, "utf8"); + if (saveUpo) { + const outputPath = resolveOutputPath( + saveUpo, + `upo-${updated.sessionState.referenceNumber}-${invoiceRef}.xml`, + ); + await saveOutputFile(outputPath, upoXml, context.cwd, { overwrite: saveUpoOverwrite }); + result.upoPath = path.resolve(context.cwd, outputPath); + } else { + result.upoPath = ""; + } + } + + return result; + } + + if (subcommand === "close") { + const sessionId = requireSessionId(options); + const checkpoint = await requireOnlineCheckpoint(context, profileName, sessionId); + ensureCheckpointNotClosed(checkpoint, "ksef-ts session online close"); + const client = await createAuthenticatedClientForBaseUrl( + profileName, + loaded.profile, + checkpoint.baseUrl, + context, + ); + const handle = client.workflows.sessions.online.resume( + deserializeOnlineSessionState(checkpoint.sessionState), + ); + await handle.close(); + const updated = (await updateCheckpoint(context.cliHome, checkpoint, { + stage: "closed", + })) as OnlineSessionCheckpoint; + return summarizeCheckpoint(updated); + } + + throw new CliError(`Unknown session online subcommand "${subcommand}".`); +} + +async function runSessionBatch( + positionals: string[], + options: Record, + context: CommandContext, +): Promise { + const [subcommand] = positionals; + if (!subcommand) { + throw new CliError("session batch requires subcommand: open | upload | close."); + } + const loaded = await loadProfileContext(options, context); + const profileName = loaded.profileName; + + if (subcommand === "open") { + const sessionId = requireSessionId(options); + const formCode = parseSessionFormCode(options); + const { zipBytes, payloadSource } = await buildBatchPayloadSource(context.cwd, options); + const client = await createAuthenticatedClient(profileName, loaded.profile, context); + const handle = await client.workflows.sessions.batch.open({ + formCode: formCode as never, + zipBytes, + upoV43: getBooleanOption(options, "upo-v43"), + }); + const checkpoint: BatchSessionCheckpoint = { + schemaVersion: 1, + id: sessionId, + profile: profileName, + baseUrl: resolveBaseUrl(loaded.profile), + kind: "batch", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + stage: "opened", + sessionState: serializeBatchSessionState(handle.getState()), + payloadSource, + uploadedOrdinals: [], + lastUpoRef: null, + }; + + try { + await saveCheckpoint(context.cliHome, checkpoint, { overwrite: false }); + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } + + return summarizeCheckpoint(checkpoint); + } + + if (subcommand === "upload") { + const sessionId = requireSessionId(options); + const parallelism = getNumberOption(options, "parallelism") ?? 4; + if (!Number.isInteger(parallelism) || parallelism <= 0) { + throw new CliError("Invalid --parallelism. It must be a positive integer."); + } + let checkpoint = await requireBatchCheckpoint(context, profileName, sessionId); + ensureCheckpointNotClosed(checkpoint, "ksef-ts session batch upload"); + + const client = await createAuthenticatedClientForBaseUrl( + profileName, + loaded.profile, + checkpoint.baseUrl, + context, + ); + const zipBytes = await loadBatchPayloadSourceBytes(checkpoint.payloadSource, context.cwd); + const uploaded = new Set(checkpoint.uploadedOrdinals); + const state = deserializeBatchSessionState(checkpoint.sessionState); + const totalParts = state.partUploadRequests.length; + if (uploaded.size >= totalParts && totalParts > 0) { + return { + ...summarizeCheckpoint(checkpoint), + uploadedCount: uploaded.size, + totalParts, + }; + } + const handle = await client.workflows.sessions.batch.resume(state, { zipBytes }); + let updateQueue = Promise.resolve(); + await handle.uploadParts({ + parallelism, + skipOrdinals: [...uploaded], + progressCallback: (ordinalNumber) => { + updateQueue = updateQueue.then(async () => { + if (!uploaded.has(ordinalNumber)) { + uploaded.add(ordinalNumber); + checkpoint = (await updateCheckpoint(context.cliHome, checkpoint, { + stage: "uploading", + uploadedOrdinals: [...uploaded].sort((a, b) => a - b), + })) as BatchSessionCheckpoint; + } + }); + return updateQueue; + }, + }); + await updateQueue; + checkpoint = (await updateCheckpoint(context.cliHome, checkpoint, { + stage: "uploaded", + uploadedOrdinals: [...uploaded].sort((a, b) => a - b), + })) as BatchSessionCheckpoint; + + return { + ...summarizeCheckpoint(checkpoint), + uploadedCount: checkpoint.uploadedOrdinals.length, + totalParts, + }; + } + + if (subcommand === "close") { + const sessionId = requireSessionId(options); + const waitStatus = getBooleanOption(options, "wait-status"); + const waitUpo = getBooleanOption(options, "wait-upo"); + const saveUpo = getStringOption(options, "save-upo"); + const saveUpoOverwrite = getBooleanOption(options, "save-upo-overwrite"); + if (saveUpo && !waitUpo) { + throw new CliError("Option --save-upo requires --wait-upo."); + } + const pollIntervalMs = resolvePollIntervalMs(options, 2000); + const maxAttempts = resolveMaxAttempts(options, 120); + + let checkpoint = await requireBatchCheckpoint(context, profileName, sessionId); + if (checkpoint.stage === "closed" && !waitStatus && !waitUpo) { + return summarizeCheckpoint(checkpoint); + } + const client = await createAuthenticatedClientForBaseUrl( + profileName, + loaded.profile, + checkpoint.baseUrl, + context, + ); + if (checkpoint.stage !== "closed") { + const zipBytes = await loadBatchPayloadSourceBytes(checkpoint.payloadSource, context.cwd); + const handle = await client.workflows.sessions.batch.resume( + deserializeBatchSessionState(checkpoint.sessionState), + { zipBytes }, + ); + await handle.close(); + checkpoint = (await updateCheckpoint(context.cliHome, checkpoint, { + stage: "closed", + })) as BatchSessionCheckpoint; + } + + const result: Record = { + ...summarizeCheckpoint(checkpoint), + }; + + if (waitStatus || waitUpo) { + const status = await waitForSessionStatus( + () => client.sessions.getSessionStatus(checkpoint.sessionState.referenceNumber), + pollIntervalMs, + maxAttempts, + ); + result.status = toJsonValue(status); + result.statusCode = extractStatusCode(status); + const upoRef = status.upo?.pages?.[0]?.referenceNumber ?? ""; + if (upoRef) { + checkpoint = (await updateCheckpoint(context.cliHome, checkpoint, { + lastUpoRef: upoRef, + })) as BatchSessionCheckpoint; + } + result.upoRef = upoRef; + if (waitUpo) { + if (!upoRef) { + throw new CliError("UPO reference number is not available in session status.", EXIT_REMOTE); + } + const upoXml = await client.sessions.getSessionUpo(checkpoint.sessionState.referenceNumber, upoRef); + result.upoBytes = Buffer.byteLength(upoXml, "utf8"); + if (saveUpo) { + const outputPath = resolveOutputPath( + saveUpo, + `upo-${checkpoint.sessionState.referenceNumber}-${upoRef}.xml`, + ); + await saveOutputFile(outputPath, upoXml, context.cwd, { overwrite: saveUpoOverwrite }); + result.upoPath = path.resolve(context.cwd, outputPath); + } else { + result.upoPath = ""; + } + } + } + + return result; + } + + throw new CliError(`Unknown session batch subcommand "${subcommand}".`); +} + async function runUpo( positionals: string[], options: Record, @@ -717,6 +1212,314 @@ async function runExport( }; } +function requireSessionId(options: Record, fallback?: string): string { + const value = fallback ?? getStringOption(options, "id"); + if (!value) { + throw new CliError("Missing session id. Use --id ."); + } + try { + return validateSessionId(value); + } catch (error) { + throw new CliError(formatSessionIdError(error), EXIT_USAGE); + } +} + +function formatSessionIdError(error: unknown): string { + return error instanceof SessionStoreError ? error.message : String(error); +} + +function parseSessionFormCode( + options: Record, +): { systemCode: string; schemaVersion: string; value: string } { + const formCodeAlias = getStringOption(options, "form-code"); + if (formCodeAlias) { + return parseFormCode(formCodeAlias); + } + return { + systemCode: getStringOption(options, "system-code") ?? "FA (3)", + schemaVersion: getStringOption(options, "schema-version") ?? "1-0E", + value: getStringOption(options, "form-value") ?? "FA", + }; +} + +function resolvePollIntervalMs( + options: Record, + defaultValue: number, +): number { + const explicitMs = getNumberOption(options, "poll-interval-ms"); + if (explicitMs !== undefined) { + if (!Number.isFinite(explicitMs) || explicitMs <= 0) { + throw new CliError("--poll-interval-ms must be greater than zero."); + } + return explicitMs; + } + const seconds = getNumberOption(options, "poll-interval"); + if (seconds !== undefined) { + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new CliError("--poll-interval must be greater than zero."); + } + return Math.max(1, Math.round(seconds * 1000)); + } + return defaultValue; +} + +function resolveMaxAttempts(options: Record, defaultValue: number): number { + const value = getNumberOption(options, "max-attempts"); + if (value === undefined) { + return defaultValue; + } + if (!Number.isInteger(value) || value <= 0) { + throw new CliError("--max-attempts must be a positive integer."); + } + return value; +} + +function ensureCheckpointNotClosed(checkpoint: SessionCheckpoint, commandName: string): void { + if (checkpoint.stage === "closed") { + throw new CliError( + `Session checkpoint "${checkpoint.id}" is already closed. Open a new one before running "${commandName}".`, + EXIT_USAGE, + ); + } +} + +async function requireOnlineCheckpoint( + context: CommandContext, + profileName: string, + sessionId: string, +): Promise { + const checkpoint = await loadCheckpoint(context.cliHome, profileName, sessionId); + if (checkpoint.kind !== "online") { + throw new CliError( + `Session checkpoint "${sessionId}" is not an online session. Use "session batch" commands.`, + EXIT_USAGE, + ); + } + return checkpoint; +} + +async function requireBatchCheckpoint( + context: CommandContext, + profileName: string, + sessionId: string, +): Promise { + const checkpoint = await loadCheckpoint(context.cliHome, profileName, sessionId); + if (checkpoint.kind !== "batch") { + throw new CliError( + `Session checkpoint "${sessionId}" is not a batch session. Use "session online" commands.`, + EXIT_USAGE, + ); + } + return checkpoint; +} + +async function createAuthenticatedClientForBaseUrl( + profileName: string, + profile: ProfileConfig, + baseUrl: string, + context: CommandContext, +): Promise { + const client = createClientForBaseUrl(baseUrl, profile); + const tokenStore = resolveTokenStore(profile, context.cliHome); + const tokens = await loadStoredTokens(profileName, tokenStore, context.env); + if (!tokens?.accessToken) { + throw new CliError( + `No access token found for profile "${profileName}". Run "ksef-ts auth login" first.`, + EXIT_AUTH, + ); + } + applyTokens(client, tokens); + const warning = formatTokenStoreWarning(profileName, tokenStore); + if (warning) { + context.io.stderr(warning); + } + return client; +} + +async function buildBatchPayloadSource( + cwd: string, + options: Record, +): Promise<{ zipBytes: Buffer; payloadSource: BatchPayloadSource }> { + const zipPath = getStringOption(options, "zip"); + const directory = getStringOption(options, "dir"); + const selected = [Boolean(zipPath), Boolean(directory)].filter(Boolean).length; + if (selected !== 1) { + throw new CliError("Select exactly one batch input source: --zip or --dir."); + } + + if (zipPath) { + const normalizedPath = path.resolve(cwd, zipPath); + const zipBytes = await readFile(normalizedPath); + return { + zipBytes, + payloadSource: { + kind: "zip", + path: normalizedPath, + sourceSha256Base64: CryptographyService.sha256Base64(zipBytes), + sourceSize: zipBytes.length, + }, + }; + } + + const normalizedDirectory = path.resolve(cwd, directory as string); + const zipBytes = await buildZipFromDirectory(normalizedDirectory); + return { + zipBytes, + payloadSource: { + kind: "directory", + path: normalizedDirectory, + sourceSha256Base64: CryptographyService.sha256Base64(zipBytes), + sourceSize: zipBytes.length, + }, + }; +} + +async function loadBatchPayloadSourceBytes(source: BatchPayloadSource, cwd: string): Promise { + const sourcePath = path.isAbsolute(source.path) ? source.path : path.resolve(cwd, source.path); + const zipBytes = + source.kind === "zip" + ? await readFile(sourcePath) + : await buildZipFromDirectory(sourcePath); + const currentHash = CryptographyService.sha256Base64(zipBytes); + if (currentHash !== source.sourceSha256Base64 || zipBytes.length !== source.sourceSize) { + throw new CliError( + "Batch payload source changed since checkpoint creation. Restore original source or open new session.", + EXIT_USAGE, + ); + } + return zipBytes; +} + +async function buildZipFromDirectory(directoryPath: string): Promise { + const info = await stat(directoryPath); + if (!info.isDirectory()) { + throw new CliError(`--dir path is not a directory: ${directoryPath}`); + } + const xmlFiles = await listXmlFilesRecursive(directoryPath); + if (xmlFiles.length === 0) { + throw new CliError(`No .xml files found in directory: ${directoryPath}`); + } + const entries = await Promise.all( + xmlFiles.map(async (absolutePath) => { + const relative = path.relative(directoryPath, absolutePath).replace(/\\/g, "/"); + return { + fileName: relative, + content: await readFile(absolutePath), + }; + }), + ); + return createZip(entries); +} + +async function listXmlFilesRecursive(directoryPath: string): Promise { + const out: string[] = []; + const entries = await readdir(directoryPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + out.push(...(await listXmlFilesRecursive(fullPath))); + continue; + } + if (entry.isFile() && entry.name.toLowerCase().endsWith(".xml")) { + out.push(fullPath); + } + } + out.sort((a, b) => a.localeCompare(b)); + return out; +} + +function resolveOutputPath(target: string, defaultFilename: string): string { + const normalized = target.replace(/\\/g, "/"); + if (normalized.endsWith("/")) { + return path.join(target, defaultFilename); + } + return target; +} + +async function waitForInvoiceStatus( + handle: ReturnType, + invoiceRef: string, + pollIntervalMs: number, + maxAttempts: number, +): Promise> { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const status = await handle.getInvoiceStatus(invoiceRef); + const code = extractStatusCode(status); + if (code === 200 || code === 400 || code === 410) { + return status; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + throw new CliError("Timed out while waiting for invoice status.", EXIT_REMOTE); +} + +async function waitForInvoiceUpo( + handle: ReturnType, + invoiceRef: string, + pollIntervalMs: number, + maxAttempts: number, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await handle.getInvoiceUpoByReference(invoiceRef); + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + } + if (lastError instanceof Error) { + throw new CliError(`Timed out while waiting for invoice UPO: ${lastError.message}`, EXIT_REMOTE); + } + throw new CliError("Timed out while waiting for invoice UPO.", EXIT_REMOTE); +} + +async function waitForSessionStatus( + fetchStatus: () => Promise, + pollIntervalMs: number, + maxAttempts: number, +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const status = await fetchStatus(); + const code = status.status?.code; + if (code === 200 || code === 400 || code === 410) { + return status; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + throw new CliError("Timed out while waiting for session status.", EXIT_REMOTE); +} + +function extractStatusCode(payload: unknown): number | null { + if (!payload || typeof payload !== "object") { + return null; + } + const status = (payload as Record).status; + if (!status || typeof status !== "object") { + return null; + } + const code = (status as Record).code; + return typeof code === "number" ? code : null; +} + +function extractKsefNumber(payload: unknown): string | null { + if (!payload || typeof payload !== "object") { + return null; + } + const direct = (payload as Record).ksefNumber; + if (typeof direct === "string" && direct) { + return direct; + } + const invoice = (payload as Record).invoice; + if (invoice && typeof invoice === "object") { + const nested = (invoice as Record).ksefNumber; + if (typeof nested === "string" && nested) { + return nested; + } + } + return null; +} + async function createAuthenticatedClient( profileName: string, profile: ProfileConfig, @@ -762,8 +1565,15 @@ function createClient(profile: ProfileConfig): KsefClient { ? { baseUrl: profile.baseUrl } : { environment: profile.environment ?? "TEST" }; + return createClientForBaseUrl( + "baseUrl" in baseOptions ? baseOptions.baseUrl : resolveBaseUrl(profile), + profile, + ); +} + +function createClientForBaseUrl(baseUrl: string, profile: ProfileConfig): KsefClient { return new KsefClient({ - ...baseOptions, + baseUrl, ...(profile.strictPresignedUrlValidation !== undefined && { strictPresignedUrlValidation: profile.strictPresignedUrlValidation, }), @@ -986,6 +1796,27 @@ async function writeOutputFile(filePath: string, content: string, cwd: string): await writeFile(absolute, content, "utf8"); } +async function saveOutputFile( + filePath: string, + content: string, + cwd: string, + options: { overwrite: boolean }, +): Promise { + const absolute = path.resolve(cwd, filePath); + await mkdir(path.dirname(absolute), { recursive: true }); + if (!options.overwrite) { + try { + await stat(absolute); + throw new CliError(`Output file already exists: ${absolute}. Use --save-upo-overwrite.`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } + await writeFile(absolute, content, "utf8"); +} + function normalizeError(error: unknown): { message: string; exitCode: number; name: string } { if (error instanceof CliError) { return { @@ -1019,6 +1850,14 @@ function normalizeError(error: unknown): { message: string; exitCode: number; na }; } + if (error instanceof SessionStoreError) { + return { + message: error.message, + exitCode: error.kind === "validation" ? EXIT_USAGE : EXIT_CONFIG, + name: error.name, + }; + } + const message = error instanceof Error ? error.message : String(error); return { message, @@ -1080,6 +1919,7 @@ function helpText(): string { " lighthouse Query KSeF lighthouse status endpoint", " invoice Invoice operations (get, query)", " send Open session and send invoice XML", + " session Manage resumable session checkpoints", " upo Download UPO by session + invoice selector", " export Start/wait/download invoice export", "", @@ -1088,6 +1928,7 @@ function helpText(): string { " ksef-ts auth login --token ", " ksef-ts health --with-auth", " ksef-ts invoice get --output invoice.xml", + " ksef-ts session online open --id demo-online --form-code FA3", " ksef-ts send --invoice-file ./invoice.xml --wait-upo --upo-output upo.xml", " ksef-ts export --filters-file ./filters.json --only-metadata --out-dir ./exports", ].join("\n"); diff --git a/src/cli/sessionStore.ts b/src/cli/sessionStore.ts new file mode 100644 index 0000000..7c48671 --- /dev/null +++ b/src/cli/sessionStore.ts @@ -0,0 +1,521 @@ +import path from "node:path"; +import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { BatchSessionState } from "../services/batchSessionWorkflow"; +import { OnlineSessionState } from "../services/onlineSessionWorkflow"; + +const CHECKPOINT_SCHEMA_VERSION = 1; +const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export function formatPersistenceError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +type SessionStoreErrorKind = "validation" | "config" | "io"; + +export class SessionStoreError extends Error { + readonly kind: SessionStoreErrorKind; + + constructor(message: string, kind: SessionStoreErrorKind) { + super(message); + this.kind = kind; + this.name = "SessionStoreError"; + } +} + +export interface BatchPayloadSource { + kind: "zip" | "directory"; + path: string; + sourceSha256Base64: string; + sourceSize: number; +} + +interface SerializedEncryptionData { + cipherKeyBase64: string; + cipherIvBase64: string; + encryptionInfo: { + encryptedSymmetricKey: string; + initializationVector: string; + }; +} + +interface SerializedOnlineSessionState { + referenceNumber: string; + encryptionData: SerializedEncryptionData; + upoV43?: boolean; +} + +interface SerializedBatchSessionState { + referenceNumber: string; + encryptionData: SerializedEncryptionData; + batchFile: BatchSessionState["batchFile"]; + partUploadRequests: BatchSessionState["partUploadRequests"]; + encryptedPartsBase64: string[]; + upoV43?: boolean; + offlineMode?: boolean; +} + +export interface OnlineSessionCheckpoint { + schemaVersion: 1; + id: string; + profile: string; + baseUrl: string; + kind: "online"; + createdAt: string; + updatedAt: string; + stage: string; + sessionState: SerializedOnlineSessionState; + lastInvoiceRef: string | null; + sentInvoiceRefs: string[]; +} + +export interface BatchSessionCheckpoint { + schemaVersion: 1; + id: string; + profile: string; + baseUrl: string; + kind: "batch"; + createdAt: string; + updatedAt: string; + stage: string; + sessionState: SerializedBatchSessionState; + payloadSource: BatchPayloadSource; + uploadedOrdinals: number[]; + lastUpoRef: string | null; +} + +export type SessionCheckpoint = OnlineSessionCheckpoint | BatchSessionCheckpoint; + +export interface SessionCheckpointSummary { + id: string; + kind: "online" | "batch"; + profile: string; + baseUrl: string; + stage: string; + createdAt: string; + updatedAt: string; + sessionRef: string; + lastInvoiceRef?: string; + sentInvoiceCount?: number; + uploadedOrdinals?: number[]; + lastUpoRef?: string; + payloadSource?: BatchPayloadSource; +} + +export function validateSessionId(sessionId: string): string { + const normalized = sessionId.trim(); + if (!SESSION_ID_RE.test(normalized)) { + throw new SessionStoreError( + "Invalid session id. Use 1-128 chars from: letters, digits, dot, dash, underscore.", + "validation", + ); + } + return normalized; +} + +export function summarizeCheckpoint(checkpoint: SessionCheckpoint): SessionCheckpointSummary { + const payload: SessionCheckpointSummary = { + id: checkpoint.id, + kind: checkpoint.kind, + profile: checkpoint.profile, + baseUrl: checkpoint.baseUrl, + stage: checkpoint.stage, + createdAt: checkpoint.createdAt, + updatedAt: checkpoint.updatedAt, + sessionRef: checkpoint.sessionState.referenceNumber, + }; + if (checkpoint.kind === "online") { + payload.lastInvoiceRef = checkpoint.lastInvoiceRef ?? ""; + payload.sentInvoiceCount = checkpoint.sentInvoiceRefs.length; + } else { + payload.uploadedOrdinals = [...checkpoint.uploadedOrdinals]; + payload.lastUpoRef = checkpoint.lastUpoRef ?? ""; + payload.payloadSource = { ...checkpoint.payloadSource }; + } + return payload; +} + +export function serializeOnlineSessionState(state: OnlineSessionState): SerializedOnlineSessionState { + return { + referenceNumber: state.referenceNumber, + encryptionData: { + cipherKeyBase64: state.encryptionData.cipherKey.toString("base64"), + cipherIvBase64: state.encryptionData.cipherIv.toString("base64"), + encryptionInfo: { + ...state.encryptionData.encryptionInfo, + }, + }, + ...(state.upoV43 !== undefined ? { upoV43: state.upoV43 } : {}), + }; +} + +export function deserializeOnlineSessionState(state: SerializedOnlineSessionState): OnlineSessionState { + return { + referenceNumber: state.referenceNumber, + encryptionData: { + cipherKey: Buffer.from(state.encryptionData.cipherKeyBase64, "base64"), + cipherIv: Buffer.from(state.encryptionData.cipherIvBase64, "base64"), + encryptionInfo: { ...state.encryptionData.encryptionInfo }, + }, + ...(state.upoV43 !== undefined ? { upoV43: state.upoV43 } : {}), + }; +} + +export function serializeBatchSessionState(state: BatchSessionState): SerializedBatchSessionState { + return { + referenceNumber: state.referenceNumber, + encryptionData: { + cipherKeyBase64: state.encryptionData.cipherKey.toString("base64"), + cipherIvBase64: state.encryptionData.cipherIv.toString("base64"), + encryptionInfo: { + ...state.encryptionData.encryptionInfo, + }, + }, + batchFile: { + fileSize: state.batchFile.fileSize, + fileHash: state.batchFile.fileHash, + fileParts: state.batchFile.fileParts.map((part) => ({ ...part })), + }, + partUploadRequests: state.partUploadRequests.map((item) => ({ + ...item, + headers: { ...(item.headers ?? {}) }, + })), + encryptedPartsBase64: [...state.encryptedPartsBase64], + ...(state.upoV43 !== undefined ? { upoV43: state.upoV43 } : {}), + ...(state.offlineMode !== undefined ? { offlineMode: state.offlineMode } : {}), + }; +} + +export function deserializeBatchSessionState(state: SerializedBatchSessionState): BatchSessionState { + return { + referenceNumber: state.referenceNumber, + encryptionData: { + cipherKey: Buffer.from(state.encryptionData.cipherKeyBase64, "base64"), + cipherIv: Buffer.from(state.encryptionData.cipherIvBase64, "base64"), + encryptionInfo: { ...state.encryptionData.encryptionInfo }, + }, + batchFile: { + fileSize: state.batchFile.fileSize, + fileHash: state.batchFile.fileHash, + fileParts: state.batchFile.fileParts.map((part) => ({ ...part })), + }, + partUploadRequests: state.partUploadRequests.map((item) => ({ + ...item, + headers: { ...(item.headers ?? {}) }, + })), + encryptedPartsBase64: [...state.encryptedPartsBase64], + ...(state.upoV43 !== undefined ? { upoV43: state.upoV43 } : {}), + ...(state.offlineMode !== undefined ? { offlineMode: state.offlineMode } : {}), + }; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function checkpointRoot(cliHome: string): string { + return path.join(cliHome, "cache", "sessions"); +} + +function checkpointPath(cliHome: string, profile: string, sessionId: string): string { + return path.join(checkpointRoot(cliHome), profile, `${validateSessionId(sessionId)}.json`); +} + +async function writeJsonAtomic(targetPath: string, payload: unknown): Promise { + await mkdir(path.dirname(targetPath), { recursive: true }); + const tempPath = path.join( + path.dirname(targetPath), + `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`, + ); + const json = JSON.stringify(payload, null, 2); + try { + await writeFile(tempPath, json, "utf8"); + await rename(tempPath, targetPath); + } catch (error) { + await unlink(tempPath).catch(() => undefined); + const message = formatPersistenceError(error); + throw new SessionStoreError(`Cannot persist session checkpoint: ${message}`, "config"); + } +} + +function ensureString(value: unknown, fieldName: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new SessionStoreError(`Invalid ${fieldName}: expected non-empty string.`, "config"); + } + return value; +} + +function ensureStringArray(value: unknown, fieldName: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new SessionStoreError(`Invalid ${fieldName}: expected string array.`, "config"); + } + return value; +} + +function ensureNumberArray(value: unknown, fieldName: string): number[] { + if ( + !Array.isArray(value) || + value.some((item) => !Number.isInteger(item) || (item as number) < 0) + ) { + throw new SessionStoreError(`Invalid ${fieldName}: expected integer array.`, "config"); + } + return value as number[]; +} + +function parseCheckpoint(raw: unknown): SessionCheckpoint { + if (!raw || typeof raw !== "object") { + throw new SessionStoreError("Invalid session checkpoint payload.", "config"); + } + + const payload = raw as Record; + if (payload.schemaVersion !== CHECKPOINT_SCHEMA_VERSION) { + throw new SessionStoreError( + `Unsupported checkpoint schemaVersion: ${String(payload.schemaVersion)}.`, + "config", + ); + } + + const kind = payload.kind; + if (kind !== "online" && kind !== "batch") { + throw new SessionStoreError(`Unsupported checkpoint kind: ${String(kind)}.`, "config"); + } + + const common = { + schemaVersion: 1 as const, + id: validateSessionId(ensureString(payload.id, "id")), + profile: ensureString(payload.profile, "profile"), + baseUrl: ensureString(payload.baseUrl, "baseUrl"), + kind, + createdAt: ensureString(payload.createdAt, "createdAt"), + updatedAt: ensureString(payload.updatedAt, "updatedAt"), + stage: ensureString(payload.stage, "stage"), + }; + + if (kind === "online") { + return { + ...common, + kind: "online", + sessionState: payload.sessionState as SerializedOnlineSessionState, + lastInvoiceRef: + payload.lastInvoiceRef === null || payload.lastInvoiceRef === undefined + ? null + : ensureString(payload.lastInvoiceRef, "lastInvoiceRef"), + sentInvoiceRefs: ensureStringArray(payload.sentInvoiceRefs ?? [], "sentInvoiceRefs"), + }; + } + + if (!payload.payloadSource || typeof payload.payloadSource !== "object") { + throw new SessionStoreError("Invalid payloadSource: expected object.", "config"); + } + const source = payload.payloadSource as Record; + const sourceKind = source.kind; + if (sourceKind !== "zip" && sourceKind !== "directory") { + throw new SessionStoreError("Invalid payloadSource.kind.", "config"); + } + const sourceSize = source.sourceSize; + if (!Number.isInteger(sourceSize) || (sourceSize as number) < 0) { + throw new SessionStoreError("Invalid payloadSource.sourceSize.", "config"); + } + + return { + ...common, + kind: "batch", + sessionState: payload.sessionState as SerializedBatchSessionState, + payloadSource: { + kind: sourceKind, + path: ensureString(source.path, "payloadSource.path"), + sourceSha256Base64: ensureString( + source.sourceSha256Base64, + "payloadSource.sourceSha256Base64", + ), + sourceSize: sourceSize as number, + }, + uploadedOrdinals: ensureNumberArray(payload.uploadedOrdinals ?? [], "uploadedOrdinals"), + lastUpoRef: + payload.lastUpoRef === null || payload.lastUpoRef === undefined + ? null + : ensureString(payload.lastUpoRef, "lastUpoRef"), + }; +} + +async function readCheckpointFile(filePath: string): Promise { + let rawText: string; + try { + rawText = await readFile(filePath, "utf8"); + } catch (error) { + const message = formatPersistenceError(error); + throw new SessionStoreError(`Cannot read session checkpoint: ${message}`, "config"); + } + + let payload: unknown; + try { + payload = JSON.parse(rawText); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new SessionStoreError(`Invalid session checkpoint JSON: ${message}`, "config"); + } + return parseCheckpoint(payload); +} + +export async function saveCheckpoint( + cliHome: string, + checkpoint: SessionCheckpoint, + options: { overwrite?: boolean } = {}, +): Promise { + const filePath = checkpointPath(cliHome, checkpoint.profile, checkpoint.id); + const overwrite = options.overwrite ?? true; + if (!overwrite) { + try { + await stat(filePath); + throw new SessionStoreError( + `Session checkpoint "${checkpoint.id}" already exists. Choose a different --id.`, + "validation", + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } + await writeJsonAtomic(filePath, checkpoint); + return filePath; +} + +export async function loadCheckpoint( + cliHome: string, + profile: string, + sessionId: string, +): Promise { + const filePath = checkpointPath(cliHome, profile, sessionId); + try { + await stat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new SessionStoreError( + `Session checkpoint "${sessionId}" does not exist. Create it first with session open.`, + "config", + ); + } + throw error; + } + return readCheckpointFile(filePath); +} + +export async function listCheckpoints( + cliHome: string, + profile: string, +): Promise { + const directory = path.join(checkpointRoot(cliHome), profile); + let entries: string[]; + try { + entries = await readdir(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + const checkpoints: SessionCheckpoint[] = []; + for (const name of entries.filter((item) => item.endsWith(".json")).sort()) { + try { + checkpoints.push(await readCheckpointFile(path.join(directory, name))); + } catch { + // ignore malformed checkpoint files + } + } + return checkpoints; +} + +export async function deleteCheckpoint( + cliHome: string, + profile: string, + sessionId: string, +): Promise { + const filePath = checkpointPath(cliHome, profile, sessionId); + try { + await unlink(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new SessionStoreError(`Session checkpoint "${sessionId}" does not exist.`, "config"); + } + const message = formatPersistenceError(error); + throw new SessionStoreError(`Cannot delete session checkpoint: ${message}`, "config"); + } +} + +export async function updateCheckpoint( + cliHome: string, + checkpoint: SessionCheckpoint, + changes: Partial, +): Promise { + const updated = { + ...checkpoint, + ...changes, + updatedAt: nowIso(), + } as SessionCheckpoint; + await saveCheckpoint(cliHome, updated, { overwrite: true }); + return updated; +} + +export async function exportCheckpoint( + cliHome: string, + profile: string, + sessionId: string, + outPath: string, +): Promise { + const checkpoint = await loadCheckpoint(cliHome, profile, sessionId); + let target = path.resolve(outPath); + const normalizedOut = outPath.replace(/\\/g, "/"); + if (normalizedOut.endsWith("/")) { + target = path.join(target, `session-${checkpoint.id}.json`); + } else { + try { + const info = await stat(target); + if (info.isDirectory()) { + target = path.join(target, `session-${checkpoint.id}.json`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } + await writeJsonAtomic(target, checkpoint); + return target; +} + +export async function importCheckpoint( + cliHome: string, + profile: string, + sourcePath: string, + options: { sessionId?: string } = {}, +): Promise { + const resolvedSource = path.resolve(sourcePath); + let sourceInfo; + try { + sourceInfo = await stat(resolvedSource); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new SessionStoreError(`Checkpoint file does not exist: ${resolvedSource}`, "io"); + } + throw error; + } + if (!sourceInfo.isFile()) { + throw new SessionStoreError(`Checkpoint path is not a file: ${resolvedSource}`, "io"); + } + + const checkpoint = await readCheckpointFile(resolvedSource); + if (checkpoint.profile !== profile) { + throw new SessionStoreError( + `Checkpoint profile is "${checkpoint.profile}", selected profile is "${profile}".`, + "validation", + ); + } + + const imported: SessionCheckpoint = options.sessionId + ? { ...checkpoint, id: validateSessionId(options.sessionId), updatedAt: nowIso() } + : checkpoint; + + await saveCheckpoint(cliHome, imported, { overwrite: true }); + return imported; +} diff --git a/src/documents/fa3/builder.ts b/src/documents/fa3/builder.ts new file mode 100644 index 0000000..2e42b70 --- /dev/null +++ b/src/documents/fa3/builder.ts @@ -0,0 +1,1500 @@ +import path from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { createZip } from "../../utils/zip"; +import { buildFakturaXml, FakturaInput } from "../../xml/invoice"; +import { XmlObject } from "../../xml/xml"; +import { KsefValidationError } from "../../errors/errors"; +import { validateFa3Xml } from "./xml"; +import { + FA3AdvancePayment, + FA3BankAccount, + FA3DraftInput, + FA3InvoiceKind, + FA3Line, + FA3Party, + FA3PaymentMethod, + FA3PaymentDueDescription, + FA3PaymentTerms, + FA3Settlement, + FA3ValidationIssue, +} from "./types"; +import { + mapPartyIdentityToXml, + resolvePartyIdentifier, + validatePartyIdentifier, + validateSellerPartyIdentifier, +} from "./identifier"; + +interface FA3XmlOptions { + pretty?: boolean; + xsdValidate?: boolean; +} + +type NormalizedFA3Draft = FA3DraftInput & { + kind: FA3InvoiceKind; + lines: FA3Line[]; + issuePlace: string; + advancePayments: FA3AdvancePayment[]; + additionalParties: FA3Party[]; + rawExtensions: NonNullable; + additionalDescriptions: NonNullable; +}; + +const KIND_CODE: Record = { + basic: "VAT", + simplified: "UPR", + correction: "KOR", + advance: "ZAL", + settlement: "ROZ", + advance_correction: "KOR_ZAL", + settlement_correction: "KOR_ROZ", +}; + +const CORRECTION_KINDS = new Set([ + "correction", + "advance_correction", + "settlement_correction", +]); + +const SETTLEMENT_KINDS = new Set(["settlement", "settlement_correction"]); + +const CORRECTION_TYPE_TO_CODE: Record = { + "1": "1", + "2": "2", + "3": "3", + tax_base_or_tax: "1", + other: "2", + no_tax_impact: "3", +}; + +const PAYMENT_METHOD_TO_CODE: Record = { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + cash: "1", + card: "2", + voucher: "3", + check: "4", + credit: "5", + transfer: "6", + compensation: "7", + mobile: "2", + other: "7", +}; + +const THIRD_PARTY_ROLE_TO_CODE: Record = { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "8": "8", + "10": "10", + "11": "11", + original_entity: "1", + additional_buyer: "2", + recipient: "3", + payer: "4", + jst_subunit: "8", + vat_group_member: "10", + other: "11", +}; + +const TRANSPORT_KIND_TO_CODE: Record = { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "7": "7", + "8": "8", + sea: "1", + rail: "2", + road: "3", + air: "4", + postal: "5", + fixed_transport: "7", + other: "8", +}; + +function toNumber(value: number | string): number { + const parsed = typeof value === "number" ? value : Number(String(value).replace(",", ".")); + if (!Number.isFinite(parsed)) { + throw new KsefValidationError(`Invalid numeric value: ${String(value)}`); + } + return parsed; +} + +function money(value: number | string): string { + return toNumber(value).toFixed(2); +} + +function toDateOnly(value: string): string { + return value.includes("T") ? value.slice(0, 10) : value; +} + +function isZeroVat(value: number | string | null | undefined): boolean { + if (value === null || value === undefined) { + return true; + } + return Math.abs(toNumber(value)) < 1e-12; +} + +function normalizeCurrency(value: string | undefined): string { + return (value ?? "PLN").trim().toUpperCase(); +} + +function normalizePaymentMethod(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + return PAYMENT_METHOD_TO_CODE[value] ?? value; +} + +function mapParty(party: FA3Party, context: "seller" | "buyer" = "buyer"): XmlObject { + const identifier = resolvePartyIdentifier(party); + return { + ...(party.eori ? { NrEORI: party.eori } : {}), + DaneIdentyfikacyjne: mapPartyIdentityToXml(identifier, party.name, context), + ...((party.countryCode ?? "PL") && party.addressLine1 + ? { + Adres: { + KodKraju: party.countryCode ?? "PL", + AdresL1: party.addressLine1, + ...(party.addressLine2 ? { AdresL2: party.addressLine2 } : {}), + ...(party.addressLine3 ? { AdresL3: party.addressLine3 } : {}), + }, + } + : {}), + ...(party.email || party.phone + ? { + DaneKontaktowe: { + ...(party.email ? { Email: party.email } : {}), + ...(party.phone ? { Telefon: party.phone } : {}), + }, + } + : {}), + }; +} + +function mapBuyer(party: FA3Party): XmlObject { + return { + ...mapParty(party, "buyer"), + JST: party.isJstSubunit ? "1" : "2", + GV: party.isVatGroupMember ? "1" : "2", + }; +} + +function mapAdditionalParty(party: FA3Party): XmlObject { + const roleCode = party.role ? THIRD_PARTY_ROLE_TO_CODE[party.role] ?? party.role : "2"; + const role: XmlObject = + roleCode === "11" || party.role === "other" + ? { RolaInna: "1", ...(party.otherRoleDescription ? { OpisRoli: party.otherRoleDescription } : {}) } + : { Rola: roleCode }; + return { + ...(party.buyerId ? { IDNabywcy: party.buyerId } : {}), + ...mapParty(party, "buyer"), + ...role, + ...(party.share !== undefined ? { Udzial: String(party.share) } : {}), + ...(party.customerNumber ? { NrKlienta: party.customerNumber } : {}), + }; +} + +function computeLineAmounts(line: FA3Line): { + net: number; + vat: number; + gross: number; + vatRate: number; +} { + const quantity = toNumber(line.quantity); + const unitNetPrice = toNumber(line.unitNetPrice); + const baseNet = quantity * unitNetPrice; + const vatRate = isZeroVat(line.vatRate) ? 0 : toNumber(line.vatRate as number | string); + const vat = vatRate === 0 ? 0 : (baseNet * vatRate) / 100; + const gross = baseNet + vat; + return { + net: toNumber(line.netAmount ?? baseNet), + vat: toNumber(line.vatAmount ?? vat), + gross: toNumber(line.grossAmount ?? gross), + vatRate, + }; +} + +function mapLine(line: FA3Line, index: number): XmlObject { + const amounts = computeLineAmounts(line); + const vatRateText = amounts.vatRate === 0 ? "0" : String(amounts.vatRate); + return { + NrWierszaFa: String(index + 1), + P_7: line.description, + P_8A: line.unit, + P_8B: String(line.quantity), + P_9A: money(line.unitNetPrice), + P_11: money(amounts.net), + P_11Vat: money(amounts.vat), + P_12: vatRateText, + P_12_XII: amounts.vatRate === 0 ? "true" : "false", + P_14_5: money(amounts.gross), + ...(line.serviceDate ? { P_6A: toDateOnly(line.serviceDate) } : {}), + ...(line.beforeCorrection ? { StanPrzed: "1" } : {}), + ...(line.uniqueId ? { UU_ID: line.uniqueId } : {}), + ...(line.gtu ? { GTU: line.gtu } : {}), + ...(line.procedure ? { Procedura: line.procedure } : {}), + }; +} + +function computeAdvanceNetVat(payments: FA3AdvancePayment[]): { net: number; vat: number; gross: number } { + let net = 0; + let vat = 0; + let gross = 0; + for (const payment of payments) { + const amount = toNumber(payment.amount); + const vatRate = isZeroVat(payment.vatRate) ? null : toNumber(payment.vatRate as number | string); + if (vatRate === null) { + net += amount; + gross += amount; + continue; + } + const divisor = 1 + vatRate / 100; + const paymentNet = amount / divisor; + const paymentVat = amount - paymentNet; + net += paymentNet; + vat += paymentVat; + gross += amount; + } + return { net, vat, gross }; +} + +function extractTotals(input: NormalizedFA3Draft): { net: number; vat: number; gross: number } { + const linesTotals = input.lines.reduce( + (acc, line) => { + const amounts = computeLineAmounts(line); + const sign = CORRECTION_KINDS.has(input.kind) && line.beforeCorrection ? -1 : 1; + acc.net += sign * amounts.net; + acc.vat += sign * amounts.vat; + acc.gross += sign * amounts.gross; + return acc; + }, + { net: 0, vat: 0, gross: 0 }, + ); + const advanceTotals = computeAdvanceNetVat(input.advancePayments); + return { + net: linesTotals.net + advanceTotals.net, + vat: linesTotals.vat + advanceTotals.vat, + gross: linesTotals.gross + advanceTotals.gross, + }; +} + +function mapKindSpecificFields(input: NormalizedFA3Draft): XmlObject { + const kind = input.kind; + const payload: XmlObject = { RodzajFaktury: KIND_CODE[kind] }; + + if (CORRECTION_KINDS.has(kind)) { + if (input.correctionType) { + payload.TypKorekty = CORRECTION_TYPE_TO_CODE[input.correctionType] ?? input.correctionType; + } + if (input.correctionReason) { + payload.PrzyczynaKorekty = input.correctionReason; + } + if (input.correctedInvoiceNumber) { + payload.NrFaKorygowanej = input.correctedInvoiceNumber; + } + if (input.correctedPeriod) { + payload.OkresFaKorygowanej = input.correctedPeriod; + } + if (input.correctedInvoiceNumberOverride) { + payload.NrFaKorygowany = input.correctedInvoiceNumberOverride; + } + if (input.correctedKsefNumber || input.correctedInvoiceDate) { + payload.DaneFaKorygowanej = { + ...(input.correctedKsefNumber ? { NumerKSeF: input.correctedKsefNumber } : {}), + ...(input.correctedInvoiceDate ? { DataWystawieniaFa: input.correctedInvoiceDate } : {}), + }; + } + } + + if ( + kind === "advance" || + kind === "advance_correction" || + kind === "settlement" || + kind === "settlement_correction" + ) { + if (input.advanceInvoiceNumber || input.advanceKsefNumber) { + payload.FakturaZaliczkowa = { + ...(input.advanceInvoiceNumber ? { NrFakturyZaliczkowej: input.advanceInvoiceNumber } : {}), + ...(input.advanceKsefNumber ? { NrKSeFFaZaliczkowej: input.advanceKsefNumber } : {}), + }; + } + } + + if (kind === "advance" || kind === "advance_correction") { + if (input.advancePayments.length > 0) { + payload.ZaliczkaCzesciowa = input.advancePayments.map((payment) => ({ + P_15Z: money(payment.amount), + ...(payment.currencyRate !== undefined ? { KursWalutyZW: String(payment.currencyRate) } : {}), + })); + } + if (input.foreignCurrencyRate !== undefined) { + payload.KursWalutyZ = String(input.foreignCurrencyRate); + } + } + + return payload; +} + +function validateBusinessRules(input: NormalizedFA3Draft): FA3ValidationIssue[] { + const issues: FA3ValidationIssue[] = []; + const kind = input.kind; + const currency = normalizeCurrency(input.currency); + const lines = input.lines; + + if (!input.invoiceNumber?.trim()) { + issues.push({ code: "invoice_number_required", path: "invoice.invoiceNumber", message: "Numer faktury jest wymagany." }); + } + if (!input.issueDate?.trim()) { + issues.push({ code: "issue_date_required", path: "invoice.issueDate", message: "Data wystawienia jest wymagana." }); + } + if (!input.seller?.name?.trim() || !input.seller?.taxId?.trim()) { + issues.push({ code: "seller_required", path: "invoice.seller", message: "Dane sprzedawcy są wymagane." }); + } else if (input.seller) { + issues.push( + ...validateSellerPartyIdentifier( + resolvePartyIdentifier(input.seller), + "invoice.seller", + ), + ); + } + if (!input.buyer?.name?.trim() || !input.buyer?.taxId?.trim()) { + issues.push({ code: "buyer_required", path: "invoice.buyer", message: "Dane nabywcy są wymagane." }); + } else if (input.buyer) { + issues.push( + ...validatePartyIdentifier(resolvePartyIdentifier(input.buyer), "invoice.buyer"), + ); + } + if (lines.length === 0 && kind !== "advance" && kind !== "advance_correction") { + issues.push({ + code: "lines_required", + path: "invoice.lines", + message: "Faktura wymaga co najmniej jednej pozycji.", + }); + } + + if (CORRECTION_KINDS.has(kind)) { + if (!input.correctionReason?.trim()) { + issues.push({ + code: "correction_reason_required", + path: "invoice.correctionReason", + message: "Przyczyna korekty jest wymagana.", + }); + } + if (!input.correctedInvoiceNumber?.trim()) { + issues.push({ + code: "corrected_invoice_number_required", + path: "invoice.correctedInvoiceNumber", + message: "Numer faktury korygowanej jest wymagany.", + }); + } + if (!input.correctedInvoiceDate?.trim()) { + issues.push({ + code: "corrected_invoice_date_required", + path: "invoice.correctedInvoiceDate", + message: "Data faktury korygowanej jest wymagana.", + }); + } + } + + if (SETTLEMENT_KINDS.has(kind)) { + if (!input.advanceInvoiceNumber && !input.advanceKsefNumber) { + issues.push({ + code: "advance_reference_required", + path: "invoice.advanceInvoice", + message: "Podaj numer faktury zaliczkowej albo numer KSeF.", + }); + } + } + + if (input.advanceInvoiceNumber && input.advanceKsefNumber) { + issues.push({ + code: "advance_reference_conflict", + path: "invoice.advanceInvoice", + message: "Podaj numer faktury zaliczkowej albo numer KSeF, nie oba.", + }); + } + + if (input.saleDate && (input.periodFrom || input.periodTo)) { + issues.push({ + code: "sale_date_period_conflict", + path: "invoice.saleDate", + message: "Podaj date sprzedaży albo okres faktury, nie oba.", + }); + } + if ((input.periodFrom && !input.periodTo) || (!input.periodFrom && input.periodTo)) { + issues.push({ + code: "half_period", + path: "invoice.period", + message: "Okres faktury wymaga obu dat: od i do.", + }); + } + + const payment = input.paymentTerms; + if (payment?.paidDate && (payment.partialPayments?.length ?? 0) > 0) { + issues.push({ + code: "payment_paid_vs_partial_conflict", + path: "invoice.paymentTerms", + message: "Podaj date zapłaty albo płatności częściowe, nie oba.", + }); + } + if (payment?.method && payment.otherMethodDescription) { + issues.push({ + code: "payment_method_other_conflict", + path: "invoice.paymentTerms", + message: "Podaj forme płatności albo opis innej formy, nie oba.", + }); + } + for (const [index, partial] of (payment?.partialPayments ?? []).entries()) { + if (partial.method && partial.otherMethodDescription) { + issues.push({ + code: "partial_payment_method_other_conflict", + path: `invoice.paymentTerms.partialPayments[${index + 1}]`, + message: "Podaj forme płatności albo opis innej formy, nie oba.", + }); + } + } + + const settlement = input.settlement; + if (settlement?.amountDue !== undefined && settlement.amountToSettle !== undefined) { + issues.push({ + code: "settlement_choice_conflict", + path: "invoice.settlement", + message: "Podaj DoZaplaty albo DoRozliczenia, nie oba.", + }); + } + if (settlement?.amountDue !== undefined) { + const totals = extractTotals(input); + const charges = (settlement.charges ?? []).reduce((sum, row) => sum + toNumber(row.amount), 0); + const deductions = (settlement.deductions ?? []).reduce((sum, row) => sum + toNumber(row.amount), 0); + const expectedDue = toNumber((totals.gross + charges - deductions).toFixed(2)); + const providedDue = toNumber(settlement.amountDue); + if (Math.abs(expectedDue - providedDue) > 0.009) { + issues.push({ + code: "settlement_amount_due_inconsistent", + path: "invoice.settlement.amountDue", + message: + `Rozliczenie niespójne; oczekiwane DoZaplaty=${money(expectedDue)} ` + + `(P_15 + SumaObciazen - SumaOdliczen), otrzymano ${money(providedDue)}.`, + }); + } + } + + if (input.rawExtensions.length > 0) { + issues.push({ + code: "raw_extension_unsupported", + path: "invoice.rawExtensions", + message: "Raw XML extension nie jest wspierany w typed FA(3) SDK.", + }); + } + + if (input.simplifiedReceiptLike) { + const gross = extractTotals(input).gross; + if (currency !== "PLN") { + issues.push({ + code: "simplified_receipt_currency", + path: "invoice.currency", + message: "Faktura uproszczona paragonowa z limitem 450 PLN wymaga waluty PLN.", + }); + } + if (gross > 450.0 + 1e-9) { + issues.push({ + code: "simplified_receipt_limit", + path: "invoice.totalGross", + message: "Faktura uproszczona paragonowa nie może przekroczyć 450 PLN.", + }); + } + } + + for (const [index, party] of input.additionalParties.entries()) { + const partyPath = `invoice.additionalParties[${index + 1}]`; + if (party.role === "other" && !party.otherRoleDescription?.trim()) { + issues.push({ + code: "third_party_other_description_required", + path: partyPath, + message: "otherRoleDescription jest wymagany dla roli 'other'.", + }); + } + issues.push(...validatePartyIdentifier(resolvePartyIdentifier(party), partyPath)); + } + + if (input.buyer?.isJstSubunit) { + const hasJst = input.additionalParties.some((party) => party.role === "jst_subunit"); + if (!hasJst) { + issues.push({ + code: "jst_subunit_missing_party3", + path: "invoice.additionalParties", + message: "JST wymaga podmiotu dodatkowego z rolą jst_subunit.", + }); + } + } + + if (input.buyer?.isVatGroupMember) { + const hasGv = input.additionalParties.some((party) => party.role === "vat_group_member"); + if (!hasGv) { + issues.push({ + code: "vat_group_missing_party3", + path: "invoice.additionalParties", + message: "GV wymaga podmiotu dodatkowego z rolą vat_group_member.", + }); + } + } + + return issues; +} + +function mapBankAccount(account: FA3BankAccount): XmlObject { + return { + NrRB: account.number, + ...(account.swift ? { SWIFT: account.swift } : {}), + ...(account.ownBankAccountType ? { RachunekWlasnyBanku: account.ownBankAccountType } : {}), + ...(account.bankName ? { NazwaBanku: account.bankName } : {}), + ...(account.description ? { OpisRachunku: account.description } : {}), + }; +} + +function parseDueDescription(value: string): FA3PaymentDueDescription | undefined { + const match = /^(\d+(?:[.,]\d+)?)\s+(.+?)\s+od\s+(.+)$/i.exec(value.trim()); + if (!match?.[1] || !match[2] || !match[3]) { + return undefined; + } + return { + amount: match[1], + unit: match[2], + startsFrom: match[3], + }; +} + +function mapDueDescription(value: FA3PaymentDueDescription): XmlObject { + return { + Ilosc: String(value.amount), + Jednostka: value.unit, + ZdarzeniePoczatkowe: value.startsFrom, + }; +} + +function mapPaymentChoice(method?: string, otherMethodDescription?: string): XmlObject { + const normalized = normalizePaymentMethod(method); + if (normalized && normalized !== "7") { + return { FormaPlatnosci: normalized }; + } + if (otherMethodDescription) { + return { + PlatnoscInna: "1", + OpisPlatnosci: otherMethodDescription, + }; + } + return normalized ? { FormaPlatnosci: normalized } : {}; +} + +function mapPaymentTerms(paymentTerms?: FA3PaymentTerms): XmlObject | undefined { + if (!paymentTerms) { + return undefined; + } + const dueDescription = paymentTerms.dueDescriptionParts ?? ( + paymentTerms.dueDescription ? parseDueDescription(paymentTerms.dueDescription) : undefined + ); + const partialPayments = paymentTerms.partialPayments ?? []; + const bankAccounts = paymentTerms.bankAccounts ?? []; + const factorBankAccounts = paymentTerms.factorBankAccounts ?? []; + return { + ...(paymentTerms.paidDate ? { Zaplacono: "1", DataZaplaty: paymentTerms.paidDate } : {}), + ...(partialPayments.length > 0 + ? { + ZnacznikZaplatyCzesciowej: "1", + ZaplataCzesciowa: partialPayments.map((partial) => ({ + KwotaZaplatyCzesciowej: money(partial.amount), + DataZaplatyCzesciowej: partial.paidOn, + ...mapPaymentChoice(partial.method, partial.otherMethodDescription), + })), + } + : {}), + ...(paymentTerms.dueDate || dueDescription + ? { + TerminPlatnosci: { + ...(paymentTerms.dueDate ? { Termin: paymentTerms.dueDate } : {}), + ...(dueDescription ? { TerminOpis: mapDueDescription(dueDescription) } : {}), + }, + } + : {}), + ...mapPaymentChoice(paymentTerms.method, paymentTerms.otherMethodDescription), + ...(bankAccounts.length > 0 + ? { RachunekBankowy: bankAccounts.map(mapBankAccount) } + : {}), + ...(factorBankAccounts.length > 0 + ? { RachunekBankowyFaktora: factorBankAccounts.map(mapBankAccount) } + : {}), + ...(paymentTerms.paymentLink ? { LinkDoPlatnosci: paymentTerms.paymentLink } : {}), + ...(paymentTerms.ipksef ? { IPKSeF: paymentTerms.ipksef } : {}), + }; +} + +function mapSettlement(input: NormalizedFA3Draft): XmlObject | undefined { + const settlement = input.settlement; + if (!settlement && input.settlementAmount === undefined) { + return undefined; + } + const charges = settlement?.charges ?? []; + const deductions = settlement?.deductions ?? []; + return { + ...(charges.length > 0 + ? { + Obciazenia: charges.map((row) => ({ + Kwota: money(row.amount), + Powod: row.reason, + })), + SumaObciazen: money(charges.reduce((sum, row) => sum + toNumber(row.amount), 0)), + } + : {}), + ...(deductions.length > 0 + ? { + Odliczenia: deductions.map((row) => ({ + Kwota: money(row.amount), + Powod: row.reason, + })), + SumaOdliczen: money(deductions.reduce((sum, row) => sum + toNumber(row.amount), 0)), + } + : {}), + ...(settlement?.amountDue !== undefined + ? { DoZaplaty: money(settlement.amountDue) } + : {}), + ...(settlement?.amountToSettle !== undefined + ? { DoRozliczenia: money(settlement.amountToSettle) } + : {}), + ...(input.settlementAmount !== undefined ? { DoZaplaty: money(input.settlementAmount) } : {}), + }; +} + +function mapOrder(input: NormalizedFA3Draft): XmlObject | undefined { + const order = input.order; + const orderLines = order?.lines ?? []; + if (!order || order.totalGross === undefined || orderLines.length === 0) { + return undefined; + } + return { + WartoscZamowienia: money(order.totalGross), + ZamowienieWiersz: orderLines.map((line, index) => { + const net = toNumber(line.quantity) * toNumber(line.unitNetPrice); + const vatRate = isZeroVat(line.vatRate) ? 0 : toNumber(line.vatRate as number | string); + const vat = vatRate === 0 ? 0 : (net * vatRate) / 100; + return { + NrWierszaZam: String(index + 1), + P_7Z: line.description, + P_8AZ: "szt", + P_8BZ: String(line.quantity), + P_9AZ: money(line.unitNetPrice), + P_11NettoZ: money(net), + P_11VatZ: money(vat), + ...(line.vatRate !== undefined ? { P_12Z: String(line.vatRate) } : {}), + }; + }), + }; +} + +function mapTransport(input: NormalizedFA3Draft): XmlObject | undefined { + const transport = input.transport; + if (!transport) { + return undefined; + } + const kind = TRANSPORT_KIND_TO_CODE[transport.kind] ?? transport.kind; + return { + ...(kind === "8" + ? { TransportInny: "1", OpisInnegoTransportu: transport.kind } + : { RodzajTransportu: kind }), + ...(transport.orderNumber ? { NrZleceniaTransportu: transport.orderNumber } : {}), + ...(transport.cargoDescription ? { OpisLadunku: transport.cargoDescription } : {}), + ...(transport.packageUnit ? { JednostkaOpakowania: transport.packageUnit } : {}), + }; +} + +export class FA3Draft { + private readonly value: NormalizedFA3Draft; + + constructor(value: FA3DraftInput) { + this.value = { + ...value, + kind: value.kind ?? "basic", + currency: normalizeCurrency(value.currency), + issuePlace: value.issuePlace ?? "", + lines: value.lines.map((line) => ({ ...line })), + advancePayments: (value.advancePayments ?? []).map((row) => ({ ...row })), + additionalParties: (value.additionalParties ?? []).map((row) => ({ ...row })), + rawExtensions: (value.rawExtensions ?? []).map((row) => ({ ...row })), + additionalDescriptions: (value.additionalDescriptions ?? []).map((row) => ({ ...row })), + ...(value.contract ? { contract: { ...value.contract } } : {}), + ...(value.order + ? { + order: { + ...value.order, + ...(value.order.lines ? { lines: value.order.lines.map((line) => ({ ...line })) } : {}), + }, + } + : {}), + ...(value.transport ? { transport: { ...value.transport } } : {}), + ...(value.transactionTerms ? { transactionTerms: { ...value.transactionTerms } } : {}), + ...(value.attachment + ? { + attachment: { + blocks: value.attachment.blocks.map((block) => ({ + ...block, + ...(block.paragraphs ? { paragraphs: [...block.paragraphs] } : {}), + ...(block.tables + ? { + tables: block.tables.map((table) => ({ + ...table, + headers: [...table.headers], + rows: table.rows.map((row) => [...row]), + })), + } + : {}), + })), + }, + } + : {}), + }; + } + + static fromDict(value: FA3DraftInput | (FA3DraftInput & Record)): FA3Draft { + const source = value as Record; + const normalized: FA3DraftInput = { + ...value, + invoiceNumber: String(value.invoiceNumber ?? source["invoice_number"] ?? ""), + issueDate: String(value.issueDate ?? source["issue_date"] ?? ""), + currency: normalizeCurrency(String(value.currency ?? source["waluta"] ?? "PLN")), + seller: (value.seller ?? {}) as FA3Party, + buyer: (value.buyer ?? {}) as FA3Party, + lines: (value.lines ?? []) as FA3Line[], + }; + return new FA3Draft(normalized); + } + + toDict(): FA3DraftInput { + return JSON.parse(JSON.stringify(this.value)) as FA3DraftInput; + } + + validate(): FA3ValidationIssue[] { + return validateBusinessRules(this.value); + } + + toFakturaInput(): FakturaInput { + const kind = this.value.kind; + const lines = this.value.lines.map((line, index) => mapLine(line, index)); + const totals = extractTotals(this.value); + const faPayload: XmlObject = { + KodWaluty: normalizeCurrency(this.value.currency), + P_1: toDateOnly(this.value.issueDate), + P_1M: this.value.issuePlace, + P_2: this.value.invoiceNumber, + P_13_1: money(totals.net), + P_14_1: money(totals.vat), + P_15: money(totals.gross), + ...(this.value.lines.some((line) => line.annex15) ? { P_18A: "1" } : {}), + FaWiersz: lines, + FaWiersze: { + LiczbaWierszyFa: String(lines.length), + WartoscWierszyFa: money(totals.gross), + }, + ...mapKindSpecificFields(this.value), + }; + + const payment = mapPaymentTerms(this.value.paymentTerms); + if (payment) { + faPayload.Platnosc = payment; + } + + if (this.value.saleDate) { + faPayload.P_6 = toDateOnly(this.value.saleDate); + } + if (this.value.periodFrom && this.value.periodTo) { + faPayload.OkresFa = { + P_6_Od: toDateOnly(this.value.periodFrom), + P_6_Do: toDateOnly(this.value.periodTo), + }; + } + if (this.value.attachmentText) { + faPayload.Zalacznik = { + Opis: this.value.attachmentText, + }; + } + if (this.value.additionalDescriptions.length > 0) { + faPayload.DodatkowyOpis = this.value.additionalDescriptions.map((item) => ({ + Klucz: item.key, + Wartosc: item.value, + })); + } + if (this.value.transactionTerms || this.value.contract || this.value.order?.number || this.value.order?.date) { + const transactionTerms: XmlObject = { + ...(this.value.contract + ? { + Umowy: [{ + ...(this.value.contract.date ? { DataUmowy: this.value.contract.date } : {}), + NrUmowy: this.value.contract.number, + }], + } + : {}), + ...(this.value.order?.number || this.value.order?.date + ? { + Zamowienia: [{ + ...(this.value.order.date ? { DataZamowienia: this.value.order.date } : {}), + ...(this.value.order.number ? { NrZamowienia: this.value.order.number } : {}), + }], + } + : {}), + ...(this.value.transactionTerms?.deliveryTerms + ? { WarunkiDostawy: this.value.transactionTerms.deliveryTerms } + : {}), + ...(this.value.transactionTerms?.contractualRate + ? { KursUmowny: this.value.transactionTerms.contractualRate } + : {}), + ...(this.value.transactionTerms?.contractualCurrency + ? { WalutaUmowna: this.value.transactionTerms.contractualCurrency } + : {}), + ...(this.value.transactionTerms?.intermediary !== undefined + ? { Posrednik: this.value.transactionTerms.intermediary ? "1" : "0" } + : {}), + }; + faPayload.WarunkiTransakcji = transactionTerms; + } + const transport = mapTransport(this.value); + if (transport) { + faPayload.Transport = transport; + } + const order = mapOrder(this.value); + if (order) { + faPayload.Zamowienie = order; + } + if (this.value.attachment && this.value.attachment.blocks.length > 0) { + faPayload.Zalacznik = { + Blok: this.value.attachment.blocks.map((block) => { + const blockTables = block.tables ?? []; + return { + ...(block.header ? { Naglowek: block.header } : {}), + ...(block.paragraphs ? { Akapit: [...block.paragraphs] } : {}), + ...(blockTables.length > 0 + ? { + Tabela: blockTables.map((table) => ({ + Naglowki: table.headers, + Wiersze: table.rows, + })), + } + : {}), + }; + }), + }; + } + if (kind === "advance" || kind === "advance_correction") { + if (this.value.advancePayments.length > 0) { + faPayload.ZaliczkaCzesciowa = this.value.advancePayments.map((row) => ({ + P_15Z: money(row.amount), + ...(row.currencyRate !== undefined ? { KursWalutyZW: String(row.currencyRate) } : {}), + })); + } + } + if (kind === "settlement" || kind === "settlement_correction") { + const settlement = mapSettlement(this.value); + if (settlement) { + faPayload.Rozliczenie = settlement; + } + } + + const root: FakturaInput = { + Naglowek: { + KodFormularza: { + systemCode: "FA (3)", + schemaVersion: "1-0E", + value: "FA", + }, + WariantFormularza: "1", + DataWytworzeniaFa: this.value.issueDate, + SystemInfo: "ksef-client-typescript-fa3", + }, + Podmiot1: mapParty(this.value.seller, "seller"), + Podmiot2: mapBuyer(this.value.buyer), + Fa: faPayload, + }; + if (this.value.additionalParties.length > 0) { + root.Podmiot3 = this.value.additionalParties.map(mapAdditionalParty); + } + return { + ...root, + }; + } + + async toXml(options: FA3XmlOptions = {}): Promise { + const issues = this.validate(); + if (issues.length > 0) { + const detail = issues.map((issue) => issue.message).join(" "); + throw new KsefValidationError(detail); + } + const xml = buildFakturaXml(this.toFakturaInput(), { + schema: "FA3", + ...(options.pretty !== undefined ? { pretty: options.pretty } : {}), + }); + return options.xsdValidate ? validateAndReturnXml(xml) : xml; + } +} + +export async function validateAndReturnXml( + xml: string, + validate: (value: string) => Promise = validateFa3Xml, +): Promise { + await validate(xml); + return xml; +} + +export class FA3InvoiceBuilder { + private readonly value: FA3DraftInput; + + constructor(invoiceNumber: string, kind: FA3InvoiceKind = "basic") { + this.value = { + invoiceNumber, + issueDate: new Date().toISOString(), + seller: { name: "", taxId: "" }, + buyer: { name: "", taxId: "" }, + lines: [], + kind, + currency: "PLN", + }; + } + + issuedOn(value: Date): FA3InvoiceBuilder { + this.value.issueDate = value.toISOString(); + return this; + } + + issueDate(value: string): FA3InvoiceBuilder { + this.value.issueDate = value; + return this; + } + + saleDate(value: string): FA3InvoiceBuilder { + this.value.saleDate = value; + return this; + } + + period(from: string, to: string): FA3InvoiceBuilder { + this.value.periodFrom = from; + this.value.periodTo = to; + return this; + } + + seller(value: FA3Party): FA3InvoiceBuilder { + this.value.seller = { ...value }; + return this; + } + + buyer(value: FA3Party): FA3InvoiceBuilder { + this.value.buyer = { ...value }; + return this; + } + + addParty(value: FA3Party): FA3InvoiceBuilder { + this.value.additionalParties = [...(this.value.additionalParties ?? []), { ...value }]; + return this; + } + + currency(value: string): FA3InvoiceBuilder { + this.value.currency = normalizeCurrency(value); + return this; + } + + issuePlace(value: string): FA3InvoiceBuilder { + this.value.issuePlace = value; + return this; + } + + additionalDescription(key: string, value: string): FA3InvoiceBuilder { + this.value.additionalDescriptions = [ + ...(this.value.additionalDescriptions ?? []), + { key, value }, + ]; + return this; + } + + contract(number: string, date?: string): FA3InvoiceBuilder { + this.value.contract = { + number, + ...(date ? { date } : {}), + }; + return this; + } + + orderReference(number: string, date?: string): FA3InvoiceBuilder { + const current = this.value.order ?? {}; + this.value.order = { + ...current, + number, + ...(date ? { date } : {}), + }; + return this; + } + + order(totalGross: number | string): FA3InvoiceBuilder { + const current = this.value.order ?? {}; + this.value.order = { + ...current, + totalGross, + }; + return this; + } + + orderLine(value: { + description: string; + quantity: number | string; + unitNetPrice: number | string; + vatRate?: number | string | null; + }): FA3InvoiceBuilder { + const current = this.value.order ?? {}; + const lines = current.lines ?? []; + this.value.order = { + ...current, + lines: [...lines, { ...value }], + }; + return this; + } + + transactionTerms(value: { + deliveryTerms?: string; + contractualRate?: string; + contractualCurrency?: string; + intermediary?: boolean; + }): FA3InvoiceBuilder { + this.value.transactionTerms = { ...value }; + return this; + } + + transport( + kind: string, + options: { orderNumber?: string; cargoDescription?: string; packageUnit?: string } = {}, + ): FA3InvoiceBuilder { + this.value.transport = { + kind, + ...(options.orderNumber ? { orderNumber: options.orderNumber } : {}), + ...(options.cargoDescription ? { cargoDescription: options.cargoDescription } : {}), + ...(options.packageUnit ? { packageUnit: options.packageUnit } : {}), + }; + return this; + } + + correction(value: { + reason: string; + correctedInvoiceNumber: string; + correctedInvoiceDate?: string; + correctedKsefNumber?: string; + correctionType?: string; + }): FA3InvoiceBuilder { + this.value.correctionReason = value.reason; + this.value.correctedInvoiceNumber = value.correctedInvoiceNumber; + if (value.correctedInvoiceDate !== undefined) { + this.value.correctedInvoiceDate = value.correctedInvoiceDate; + } + if (value.correctedKsefNumber !== undefined) { + this.value.correctedKsefNumber = value.correctedKsefNumber; + } + if (value.correctionType !== undefined) { + this.value.correctionType = value.correctionType; + } + return this; + } + + correctedPeriod(value: string): FA3InvoiceBuilder { + this.value.correctedPeriod = value; + return this; + } + + correctedInvoiceNumberOverride(value: string): FA3InvoiceBuilder { + this.value.correctedInvoiceNumberOverride = value; + return this; + } + + settlesAdvance(value: { invoiceNumber?: string; ksefNumber?: string } = {}): FA3InvoiceBuilder { + if (value.invoiceNumber === undefined) { + delete this.value.advanceInvoiceNumber; + } else { + this.value.advanceInvoiceNumber = value.invoiceNumber; + } + if (value.ksefNumber === undefined) { + delete this.value.advanceKsefNumber; + } else { + this.value.advanceKsefNumber = value.ksefNumber; + } + return this; + } + + advanceReference(value: { invoiceNumber?: string; ksefNumber?: string }): FA3InvoiceBuilder { + if (value.invoiceNumber === undefined) { + delete this.value.advanceInvoiceNumber; + } else { + this.value.advanceInvoiceNumber = value.invoiceNumber; + } + if (value.ksefNumber === undefined) { + delete this.value.advanceKsefNumber; + } else { + this.value.advanceKsefNumber = value.ksefNumber; + } + return this; + } + + advancePayment(value: FA3AdvancePayment): FA3InvoiceBuilder { + this.value.advancePayments = [...(this.value.advancePayments ?? []), { ...value }]; + return this; + } + + settlementAmount(value: number | string): FA3InvoiceBuilder { + this.value.settlementAmount = value; + return this; + } + + settlementDetails(value: FA3Settlement): FA3InvoiceBuilder { + this.value.settlement = { + ...value, + charges: (value.charges ?? []).map((row) => ({ ...row })), + deductions: (value.deductions ?? []).map((row) => ({ ...row })), + }; + return this; + } + + payment(value: FA3PaymentTerms): FA3InvoiceBuilder { + this.value.paymentTerms = { + ...value, + partialPayments: (value.partialPayments ?? []).map((row) => ({ ...row })), + bankAccounts: (value.bankAccounts ?? []).map((row) => ({ ...row })), + factorBankAccounts: (value.factorBankAccounts ?? []).map((row) => ({ ...row })), + }; + return this; + } + + bankAccount(value: FA3BankAccount): FA3InvoiceBuilder { + const current = this.value.paymentTerms ?? {}; + this.value.paymentTerms = { + ...current, + bankAccounts: [...(current.bankAccounts ?? []), { ...value }], + }; + return this; + } + + paymentLink(value: string, ipksef?: string): FA3InvoiceBuilder { + const current = this.value.paymentTerms ?? {}; + this.value.paymentTerms = { + ...current, + paymentLink: value, + ...(ipksef ? { ipksef } : {}), + }; + return this; + } + + paymentDue( + date: string, + method: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "transfer" | "cash" | "card" | "voucher" | "check" | "credit" | "compensation" | "other" = "6", + ): FA3InvoiceBuilder { + const current = this.value.paymentTerms ?? {}; + this.value.paymentTerms = { + ...current, + dueDate: date, + method, + }; + return this; + } + + paymentDueDescription( + amount: number, + unit: string, + startsFrom: string, + method: "1" | "2" | "3" | "4" | "5" | "6" | "7" | "transfer" | "cash" | "card" | "voucher" | "check" | "credit" | "compensation" | "other" = "6", + ): FA3InvoiceBuilder { + const current = this.value.paymentTerms ?? {}; + this.value.paymentTerms = { + ...current, + dueDescription: `${amount} ${unit} od ${startsFrom}`, + method, + }; + return this; + } + + paid(value: string): FA3InvoiceBuilder { + const current = this.value.paymentTerms ?? {}; + this.value.paymentTerms = { + ...current, + paidDate: value, + }; + return this; + } + + partiallyPaid(value: { + amount: number | string; + paidOn: string; + method?: FA3PaymentMethod; + otherMethodDescription?: string; + }): FA3InvoiceBuilder { + const current = this.value.paymentTerms ?? {}; + const list = current.partialPayments ?? []; + this.value.paymentTerms = { + ...current, + partialPayments: [...list, { ...value }], + }; + return this; + } + + foreignCurrencyRate(value: number | string): FA3InvoiceBuilder { + this.value.foreignCurrencyRate = value; + return this; + } + + asSimplifiedReceiptLike(): FA3InvoiceBuilder { + this.value.simplifiedReceiptLike = true; + return this; + } + + attachmentText(value: string): FA3InvoiceBuilder { + this.value.attachmentText = value; + return this; + } + + attachment(value: { + blocks: Array<{ + header?: string; + paragraphs?: string[]; + tables?: Array<{ headers: string[]; rows: string[][] }>; + }>; + }): FA3InvoiceBuilder { + this.value.attachment = { + blocks: value.blocks.map((block) => ({ + ...block, + ...(block.paragraphs ? { paragraphs: [...block.paragraphs] } : {}), + ...(block.tables + ? { + tables: block.tables.map((table) => ({ + headers: [...table.headers], + rows: table.rows.map((row) => [...row]), + })), + } + : {}), + })), + }; + return this; + } + + addLine(value: FA3Line): FA3InvoiceBuilder { + this.value.lines.push({ ...value }); + return this; + } + + addGoodsLine( + description: string, + options: { + quantity: number | string; + unitNetPrice: number | string; + unit?: string; + vatRate?: number | string | null; + gtu?: string; + procedure?: string; + annex15?: boolean; + }, + ): FA3InvoiceBuilder { + return this.addLine({ + description, + quantity: options.quantity, + unit: options.unit ?? "szt", + unitNetPrice: options.unitNetPrice, + ...(options.vatRate !== undefined ? { vatRate: options.vatRate } : {}), + ...(options.gtu !== undefined ? { gtu: options.gtu } : {}), + ...(options.procedure !== undefined ? { procedure: options.procedure } : {}), + ...(options.annex15 !== undefined ? { annex15: options.annex15 } : {}), + }); + } + + addServiceLine( + description: string, + options: Parameters[1], + ): FA3InvoiceBuilder { + return this.addGoodsLine(description, options); + } + + splitPayment(): FA3InvoiceBuilder { + const current = this.value.paymentTerms ?? {}; + this.value.paymentTerms = { + ...current, + method: "6", + }; + return this; + } + + addCorrectedLineBeforeAfter(value: { before: FA3Line; after: FA3Line }): FA3InvoiceBuilder { + this.value.lines.push({ ...value.before, beforeCorrection: true }); + this.value.lines.push({ ...value.after, beforeCorrection: false }); + return this; + } + + rawExtension(pathValue: string, xml: string): FA3InvoiceBuilder { + this.value.rawExtensions = [...(this.value.rawExtensions ?? []), { path: pathValue, xml }]; + return this; + } + + build(): FA3Draft { + return new FA3Draft(this.value); + } + + validate(): FA3ValidationIssue[] { + return this.build().validate(); + } + + async toXml(options: FA3XmlOptions = {}): Promise { + return this.build().toXml(options); + } +} + +export class BasicInvoiceBuilder extends FA3InvoiceBuilder { + constructor(invoiceNumber: string) { + super(invoiceNumber, "basic"); + } +} + +export class SimplifiedInvoiceBuilder extends FA3InvoiceBuilder { + constructor(invoiceNumber: string) { + super(invoiceNumber, "simplified"); + } +} + +export class CorrectionInvoiceBuilder extends FA3InvoiceBuilder { + constructor(invoiceNumber: string, kind: FA3InvoiceKind = "correction") { + super(invoiceNumber, kind); + } +} + +export class AdvanceInvoiceBuilder extends FA3InvoiceBuilder { + constructor(invoiceNumber: string, kind: FA3InvoiceKind = "advance") { + super(invoiceNumber, kind); + } +} + +export class SettlementInvoiceBuilder extends FA3InvoiceBuilder { + constructor(invoiceNumber: string, kind: FA3InvoiceKind = "settlement") { + super(invoiceNumber, kind); + } +} + +export class AdvanceCorrectionInvoiceBuilder extends FA3InvoiceBuilder { + constructor(invoiceNumber: string) { + super(invoiceNumber, "advance_correction"); + } +} + +export class SettlementCorrectionInvoiceBuilder extends FA3InvoiceBuilder { + constructor(invoiceNumber: string) { + super(invoiceNumber, "settlement_correction"); + } +} + +export class FA3Invoice { + static basic(invoiceNumber: string): BasicInvoiceBuilder { + return new BasicInvoiceBuilder(invoiceNumber); + } + + static simplified(invoiceNumber: string): SimplifiedInvoiceBuilder { + return new SimplifiedInvoiceBuilder(invoiceNumber); + } + + static correction(invoiceNumber: string): CorrectionInvoiceBuilder { + return new CorrectionInvoiceBuilder(invoiceNumber); + } + + static advance(invoiceNumber: string): AdvanceInvoiceBuilder { + return new AdvanceInvoiceBuilder(invoiceNumber); + } + + static settlement(invoiceNumber: string): SettlementInvoiceBuilder { + return new SettlementInvoiceBuilder(invoiceNumber); + } + + static advanceCorrection(invoiceNumber: string): AdvanceCorrectionInvoiceBuilder { + return new AdvanceCorrectionInvoiceBuilder(invoiceNumber); + } + + static settlementCorrection(invoiceNumber: string): SettlementCorrectionInvoiceBuilder { + return new SettlementCorrectionInvoiceBuilder(invoiceNumber); + } + + static advance_correction(invoiceNumber: string): AdvanceCorrectionInvoiceBuilder { + return new AdvanceCorrectionInvoiceBuilder(invoiceNumber); + } + + static settlement_correction(invoiceNumber: string): SettlementCorrectionInvoiceBuilder { + return new SettlementCorrectionInvoiceBuilder(invoiceNumber); + } +} + +export class FA3BatchDraft { + readonly drafts: FA3Draft[]; + + constructor(drafts: FA3Draft[]) { + this.drafts = drafts; + } + + toDict(): { drafts: FA3DraftInput[] } { + return { + drafts: this.drafts.map((draft) => draft.toDict()), + }; + } + + toJson(): string { + return JSON.stringify(this.toDict(), null, 2); + } + + static fromJson(value: string): FA3BatchDraft { + const parsed = JSON.parse(value) as { drafts?: FA3DraftInput[] }; + return new FA3BatchDraft((parsed.drafts ?? []).map((draft) => FA3Draft.fromDict(draft))); + } + + async toXmlFiles(outDir: string, options: FA3XmlOptions = {}): Promise { + const output = path.resolve(outDir); + await mkdir(output, { recursive: true }); + const files: string[] = []; + for (let i = 0; i < this.drafts.length; i += 1) { + const draft = this.drafts[i]; + if (!draft) { + continue; + } + const xml = await draft.toXml(options); + const fileName = `${sanitizeFileName(draft.toDict().invoiceNumber, `invoice-${i + 1}`)}.xml`; + const target = path.join(output, fileName); + await writeFile(target, xml, "utf8"); + files.push(target); + } + return files; + } + + async toXmlZip(targetPath: string, options: FA3XmlOptions = {}): Promise { + const entries = []; + for (let i = 0; i < this.drafts.length; i += 1) { + const draft = this.drafts[i]; + if (!draft) { + continue; + } + const xml = await draft.toXml(options); + entries.push({ + fileName: `${sanitizeFileName(draft.toDict().invoiceNumber, `invoice-${i + 1}`)}.xml`, + content: Buffer.from(xml, "utf8"), + }); + } + const zip = await createZip(entries); + const resolved = path.resolve(targetPath); + await mkdir(path.dirname(resolved), { recursive: true }); + await writeFile(resolved, zip); + return resolved; + } +} + +export function sanitizeFileName(value: string, fallback = "faktura"): string { + const candidate = value.trim().replace(/[^A-Za-z0-9_.-]+/g, "_"); + return candidate || fallback; +} + +export const BaseFA3Builder = FA3InvoiceBuilder; diff --git a/src/documents/fa3/domain.ts b/src/documents/fa3/domain.ts new file mode 100644 index 0000000..d9a2b04 --- /dev/null +++ b/src/documents/fa3/domain.ts @@ -0,0 +1,373 @@ +import { + FA3BankAccount, + FA3Discount, + FA3Line, + FA3PartialPayment, + FA3Party, + FA3PartyIdentifier, + FA3PaymentMethod, + FA3PaymentTerms, + FA3Settlement, + FA3SettlementAdjustment, + FA3TaxCategory, + FA3ValidationIssue, +} from "./types"; +import { PartyIdentifierKind } from "./identifier"; + +export class FA3ValidationResult { + readonly errors: FA3ValidationIssue[]; + readonly warnings: FA3ValidationIssue[]; + + constructor(errors: FA3ValidationIssue[] = [], warnings: FA3ValidationIssue[] = []) { + this.errors = errors; + this.warnings = warnings; + } +} + +export class Address { + static polish(line1: string, line2?: string, line3?: string): { + countryCode: "PL"; + line1: string; + line2?: string; + line3?: string; + } { + return { + countryCode: "PL", + line1, + ...(line2 ? { line2 } : {}), + ...(line3 ? { line3 } : {}), + }; + } + + static foreign( + countryCode: string, + line1: string, + line2?: string, + line3?: string, + ): { countryCode: string; line1: string; line2?: string; line3?: string } { + return { + countryCode: countryCode.toUpperCase(), + line1, + ...(line2 ? { line2 } : {}), + ...(line3 ? { line3 } : {}), + }; + } +} + +export class Contact { + static create(value: { email?: string; phone?: string }): { email?: string; phone?: string } { + return { ...value }; + } +} + +export const ThirdPartyRole = { + ORIGINAL_ENTITY: "1", + ADDITIONAL_BUYER: "2", + RECIPIENT: "3", + PAYER: "4", + JST_SUBUNIT: "8", + VAT_GROUP_MEMBER: "10", + OTHER: "11", +} as const; + +export const AuthorizedPartyRole = { + REPRESENTATIVE: "1", + BAILIFF: "2", + ENFORCEMENT_AUTHORITY: "3", +} as const; + +export { PartyIdentifierKind } from "./identifier"; + +export class PartyIdentifier { + static polishNip(value: string): FA3PartyIdentifier { + return { kind: PartyIdentifierKind.NIP, value }; + } + + static euVat(countryCode: string, vatId: string): FA3PartyIdentifier { + return { + kind: PartyIdentifierKind.EU_VAT, + value: vatId, + countryCode: countryCode.toUpperCase(), + }; + } + + static foreign(identifier: string, countryCode?: string): FA3PartyIdentifier { + return { + kind: PartyIdentifierKind.FOREIGN, + value: identifier, + ...(countryCode ? { countryCode: countryCode.toUpperCase() } : {}), + }; + } + + static internal(value: string): FA3PartyIdentifier { + return { kind: PartyIdentifierKind.INTERNAL, value }; + } + + static none(): FA3PartyIdentifier { + return { kind: PartyIdentifierKind.NONE }; + } +} + +export class InvoiceParty { + static polishCompany(input: { + nip: string; + name: string; + address?: string; + email?: string; + phone?: string; + }): FA3Party { + return { + name: input.name, + taxId: input.nip, + identifier: PartyIdentifier.polishNip(input.nip), + ...(input.address ? { addressLine1: input.address } : {}), + ...(input.email ? { email: input.email } : {}), + ...(input.phone ? { phone: input.phone } : {}), + }; + } + + static euCompany(input: { + vatId: string; + countryCode: string; + name: string; + address?: string; + email?: string; + phone?: string; + }): FA3Party { + const identifier = PartyIdentifier.euVat(input.countryCode, input.vatId); + return { + name: input.name, + taxId: `${input.countryCode.toUpperCase()}${input.vatId}`, + identifier, + countryCode: input.countryCode.toUpperCase(), + ...(input.address ? { addressLine1: input.address } : {}), + ...(input.email ? { email: input.email } : {}), + ...(input.phone ? { phone: input.phone } : {}), + }; + } + + static foreignCompany(input: { + identifier: string; + countryCode: string; + name: string; + address?: string; + }): FA3Party { + const identifier = PartyIdentifier.foreign(input.identifier, input.countryCode); + return { + name: input.name, + taxId: `${input.countryCode.toUpperCase()}:${input.identifier}`, + identifier, + countryCode: input.countryCode.toUpperCase(), + ...(input.address ? { addressLine1: input.address } : {}), + }; + } + + static withoutTaxId(input: { name: string; countryCode?: string; address?: string }): FA3Party { + return { + name: input.name, + taxId: "BRAK", + identifier: PartyIdentifier.none(), + countryCode: (input.countryCode ?? "PL").toUpperCase(), + ...(input.address ? { addressLine1: input.address } : {}), + }; + } +} + +export const Party = InvoiceParty; + +export class TaxCategory { + static standard23(): FA3TaxCategory { + return { code: "23", vatRate: 23 }; + } + static standard22(): FA3TaxCategory { + return { code: "22", vatRate: 22 }; + } + static reduced8(): FA3TaxCategory { + return { code: "8", vatRate: 8 }; + } + static reduced7(): FA3TaxCategory { + return { code: "7", vatRate: 7 }; + } + static reduced5(): FA3TaxCategory { + return { code: "5", vatRate: 5 }; + } + static zeroDomestic(): FA3TaxCategory { + return { code: "0 KR", vatRate: 0 }; + } + static zeroWdt(): FA3TaxCategory { + return { code: "0 WDT", vatRate: 0 }; + } + static zeroExport(): FA3TaxCategory { + return { code: "0 EX", vatRate: 0 }; + } + static exempt(basis: string): FA3TaxCategory { + return { code: "zw", exemptionBasis: basis, vatRate: null }; + } + static outsideCountry(): FA3TaxCategory { + return { code: "np I", vatRate: null }; + } + static serviceArticle100(): FA3TaxCategory { + return { code: "np II", vatRate: null }; + } + static reverseCharge(): FA3TaxCategory { + return { code: "oo", vatRate: null }; + } +} + +export const VatClass = TaxCategory; + +export class Discount { + static amount(value: number | string, reason?: string): FA3Discount { + return { + kind: "amount", + value, + ...(reason ? { reason } : {}), + }; + } + + static percent(value: number | string, reason?: string): FA3Discount { + return { + kind: "percent", + value, + ...(reason ? { reason } : {}), + }; + } +} + +export class InvoiceLine { + static goods( + description: string, + input: { + quantity: number | string; + unitNetPrice: number | string; + unit?: string; + vatRate?: number | string | null; + beforeCorrection?: boolean; + gtu?: string; + procedure?: string; + annex15?: boolean; + }, + ): FA3Line { + return { + description, + quantity: input.quantity, + unit: input.unit ?? "szt", + unitNetPrice: input.unitNetPrice, + ...(input.vatRate !== undefined ? { vatRate: input.vatRate } : {}), + ...(input.beforeCorrection !== undefined ? { beforeCorrection: input.beforeCorrection } : {}), + ...(input.gtu !== undefined ? { gtu: input.gtu } : {}), + ...(input.procedure !== undefined ? { procedure: input.procedure } : {}), + ...(input.annex15 !== undefined ? { annex15: input.annex15 } : {}), + }; + } + + static service(description: string, input: Parameters[1]): FA3Line { + return InvoiceLine.goods(description, input); + } + + static correctedBefore( + description: string, + input: Omit[1], "beforeCorrection">, + ): FA3Line { + return InvoiceLine.goods(description, { ...input, beforeCorrection: true }); + } + + static correctedAfter( + description: string, + input: Omit[1], "beforeCorrection">, + ): FA3Line { + return InvoiceLine.goods(description, { ...input, beforeCorrection: false }); + } +} + +export interface CorrectionReference { + invoiceNumber: string; + issueDate?: string; + ksefNumber?: string; +} + +export type BankAccount = FA3BankAccount; + +export interface PaymentTerms extends FA3PaymentTerms { + bankAccounts?: BankAccount[]; + paymentLink?: string; + ipksef?: string; +} + +export class PartialPayment { + static create( + amount: number | string, + paidOn: string, + options: { + method?: FA3PaymentMethod; + otherMethodDescription?: string; + } = {}, + ): FA3PartialPayment { + return { + amount, + paidOn, + ...(options.method !== undefined ? { method: options.method } : {}), + ...(options.otherMethodDescription !== undefined + ? { otherMethodDescription: options.otherMethodDescription } + : {}), + }; + } +} + +export class SettlementAdjustment { + static create(amount: number | string, reason: string): FA3SettlementAdjustment { + return { amount, reason }; + } +} + +export type Settlement = FA3Settlement; + +export interface Annotation { + key: string; + value: string; +} + +export class AnnotationSet { + readonly items: Annotation[]; + + constructor(items: Annotation[] = []) { + this.items = items; + } + + static default(): AnnotationSet { + return new AnnotationSet([]); + } + + static splitPayment(): Annotation { + return { key: "split_payment", value: "1" }; + } + + static cashMethod(): Annotation { + return { key: "cash_method", value: "1" }; + } +} + +export interface AttachmentTable { + headers: string[]; + rows: string[][]; + columnTypes?: string[]; + metadata?: Array<[string, string]>; + description?: string; + footer?: string[]; +} + +export interface AttachmentBlock { + header?: string; + metadata?: Array<[string, string]>; + paragraphs?: string[]; + tables?: AttachmentTable[]; +} + +export interface Attachment { + blocks: AttachmentBlock[]; +} + +export interface RawXmlExtension { + path: string; + xml: string; +} diff --git a/src/documents/fa3/enums.ts b/src/documents/fa3/enums.ts new file mode 100644 index 0000000..98647d5 --- /dev/null +++ b/src/documents/fa3/enums.ts @@ -0,0 +1,87 @@ +export const CorrectionType = { + TAX_BASE_OR_TAX: "1", + OTHER: "2", + NO_TAX_IMPACT: "3", +} as const; + +export const PaymentMethod = { + CASH: "1", + CARD: "2", + VOUCHER: "3", + CHECK: "4", + CREDIT: "5", + TRANSFER: "6", + COMPENSATION: "7", +} as const; + +export const VatRateCode = { + RATE_23: "23", + RATE_22: "22", + RATE_8: "8", + RATE_7: "7", + RATE_5: "5", + RATE_4: "4", + RATE_3: "3", + ZERO_DOMESTIC: "0 KR", + ZERO_WDT: "0 WDT", + ZERO_EXPORT: "0 EX", + EXEMPT: "zw", + REVERSE_CHARGE: "oo", + OUTSIDE_COUNTRY_1: "np I", + OUTSIDE_COUNTRY_2: "np II", +} as const; + +export const LineProcedure = { + WSTO_EE: "WSTO_EE", + IED: "IED", + TT_D: "TT_D", + I_42: "I_42", + I_63: "I_63", + B_SPV: "B_SPV", + B_SPV_DOSTAWA: "B_SPV_DOSTAWA", + B_MPV_PROWIZJA: "B_MPV_PROWIZJA", +} as const; + +export const OrderLineProcedure = { + WSTO_EE: "WSTO_EE", + IED: "IED", + TT_D: "TT_D", + B_SPV: "B_SPV", + B_SPV_DOSTAWA: "B_SPV_DOSTAWA", + B_MPV_PROWIZJA: "B_MPV_PROWIZJA", +} as const; + +export const GTUCode = { + GTU_01: "GTU_01", + GTU_02: "GTU_02", + GTU_03: "GTU_03", + GTU_04: "GTU_04", + GTU_05: "GTU_05", + GTU_06: "GTU_06", + GTU_07: "GTU_07", + GTU_08: "GTU_08", + GTU_09: "GTU_09", + GTU_10: "GTU_10", + GTU_11: "GTU_11", + GTU_12: "GTU_12", + GTU_13: "GTU_13", +} as const; + +export const TransportKind = { + SEA: "1", + RAIL: "2", + ROAD: "3", + AIR: "4", + POSTAL: "5", + FIXED_TRANSPORT: "7", + OTHER: "8", +} as const; + +export const MarginProcedure = { + TRAVEL: "travel", + USED_GOODS: "used_goods", + ART: "art", + COLLECTIBLES: "collectibles", +} as const; + +export type EnumValue> = T[keyof T]; diff --git a/src/documents/fa3/identifier.ts b/src/documents/fa3/identifier.ts new file mode 100644 index 0000000..19fd2b2 --- /dev/null +++ b/src/documents/fa3/identifier.ts @@ -0,0 +1,210 @@ +import { KsefValidationError } from "../../errors/errors"; +import { XmlObject } from "../../xml/xml"; +import type { FA3Party, FA3PartyIdentifier, FA3ValidationIssue } from "./types"; + +export const PartyIdentifierKind = { + NIP: "NIP", + EU_VAT: "EU_VAT", + FOREIGN: "FOREIGN", + INTERNAL: "INTERNAL", + NONE: "NONE", +} as const; + +export type PartyIdentifierKindValue = (typeof PartyIdentifierKind)[keyof typeof PartyIdentifierKind]; + +export type ResolvedPartyIdentifier = FA3PartyIdentifier; + +export const SELLER_NIP_REQUIRED_MESSAGE = + "Podmiot1/PodmiotUpowazniony wymaga identyfikatora NIP."; + +const EU_COUNTRY_CODES = new Set([ + "AT", + "BE", + "BG", + "CY", + "CZ", + "DE", + "DK", + "EE", + "EL", + "ES", + "FI", + "FR", + "HR", + "HU", + "IE", + "IT", + "LT", + "LU", + "LV", + "MT", + "NL", + "PL", + "PT", + "RO", + "SE", + "SI", + "SK", +]); + +function normalizeParty(party: FA3Party): FA3Party & { countryCode: string } { + return { + ...party, + countryCode: (party.countryCode ?? "PL").toUpperCase(), + }; +} + +export function resolvePartyIdentifier(party: FA3Party): ResolvedPartyIdentifier { + if (party.identifier) { + const identifier = party.identifier; + return { + kind: identifier.kind, + ...(identifier.value !== undefined ? { value: identifier.value.trim() } : {}), + ...(identifier.countryCode + ? { countryCode: identifier.countryCode.toUpperCase() } + : {}), + }; + } + + const normalized = normalizeParty(party); + const taxId = normalized.taxId.trim(); + const countryCode = normalized.countryCode; + + if (normalized.internalId?.trim()) { + return { + kind: PartyIdentifierKind.INTERNAL, + value: normalized.internalId.trim(), + }; + } + + if (taxId.toUpperCase() === "BRAK") { + return { kind: PartyIdentifierKind.NONE }; + } + + const foreignMatch = /^([A-Z]{2}):(.*)$/i.exec(taxId); + if (foreignMatch?.[1] && foreignMatch[2]?.trim()) { + return { + kind: PartyIdentifierKind.FOREIGN, + countryCode: foreignMatch[1].toUpperCase(), + value: foreignMatch[2].trim(), + }; + } + + const vatPrefix = taxId.slice(0, 2).toUpperCase(); + if (countryCode !== "PL" && EU_COUNTRY_CODES.has(vatPrefix) && taxId.length > 2) { + return { + kind: PartyIdentifierKind.EU_VAT, + countryCode: vatPrefix, + value: taxId.slice(2), + }; + } + + if (countryCode !== "PL") { + return { + kind: PartyIdentifierKind.FOREIGN, + countryCode, + value: taxId, + }; + } + + return { + kind: PartyIdentifierKind.NIP, + value: taxId, + }; +} + +export function validatePartyIdentifier( + identifier: ResolvedPartyIdentifier, + path: string, +): FA3ValidationIssue[] { + const issues: FA3ValidationIssue[] = []; + if (identifier.kind === PartyIdentifierKind.NONE) { + return issues; + } + if (!String(identifier.value ?? "").trim()) { + issues.push({ + code: "identifier_required", + path: `${path}.identifier`, + message: `${path}: identyfikator jest wymagany.`, + }); + } + if ( + identifier.kind === PartyIdentifierKind.EU_VAT && + !String(identifier.countryCode ?? "").trim() + ) { + issues.push({ + code: "eu_vat_country_required", + path: `${path}.identifier.countryCode`, + message: `${path}: kod kraju UE jest wymagany dla numeru VAT UE.`, + }); + } + return issues; +} + +export function validateSellerPartyIdentifier( + identifier: ResolvedPartyIdentifier, + path: string, +): FA3ValidationIssue[] { + const issues = validatePartyIdentifier(identifier, path); + if (identifier.kind !== PartyIdentifierKind.NIP) { + issues.push({ + code: "seller_nip_required", + path, + message: SELLER_NIP_REQUIRED_MESSAGE, + }); + } + return issues; +} + +export function mapPartyIdentityToXml( + identifier: ResolvedPartyIdentifier, + name: string, + context: "seller" | "buyer", +): XmlObject { + const baseName = { Nazwa: name }; + + if (context === "seller") { + if (identifier.kind !== PartyIdentifierKind.NIP) { + throw new KsefValidationError(SELLER_NIP_REQUIRED_MESSAGE); + } + return { + NIP: identifier.value ?? "", + ...baseName, + }; + } + + switch (identifier.kind) { + case PartyIdentifierKind.NIP: + return { + NIP: identifier.value ?? "", + ...baseName, + }; + case PartyIdentifierKind.EU_VAT: + return { + KodUE: identifier.countryCode ?? "", + NrVatUE: identifier.value ?? "", + ...baseName, + }; + case PartyIdentifierKind.FOREIGN: + return { + ...(identifier.countryCode ? { KodKraju: identifier.countryCode } : {}), + NrID: identifier.value ?? "", + ...baseName, + }; + case PartyIdentifierKind.NONE: + return { + BrakID: "1", + ...baseName, + }; + case PartyIdentifierKind.INTERNAL: + return { + IDWew: identifier.value ?? "", + ...baseName, + }; + default: + return { + NIP: identifier.value ?? "", + ...baseName, + }; + } +} diff --git a/src/documents/fa3/importer.ts b/src/documents/fa3/importer.ts new file mode 100644 index 0000000..3a85f89 --- /dev/null +++ b/src/documents/fa3/importer.ts @@ -0,0 +1,166 @@ +import fs from "node:fs"; +import path from "node:path"; +import { FA3BatchDraft, FA3Draft } from "./builder"; +import type { FA3DraftInput, FA3ValidationIssue } from "./types"; + +export const ImportMode = { + NORMAL: "normal", + VALIDATE_ONLY: "validate_only", + FAIL_FAST: "fail_fast", +} as const; + +export type ImportModeValue = (typeof ImportMode)[keyof typeof ImportMode]; + +export class FA3InvalidRow { + readonly rowNumber: number | undefined; + readonly invoiceNumber: string | undefined; + readonly message: string; + + constructor(message: string, rowNumber?: number, invoiceNumber?: string) { + this.message = message; + this.rowNumber = rowNumber; + this.invoiceNumber = invoiceNumber; + } +} + +export class FA3ImportResult { + readonly validDrafts: FA3Draft[]; + readonly invalidRows: FA3InvalidRow[]; + readonly errors: FA3ValidationIssue[]; + readonly warnings: FA3ValidationIssue[]; + + constructor(value: { + validDrafts?: FA3Draft[]; + invalidRows?: FA3InvalidRow[]; + errors?: FA3ValidationIssue[]; + warnings?: FA3ValidationIssue[]; + } = {}) { + this.validDrafts = [...(value.validDrafts ?? [])]; + this.invalidRows = [...(value.invalidRows ?? [])]; + this.errors = [...(value.errors ?? [])]; + this.warnings = [...(value.warnings ?? [])]; + } +} + +export class FA3ImportError extends Error { + constructor(message: string) { + super(message); + this.name = "FA3ImportError"; + } +} + +function toIssue(message: string): FA3ValidationIssue { + return { + code: "import_error", + message, + }; +} + +function loadPayload(source: string | Record): unknown { + if (typeof source !== "string") { + return source; + } + const resolved = path.resolve(source); + if (fs.existsSync(resolved)) { + return JSON.parse(fs.readFileSync(resolved, "utf8")); + } + return JSON.parse(source); +} + +function draftRows(payload: unknown): FA3DraftInput[] { + if (!payload || typeof payload !== "object") { + return []; + } + const raw = payload as Record; + const rows = raw.drafts ?? raw.faktury; + if (!Array.isArray(rows)) { + return []; + } + return rows as FA3DraftInput[]; +} + +export class FA3Importer { + static fromJson( + source: string | Record, + options: { mode?: ImportModeValue } = {}, + ): FA3ImportResult { + const mode = options.mode ?? ImportMode.NORMAL; + let payload: unknown; + try { + payload = loadPayload(source); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (mode === ImportMode.FAIL_FAST) { + throw new FA3ImportError(`Nie można odczytać JSON: ${message}`); + } + return new FA3ImportResult({ + validDrafts: [], + invalidRows: [new FA3InvalidRow(`Nie można odczytać JSON: ${message}`)], + errors: [toIssue(`Nie można odczytać JSON: ${message}`)], + warnings: [], + }); + } + + const rows = draftRows(payload); + if (rows.length === 0) { + const message = "JSON draft jest nieprawidłowy: oczekiwano pola drafts lub faktury."; + if (mode === ImportMode.FAIL_FAST) { + throw new FA3ImportError(message); + } + return new FA3ImportResult({ + validDrafts: [], + invalidRows: [new FA3InvalidRow(message)], + errors: [toIssue(message)], + warnings: [], + }); + } + + const validDrafts: FA3Draft[] = []; + const invalidRows: FA3InvalidRow[] = []; + const errors: FA3ValidationIssue[] = []; + for (const [index, row] of rows.entries()) { + try { + const draft = FA3Draft.fromDict(row); + const issues = draft.validate(); + if (issues.length > 0) { + const message = issues.map((item) => item.message).join(" "); + if (mode === ImportMode.FAIL_FAST) { + throw new FA3ImportError(message); + } + invalidRows.push(new FA3InvalidRow(message, index + 1, draft.toDict().invoiceNumber)); + errors.push(...issues); + continue; + } + if (mode !== ImportMode.VALIDATE_ONLY) { + validDrafts.push(draft); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (mode === ImportMode.FAIL_FAST) { + throw new FA3ImportError(message); + } + invalidRows.push(new FA3InvalidRow(message, index + 1)); + errors.push(toIssue(message)); + } + } + + return new FA3ImportResult({ validDrafts, invalidRows, errors, warnings: [] }); + } + + static fromXlsx(): never { + throw new FA3ImportError( + "Import XLSX nie jest jeszcze dostępny w TypeScript SDK. Użyj importu JSON.", + ); + } + + static from_json( + source: string | Record, + options: { mode?: ImportModeValue } = {}, + ): FA3ImportResult { + return FA3Importer.fromJson(source, options); + } +} + +export function toBatchDraft(result: FA3ImportResult): FA3BatchDraft { + return new FA3BatchDraft(result.validDrafts); +} diff --git a/src/documents/fa3/index.ts b/src/documents/fa3/index.ts new file mode 100644 index 0000000..7475f85 --- /dev/null +++ b/src/documents/fa3/index.ts @@ -0,0 +1,37 @@ +export * from "./types"; +export { PartyIdentifierKind, validatePartyIdentifier, mapPartyIdentityToXml, resolvePartyIdentifier } from "./identifier"; +export type { PartyIdentifierKindValue, ResolvedPartyIdentifier } from "./identifier"; +export * from "./builder"; +export * from "./domain"; +export * from "./sections"; +export * from "./enums"; +export * from "./tax"; +export * from "./xml"; +export * from "./importer"; +export * from "./template"; +export { + Annotation, + Attachment, + AttachmentBlock, + AttachmentTable, + BankAccount, + Contract, + CorrectionReference, + FA3InvoiceKind, + FA3Line, + FA3Party, + FA3ValidationIssue, + LineIdentifiers, + NewTransportMeans, + Order, + OrderLine, + PaymentTerms, + RawXmlExtension, + Settlement, + TransactionTerms, + Transport, + ValidationContext, +} from "./publicApi"; +export * from "./xsdMap"; +export * from "./xsdAudit"; +export * from "./xsd"; diff --git a/src/documents/fa3/publicApi.ts b/src/documents/fa3/publicApi.ts new file mode 100644 index 0000000..7af05c5 --- /dev/null +++ b/src/documents/fa3/publicApi.ts @@ -0,0 +1,229 @@ +import type { + FA3InvoiceKind as FA3InvoiceKindType, + FA3Line as FA3LineType, + FA3Party as FA3PartyType, + FA3PaymentMethod, + FA3Settlement as FA3SettlementType, + FA3ValidationIssue as FA3ValidationIssueType, +} from "./types"; +import type { + Annotation as AnnotationType, + Attachment as AttachmentType, + AttachmentBlock as AttachmentBlockType, + AttachmentTable as AttachmentTableType, + BankAccount as BankAccountType, + CorrectionReference as CorrectionReferenceType, + PaymentTerms as PaymentTermsType, + RawXmlExtension as RawXmlExtensionType, +} from "./domain"; +import type { + Contract as ContractType, + LineIdentifiers as LineIdentifiersType, + NewTransportMeans as NewTransportMeansType, + Order as OrderType, + OrderLine as OrderLineType, + TransactionTerms as TransactionTermsType, + Transport as TransportType, + ValidationContext as ValidationContextType, +} from "./sections"; + +export const FA3InvoiceKind = { + BASIC: "basic", + SIMPLIFIED: "simplified", + CORRECTION: "correction", + ADVANCE: "advance", + SETTLEMENT: "settlement", + ADVANCE_CORRECTION: "advance_correction", + SETTLEMENT_CORRECTION: "settlement_correction", +} as const satisfies Record; + +export const FA3Party = { + create(input: FA3PartyType): FA3PartyType { + return { ...input }; + }, +}; + +export const FA3Line = { + create(input: FA3LineType): FA3LineType { + return { ...input }; + }, +}; + +export class FA3ValidationIssue implements FA3ValidationIssueType { + code: string; + message: string; + path?: string; + + constructor(message: string, code = "validation_issue", path?: string) { + this.code = code; + this.message = message; + if (path !== undefined) { + this.path = path; + } + } + + withLocation(path: string): FA3ValidationIssue { + return new FA3ValidationIssue(this.message, this.code, path); + } +} + +export const Annotation = { + create(key: string, value: string): AnnotationType { + return { key, value }; + }, + splitPayment(): AnnotationType { + return { key: "split_payment", value: "1" }; + }, + cashMethod(): AnnotationType { + return { key: "cash_method", value: "1" }; + }, +}; + +export const AttachmentTable = { + create(input: AttachmentTableType): AttachmentTableType { + return { + headers: [...input.headers], + rows: input.rows.map((row) => [...row]), + ...(input.columnTypes ? { columnTypes: [...input.columnTypes] } : {}), + ...(input.metadata ? { metadata: [...input.metadata] } : {}), + ...(input.description ? { description: input.description } : {}), + ...(input.footer ? { footer: [...input.footer] } : {}), + }; + }, +}; + +export const AttachmentBlock = { + create(input: AttachmentBlockType = {}): AttachmentBlockType { + return { + ...(input.header ? { header: input.header } : {}), + ...(input.metadata ? { metadata: [...input.metadata] } : {}), + ...(input.paragraphs ? { paragraphs: [...input.paragraphs] } : {}), + ...(input.tables ? { tables: [...input.tables] } : {}), + }; + }, +}; + +export const Attachment = { + create(blocks: AttachmentBlockType[]): AttachmentType { + return { blocks: [...blocks] }; + }, + text(header: string, ...paragraphs: string[]): AttachmentType { + return { + blocks: [ + { + header, + paragraphs, + }, + ], + }; + }, +}; + +export const BankAccount = { + create(number: string, description?: string): BankAccountType { + return { + number, + ...(description ? { description } : {}), + }; + }, +}; + +export const Contract = { + create(number: string, date?: string): ContractType { + return { + number, + ...(date ? { date } : {}), + }; + }, +}; + +export const CorrectionReference = { + create(invoiceNumber: string, issueDate?: string, ksefNumber?: string): CorrectionReferenceType { + return { + invoiceNumber, + ...(issueDate ? { issueDate } : {}), + ...(ksefNumber ? { ksefNumber } : {}), + }; + }, +}; + +export const LineIdentifiers = { + create(value: LineIdentifiersType): LineIdentifiersType { + return { ...value }; + }, +}; + +export const NewTransportMeans = { + create(value: NewTransportMeansType): NewTransportMeansType { + return { ...value }; + }, +}; + +export const OrderLine = { + create(value: OrderLineType): OrderLineType { + return { ...value }; + }, +}; + +export const Order = { + create(value: OrderType): OrderType { + return { + ...value, + ...(value.lines ? { lines: [...value.lines] } : {}), + }; + }, +}; + +export const PaymentTerms = { + create(value: PaymentTermsType): PaymentTermsType { + return { + ...value, + ...(value.partialPayments ? { partialPayments: [...value.partialPayments] } : {}), + ...(value.bankAccounts ? { bankAccounts: [...value.bankAccounts] } : {}), + }; + }, + transfer(options: { dueDate?: string; bankAccount?: BankAccountType } = {}): PaymentTermsType { + return { + ...(options.dueDate ? { dueDate: options.dueDate } : {}), + method: "6" as FA3PaymentMethod, + ...(options.bankAccount ? { bankAccounts: [options.bankAccount] } : {}), + }; + }, +}; + +export const RawXmlExtension = { + create(path: string, xml: string): RawXmlExtensionType { + return { path, xml }; + }, +}; + +export const Settlement = { + create(value: FA3SettlementType): FA3SettlementType { + return { + ...value, + ...(value.charges ? { charges: [...value.charges] } : {}), + ...(value.deductions ? { deductions: [...value.deductions] } : {}), + }; + }, +}; + +export const TransactionTerms = { + create(value: TransactionTermsType): TransactionTermsType { + return { ...value }; + }, +}; + +export const Transport = { + create(value: TransportType): TransportType { + return { ...value }; + }, +}; + +export const ValidationContext = { + create(source?: string, rowNumber?: number): ValidationContextType { + return { + ...(source ? { source } : {}), + ...(rowNumber !== undefined ? { rowNumber } : {}), + }; + }, +}; diff --git a/src/documents/fa3/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd b/src/documents/fa3/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd new file mode 100644 index 0000000..c259ac2 --- /dev/null +++ b/src/documents/fa3/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd @@ -0,0 +1 @@ + Definicje podstawowych typów używanych w deklaracjach elektronicznych. Na podstawie poniższych typów można budować deklaracje Typ znakowy ograniczony do jednej linii Typ znakowy ograniczony do 3500 znaków Wartość procentowa z dokładnością do 2 miejsc po przecinku Liczby naturalne Liczby naturalne Liczby wykazywane z dokładnością do dwóch miejsc po przecinku Wartość kwotowa wykazana w zł i gr Wartość kwotowa wykazana w zł Wartość kwotowa nieujemna wykazana w zł i gr Wartość kwotowa nieujemna wykazana w zł Typ daty Typ daty i godziny Oznaczenie roku Element będący numerem miesiąca Element będący numerem kwartału Adres e-mail Identyfikator podatkowy NIP Identyfikator podatkowy numer PESEL Numer REGON Numer akcyzowy Numer Krajowego Rejestru Sądowego Numer służący identyfikacji dla celów podatkowych Numer dokumentu stwierdzającego tożsamość Pierwsze imię Typ określający nazwę miejscowości Nazwisko Typ określający nazwę województwa, nazwę powiatu lub nazwę gminy Nazwa ulicy Numer budynku Numer lokalu Kod pocztowy Kod kraju urodzenia Kod kraju wydania numeru identyfikacyjnego Określa, czy to jest złożenie, czy korekta dokumentu złożenie po raz pierwszy deklaracji za dany okres korekta deklaracji za dany okres Numer dokumentu Pojedyncze pole wyboru Podwójne pole wyboru Potrójne pole wyboru \ No newline at end of file diff --git a/src/documents/fa3/schemas/bazowe/KodyKrajow_v10-0E.xsd b/src/documents/fa3/schemas/bazowe/KodyKrajow_v10-0E.xsd new file mode 100644 index 0000000..ad60b25 --- /dev/null +++ b/src/documents/fa3/schemas/bazowe/KodyKrajow_v10-0E.xsd @@ -0,0 +1,1283 @@ + + + + Słownik krajów + + + + Słownik kodów krajów + + + + + AFGANISTAN + + + + + ALAND ISLANDS + + + + + ALBANIA + + + + + ALGIERIA + + + + + ANDORA + + + + + ANGOLA + + + + + ANGUILLA + + + + + ANTARKTYDA + + + + + ANTIGUA I BARBUDA + + + + + ANTYLE HOLENDERSKIE + + + + + ARABIA SAUDYJSKA + + + + + ARGENTYNA + + + + + ARMENIA + + + + + ARUBA + + + + + AUSTRALIA + + + + + AUSTRIA + + + + + AZERBEJDŻAN + + + + + BAHAMY + + + + + BAHRAJN + + + + + BANGLADESZ + + + + + BARBADOS + + + + + BELGIA + + + + + BELIZE + + + + + BENIN + + + + + BERMUDY + + + + + BHUTAN + + + + + BIAŁORUŚ + + + + + BOLIWIA + + + + + BONAIRE, SINT EUSTATIUS I SABA + + + + + BOŚNIA I HERCEGOWINA + + + + + BOTSWANA + + + + + BRAZYLIA + + + + + BRUNEI DARUSSALAM + + + + + BRYTYJSKIE TERYTORIUM OCEANU INDYJSKIEGO + + + + + BUŁGARIA + + + + + BURKINA FASO + + + + + BURUNDI + + + + + CEUTA + + + + + CHILE + + + + + CHINY + + + + + CHORWACJA + + + + + CURAÇAO + + + + + CYPR + + + + + CZAD + + + + + CZARNOGÓRA + + + + + DANIA + + + + + DOMINIKA + + + + + DOMINIKANA + + + + + DŻIBUTI + + + + + EGIPT + + + + + EKWADOR + + + + + ERYTREA + + + + + ESTONIA + + + + + ETIOPIA + + + + + FALKLANDY + + + + + FIDŻI REPUBLIKA + + + + + FILIPINY + + + + + FINLANDIA + + + + + FRANCJA + + + + + FRANCUSKIE TERYTORIUM POŁUDNIOWE + + + + + GABON + + + + + GAMBIA + + + + + GHANA + + + + + GIBRALTAR + + + + + GRECJA + + + + + GRENADA + + + + + GRENLANDIA + + + + + GRUZJA + + + + + GUAM + + + + + GUERNSEY + + + + + GUJANA + + + + + GUJANA FRANCUSKA + + + + + GWADELUPA + + + + + GWATEMALA + + + + + GWINEA + + + + + GWINEA RÓWNIKOWA + + + + + GWINEA-BISSAU + + + + + HAITI + + + + + HISZPANIA + + + + + HONDURAS + + + + + HONGKONG + + + + + INDIE + + + + + INDONEZJA + + + + + IRAK + + + + + IRAN + + + + + IRLANDIA + + + + + ISLANDIA + + + + + IZRAEL + + + + + JAMAJKA + + + + + JAPONIA + + + + + JEMEN + + + + + JERSEY + + + + + JORDANIA + + + + + KAJMANY + + + + + KAMBODŻA + + + + + KAMERUN + + + + + KANADA + + + + + KATAR + + + + + KAZACHSTAN + + + + + KENIA + + + + + KIRGISTAN + + + + + KIRIBATI + + + + + KOLUMBIA + + + + + KOMORY + + + + + KONGO + + + + + KONGO, REPUBLIKA DEMOKRATYCZNA + + + + + KOREAŃSKA REPUBLIKA LUDOWO-DEMOKRATYCZNA + + + + + KOSOWO + + + + + KOSTARYKA + + + + + KUBA + + + + + KUWEJT + + + + + LAOS + + + + + LESOTHO + + + + + LIBAN + + + + + LIBERIA + + + + + LIBIA + + + + + LIECHTENSTEIN + + + + + LITWA + + + + + ŁOTWA + + + + + LUKSEMBURG + + + + + MACEDONIA + + + + + MADAGASKAR + + + + + MAJOTTA + + + + + MAKAU + + + + + MALAWI + + + + + MALEDIWY + + + + + MALEZJA + + + + + MALI + + + + + MALTA + + + + + MARIANY PÓŁNOCNE + + + + + MAROKO + + + + + MARTYNIKA + + + + + MAURETANIA + + + + + MAURITIUS + + + + + MEKSYK + + + + + MELILLA + + + + + MIKRONEZJA + + + + + MINOR + + + + + MOŁDOWA + + + + + MONAKO + + + + + MONGOLIA + + + + + MONTSERRAT + + + + + MOZAMBIK + + + + + MYANMAR (BURMA) + + + + + NAMIBIA + + + + + NAURU + + + + + NEPAL + + + + + NIDERLANDY (HOLANDIA) + + + + + NIEMCY + + + + + NIGER + + + + + NIGERIA + + + + + NIKARAGUA + + + + + NIUE + + + + + NORFOLK + + + + + NORWEGIA + + + + + NOWA KALEDONIA + + + + + NOWA ZELANDIA + + + + + OKUPOWANE TERYTORIUM PALESTYNY + + + + + OMAN + + + + + PAKISTAN + + + + + PALAU + + + + + PANAMA + + + + + PAPUA NOWA GWINEA + + + + + PARAGWAJ + + + + + PERU + + + + + PITCAIRN + + + + + POLINEZJA FRANCUSKA + + + + + POLSKA + + + + + POŁUDNIOWA GEORGIA I POŁUD.WYSPY SANDWICH + + + + + PORTUGALIA + + + + + PORTORYKO + + + + + REP.ŚRODKOWOAFRYKAŃSKA + + + + + REPUBLIKA CZESKA + + + + + REPUBLIKA KOREI + + + + + REPUBLIKA POŁUDNIOWEJ AFRYKI + + + + + REUNION + + + + + ROSJA + + + + + RUMUNIA + + + + + RWANDA + + + + + SAHARA ZACHODNIA + + + + + SAINT BARTHELEMY + + + + + SAINT KITTS I NEVIS + + + + + SAINT LUCIA + + + + + SAINT MARTIN + + + + + SAINT VINCENT I GRENADYNY + + + + + SALWADOR + + + + + SAMOA + + + + + SAMOA AMERYKAŃSKIE + + + + + SAN MARINO + + + + + SENEGAL + + + + + SERBIA + + + + + SESZELE + + + + + SIERRA LEONE + + + + + SINGAPUR + + + + + SŁOWACJA + + + + + SŁOWENIA + + + + + SOMALIA + + + + + SRI LANKA + + + + + SAINT PIERRE I MIQUELON + + + + + STANY ZJEDNOCZONE AMERYKI + + + + + SUAZI + + + + + SUDAN + + + + + SUDAN POŁUDNIOWY + + + + + SURINAM + + + + + SVALBARD I JAN MAYEN + + + + + ŚWIĘTA HELENA + + + + + SYRIA + + + + + SZWAJCARIA + + + + + SZWECJA + + + + + TADŻYKISTAN + + + + + TAJLANDIA + + + + + TAJWAN + + + + + TANZANIA + + + + + TOGO + + + + + TOKELAU + + + + + TONGA + + + + + TRYNIDAD I TOBAGO + + + + + TUNEZJA + + + + + TURCJA + + + + + TURKMENISTAN + + + + + TUVALU + + + + + UGANDA + + + + + UKRAINA + + + + + URUGWAJ + + + + + UZBEKISTAN + + + + + VANUATU + + + + + WALLIS I FUTUNA + + + + + WATYKAN + + + + + WĘGRY + + + + + WENEZUELA + + + + + WIELKA BRYTANIA + + + + + WIETNAM + + + + + WŁOCHY + + + + + WSCHODNI TIMOR + + + + + WYBRZEŻE KOŚCI SŁONIOWEJ + + + + + WYSPA BOUVETA + + + + + WYSPA BOŻEGO NARODZENIA + + + + + WYSPA MAN + + + + + WYSPA SINT MAARTEN (CZĘŚĆ HOLENDERSKA WYSPY) + + + + + WYSPY COOKA + + + + + WYSPY DZIEWICZE-USA + + + + + WYSPY DZIEWICZE-W.B. + + + + + WYSPY HEARD I MCDONALD + + + + + WYSPY KOKOSOWE (KEELINGA) + + + + + WYSPY MARSHALLA + + + + + WYSPY OWCZE + + + + + WYSPY SALOMONA + + + + + WYSPY ŚWIĘTEGO TOMASZA I KSIĄŻĘCA + + + + + WYSPY TURKS I CAICOS + + + + + ZAMBIA + + + + + ZIELONY PRZYLĄDEK + + + + + ZIMBABWE + + + + + ZJEDNOCZONE EMIRATY ARABSKIE + + + + + ZJEDNOCZONE KRÓLESTWO (IRLANDIA PÓŁNOCNA) + + + + + diff --git a/src/documents/fa3/schemas/bazowe/StrukturyDanych_v10-0E.xsd b/src/documents/fa3/schemas/bazowe/StrukturyDanych_v10-0E.xsd new file mode 100644 index 0000000..6325080 --- /dev/null +++ b/src/documents/fa3/schemas/bazowe/StrukturyDanych_v10-0E.xsd @@ -0,0 +1 @@ + Dane określające adres Dane określające adres - bez elementu Poczta w adresie polskim Informacje opisujące adres polski Kraj Województwo Powiat Gmina Nazwa ulicy Numer budynku Numer lokalu Nazwa miejscowości Kod pocztowy Nazwa urzędu pocztowego Informacje opisujące adres polski - bez elementu Poczta Kraj Województwo Powiat Gmina Nazwa ulicy Numer budynku Numer lokalu Nazwa miejscowości Kod pocztowy Informacje opisujące adres zagraniczny Kod Kraju [Country Code] Kod pocztowy [Postal code] Nazwa miejscowości [City] Nazwa ulicy [Street] Numer budynku [Building number] Numer lokalu [Flat number] Podstawowy zestaw danych identyfikacyjnych o osobie fizycznej Identyfikator podatkowy NIP Pierwsze imię Nazwisko Data urodzenia Identyfikator podatkowy numer PESEL Podstawowy zestaw danych identyfikacyjnych o osobie fizycznej z identyfikatorem NIP albo PESEL Identyfikator podatkowy NIP Identyfikator podatkowy numer PESEL Pierwsze imię Nazwisko Data urodzenia Podstawowy zestaw danych identyfikacyjnych o osobie fizycznej z identyfikatorem NIP Identyfikator podatkowy NIP Pierwsze imię Nazwisko Data urodzenia Pełny zestaw danych identyfikacyjnych o osobie fizycznej Identyfikator podatkowy NIP Pierwsze imię Nazwisko Data urodzenia Imię ojca Imię matki Identyfikator podatkowy numer PESEL Zestaw danych identyfikacyjnych dla osoby fizycznej zagranicznej Imię pierwsze [First name] Nazwisko [Family name] Data urodzenia [Date of Birth] Miejsce urodzenia [Place of Birth] Imię ojca [Father’s name] Imię matki [Mother’s name] Identyfikator podatkowy NIP [Tax Identification Number (NIP)] Podstawowy zestaw danych identyfikacyjnych o osobie niefizycznej Identyfikator podatkowy NIP Pełna nazwa Numer REGON Podstawowy zestaw danych identyfikacyjnych o osobie niefizycznej - bez elementu Numer REGON Identyfikator podatkowy NIP Pełna nazwa Pełny zestaw danych identyfikacyjnych o osobie niefizycznej Identyfikator podatkowy NIP Pełna nazwa Skrócona nazwa Numer REGON Zestaw danych identyfikacyjnych dla osoby niefizycznej zagranicznej Pełna nazwa [Name] Nazwa skrócona [Short Name] Identyfikator podatkowy NIP [Tax Identification Number (NIP)] Skrócony zestaw danych o osobie fizycznej lub niefizycznej Skrócony zestaw danych o osobie fizycznej lub niefizycznej z identyfikatorem NIP albo PESEL Skrócony zestaw danych o osobie fizycznej lub niefizycznej z identyfikatorem NIP Skrócony zestaw danych o osobie fizycznej lub niefizycznej z identyfikatorem NIP - bez elementu numer REGON dla osoby niefizycznej Podstawowy zestaw danych o osobie fizycznej Podstawowy zestaw danych o osobie fizycznej z identyfikatorem NIP albo PESEL Podstawowy zestaw danych o osobie fizycznej - bez elementu Poczta w adresie polskim Podstawowy zestaw danych o osobie fizycznej z identyfikatorem NIP albo PESEL - bez elementu Poczta w adresie polskim Podstawowy zestaw danych o osobie fizycznej z identyfikatorem NIP Podstawowy zestaw danych o osobie niefizycznej Podstawowy zestaw danych o osobie niefizycznej - bez elementu Poczta w adresie polskim Podstawowy zestaw danych o osobie niefizycznej - bez elementu Numer REGON oraz bez elementu Poczta w adresie polskim Podstawowy zestaw danych o osobie fizycznej z identyfikatorem NIP - bez elementu Poczta w adresie polskim Podstawowy zestaw danych o osobie fizycznej lub niefizycznej Pełny zestaw danych o osobie fizycznej Pełny zestaw danych o osobie fizycznej - bez elementu Poczta w adresie polskim Podstawowy zestaw danych o osobie fizycznej lub niefizycznej - bez elementu Poczta w adresie polskim Podstawowy zestaw danych o osobie fizycznej lub niefizycznej - bez elementu Numer REGON oraz bez elementu Poczta w adresie polskim Pełny zestaw danych o niefizycznej Pełny zestaw danych o osobie fizycznej lub niefizycznej Pełny zestaw danych o osobie fizycznej lub niefizycznej - bez elementu Poczta w adresie polskim Pełny zestaw danych o osobie niefizycznej - bez elementu Poczta w adresie polskim \ No newline at end of file diff --git a/src/documents/fa3/schemas/schemat_FA(3)_v1-0E.xsd b/src/documents/fa3/schemas/schemat_FA(3)_v1-0E.xsd new file mode 100644 index 0000000..f55341f --- /dev/null +++ b/src/documents/fa3/schemas/schemat_FA(3)_v1-0E.xsd @@ -0,0 +1,3950 @@ + + + + + Kody krajów członkowskich Unii Europejskiej, w tym kod dla obszaru Irlandii Północnej + + + + + AUSTRIA + + + + + BELGIA + + + + + BUŁGARIA + + + + + CYPR + + + + + CZECHY + + + + + DANIA + + + + + ESTONIA + + + + + FINLANDIA + + + + + FRANCJA + + + + + NIEMCY + + + + + GRECJA + + + + + CHORWACJA + + + + + WĘGRY + + + + + IRLANDIA + + + + + WŁOCHY + + + + + ŁOTWA + + + + + LITWA + + + + + LUKSEMBURG + + + + + MALTA + + + + + HOLANDIA + + + + + POLSKA + + + + + PORTUGALIA + + + + + RUMUNIA + + + + + SŁOWACJA + + + + + SŁOWENIA + + + + + HISZPANIA + + + + + SZWECJA + + + + + IRLANDIA PÓŁNOCNA + + + + + + + Słownik kodów walut + + + + + DIRHAM ZEA + + + + + AFGANI + + + + + LEK + + + + + DRAM + + + + + GULDEN ANTYLI HOLENDERSKICH + + + + + KWANZA + + + + + PESO ARGENTYŃSKIE + + + + + DOLAR AUSTRALIJSKI + + + + + GULDEN ARUBAŃSKI + + + + + MANAT AZERBEJDŻAŃSKI + + + + + MARKA ZAMIENNA + + + + + DOLAR BARBADOSKI + + + + + TAKA + + + + + LEW + + + + + DINAR BAHRAJSKI + + + + + FRANK BURUNDYJSKI + + + + + DOLAR BERMUDZKI + + + + + DOLAR BRUNEJSKI + + + + + BOLIWIANO + + + + + BOLIWIANO MVDOL + + + + + REAL + + + + + DOLAR BAHAMSKI + + + + + NGULTRUM + + + + + PULA + + + + + RUBEL BIAŁORUSKI + + + + + DOLAR BELIZEŃSKI + + + + + DOLAR KANADYJSKI + + + + + FRANK KONGIJSKI + + + + + FRANK SZWAJCARSKI VIR EURO + + + + + FRANK SZWAJCARSKI + + + + + FRANK SZWAJCARSKI VIR FRANK + + + + + JEDNOSTKA ROZLICZENIOWA CHILIJSKA + + + + + PESO CHILIJSKIE + + + + + YUAN RENMINBI + + + + + PESO KOLUMBIJSKIE + + + + + UNIDAD DE VALOR REAL KOLUMBILSKIE + + + + + COLON KOSTARYKAŃSKI + + + + + PESO WYMIENIALNE + + + + + PESO KUBAŃSKIE + + + + + ESCUDO REPUBLIKI ZIELONEGO PRZYLĄDKA + + + + + KORONA CZESKA + + + + + FRANK DŻIBUTI + + + + + KORONA DUŃSKA + + + + + PESO DOMINIKAŃSKIE + + + + + DINAR ALGIERSKI + + + + + FUNT EGIPSKI + + + + + NAKFA + + + + + BIRR + + + + + EURO + + + + + DOLAR FIDŻI + + + + + FUNT FALKLANDZKI + + + + + FUNT SZTERLING + + + + + LARI + + + + + FUNT GUERNSEY + + + + + GHANA CEDI + + + + + FUNT GIBRALTARSKI + + + + + DALASI + + + + + FRANK GWINEJSKI + + + + + QUETZAL + + + + + DOLAR GUJAŃSKI + + + + + DOLAR HONGKONGU + + + + + LEMPIRA + + + + + KUNA + + + + + GOURDE + + + + + FORINT + + + + + RUPIA INDONEZYJSKA + + + + + SZEKEL + + + + + FUNT MANX + + + + + RUPIA INDYJSKA + + + + + DINAR IRACKI + + + + + RIAL IRAŃSKI + + + + + KORONA ISLANDZKA + + + + + FUNT JERSEY + + + + + DOLAR JAMAJSKI + + + + + DINAR JORDAŃSKI + + + + + JEN + + + + + SZYLING KENIJSKI + + + + + SOM + + + + + RIEL + + + + + FRANK KOMORÓW + + + + + WON PÓŁNOCNO­KOREAŃSKI + + + + + WON POŁUDNIOWO­KOREAŃSKI + + + + + DINAR KUWEJCKI + + + + + DOLAR KAJMAŃSKI + + + + + TENGE + + + + + KIP + + + + + FUNT LIBAŃSKI + + + + + RUPIA LANKIJSKA + + + + + DOLAR LIBERYJSKI + + + + + LOTI + + + + + DINAR LIBIJSKI + + + + + DIRHAM MAROKAŃSKI + + + + + LEJ MOŁDAWII + + + + + ARIARY + + + + + DENAR + + + + + KYAT + + + + + TUGRIK + + + + + PATACA + + + + + OUGUIYA + + + + + RUPIA MAURITIUSU + + + + + RUPIA MALEDIWSKA + + + + + KWACHA MALAWIJSKA + + + + + PESO MEKSYKAŃSKIE + + + + + UNIDAD DE INVERSION (UDI) MEKSYKAŃSKIE + + + + + RINGGIT + + + + + METICAL + + + + + DOLAR NAMIBIJSKI + + + + + NAIRA + + + + + CORDOBA ORO + + + + + KORONA NORWESKA + + + + + RUPIA NEPALSKA + + + + + DOLAR NOWOZELANDZKI + + + + + RIAL OMAŃSKI + + + + + BALBOA + + + + + SOL + + + + + KINA + + + + + PESO FILIPIŃSKIE + + + + + RUPIA PAKISTAŃSKA + + + + + ZŁOTY + + + + + GUARANI + + + + + RIAL KATARSKI + + + + + LEJ RUMUŃSKI + + + + + DINAR SERBSKI + + + + + RUBEL ROSYJSKI + + + + + FRANK RWANDYJSKI + + + + + RIAL SAUDYJSKI + + + + + DOLAR WYSP SALOMONA + + + + + RUPIA SESZELSKA + + + + + FUNT SUDAŃSKI + + + + + KORONA SZWEDZKA + + + + + DOLAR SINGAPURSKI + + + + + FUNT ŚWIĘTEJ HELENY (ŚWIĘTA HELENA I WYSPA WNIEBOWSTĄPIENIA) + + + + + LEONE + + + + + SZYLING SOMALIJSKI + + + + + DOLAR SURINAMSKI + + + + + FUNT POŁUDNIOWOSUDAŃSKI + + + + + DOBRA + + + + + COLON SALWADORSKI (SV1) + + + + + FUNT SYRYJSKI + + + + + LILANGENI + + + + + BAT + + + + + SOMONI + + + + + MANAT TURKMEŃSKI + + + + + DINAR TUNEZYJSKI + + + + + PAANGA + + + + + LIRA TURECKA + + + + + DOLAR TRYNIDADU I TOBAGO + + + + + NOWY DOLAR TAJWAŃSKI + + + + + SZYLING TANZAŃSKI + + + + + HRYWNA + + + + + SZYLING UGANDYJSKI + + + + + DOLAR AMERYKAŃSKI + + + + + DOLAR AMERYKAŃSKI (NEXT DAY) + + + + + PESO EN UNIDADES INDEXADAS URUGWAJSKIE + + + + + PESO URUGWAJSKIE + + + + + PESO EN UNIDADES INDEXADAS URUGWAJSKIE + + + + + SUM + + + + + BOLIWAR SOBERANO  + + + + + DONG + + + + + VATU + + + + + TALA + + + + + FRANK CFA (BEAC) + + + + + SREBRO + + + + + ZŁOTO + + + + + BOND MARKETS UNIT EUROPEAN COMPOSITE UNIT (EURCO) + + + + + BOND MARKETS UNIT EUROPEAN MONETARY UNIT (E.M.U.-6) + + + + + BOND MARKETS UNIT EUROPEAN UNIT OF ACCOUNT 9 (E.U.A.-9) + + + + + BOND MARKETS UNIT EUROPEAN UNIT OF ACCOUNT 17 (E.U.A.-17) + + + + + DOLAR WSCHODNIO­KARAIBSKI + + + + + GULDEN KARAIBSKI + + + + + SDR MIĘDZYNARODOWY FUNDUSZ WALUTOWY + + + + + FRANK CFA (BCEAO) + + + + + PALLAD + + + + + FRANK CFP + + + + + PLATYNA + + + + + SUCRE SISTEMA UNITARIO DE COMPENSACION REGIONAL DE PAGOS SUCRE + + + + + ADB UNIT OF ACCOUNT MEMBER COUNTRIES OF THE AFRICAN DEVELOPMENT BANK GROUP + + + + + BRAK WALUTY + + + + + RIAL JEMEŃSKI + + + + + RAND + + + + + KWACHA ZAMBIJSKA + + + + + DOLAR ZIMBABWE + + + + + + + Symbol wzoru formularza + + + + + + + + Nagłówek + + + + + + + + + + + + + + + + + + + + + + Data i czas wytworzenia faktury + + + + + + + + + + + Nazwa systemu teleinformatycznego, z którego korzysta podatnik + + + + + + + Informacje opisujące adres + + + + + Kod Kraju [Country Code] + + + + + Adres [Address] + + + + + Adres [Address] + + + + + Globalny Numer Lokalizacyjny [Global Location Number] + + + + + + + Wartość numeryczna 18 znaków max, w tym 2 znaki po przecinku + + + + + + + + + + Wartość numeryczna 22 znaki max, w tym 8 znaków po przecinku + + + + + + + + + + Liczby naturalne większe od zera + + + + + + + + Typ znakowy ograniczony do 256 znaków + + + + + + + + + Typ znakowy ograniczony do 256 znaków + + + + + + + + + Typ znakowy ograniczony do 20 znaków + + + + + + + + + Typ znakowy ograniczony do 50 znaków + + + + + + + + + Typ znakowy ograniczony do 512 znaków + + + + + + + + + Numer identyfikujący fakturę w Krajowym Systemie e-Faktur (KSeF) + + + + + + + + Typ złożony, klucz-wartość + + + + + Numer wiersza podany w polu NrWierszaFa lub NrWierszaZam, jeśli informacja odnosi się wyłącznie do danej pozycji faktury + + + + + Klucz + + + + + Wartość + + + + + + + Typ wykorzystywany do określenia ilości. Wartość numeryczna 22 znaki max, w tym 6 po przecinku + + + + + + + + + + Data zdarzenia w okresie od 2016-07-01 do 2050-01-01 + + + + + + + + + Data zdarzenia w okresie od 2006-01-01 do 2050-01-01 + + + + + + + + + Data zdarzenia w okresie od 1990-01-01 do 2050-01-01 + + + + + + + + + Data i czas zdarzenia w okresie od 01.10.2021T00:00:00Z do 01.01.2050T23:59:59Z + + + + + + + + + Numer Identyfikacyjny VAT kontrahenta UE + + + + + + + + Identyfikator wewnętrzny + + + + + + + + Numer rachunku + + + + + + + + + Kod SWIFT + + + + + + + + Typy form płatności + + + + + Gotówka + + + + + Karta + + + + + Bon + + + + + Czek + + + + + Kredyt + + + + + Przelew + + + + + Mobilna + + + + + + + Typy ładunków + + + + + Bańka + + + + + Beczka + + + + + Butla + + + + + Karton + + + + + Kanister + + + + + Klatka + + + + + Kontener + + + + + Kosz/koszyk + + + + + Łubianka + + + + + Opakowanie zbiorcze + + + + + Paczka + + + + + Pakiet + + + + + Paleta + + + + + Pojemnik + + + + + Pojemnik do ładunków masowych stałych + + + + + Pojemnik do ładunków masowych w postaci płynnej + + + + + Pudełko + + + + + Puszka + + + + + Skrzynia + + + + + Worek + + + + + + + Wartość procentowa z dokładnością do 6 miejsc po przecinku + + + + + + + + + + + + Typy rachunków własnych + + + + + Rachunek banku lub rachunek spółdzielczej kasy oszczędnościowo-kredytowej służący do dokonywania rozliczeń z tytułu nabywanych przez ten bank lub tę kasę wierzytelności pieniężnych + + + + + Rachunek banku lub rachunek spółdzielczej kasy oszczędnościowo-kredytowej wykorzystywany przez ten bank lub tę kasę do pobrania należności od nabywcy towarów lub usług za dostawę towarów lub świadczenie usług, potwierdzone fakturą, i przekazania jej w całości albo części dostawcy towarów lub usługodawcy + + + + + Rachunek banku lub rachunek spółdzielczej kasy oszczędnościowo-kredytowej prowadzony przez ten bank lub tę kasę w ramach gospodarki własnej, niebędący rachunkiem rozliczeniowym + + + + + + + Informacje o rachunku + + + + + + Pełny numer rachunku + + + + + Kod SWIFT + + + + + + Rachunek własny + + + + + Nazwa + + + + + Opis rachunku + + + + + + + Zestaw danych identyfikacyjnych oraz danych adresowych podatnika + + + + + Identyfikator podatkowy NIP + + + + + Imię i nazwisko lub nazwa + + + + + + + Zestaw danych identyfikacyjnych oraz danych adresowych nabywcy + + + + + Numer, za pomocą którego nabywca towarów lub usług jest zidentyfikowany na potrzeby podatku lub podatku od wartości dodanej, pod którym otrzymał on towary lub usługi, z zastrzeżeniem art. 106e ust. 1 pkt 24 lit. b ustawy. Pole opcjonalne dla przypadku określonego w art. 106e ust. 5 pkt 2 ustawy. W przypadku faktur wystawianych w procedurze uproszczonej przez drugiego w kolejności podatnika, o którym mowa w art. 135 ust. 1 pkt 4 lit. b i c oraz ust. 2 ustawy - numer, o którym mowa w art. 136 ust. 1 pkt 4 ustawy + + + + Identyfikator podatkowy NIP + + + + + + Kod (prefiks) nabywcy VAT UE, o którym mowa w art. 106e ust. 1 pkt 24 ustawy oraz w przypadku, o którym mowa w art. 136 ust. 1 pkt 4 ustawy + + + + + Numer Identyfikacyjny VAT kontrahenta UE + + + + + + + Kod kraju nadania identyfikatora podatkowego + + + + + Identyfikator podatkowy inny + + + + + + + + + Podmiot nie posiada identyfikatora podatkowego lub identyfikator nie występuje na fakturze: 1- tak + + + + + + Dane opcjonalne dla przypadków, o których mowa w art. 106e ust. 5 pkt 3 ustawy + + + + Imię i nazwisko lub nazwa + + + + + + + + Zestaw danych identyfikacyjnych oraz danych adresowych podmiotów trzecich + + + + + Numer, za pomocą którego nabywca towarów lub usług jest zidentyfikowany na potrzeby podatku lub podatku od wartości dodanej, pod którym otrzymał on towary lub usługi, z zastrzeżeniem art. 106e ust. 1 pkt 24 lit. b ustawy. Pole opcjonalne dla przypadku określonego w art. 106e ust. 5 pkt 2 ustawy. W przypadku faktur wystawianych w procedurze uproszczonej przez drugiego w kolejności podatnika, o którym mowa w art. 135 ust. 1 pkt 4 lit. b i c oraz ust. 2 ustawy - numer, o którym mowa w art. 136 ust. 1 pkt 4 ustawy + + + + Identyfikator podatkowy NIP + + + + + Identyfikator wewnętrzny z NIP + + + + + + Kod (prefiks) nabywcy VAT UE, o którym mowa w art. 106e ust. 1 pkt 24 ustawy oraz w przypadku, o którym mowa w art. 136 ust. 1 pkt 4 ustawy + + + + + Numer Identyfikacyjny VAT kontrahenta UE + + + + + + + Kod kraju nadania identyfikatora podatkowego + + + + + Identyfikator podatkowy inny + + + + + + + + + Podmiot nie posiada identyfikatora podatkowego lub identyfikator nie występuje na fakturze: 1- tak + + + + + + Dane opcjonalne dla przypadków, o których mowa w art. 106e ust. 5 pkt 3 ustawy + + + + Imię i nazwisko lub nazwa + + + + + + + + Status podatnika + + + + + Podatnik znajdujący się w stanie likwidacji + + + + + Podatnik, który jest w trakcie postępowania restrukturyzacyjnego + + + + + Podatnik znajdujący się w stanie upadłości + + + + + Przedsiębiorstwo w spadku + + + + + + + Rola podmiotu upoważnionego + + + + + Organ egzekucyjny - w przypadku, o którym mowa w art. 106c pkt 1 ustawy + + + + + Komornik sądowy - w przypadku, o którym mowa w art. 106c pkt 2 ustawy + + + + + Przedstawiciel podatkowy - w przypadku gdy na fakturze występują dane przedstawiciela podatkowego, o którym mowa w art. 18a - 18d ustawy + + + + + + + Rola podmiotu trzeciego + + + + + Faktor - w przypadku gdy na fakturze występują dane faktora + + + + + Odbiorca - w przypadku gdy na fakturze występują dane jednostek wewnętrznych, oddziałów, wyodrębnionych w ramach nabywcy, które same nie stanowią nabywcy w rozumieniu ustawy + + + + + Podmiot pierwotny - w przypadku gdy na fakturze występują dane podmiotu będącego w stosunku do podatnika podmiotem przejętym lub przekształconym, który dokonywał dostawy lub świadczył usługę. Z wyłączeniem przypadków, o których mowa w art. 106j ust.2 pkt 3 ustawy, gdy dane te wykazywane są w części Podmiot1K + + + + + Dodatkowy nabywca - w przypadku gdy na fakturze występują dane kolejnych (innych niż wymieniony w części Podmiot2) nabywców + + + + + Wystawca faktury - w przypadku gdy na fakturze występują dane podmiotu wystawiającego fakturę w imieniu podatnika. Nie dotyczy przypadku, gdy wystawcą faktury jest nabywca + + + + + Dokonujący płatności - w przypadku gdy na fakturze występują dane podmiotu regulującego zobowiązanie w miejsce nabywcy + + + + + Jednostka samorządu terytorialnego - wystawca + + + + + Jednostka samorządu terytorialnego - odbiorca + + + + + Członek grupy VAT - wystawca + + + + + Członek grupy VAT - odbiorca + + + + + Pracownik + + + + + + + Numer telefonu + + + + + + + + Rodzaj faktury + + + + + Faktura podstawowa + + + + + Faktura korygująca + + + + + Faktura dokumentująca otrzymanie zapłaty lub jej części przed dokonaniem czynności oraz faktura wystawiona w związku z art. 106f ust. 4 ustawy (faktura zaliczkowa) + + + + + Faktura wystawiona w związku z art. 106f ust. 3 ustawy + + + + + Faktura, o której mowa w art. 106e ust. 5 pkt 3 ustawy + + + + + Faktura korygująca fakturę dokumentującą otrzymanie zapłaty lub jej części przed dokonaniem czynności oraz fakturę wystawioną w związku z art. 106f ust. 4 ustawy (faktura korygująca fakturę zaliczkową) + + + + + Faktura korygująca fakturę wystawioną w związku z art. 106f ust. 3 ustawy + + + + + + + Typ skutku korekty w ewidencji dla podatku od towarów i usług + + + + + Korekta skutkująca w dacie ujęcia faktury pierwotnej + + + + + Korekta skutkująca w dacie wystawienia faktury korygującej + + + + + Korekta skutkująca w dacie innej, w tym gdy dla różnych pozycji faktury korygującej daty te są różne + + + + + + + Stawka podatku + + + + + + + + + + + + Stawka 0% w przypadku sprzedaży towarów i świadczenia usług na terytorium kraju (z wyłączeniem WDT i eksportu) + + + + + Stawka 0% w przypadku wewnątrzwspólnotowej dostawy towarów (WDT) + + + + + Stawka 0% w przypadku eksportu towarów + + + + + zwolnione od podatku + + + + + odwrotne obciążenie + + + + + niepodlegające opodatkowaniu- dostawy towarów oraz świadczenia usług poza terytorium kraju, z wyłączeniem transakcji, o których mowa w art. 100 ust. 1 pkt 4 ustawy oraz OSS + + + + + niepodlegajace opodatkowaniu na terytorium kraju, świadczenie usług o których mowa w art. 100 ust. 1 pkt 4 ustawy + + + + + + + Oznaczenia dotyczące procedur dla faktur + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 2a rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 2b rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 5 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 8 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 9 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 10 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 11 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 12 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + + + Oznaczenia dotyczące procedur dla zamówień + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 2a rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 2b rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 5 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 10 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 11 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Oznaczenie dotyczące procedury, o której mowa w § 10 ust. 4 pkt 12 rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + + + Oznaczenie dotyczące dostawy towarów i świadczenia usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. a rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. b rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. c rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. d rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. e rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. f rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. g rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. h rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. i rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Dostawa towarów, o których mowa w § 10 ust. 3 pkt 1 lit. j rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Świadczenie usług, o których mowa w § 10 ust. 3 pkt 2 lit. a rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Świadczenie usług, o których mowa w § 10 ust. 3 pkt 2 lit. b rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Świadczenie usług, o których mowa w § 10 ust. 3 pkt 2 lit. c rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + + + Typ Globalnego Numeru Lokalizacyjnego + + + + + + + + + Rodzaj transportu + + + + + Transport morski + + + + + Transport kolejowy + + + + + Transport drogowy + + + + + Transport lotniczy + + + + + Przesyłka pocztowa + + + + + Stałe instalacje przesyłowe + + + + + Żegluga śródlądowa + + + + + + + Faktura VAT + + + + + + Nagłówek + + + + + Dane podatnika. Imię i nazwisko lub nazwa sprzedawcy towarów lub usług + + + + + + Kod (prefiks) podatnika VAT UE dla przypadków określonych w art. 97 ust. 10 pkt 2 i 3 ustawy oraz w przypadku, o którym mowa w art. 136 ust. 1 pkt 3 ustawy + + + + + Numer EORI podatnika (sprzedawcy) + + + + + Dane identyfikujące podatnika + + + + + Adres podatnika + + + + + Adres korespondencyjny podatnika + + + + + + + + + + Dane kontaktowe podatnika + + + + + + Adres e-mail podatnika + + + + + Numer telefonu podatnika + + + + + + + + Status podatnika + + + + + + + + Dane nabywcy + + + + + + Numer EORI nabywcy towarów + + + + + Dane identyfikujące nabywcę + + + + + Adres nabywcy. Pola opcjonalne dla przypadków określonych w art. 106e ust. 5 pkt 3 ustawy + + + + + Adres korespondencyjny nabywcy + + + + + Dane kontaktowe nabywcy + + + + + + Adres e-mail nabywcy + + + + + Numer telefonu nabywcy + + + + + + + + Numer klienta dla przypadków, w których nabywca posługuje się nim w umowie lub zamówieniu + + + + + Unikalny klucz powiązania danych nabywcy na fakturach korygujących, w przypadku gdy dane nabywcy na fakturze korygującej zmieniły się w stosunku do danych na fakturze korygowanej + + + + + + + + + + Znacznik jednostki podrzędnej JST. + +Wartość "1" oznacza, że faktura dotyczy jednostki podrzędnej JST. W takim przypadku, aby udostępnić fakturę jednostce podrzędnej JST, należy wypełnić sekcję Podmiot3, w szczególności podać NIP lub ID-Wew i określić rolę jako 8. +Wartość "2" oznacza, że faktura nie dotyczy jednostki podrzędnej JST + + + + + + Tak + + + + + Nie + + + + + + + + Znacznik członka grupy VAT. Wartość "1" oznacza, że faktura dotyczy członka grupy VAT. W takim przypadku, aby udostępnić fakturę członkowi grupy VAT, należy wypełnić sekcję Podmiot3, w szczególności podać NIP lub ID-Wew i określić rolę jako 10. + Wartość "2" oznacza, że faktura nie dotyczy członka grupy VAT + + + + + + Tak + + + + + Nie + + + + + + + + + + + Dane podmiotu/-ów trzeciego/-ich (innego/-ych niż sprzedawca i nabywca wymieniony w części Podmiot2), związanego/-ych z fakturą + + + + + + Unikalny klucz powiązania danych nabywcy na fakturach korygujących, w przypadku gdy dane nabywcy na fakturze korygującej zmieniły się w stosunku do danych na fakturze korygowanej + + + + + + + + + + Numer EORI podmiotu trzeciego + + + + + Dane identyfikujące podmiot trzeci + + + + + Adres podmiotu trzeciego + + + + + Adres korespondencyjny podmiotu trzeciego + + + + + Dane kontaktowe podmiotu trzeciego + + + + + + Adres e-mail podmiotu trzeciego + + + + + Numer telefonu podmiotu trzeciego + + + + + + + + + Rola podmiotu + + + + + + Znacznik innego podmiotu: 1 - Inny podmiot + + + + + Opis roli podmiotu - w przypadku wyboru roli jako Inny podmiot + + + + + + + Udział - procentowy udział dodatkowego nabywcy. Różnica pomiędzy wartością 100% a sumą udziałów dodatkowych nabywców jest udziałem nabywcy wymienionego w części Podmiot2. W przypadku niewypełnienia pola przyjmuje się, że udziały występujących na fakturze nabywców są równe + + + + + Numer klienta dla przypadków, w których podmiot wymieniony jako podmiot trzeci posługuje się nim w umowie lub zamówieniu + + + + + + + + Dane podmiotu upoważnionego, związanego z fakturą + + + + + + Numer EORI podmiotu upoważnionego + + + + + Dane identyfikujące podmiotu upoważnionego + + + + + Adres podmiotu upoważnionego + + + + + Adres korespondencyjny podmiotu upoważnionego + + + + + Dane kontaktowe podmiotu upoważnionego + + + + + + Adres e-mail podmiotu upoważnionego + + + + + Numer telefonu podmiotu upoważnionego + + + + + + + + Rola podmiotu upoważnionego + + + + + + + + Na podstawie art. 106a - 106q ustawy. Pola dotyczące wartości sprzedaży i podatku wypełnia się w walucie, w której wystawiono fakturę, z wyjątkiem pól dotyczących podatku przeliczonego zgodnie z przepisami działu VI w związku z art. 106e ust. 11 ustawy. W przypadku wystawienia faktury korygującej wypełnia się wszystkie pola wg stanu po korekcie, a pola dotyczące podstaw opodatkowania, podatku oraz należności ogółem wypełnia się poprzez różnicę + + + + + + Kod waluty (ISO 4217) + + + + + Data wystawienia, z zastrzeżeniem art. 106na ust. 1 ustawy + + + + + Miejsce wystawienia faktury + + + + + Kolejny numer faktury, nadany w ramach jednej lub więcej serii, który w sposób jednoznaczny identyfikuje fakturę + + + + + Numery dokumentów magazynowych WZ (wydanie na zewnątrz) związane z fakturą + + + + + + Data dokonania lub zakończenia dostawy towarów lub wykonania usługi lub data otrzymania zapłaty, o której mowa w art. 106b ust. 1 pkt 4 ustawy, o ile taka data jest określona i różni się od daty wystawienia faktury. Pole wypełnia się w przypadku, gdy dla wszystkich pozycji faktury data jest wspólna + + + + + Okres, którego dotyczy faktura - w przypadkach, o których mowa w art. 19a ust. 3 zdanie pierwsze i ust. 4 oraz ust. 5 pkt 4 ustawy + + + + + + Data początkowa okresu, którego dotyczy faktura + + + + + Data końcowa okresu, którego dotyczy faktura - data dokonania lub zakończenia dostawy towarów lub wykonania usługi + + + + + + + + + Pola wypełniane w przypadku wystąpienia na fakturze sprzedaży objętej stawką podstawową - aktualnie 23% albo 22%, z wyłączeniem procedury marży + + + + Suma wartości sprzedaży netto objętej stawką podstawową - aktualnie 23% albo 22%. W przypadku faktur zaliczkowych - wartość zaliczki netto. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + Kwota podatku od sumy wartości sprzedaży netto objętej stawką podstawową - aktualnie 23% albo 22%. W przypadku faktur zaliczkowych - kwota podatku wyliczona według wzoru, o którym mowa w art. 106f ust. 1 pkt 3 ustawy. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + W przypadku gdy faktura jest wystawiona w walucie obcej - kwota podatku od sumy wartości sprzedaży netto objętej stawką podstawową, przeliczona zgodnie z przepisami działu VI w związku z art. 106e ust. 11 ustawy - aktualnie 23% albo 22%. W przypadku faktur zaliczkowych - kwota podatku wyliczona według wzoru, o którym mowa w art. 106f ust. 1 pkt 3 ustawy. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + + Pola wypełniane w przypadku wystąpienia na fakturze sprzedaży objętej stawką obniżoną pierwszą - aktualnie 8 % albo 7%, z wyłączeniem procedury marży + + + + Suma wartości sprzedaży netto objętej stawką obniżoną pierwszą - aktualnie 8 % albo 7%. W przypadku faktur zaliczkowych - wartość zaliczki netto. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + Kwota podatku od sumy wartości sprzedaży netto objętej stawką obniżoną pierwszą - aktualnie 8% albo 7%. W przypadku faktur zaliczkowych - kwota podatku wyliczona według wzoru, o którym mowa w art. 106f ust. 1 pkt 3 ustawy. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + W przypadku gdy faktura jest wystawiona w walucie obcej - kwota podatku od sumy wartości sprzedaży netto objętej stawką obniżoną pierwszą, przeliczona zgodnie z przepisami działu VI w związku z art. 106e ust. 11 ustawy - aktualnie 8% albo 7%. W przypadku faktur zaliczkowych - kwota podatku wyliczona według wzoru, o którym mowa w art. 106f ust. 1 pkt 3 ustawy. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + + Pola wypełniane w przypadku wystąpienia na fakturze sprzedaży objętej stawką obniżoną drugą - aktualnie 5%, z wyłączeniem procedury marży + + + + Suma wartości sprzedaży netto objętej stawką obniżoną drugą - aktualnie 5%. W przypadku faktur zaliczkowych - wartość zaliczki netto. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + Kwota podatku od sumy wartości sprzedaży netto objętej stawką obniżoną drugą - aktualnie 5%. W przypadku faktur zaliczkowych - kwota podatku wyliczona według wzoru, o którym mowa w art. 106f ust. 1 pkt 3 ustawy. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + W przypadku gdy faktura jest wystawiona w walucie obcej - kwota podatku od sumy wartości sprzedaży netto objętej stawką obniżoną drugą, przeliczona zgodnie z przepisami działu VI w związku z art. 106e ust. 11 ustawy - aktualnie 5%. W przypadku faktur zaliczkowych - kwota podatku wyliczona według wzoru, o którym mowa w art. 106f ust. 1 pkt 3 ustawy. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + + Pola wypełniane w przypadku wystąpienia na fakturze sprzedaży objętej stawką obniżoną trzecią – ryczałtem dla taksówek osobowych + + + + Suma wartości sprzedaży netto objętej ryczałtem dla taksówek osobowych. W przypadku faktur zaliczkowych - wartość zaliczki netto. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + Kwota podatku od sumy wartości sprzedaży netto w przypadku ryczałtu dla taksówek osobowych. W przypadku faktur zaliczkowych - kwota podatku wyliczona według wzoru, o którym mowa w art. 106f ust. 1 pkt 3 ustawy. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + W przypadku gdy faktura jest wystawiona w walucie obcej - wysokość ryczałtu dla taksówek osobowych, przeliczona zgodnie z przepisami działu VI w związku z art. 106e ust. 11 ustawy. W przypadku faktur zaliczkowych - kwota podatku wyliczona według wzoru, o którym mowa w art. 106f ust. 1 pkt 3 ustawy. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + + Pola wypełniane w przypadku wystąpienia na fakturze sprzedaży w procedurze szczególnej, o której mowa w dziale XII w rozdziale 6a ustawy + + + + Suma wartości sprzedaży netto w przypadku procedury szczególnej, o której mowa w dziale XII w rozdziale 6a ustawy. W przypadku faktur zaliczkowych - wartość zaliczki netto. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + Kwota podatku od wartości dodanej w przypadku procedury szczególnej, o której mowa w dziale XII w rozdziale 6a ustawy. W przypadku faktur zaliczkowych - kwota podatku wyliczona według wzoru, o którym mowa w art. 106f ust. 1 pkt 3 ustawy. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + + Suma wartości sprzedaży objętej stawką 0% z wyłączeniem wewnątrzwspólnotowej dostawy towarów i eksportu. W przypadku faktur zaliczkowych - wartość zaliczki. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + Suma wartości sprzedaży objętej stawką 0% w przypadku wewnątrzwspólnotowej dostawy towarów. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + Suma wartości sprzedaży objętej stawką 0% w przypadku eksportu. W przypadku faktur zaliczkowych - wartość zaliczki. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + Suma wartości sprzedaży zwolnionej od podatku. W przypadku faktur zaliczkowych - wartość zaliczki. W przypadku faktur korygujących - kwota różnicy wartości sprzedaży + + + + + Suma wartości sprzedaży w przypadku dostawy towarów oraz świadczenia usług poza terytorium kraju, z wyłączeniem kwot wykazanych w polach P_13_5 i P_13_9. W przypadku faktur zaliczkowych - wartość zaliczki. W przypadku faktur korygujących - kwota różnicy wartości sprzedaży + + + + + Suma wartości świadczenia usług, o których mowa w art. 100 ust. 1 pkt 4 ustawy. W przypadku faktur zaliczkowych - wartość zaliczki. W przypadku faktur korygujących - kwota różnicy wartości sprzedaży + + + + + Suma wartości sprzedaży w procedurze odwrotnego obciążenia, dla której podatnikiem jest nabywca zgodnie z art. 17 ust. 1 pkt 7 i 8 ustawy oraz innych przypadków odwrotnego obciążenia występujących w obrocie krajowym. W przypadku faktur zaliczkowych - wartość zaliczki. W przypadku faktur korygujących - kwota różnicy, o której mowa w art. 106j ust. 2 pkt 5 ustawy + + + + + Suma wartości sprzedaży w procedurze marży, o której mowa w art. 119 i art. 120 ustawy. W przypadku faktur zaliczkowych - wartość zaliczki. W przypadku faktur korygujących - kwota różnicy wartości sprzedaży + + + + + Kwota należności ogółem. W przypadku faktur zaliczkowych - kwota zapłaty dokumentowana fakturą. W przypadku faktur, o których mowa w art. 106f ust. 3 ustawy - kwota pozostała do zapłaty. W przypadku faktur korygujących - korekta kwoty wynikającej z faktury korygowanej. W przypadku, o którym mowa w art. 106j ust. 3 ustawy - korekta kwot wynikających z faktur korygowanych + + + + + Kurs waluty stosowany do wyliczenia kwoty podatku w przypadkach, o których mowa w dziale VI ustawy na fakturach, o których mowa w art. 106b ust. 1 pkt 4 ustawy + + + + + Inne adnotacje na fakturze + + + + + + W przypadku dostawy towarów lub świadczenia usług, w odniesieniu do których obowiązek podatkowy powstaje zgodnie z art. 19a ust. 5 pkt 1 lub art. 21 ust. 1 ustawy - wyrazy "metoda kasowa"; należy podać wartość "1", w przeciwnym przypadku - wartość "2" + + + + + W przypadku faktur, o których mowa w art. 106d ust. 1 ustawy - wyraz "samofakturowanie"; należy podać wartość "1", w przeciwnym przypadku - wartość "2" + + + + + W przypadku dostawy towarów lub wykonania usługi, dla których obowiązanym do rozliczenia podatku od wartości dodanej lub podatku o podobnym charakterze jest nabywca towaru lub usługi - wyrazy "odwrotne obciążenie"; należy podać wartość "1", w przeciwnym przypadku - wartość "2" + + + + + W przypadku faktur, w których kwota należności ogółem przekracza kwotę 15 000 zł lub jej równowartość wyrażoną w walucie obcej, obejmujących +dokonaną na rzecz podatnika dostawę towarów lub świadczenie usług, o których mowa w załączniku nr 15 do ustawy - wyrazy "mechanizm podzielonej płatności", przy czym do przeliczania na złote kwot wyrażonych w walucie obcej stosuje się zasady przeliczania kwot stosowane w celu określenia podstawy opodatkowania; należy podać wartość "1", w przeciwnym przypadku - wartość "2" + + + + + + + + + Znacznik dostawy towarów lub świadczenia usług zwolnionych od podatku na podstawie art. 43 ust. 1 ustawy, art. 113 ust. 1 i 9 ustawy albo przepisów wydanych na podstawie art. 82 ust. 3 ustawy lub na podstawie innych przepisów + + + + + + Jeśli pole P_19 równa się "1" - należy wskazać przepis ustawy albo aktu wydanego na podstawie ustawy, na podstawie którego podatnik stosuje zwolnienie od podatku + + + + + Jeśli pole P_19 równa się "1" - należy wskazać przepis dyrektywy 2006/112/WE, który zwalnia od podatku taką dostawę towarów lub takie świadczenie usług + + + + + Jeśli pole P_19 równa się "1" - należy wskazać inną podstawę prawną wskazującą na to, że dostawa towarów lub świadczenie usług korzysta ze zwolnienia od podatku + + + + + + + Znacznik braku dostawy towarów lub świadczenia usług zwolnionych od podatku na podstawie art. 43 ust. 1 ustawy, art. 113 ust. 1 i 9 ustawy albo przepisów wydanych na podstawie art. 82 ust. 3 ustawy lub na podstawie innych przepisów + + + + + + + + + + + + Znacznik wewnątrzwspólnotowej dostawy nowych środków transportu + + + + + Jeśli występuje obowiązek, o którym mowa w art. 42 ust. 5 ustawy, należy podać wartość "1", w przeciwnym przypadku - wartość "2" + + + + + + + + Data dopuszczenia nowego środka transportu do użytku + + + + + Numer wiersza faktury, w którym wykazano dostawę nowego środka transportu + + + + + Marka nowego środka transportu + + + + + Model nowego środka transportu + + + + + Kolor nowego środka transportu + + + + + Numer rejestracyjny nowego środka transportu + + + + + Rok produkcji nowego środka transportu + + + + + + + Jeśli dostawa dotyczy pojazdów lądowych, o których mowa w art. 2 pkt 10 lit. a ustawy, należy podać przebieg pojazdu + + + + + + Jeśli dostawa dotyczy pojazdów lądowych, o których mowa w art. 2 pkt 10 lit. a ustawy, można podać numer VIN + + + + + Jeśli dostawa dotyczy pojazdów lądowych, o których mowa w art. 2 pkt 10 lit. a ustawy, można podać numer nadwozia + + + + + Jeśli dostawa dotyczy pojazdów lądowych, o których mowa w art. 2 pkt 10 lit. a ustawy, można podać numer podwozia + + + + + Jeśli dostawa dotyczy pojazdów lądowych, o których mowa w art. 2 pkt 10 lit. a ustawy, można podać numer ramy + + + + + + Jeśli dostawa dotyczy pojazdów lądowych, o których mowa w art. 2 pkt 10 lit. a ustawy, można podać typ nowego środka transportu + + + + + + + Jeśli dostawa dotyczy jednostek pływających, o których mowa w art. 2 pkt 10 lit. b ustawy, należy podać liczbę godzin roboczych używania nowego środka transportu + + + + + Jeśli dostawa dotyczy jednostek pływających, o których mowa w art. 2 pkt 10 lit. b ustawy, można podać numer kadłuba nowego środka transportu + + + + + + + Jeśli dostawa dotyczy statków powietrznych, o których mowa w art. 2 pkt 10 lit. c ustawy, należy podać liczbę godzin roboczych używania nowego środka transportu + + + + + Jeśli dostawa dotyczy statków powietrznych, o których mowa w art. 2 pkt 10 lit. c ustawy, można podać numer fabryczny nowego środka transportu + + + + + + + + + + + Znacznik braku wewnątrzwspólnotowej dostawy nowych środków transportu + + + + + + + + W przypadku faktur wystawianych w procedurze uproszczonej przez drugiego w kolejności podatnika, o którym mowa w art. 135 ust. 1 pkt 4 lit. b i c oraz ust. 2 ustawy, zawierającej adnotację, o której mowa w art. 136 ust. 1 pkt 1 ustawy i stwierdzenie, o którym mowa w art. 136 ust. 1 pkt 2 ustawy, należy podać wartość "1", w przeciwnym przypadku - wartość "2" + + + + + + + + + Znacznik wystąpienia procedur marży, o których mowa w art. 119 lub art. 120 ustawy + + + + + + Znacznik świadczenia usług turystyki, dla których podstawę opodatkowania stanowi marża, zgodnie z art. 119 ust. 1 ustawy, a faktura dokumentująca świadczenie zawiera wyrazy "procedura marży dla biur podróży" + + + + + Znacznik dostawy towarów używanych, dla których podstawę opodatkowania stanowi marża, zgodnie z art. 120 ustawy, a faktura dokumentująca dostawę zawiera wyrazy "procedura marży - towary używane" + + + + + Znacznik dostawy dzieł sztuki, dla których podstawę opodatkowania stanowi marża, zgodnie z art. 120 ustawy, a faktura dokumentująca dostawę zawiera wyrazy "procedura marży - dzieła sztuki" + + + + + Znacznik dostawy przedmiotów kolekcjonerskich i antyków, dla których podstawę opodatkowania stanowi marża, zgodnie z art. 120 ustawy, a faktura dokumentująca dostawę zawiera wyrazy "procedura marży - przedmioty kolekcjonerskie i antyki" + + + + + + + Znacznik braku wystąpienia procedur marży, o których mowa w art. 119 lub art. 120 ustawy + + + + + + + + + + + Rodzaj faktury + + + + + Dane dla przypadków, gdy pole RodzajFaktury przyjmuje wartości KOR, KOR_ZAL lub KOR_ROZ + + + + Przyczyna korekty dla faktur korygujących + + + + + Typ skutku korekty w ewidencji dla podatku od towarów i usług + + + + + Dane faktury korygowanej + + + + + + Data wystawienia faktury korygowanej + + + + + Numer faktury korygowanej + + + + + + + Znacznik numeru KSeF faktury korygowanej + + + + + Numer identyfikujący fakturę korygowaną w KSeF + + + + + + Znacznik faktury korygowanej wystawionej poza KSeF + + + + + + + + + Dla faktury korygującej, o której mowa w art. 106j ust. 3 ustawy - okres, do którego odnosi się udzielany opust lub udzielana obniżka, w przypadku gdy podatnik udziela opustu lub obniżki ceny w odniesieniu do dostaw towarów lub usług dokonanych lub świadczonych na rzecz jednego odbiorcy w danym okresie + + + + + Poprawny numer faktury korygowanej w przypadku, gdy przyczyną korekty jest błędny numer faktury korygowanej. W takim przypadku błędny numer faktury należy wskazać w polu NrFaKorygowanej + + + + + W przypadku korekty danych sprzedawcy należy podać pełne dane sprzedawcy występujące na fakturze korygowanej. Pole nie dotyczy przypadku korekty błędnego NIP występującego na fakturze pierwotnej - wówczas wymagana jest korekta faktury do wartości zerowych + + + + + + Kod (prefiks) podatnika VAT UE dla przypadków określonych w art. 97 ust. 10 pkt 2 i 3 ustawy oraz w przypadku, o którym mowa w art. 136 ust. 1 pkt 3 ustawy + + + + + Dane identyfikujące podatnika + + + + + Adres podatnika + + + + + + + + W przypadku korekty danych nabywcy występującego jako Podmiot2 lub dodatkowego nabywcy występującego jako Podmiot3 należy podać pełne dane tego podmiotu występujące na fakturze korygowanej. Korekcie nie podlegają błędne numery NIP identyfikujące nabywcę oraz dodatkowego nabywcę - wówczas wymagana jest korekta faktury do wartości zerowych. W przypadku korygowania pozostałych danych nabywcy lub dodatkowego nabywcy wskazany numer identyfikacyjny ma być tożsamy z numerem w części Podmiot2 względnie Podmiot3 faktury korygującej + + + + + + Dane identyfikujące nabywcę + + + + + Adres nabywcy. Pola opcjonalne dla przypadków określonych w art. 106e ust. 5 pkt 3 ustawy + + + + + Unikalny klucz powiązania danych nabywcy na fakturach korygujących, w przypadku gdy dane nabywcy na fakturze korygującej zmieniły się w stosunku do danych na fakturze korygowanej + + + + + + + + + + + + + + W przypadku korekt faktur zaliczkowych - kwota zapłaty przed korektą. W przypadku korekt faktur, o których mowa w art. 106f ust. 3 ustawy - kwota pozostała do zapłaty przed korektą + + + + + Kurs waluty stosowany do wyliczenia kwoty podatku w przypadkach, o których mowa w dziale VI ustawy przed korektą + + + + + + + Dane dla przypadków faktur dokumentujących otrzymanie więcej niż jednej płatności, o której mowa w art. 106b ust. 1 pkt 4 ustawy. W przypadku, gdy faktura, o której mowa w art. 106f ust. 3 ustawy dokumentuje jednocześnie otrzymanie części zapłaty przed dokonaniem czynności, różnica kwoty w polu P_15 i sumy poszczególnych pól P_15Z stanowi kwotę pozostałą ponad płatności otrzymane przed wykonaniem czynności udokumentowanej fakturą + + + + + + Data otrzymania płatności, o której mowa w art. 106b ust. 1 pkt 4 ustawy + + + + + Kwota płatności, o której mowa w art. 106b ust. 1 pkt 4 ustawy, składająca się na kwotę w polu P_15. W przypadku faktur korygujących - korekta kwoty wynikającej z faktury korygowanej + + + + + Kurs waluty stosowany do wyliczenia kwoty podatku w przypadkach, o których mowa w dziale VI ustawy + + + + + + + + Faktura, o której mowa w art. 109 ust. 3d ustawy + + + + + Istniejące powiązania między nabywcą a dokonującym dostawy towarów lub usługodawcą, zgodnie z § 10 ust. 4 pkt 3, z zastrzeżeniem ust. 4b rozporządzenia w sprawie szczegółowego zakresu danych zawartych w deklaracjach podatkowych i w ewidencji w zakresie podatku od towarów i usług + + + + + Pola przeznaczone dla wykazywania dodatkowych danych na fakturze, w tym wymaganych przepisami prawa, dla których nie przewidziano innych pól/elementów + + + + + Numery faktur zaliczkowych lub ich numery KSeF, jeśli zostały wystawione z użyciem KSeF + + + + + + + Znacznik faktury zaliczkowej wystawionej poza KSeF + + + + + Numer faktury zaliczkowej wystawionej poza KSeF. Pole obowiązkowe dla faktury wystawianej po wydaniu towaru lub wykonaniu usługi, o której mowa w art. 106f ust. 3 ustawy i ostatniej z faktur, o której mowa w art. 106f ust. 4 ustawy + + + + + + Numer identyfikujący fakturę zaliczkową w KSeF. Pole obowiązkowe w przypadku, gdy faktura zaliczkowa była wystawiona za pomocą KSeF + + + + + + + + Informacja dodatkowa niezbędna dla rolników ubiegających się o zwrot podatku akcyzowego zawartego w cenie oleju napędowego + + + + + Szczegółowe pozycje faktury w walucie, w której wystawiono fakturę - węzeł opcjonalny dla faktury zaliczkowej, faktury korygującej fakturę zaliczkową oraz faktur korygujących dotyczących wszystkich dostaw towarów lub usług dokonanych lub świadczonych w danym okresie, o których mowa w art. 106j ust. 3 ustawy, dla których należy podać dane dotyczące opustu lub obniżki w podziale na stawki podatku i procedury w części Fa. W przypadku faktur korygujących, o których mowa w art. 106j ust. 3 ustawy, gdy opust lub obniżka ceny odnosi się do części dostaw towarów lub usług dokonanych lub świadczonych w danym okresie w części FaWiersz należy podać nazwy (rodzaje) towarów lub usług objętych korektą. W przypadku faktur, o których mowa w art. 106f ust. 3 ustawy, należy wykazać pełne wartości zamówienia lub umowy. W przypadku faktur korygujących pozycje faktury (w tym faktur korygujących faktury, o których mowa w art. 106f ust. 3 ustawy, jeśli korekta dotyczy wartości zamówienia) należy wykazać różnice wynikające z korekty poszczególnych pozycji lub dane pozycji korygowanych wg stanu przed korektą i po korekcie jako osobne wiersze. W przypadku faktur korygujących faktury, o których mowa w art. 106f ust. 3 ustawy, jeśli korekta nie dotyczy wartości zamówienia i jednocześnie zmienia wysokość podstawy opodatkowania lub podatku, należy wprowadzić zapis wg stanu przed korektą i zapis wg stanu po korekcie w celu potwierdzenia braku zmiany wartości danej pozycji faktury + + + + + + Kolejny numer wiersza faktury + + + + + Uniwersalny unikalny numer wiersza faktury + + + + + Data dokonania lub zakończenia dostawy towarów lub wykonania usługi lub data otrzymania zapłaty, o której mowa w art. 106b ust. 1 pkt 4 ustawy, o ile taka data jest określona i różni się od daty wystawienia faktury. Pole wypełnia się w przypadku gdy dla poszczególnych pozycji faktury występują różne daty + + + + + Nazwa (rodzaj) towaru lub usługi. Pole opcjonalne wyłącznie dla przypadku określonego w art 106j ust. 3 pkt 2 ustawy (faktura korygująca) + + + + + Pole przeznaczone do wpisania wewnętrznego kodu towaru lub usługi nadanego przez podatnika albo dodatkowego opisu + + + + + Globalny numer jednostki handlowej + + + + + Symbol Polskiej Klasyfikacji Wyrobów i Usług + + + + + Symbol Nomenklatury Scalonej + + + + + Symbol Polskiej Klasyfikacji Obiektów Budowlanych + + + + + Miara dostarczonych towarów lub zakres wykonanych usług. Pole opcjonalne dla przypadku określonego w art. 106e ust. 5 pkt 3 ustawy + + + + + Ilość (liczba) dostarczonych towarów lub zakres wykonanych usług. Pole opcjonalne dla przypadku określonego w art. 106e ust. 5 pkt 3 ustawy + + + + + Cena jednostkowa towaru lub usługi bez kwoty podatku (cena jednostkowa netto). Pole opcjonalne dla przypadków określonych w art. 106e ust. 2 i 3 oraz ust. 5 pkt 3 ustawy + + + + + Cena wraz z kwotą podatku (cena jednostkowa brutto), w przypadku zastosowania art. 106e ust. 7 i 8 ustawy + + + + + Kwoty wszelkich opustów lub obniżek cen, w tym w formie rabatu z tytułu wcześniejszej zapłaty, o ile nie zostały one uwzględnione w cenie jednostkowej netto, a w przypadku stosowania art. 106e ust. 7 ustawy w cenie jednostkowej brutto. Pole opcjonalne dla przypadków określonych w art. 106e ust. 2 i 3 oraz ust. 5 pkt 1 ustawy + + + + + Wartość dostarczonych towarów lub wykonanych usług, objętych transakcją, bez kwoty podatku (wartość sprzedaży netto). Pole opcjonalne dla przypadków określonych w art. 106e ust. 2 i 3 oraz ust. 5 pkt 3 ustawy + + + + + Wartość sprzedaży brutto, w przypadku zastosowania art. 106e ust. 7 i 8 ustawy + + + + + Kwota podatku w przypadku, o którym mowa w art. 106e ust. 10 ustawy + + + + + Stawka podatku. Pole opcjonalne dla przypadków określonych w art. 106e ust. 2, 3, ust. 4 pkt 3 i ust. 5 pkt 3 ustawy + + + + + Stawka podatku od wartości dodanej w przypadku, o którym mowa w dziale XII w rozdziale 6a ustawy + + + + + Znacznik dla towaru lub usługi wymienionych w załączniku nr 15 do ustawy - wartość "1" + + + + + Kwota podatku akcyzowego zawarta w cenie towaru + + + + + Oznaczenie dotyczące dostawy towarów i świadczenia usług + + + + + Oznaczenie dotyczące procedury + + + + + Kurs waluty stosowany do wyliczenia kwoty podatku w przypadkach, o których mowa w dziale VI ustawy + + + + + Znacznik stanu przed korektą w przypadku faktury korygującej lub faktury korygującej fakturę wystawioną w związku z art. 106f ust. 3 ustawy, w przypadku gdy korekta dotyczy danych wykazanych w pozycjach faktury i jest dokonywana w sposób polegający na wykazaniu danych przed korektą i po korekcie jako osobnych wierszy z odrębną numeracją oraz w przypadku potwierdzania braku zmiany wartości danej pozycji + + + + + + + + Dodatkowe rozliczenia na fakturze + + + + + + Obciążenia + + + + + + Kwota doliczona do kwoty wykazanej w polu P_15 + + + + + Powód obciążenia + + + + + + + + Suma obciążeń + + + + + Odliczenia + + + + + + Kwota odliczona od kwoty wykazanej w polu P_15 + + + + + Powód odliczenia + + + + + + + + Suma odliczeń + + + + + + Kwota należności do zapłaty równa polu P_15 powiększonemu o Obciazenia i pomniejszonemu o Odliczenia + + + + + Kwota nadpłacona do rozliczenia/zwrotu + + + + + + + + + Warunki płatności + + + + + + + + Znacznik informujący, że należność wynikająca z faktury została zapłacona: 1 - zapłacono + + + + + Data zapłaty, jeśli do wystawienia faktury płatność została dokonana + + + + + + + Znacznik informujący, że należność wynikająca z faktury została zapłacona w części lub w całości: 1 - zapłacono w części; 2 - zapłacono w całości, jeśli należność wynikająca z faktury została zapłacona w dwóch lub więcej częściach, a ostatnia płatność jest płatnością końcową + + + + + Dane zapłat częściowych + + + + + + Kwota zapłaty częściowej + + + + + Data zapłaty częściowej, jeśli do wystawienia faktury płatność częściowa została dokonana + + + + + + Forma płatności + + + + + + Znacznik innej formy płatności: 1 - inna forma płatności + + + + + Uszczegółowienie innej formy płatności + + + + + + + + + + + + + + + Termin płatności + + + + + Opis terminu płatności + + + + + + + + + + + + + + + + Forma płatności + + + + + + Znacznik innej formy płatności: 1 - inna forma płatności + + + + + Uszczegółowienie innej formy płatności + + + + + + + Numer rachunku + + + + + Rachunek faktora + + + + + Skonto + + + + + + Warunki, które nabywca powinien spełnić, aby skorzystać ze skonta + + + + + Wysokość skonta + + + + + + + + Link do płatności bezgotówkowej + + + + + + + + + + Identyfikator płatności Krajowego Systemu e-Faktur + + + + + + + + + + + + + + Warunki transakcji, o ile występują + + + + + + + + + Data umowy + + + + + Numer umowy + + + + + + + + + + + Data zamówienia + + + + + Numer zamówienia + + + + + + + + Numery partii towaru + + + + + Warunki dostawy towarów - w przypadku istnienia pomiędzy stronami transakcji, umowy określającej warunki dostawy tzw. Incoterms + + + + + + Kurs umowny - w przypadkach, gdy na fakturze znajduje się informacja o kursie, po którym zostały przeliczone kwoty wykazane na fakturze w złotych. Nie dotyczy przypadków, o których mowa w dziale VI ustawy + + + + + Waluta umowna - kod waluty (ISO-4217) w przypadkach gdy na fakturze znajduje się informacja o kursie, po którym zostały przeliczone kwoty wykazane na fakturze w złotych. Nie dotyczy przypadków, o których mowa w dziale VI ustawy + + + + + + + + + + Rodzaj zastosowanego transportu w przypadku dokonanej dostawy towarów + + + + + + Znacznik innego rodzaju transportu: 1 - inny rodzaj transportu + + + + + Opis innego rodzaju transportu + + + + + + + + + + Dane identyfikacyjne przewoźnika + + + + + Adres przewoźnika + + + + + + + + Numer zlecenia transportu + + + + + + + Rodzaj ładunku + + + + + + Znacznik innego ładunku: 1 - inny ładunek + + + + + Opis innego ładunku, w tym ładunek mieszany + + + + + + + Jednostka opakowania + + + + + + + Data i godzina rozpoczęcia transportu + + + + + Data i godzina zakończenia transportu + + + + + Adres miejsca wysyłki + + + + + Adres pośredni wysyłki + + + + + Adres miejsca docelowego, do którego został zlecony transport + + + + + + + + + Wartość "1" oznacza dostawę dokonaną przez podmiot, o którym mowa w art. 22 ust. 2d ustawy. Pole dotyczy przypadku, w którym podmiot uczestniczy w transakcji łańcuchowej innej niż procedura trójstronna uproszczona, o której mowa w art. 135 ust. 1 pkt 4 ustawy + + + + + + + + Zamówienie lub umowa, o których mowa w art. 106f ust. 1 pkt 4 ustawy (dla faktur zaliczkowych), w walucie, w której wystawiono fakturę zaliczkową. W przypadku faktury korygującej fakturę zaliczkową należy wykazać różnice wynikające z korekty poszczególnych pozycji zamówienia lub umowy lub dane pozycji korygowanych wg stanu przed korektą i po korekcie jako osobne wiersze, jeśli korekta dotyczy wartości zamówienia lub umowy. W przypadku faktur korygujących faktury zaliczkowe, jeśli korekta nie dotyczy wartości zamówienia lub umowy i jednocześnie zmienia wysokość podstawy opodatkowania lub podatku, należy wprowadzić zapis wg stanu przed korektą i zapis wg stanu po korekcie w celu potwierdzenia braku zmiany wartości danej pozycji + + + + + + Wartość zamówienia lub umowy z uwzględnieniem kwoty podatku + + + + + Szczegółowe pozycje zamówienia lub umowy w walucie, w której wystawiono fakturę zaliczkową + + + + + + Kolejny numer wiersza zamówienia lub umowy + + + + + Uniwersalny unikalny numer wiersza zamówienia lub umowy + + + + + Nazwa (rodzaj) towaru lub usługi + + + + + Pole przeznaczone do wpisania wewnętrznego kodu towaru lub usługi nadanego przez podatnika albo dodatkowego opisu + + + + + Globalny numer jednostki handlowej + + + + + Symbol Polskiej Klasyfikacji Wyrobów i Usług + + + + + Symbol Nomenklatury Scalonej + + + + + Symbol Polskiej Klasyfikacji Obiektów Budowlanych + + + + + Miara zamówionego towaru lub zakres usługi + + + + + Ilość zamówionego towaru lub zakres usługi + + + + + Cena jednostkowa netto + + + + + Wartość zamówionego towaru lub usługi bez kwoty podatku + + + + + Kwota podatku od zamówionego towaru lub usługi + + + + + Stawka podatku + + + + + Stawka podatku od wartości dodanej w przypadku, o którym mowa w dziale XII w rozdziale 6a ustawy + + + + + Znacznik dla towaru lub usługi wymienionych w załączniku nr 15 do ustawy - wartość "1" + + + + + Oznaczenie dotyczące dostawy towarów i świadczenia usług + + + + + Oznaczenia dotyczące procedur + + + + + Kwota podatku akcyzowego zawarta w cenie towaru + + + + + Znacznik stanu przed korektą w przypadku faktury korygującej fakturę dokumentującą otrzymanie zapłaty lub jej części przed dokonaniem czynności oraz fakturę wystawioną w związku z art. 106f ust. 4 ustawy (faktura korygująca fakturę zaliczkową), w przypadku gdy korekta dotyczy danych wykazanych w pozycjach zamówienia i jest dokonywana w sposób polegający na wykazaniu danych przed korektą i po korekcie jako osobnych wierszy z odrębną numeracją oraz w przypadku potwierdzania braku zmiany wartości danej pozycji + + + + + + + + + + + + + + Pozostałe dane na fakturze + + + + + + Pozostałe dane + + + + + + Stopka faktury + + + + + + + + Numery podmiotu lub grupy podmiotów w innych rejestrach i bazach danych + + + + + + Pełna nazwa + + + + + KRS + + + + + REGON + + + + + BDO + + + + + + + + + + + + + + + + Załącznik do faktury VAT + + + + + + Szczegółowe dane załącznika + + + + + + Nagłówek bloku danych + + + + + Dane opisowe + + + + + + Klucz + + + + + Wartość + + + + + + + + Część tekstowa bloku danych + + + + + + Opis + + + + + + + + + Tabele + + + + + + Dane opisowe dotyczące tabeli + + + + + + Klucz + + + + + Wartość + + + + + + + + Opis + + + + + Nagłówek tabeli + + + + + + + + + Zawartość pola + + + + + + + + + + + + + + + + + + + + + + + + + + + + Wiersze tabeli + + + + + + Zawartość pola + + + + + + + + Podsumowania tabeli + + + + + + Zawartość pola + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/documents/fa3/sections.ts b/src/documents/fa3/sections.ts new file mode 100644 index 0000000..850c738 --- /dev/null +++ b/src/documents/fa3/sections.ts @@ -0,0 +1,138 @@ +import { FA3AdvancePayment } from "./types"; + +export class AdditionalDescription { + readonly key: string; + readonly value: string; + + constructor(key: string, value: string) { + this.key = key; + this.value = value; + } + + static keyValue(key: string, value: string): AdditionalDescription { + return new AdditionalDescription(key, value); + } +} + +export class Registry { + readonly kind: string; + readonly number: string; + readonly fullName: string | undefined; + + constructor(kind: string, number: string, fullName?: string) { + this.kind = kind; + this.number = number; + this.fullName = fullName; + } + + static krsEntry(number: string, fullName?: string): Registry { + return new Registry("KRS", number, fullName); + } +} + +export class Footer { + readonly text: string; + + constructor(text: string) { + this.text = text; + } +} + +export interface LineIdentifiers { + uniqueId?: string; + internalIndex?: string; + gtin?: string; + pkwiu?: string; + cn?: string; + pkob?: string; +} + +export class PaymentDue { + static date(value: string): string { + return value; + } + + static description(amount: number, unit: string, startsFrom: string): string { + return `${amount} ${unit} od ${startsFrom}`; + } +} + +export interface NewTransportMeans { + allowedDate?: string; + rowNumber?: number; + kind?: string; + mileage?: string; + serialNumber?: string; + make?: string; + model?: string; +} + +export class CorrectedAdvanceState { + readonly text: string; + constructor(text: string) { + this.text = text; + } +} + +export class ExciseRefund { + readonly enabled: boolean; + constructor(enabled = true) { + this.enabled = enabled; + } +} + +export interface OrderLine { + description: string; + quantity: number | string; + unitNetPrice: number | string; + vatRate?: number | string | null; +} + +export interface Order { + number?: string; + date?: string; + totalGross?: number | string; + lines?: OrderLine[]; +} + +export interface Contract { + number: string; + date?: string; +} + +export interface Transport { + kind: string; + orderNumber?: string; + cargoDescription?: string; + packageUnit?: string; +} + +export interface TransactionTerms { + deliveryTerms?: string; + contractualRate?: string; + contractualCurrency?: string; + intermediary?: boolean; +} + +export class AdvancePayment { + static create( + amount: number | string, + options: { + vatRate?: number | string | null; + paidOn?: string; + currencyRate?: number | string; + } = {}, + ): FA3AdvancePayment { + return { + amount, + ...(options.vatRate !== undefined ? { vatRate: options.vatRate } : {}), + ...(options.paidOn !== undefined ? { paidOn: options.paidOn } : {}), + ...(options.currencyRate !== undefined ? { currencyRate: options.currencyRate } : {}), + }; + } +} + +export interface ValidationContext { + source?: string; + rowNumber?: number; +} diff --git a/src/documents/fa3/tax.ts b/src/documents/fa3/tax.ts new file mode 100644 index 0000000..c878016 --- /dev/null +++ b/src/documents/fa3/tax.ts @@ -0,0 +1,39 @@ +import { FA3Line } from "./types"; + +function toNumber(value: number | string): number { + const parsed = typeof value === "number" ? value : Number(String(value).replace(",", ".")); + return Number.isFinite(parsed) ? parsed : 0; +} + +function money(value: number): number { + return Number(value.toFixed(2)); +} + +export interface TaxSummaryRow { + rateCode: string; + net: number; + vat: number; + gross: number; +} + +export class TaxSummary { + static fromLines(lines: FA3Line[]): TaxSummaryRow[] { + const map = new Map(); + for (const line of lines) { + const qty = toNumber(line.quantity); + const unitNet = toNumber(line.unitNetPrice); + const rate = line.vatRate === null || line.vatRate === undefined ? 0 : toNumber(line.vatRate); + const rateCode = rate === 0 ? "0 KR" : String(rate); + const net = toNumber(line.netAmount ?? qty * unitNet); + const vat = toNumber(line.vatAmount ?? (rate === 0 ? 0 : (net * rate) / 100)); + const gross = toNumber(line.grossAmount ?? net + vat); + const prev = map.get(rateCode) ?? { rateCode, net: 0, vat: 0, gross: 0 }; + prev.net = money(prev.net + net); + prev.vat = money(prev.vat + vat); + prev.gross = money(prev.gross + gross); + map.set(rateCode, prev); + } + return [...map.values()]; + } +} + diff --git a/src/documents/fa3/template.ts b/src/documents/fa3/template.ts new file mode 100644 index 0000000..7e983ca --- /dev/null +++ b/src/documents/fa3/template.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import path from "node:path"; +import { FA3BatchDraft, FA3Invoice } from "./builder"; + +export class FA3Template { + static sampleBatch(): FA3BatchDraft { + const draft = FA3Invoice.basic("FV/SAMPLE/1") + .issueDate("2026-01-15") + .seller({ + name: "Sprzedawca Sp. z o.o.", + taxId: "1234567890", + addressLine1: "ul. Prosta 1, 00-001 Warszawa", + }) + .buyer({ + name: "Nabywca Sp. z o.o.", + taxId: "1111111111", + addressLine1: "ul. Testowa 2, 00-002 Warszawa", + }) + .addLine({ + description: "Usługa", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 23, + }) + .build(); + return new FA3BatchDraft([draft]); + } + + static createJson(targetPath: string): string { + const resolved = path.resolve(targetPath); + const batch = FA3Template.sampleBatch(); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + fs.writeFileSync(resolved, batch.toJson(), "utf8"); + return resolved; + } + + static create_xlsx(): never { + throw new Error( + "Szablon XLSX nie jest jeszcze dostępny w TypeScript SDK. Użyj FA3Template.createJson(...).", + ); + } +} diff --git a/src/documents/fa3/types.ts b/src/documents/fa3/types.ts new file mode 100644 index 0000000..b7fee8c --- /dev/null +++ b/src/documents/fa3/types.ts @@ -0,0 +1,316 @@ +export type FA3InvoiceKind = + | "basic" + | "simplified" + | "correction" + | "advance" + | "settlement" + | "advance_correction" + | "settlement_correction"; + +export type FA3PaymentMethod = + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "transfer" + | "cash" + | "card" + | "voucher" + | "check" + | "credit" + | "compensation" + | "other"; + +export type FA3CorrectionType = + | "1" + | "2" + | "3" + | "tax_base_or_tax" + | "other" + | "no_tax_impact"; + +export type FA3MarginProcedure = + | "travel" + | "used_goods" + | "art" + | "collectibles"; + +export type FA3TransportKind = + | "1" + | "2" + | "3" + | "4" + | "5" + | "7" + | "8" + | "road" + | "rail" + | "air" + | "sea" + | "postal" + | "fixed_transport" + | "other"; + +export type FA3ThirdPartyRole = + | "1" + | "2" + | "recipient" + | "3" + | "payer" + | "4" + | "jst_subunit" + | "8" + | "vat_group_member" + | "10" + | "other" + | "11"; + +export interface FA3Address { + countryCode?: string; + line1: string; + line2?: string; + line3?: string; +} + +export interface FA3Contact { + email?: string; + phone?: string; +} + +export type FA3PartyIdentifierKind = "NIP" | "EU_VAT" | "FOREIGN" | "INTERNAL" | "NONE"; + +export interface FA3PartyIdentifier { + kind: FA3PartyIdentifierKind; + value?: string; + countryCode?: string; +} + +export interface FA3Party { + name: string; + taxId: string; + /** Jawny identyfikator strony (jak PartyIdentifier w Python SDK). Gdy brak — wywnioskowany z taxId. */ + identifier?: FA3PartyIdentifier; + countryCode?: string; + internalId?: string; + eori?: string; + buyerId?: string; + customerNumber?: string; + addressLine1?: string; + addressLine2?: string; + addressLine3?: string; + email?: string; + phone?: string; + role?: FA3ThirdPartyRole; + otherRoleDescription?: string; + share?: number | string; + isJstSubunit?: boolean; + isVatGroupMember?: boolean; +} + +export interface FA3TaxCategory { + code?: + | "23" + | "22" + | "8" + | "7" + | "5" + | "4" + | "3" + | "0 KR" + | "0 WDT" + | "0 EX" + | "zw" + | "np I" + | "np II" + | "oo"; + vatRate?: number | string | null; + exemptionBasis?: string; + exemptionBasisType?: "law" | "directive" | "other"; +} + +export interface FA3Discount { + kind: "amount" | "percent"; + value: number | string; + reason?: string; +} + +export interface FA3Line { + description: string; + quantity: number | string; + unit: string; + unitNetPrice: number | string; + vatRate?: number | string | null; + netAmount?: number | string; + vatAmount?: number | string; + grossAmount?: number | string; + discount?: FA3Discount; + beforeCorrection?: boolean; + uniqueId?: string; + serviceDate?: string; + periodFrom?: string; + periodTo?: string; + gtu?: string; + procedure?: string; + annex15?: boolean; +} + +export interface FA3AdvanceReference { + invoiceNumber?: string; + ksefNumber?: string; +} + +export interface FA3AdvancePayment { + amount: number | string; + vatRate?: number | string | null; + paidOn?: string; + currencyRate?: number | string; +} + +export interface FA3SettlementAdjustment { + amount: number | string; + reason: string; +} + +export interface FA3Settlement { + amountDue?: number | string; + amountToSettle?: number | string; + charges?: FA3SettlementAdjustment[]; + deductions?: FA3SettlementAdjustment[]; +} + +export interface FA3PartialPayment { + amount: number | string; + paidOn: string; + method?: FA3PaymentMethod; + otherMethodDescription?: string; +} + +export interface FA3BankAccount { + number: string; + swift?: string; + bankName?: string; + description?: string; + ownBankAccountType?: "1" | "2" | "3"; +} + +export interface FA3PaymentDueDescription { + amount: number | string; + unit: string; + startsFrom: string; +} + +export interface FA3PaymentTerms { + dueDate?: string; + dueDescription?: string; + dueDescriptionParts?: FA3PaymentDueDescription; + method?: FA3PaymentMethod; + otherMethodDescription?: string; + paidDate?: string; + partialPayments?: FA3PartialPayment[]; + bankAccounts?: FA3BankAccount[]; + factorBankAccounts?: FA3BankAccount[]; + paymentLink?: string; + ipksef?: string; +} + +export interface FA3ValidationIssue { + code: string; + message: string; + path?: string; +} + +export interface FA3ContractSection { + number: string; + date?: string; +} + +export interface FA3OrderLineSection { + description: string; + quantity: number | string; + unitNetPrice: number | string; + vatRate?: number | string | null; +} + +export interface FA3OrderSection { + number?: string; + date?: string; + totalGross?: number | string; + lines?: FA3OrderLineSection[]; +} + +export interface FA3TransportSection { + kind: string; + orderNumber?: string; + cargoDescription?: string; + packageUnit?: string; +} + +export interface FA3TransactionTermsSection { + deliveryTerms?: string; + contractualRate?: string; + contractualCurrency?: string; + intermediary?: boolean; +} + +export interface FA3AdditionalDescriptionSection { + key: string; + value: string; +} + +export interface FA3AttachmentTableSection { + headers: string[]; + rows: string[][]; +} + +export interface FA3AttachmentBlockSection { + header?: string; + paragraphs?: string[]; + tables?: FA3AttachmentTableSection[]; +} + +export interface FA3AttachmentSection { + blocks: FA3AttachmentBlockSection[]; +} + +export interface FA3DraftInput { + invoiceNumber: string; + issueDate: string; + seller: FA3Party; + buyer: FA3Party; + lines: FA3Line[]; + kind?: FA3InvoiceKind; + currency?: string; + issuePlace?: string; + saleDate?: string; + periodFrom?: string; + periodTo?: string; + correctionReason?: string; + correctionType?: FA3CorrectionType | string; + correctedInvoiceNumber?: string; + correctedInvoiceDate?: string; + correctedKsefNumber?: string; + correctedPeriod?: string; + correctedInvoiceNumberOverride?: string; + advanceInvoiceNumber?: string; + advanceKsefNumber?: string; + settlementAmount?: number | string; + settlement?: FA3Settlement; + paymentTerms?: FA3PaymentTerms; + advancePayments?: FA3AdvancePayment[]; + additionalParties?: FA3Party[]; + marginProcedure?: FA3MarginProcedure; + transportKind?: FA3TransportKind; + foreignCurrencyRate?: number | string; + simplifiedReceiptLike?: boolean; + rawExtensions?: Array<{ path: string; xml: string }>; + attachmentText?: string; + contract?: FA3ContractSection; + order?: FA3OrderSection; + transport?: FA3TransportSection; + transactionTerms?: FA3TransactionTermsSection; + additionalDescriptions?: FA3AdditionalDescriptionSection[]; + attachment?: FA3AttachmentSection; +} diff --git a/src/documents/fa3/xml.ts b/src/documents/fa3/xml.ts new file mode 100644 index 0000000..8347bdf --- /dev/null +++ b/src/documents/fa3/xml.ts @@ -0,0 +1,39 @@ +import { KsefError } from "../../errors/errors"; +import { validateFa3XmlXsd } from "./xsd"; +import type { FA3Draft } from "./builder"; + +export class FA3XmlValidationError extends KsefError { + constructor(message: string) { + super(message); + this.name = "FA3XmlValidationError"; + } +} + +export function toFa3XmlValidationMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export async function validateFa3XmlWithValidator( + xml: string, + validator: (value: string) => Promise, +): Promise { + try { + await validator(xml); + } catch (error) { + throw new FA3XmlValidationError(toFa3XmlValidationMessage(error)); + } +} + +export async function validateFa3Xml(xml: string): Promise { + return validateFa3XmlWithValidator(xml, validateFa3XmlXsd); +} + +export async function invoiceToXml( + draft: FA3Draft, + options: { pretty?: boolean; xsdValidate?: boolean } = {}, +): Promise { + return draft.toXml(options); +} + +export const invoice_to_xml = invoiceToXml; +export const validate_fa3_xml_xsd = validateFa3XmlXsd; diff --git a/src/documents/fa3/xsd.ts b/src/documents/fa3/xsd.ts new file mode 100644 index 0000000..aa9f671 --- /dev/null +++ b/src/documents/fa3/xsd.ts @@ -0,0 +1,93 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { KsefError } from "../../errors/errors"; + +const SCHEMA_FILE = "schemat_FA(3)_v1-0E.xsd"; +const SCHEMA_IMPORT = "http://crd.gov.pl/xml/schematy/dziedzinowe/mf/2022/01/05/eD/DefinicjeTypy/StrukturyDanych_v10-0E.xsd"; + +function moduleDir(): string { + return path.dirname(fileURLToPath(import.meta.url)); +} + +export function resolveFa3SchemaEntryPath(candidates?: string[]): string { + const localDir = moduleDir(); + const searchPaths = candidates ?? [ + path.join(localDir, "schemas", SCHEMA_FILE), + path.join(localDir, "documents", "fa3", "schemas", SCHEMA_FILE), + path.resolve(process.cwd(), "dist", "documents", "fa3", "schemas", SCHEMA_FILE), + path.resolve(process.cwd(), "src", "documents", "fa3", "schemas", SCHEMA_FILE), + ]; + const schemaPath = searchPaths.find((candidate) => fs.existsSync(candidate)); + if (!schemaPath) { + throw new KsefError(`Missing FA(3) schema file. Checked: ${searchPaths.join(", ")}`); + } + return schemaPath; +} + +export function loadFa3SchemaWithLocalImports(): { schemaContent: string; schemaBaseUrl: string; schemaPath: string } { + const schemaPath = resolveFa3SchemaEntryPath(); + const schemaBaseDir = path.dirname(schemaPath); + const bazowePath = path.join(schemaBaseDir, "bazowe", "StrukturyDanych_v10-0E.xsd"); + const bazoweUrl = pathToFileURL(bazowePath).href; + const raw = fs.readFileSync(schemaPath, "utf8"); + const schemaContent = raw.replace( + new RegExp(`schemaLocation="${SCHEMA_IMPORT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`, "g"), + `schemaLocation="${bazoweUrl}"`, + ); + return { + schemaContent, + schemaBaseUrl: pathToFileURL(`${schemaBaseDir}${path.sep}`).href, + schemaPath, + }; +} + +type Fa3XmlParser = ( + value: string, + options?: { baseUrl?: string }, +) => { + validate: (schema: unknown) => boolean; + validationErrors?: Array<{ message?: string }>; +}; + +export async function validateFa3XmlWithParser(xml: string, parseXml: Fa3XmlParser): Promise { + const { schemaContent, schemaBaseUrl } = loadFa3SchemaWithLocalImports(); + const schemaDoc = parseXml(schemaContent, { baseUrl: schemaBaseUrl }); + const xmlDoc = parseXml(xml); + const valid = xmlDoc.validate(schemaDoc); + if (!valid) { + const errors = (xmlDoc.validationErrors ?? []) + .map((err: { message?: string }) => String(err?.message ?? "").trim()) + .filter(Boolean); + throw new KsefError( + errors.length > 0 ? `FA(3) XSD validation failed: ${errors.join(" | ")}` : "FA(3) XSD validation failed.", + ); + } +} + +export type Libxml = { parseXml: Fa3XmlParser }; + +export async function loadLibxml(): Promise { + // Indirect specifier keeps `libxmljs2` a runtime-only optional dependency: + // it must not be resolved at compile time (typecheck / dts build) because + // the native module is frequently absent (skipped optional install). + const moduleName: string = "libxmljs2"; + return (await import(moduleName)) as unknown as Libxml; +} + +export async function validateFa3XmlXsd( + xml: string, + loadModule: () => Promise = loadLibxml, +): Promise { + let parseXml: Fa3XmlParser; + try { + const imported = await loadModule(); + parseXml = imported.parseXml; + } catch { + throw new KsefError( + "FA(3) XSD validation requires optional dependency `libxmljs2`. Install with `npm install libxmljs2` and retry.", + ); + } + + await validateFa3XmlWithParser(xml, parseXml); +} diff --git a/src/documents/fa3/xsdAudit.ts b/src/documents/fa3/xsdAudit.ts new file mode 100644 index 0000000..19b736b --- /dev/null +++ b/src/documents/fa3/xsdAudit.ts @@ -0,0 +1,190 @@ +import { parseFa3XsdElements, type XsdElement } from "./xsdMap"; +import { loadFa3SchemaWithLocalImports } from "./xsd"; + +export const CoverageStatus = { + SUPPORTED: "supported", + PARTIALLY_SUPPORTED: "partially_supported", + RAW_EXTENSION: "raw_extension", + UNSUPPORTED: "unsupported", +} as const; + +export type CoverageStatusValue = (typeof CoverageStatus)[keyof typeof CoverageStatus]; + +export class XsdCoverageEntry { + readonly path: string; + readonly status: CoverageStatusValue; + readonly note: string; + readonly domainField: string | undefined; + readonly handler: string | undefined; + + constructor(value: { + path: string; + status: CoverageStatusValue; + note: string; + domainField?: string; + handler?: string; + }) { + this.path = value.path; + this.status = value.status; + this.note = value.note; + this.domainField = value.domainField; + this.handler = value.handler; + } +} + +export class XsdCoverageReport { + readonly elements: XsdElement[]; + readonly coverage: XsdCoverageEntry[]; + + constructor(elements: XsdElement[], coverage: XsdCoverageEntry[]) { + this.elements = elements; + this.coverage = coverage; + } +} + +const SUPPORTED_PATHS = new Set([ + "/Faktura", + "/Faktura/Naglowek", + "/Faktura/Naglowek/KodFormularza", + "/Faktura/Naglowek/WariantFormularza", + "/Faktura/Naglowek/DataWytworzeniaFa", + "/Faktura/Naglowek/SystemInfo", + "/Faktura/Podmiot1", + "/Faktura/Podmiot1/DaneIdentyfikacyjne", + "/Faktura/Podmiot1/DaneIdentyfikacyjne/NIP", + "/Faktura/Podmiot1/DaneIdentyfikacyjne/Nazwa", + "/Faktura/Podmiot1/Adres", + "/Faktura/Podmiot1/DaneKontaktowe", + "/Faktura/Podmiot2", + "/Faktura/Podmiot2/DaneIdentyfikacyjne", + "/Faktura/Podmiot2/Adres", + "/Faktura/Podmiot2/DaneKontaktowe", + "/Faktura/Podmiot2/JST", + "/Faktura/Podmiot2/GV", + "/Faktura/Podmiot3", + "/Faktura/Podmiot3/DaneIdentyfikacyjne", + "/Faktura/Podmiot3/Adres", + "/Faktura/Podmiot3/DaneKontaktowe", + "/Faktura/Podmiot3/Rola", + "/Faktura/Podmiot3/RolaInna", + "/Faktura/Podmiot3/OpisRoli", + "/Faktura/Podmiot3/Udzial", + "/Faktura/Fa", + "/Faktura/Fa/KodWaluty", + "/Faktura/Fa/P_1", + "/Faktura/Fa/P_1M", + "/Faktura/Fa/P_2", + "/Faktura/Fa/P_6", + "/Faktura/Fa/OkresFa", + "/Faktura/Fa/P_13_1", + "/Faktura/Fa/P_14_1", + "/Faktura/Fa/P_15", + "/Faktura/Fa/KursWalutyZ", + "/Faktura/Fa/RodzajFaktury", + "/Faktura/Fa/PrzyczynaKorekty", + "/Faktura/Fa/TypKorekty", + "/Faktura/Fa/DaneFaKorygowanej", + "/Faktura/Fa/OkresFaKorygowanej", + "/Faktura/Fa/NrFaKorygowany", + "/Faktura/Fa/ZaliczkaCzesciowa", + "/Faktura/Fa/DodatkowyOpis", + "/Faktura/Fa/FakturaZaliczkowa", + "/Faktura/Fa/FaWiersz", + "/Faktura/Fa/FaWiersze", + "/Faktura/Fa/Rozliczenie", + "/Faktura/Fa/Platnosc", +]); + +const PARTIALLY_SUPPORTED_PREFIXES = [ + "/Faktura/Fa/WarunkiTransakcji", + "/Faktura/Fa/Transport", + "/Faktura/Fa/Zamowienie", + "/Faktura/Zalacznik", + "/Faktura/Stopka", +]; + +const SUPPORTED_PREFIXES = [ + "/Faktura/Fa/FaWiersz/", + "/Faktura/Fa/FaWiersze/", + "/Faktura/Fa/Rozliczenie/", + "/Faktura/Fa/Platnosc/", + "/Faktura/Fa/DodatkowyOpis/", + "/Faktura/Fa/ZaliczkaCzesciowa/", + "/Faktura/Fa/FakturaZaliczkowa/", + "/Faktura/Fa/DaneFaKorygowanej/", +]; + +function coverageStatus(pathValue: string): CoverageStatusValue { + if (SUPPORTED_PATHS.has(pathValue) || SUPPORTED_PREFIXES.some((prefix) => pathValue.startsWith(prefix))) { + return CoverageStatus.SUPPORTED; + } + if (PARTIALLY_SUPPORTED_PREFIXES.some((prefix) => pathValue === prefix || pathValue.startsWith(`${prefix}/`))) { + return CoverageStatus.PARTIALLY_SUPPORTED; + } + if (pathValue.includes("RawXmlExtension")) { + return CoverageStatus.RAW_EXTENSION; + } + return CoverageStatus.UNSUPPORTED; +} + +function coverageNote(status: CoverageStatusValue): string { + if (status === CoverageStatus.SUPPORTED) { + return "covered by typed FA(3) builder or serializer"; + } + if (status === CoverageStatus.PARTIALLY_SUPPORTED) { + return "covered for a documented subset of fields"; + } + if (status === CoverageStatus.RAW_EXTENSION) { + return "reserved for raw XML extension handling"; + } + return "not exposed by the typed FA(3) contract"; +} + +function handlerName(pathValue: string, status: CoverageStatusValue): string | undefined { + if (status === CoverageStatus.UNSUPPORTED) { + return undefined; + } + if (pathValue.startsWith("/Faktura/Fa/Platnosc")) { + return "mapPaymentTerms"; + } + if (pathValue.startsWith("/Faktura/Fa/FaWiersz")) { + return "mapLine"; + } + if (pathValue.startsWith("/Faktura/Fa/Zamowienie")) { + return "mapOrder"; + } + if (pathValue.startsWith("/Faktura/Fa/Rozliczenie")) { + return "mapSettlement"; + } + if (pathValue.startsWith("/Faktura/Fa/Transport")) { + return "mapTransport"; + } + if (pathValue.startsWith("/Faktura/Podmiot")) { + return "mapParty"; + } + return "FA3Draft.toFakturaInput"; +} + +function domainField(pathValue: string): string { + return `invoice.${pathValue.replace(/^\/Faktura\/?/, "").replaceAll("/", ".")}`; +} + +export function auditFa3XsdCoverage(options: { elements?: XsdElement[] } = {}): XsdCoverageReport { + const elements = + options.elements ?? + parseFa3XsdElements(loadFa3SchemaWithLocalImports().schemaContent); + const coverage = elements.map((element) => { + const status = coverageStatus(element.path); + const handler = handlerName(element.path, status); + return new XsdCoverageEntry({ + path: element.path, + status, + note: coverageNote(status), + ...(status === CoverageStatus.UNSUPPORTED ? {} : { domainField: domainField(element.path) }), + ...(handler ? { handler } : {}), + }); + }); + return new XsdCoverageReport(elements, coverage); +} + +export const audit_fa3_xsd_coverage = auditFa3XsdCoverage; diff --git a/src/documents/fa3/xsdMap.ts b/src/documents/fa3/xsdMap.ts new file mode 100644 index 0000000..876f125 --- /dev/null +++ b/src/documents/fa3/xsdMap.ts @@ -0,0 +1,174 @@ +import { DOMParser } from "@xmldom/xmldom"; + +interface XmlNodeListLike { + length: number; + item(index: number): XmlLikeNode | null; +} + +interface XmlLikeNode { + nodeName: string; + nodeType: number; + ELEMENT_NODE: number; + childNodes: XmlNodeListLike; + getAttribute?(name: string): string | null; + getElementsByTagNameNS?(namespace: string, localName: string): XmlNodeListLike; +} + +type XmlLikeElement = XmlLikeNode & { + getAttribute(name: string): string | null; + getElementsByTagNameNS(namespace: string, localName: string): XmlNodeListLike; +}; + +export class XsdElement { + readonly path: string; + readonly name: string; + readonly typeName: string | undefined; + readonly minOccurs: string; + readonly maxOccurs: string; + readonly choices: number; + readonly enumValues: string[]; + + constructor(value: { + path: string; + name: string; + typeName?: string; + minOccurs?: string; + maxOccurs?: string; + choices?: number; + enumValues?: string[]; + }) { + this.path = value.path; + this.name = value.name; + this.typeName = value.typeName; + this.minOccurs = value.minOccurs ?? "1"; + this.maxOccurs = value.maxOccurs ?? "1"; + this.choices = value.choices ?? 0; + this.enumValues = [...(value.enumValues ?? [])]; + } +} + +function localName(nodeName: string): string { + const index = nodeName.indexOf(":"); + return index >= 0 ? nodeName.slice(index + 1) : nodeName; +} + +function asElement(node: XmlLikeNode | null): XmlLikeElement | null { + if (!node || node.nodeType !== node.ELEMENT_NODE) { + return null; + } + return node as XmlLikeElement; +} + +function elementChildren(node: XmlLikeNode): XmlLikeElement[] { + const out: XmlLikeElement[] = []; + for (let i = 0; i < node.childNodes.length; i += 1) { + const child = asElement(node.childNodes.item(i)); + if (child) { + out.push(child); + } + } + return out; +} + +function walkSimpleTypeEnums(root: XmlLikeElement): Map { + const enums = new Map(); + const simpleTypes = root.getElementsByTagNameNS("*", "simpleType"); + for (let i = 0; i < simpleTypes.length; i += 1) { + const simpleType = simpleTypes.item(i) as XmlLikeElement; + const name = simpleType.getAttribute("name"); + if (!name) { + continue; + } + const values: string[] = []; + const enumNodes = simpleType.getElementsByTagNameNS("*", "enumeration"); + for (let enumIndex = 0; enumIndex < enumNodes.length; enumIndex += 1) { + const enumNode = asElement(enumNodes.item(enumIndex)); + const value = enumNode?.getAttribute("value"); + if (value) { + values.push(value); + } + } + if (values.length > 0) { + enums.set(name, values); + } + } + return enums; +} + +function countChoices(node: XmlLikeElement): number { + return node.getElementsByTagNameNS("*", "choice").length; +} + +function walkParticles( + node: XmlLikeElement, + path: string, + out: XsdElement[], + enums: Map, +): void { + for (const child of elementChildren(node)) { + const childLocalName = localName(child.nodeName); + if (childLocalName === "element") { + const name = child.getAttribute("name"); + if (name) { + walkElement(child, `${path}/${name}`, out, enums); + } + continue; + } + if (childLocalName === "sequence" || childLocalName === "choice" || childLocalName === "all") { + walkParticles(child, path, out, enums); + } + } +} + +function walkElement( + node: XmlLikeElement, + path: string, + out: XsdElement[], + enums: Map, +): void { + const typeName = node.getAttribute("type") || undefined; + const localTypeName = typeName ? localName(typeName) : undefined; + out.push( + new XsdElement({ + path, + name: path.slice(path.lastIndexOf("/") + 1), + ...(typeName ? { typeName } : {}), + minOccurs: node.getAttribute("minOccurs") || "1", + maxOccurs: node.getAttribute("maxOccurs") || "1", + choices: countChoices(node), + enumValues: localTypeName ? enums.get(localTypeName) ?? [] : [], + }), + ); + + for (const child of elementChildren(node)) { + if (localName(child.nodeName) === "complexType") { + walkParticles(child, path, out, enums); + } + } +} + +export function parseFa3XsdElements(schemaText: string): XsdElement[] { + const doc = new DOMParser().parseFromString(schemaText, "text/xml"); + const root = doc?.documentElement; + if (!root) { + return []; + } + const enums = walkSimpleTypeEnums(root); + const allElements = root.getElementsByTagNameNS("*", "element"); + let fakturaElement: XmlLikeElement | null = null; + for (let i = 0; i < allElements.length; i += 1) { + const candidate = asElement(allElements.item(i)); + if (candidate?.getAttribute("name") === "Faktura") { + fakturaElement = candidate; + break; + } + } + if (!fakturaElement) { + return []; + } + const out: XsdElement[] = []; + walkElement(fakturaElement, "/Faktura", out, enums); + return out; +} + +export const parse_fa3_xsd_elements = parseFa3XsdElements; diff --git a/src/documents/index.ts b/src/documents/index.ts new file mode 100644 index 0000000..898c734 --- /dev/null +++ b/src/documents/index.ts @@ -0,0 +1,2 @@ +export * as fa3 from "./fa3"; +export * from "./fa3"; diff --git a/src/index.ts b/src/index.ts index c61e40a..03ab0d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,5 +25,6 @@ export * from "./xml/xml"; export * from "./xml/invoice"; export * from "./xml/pef"; export * from "./xml/upo"; +export * from "./documents"; export * from "./types"; export * as OpenApiModels from "./types/openapi.generated"; diff --git a/src/services/batchSessionWorkflow.ts b/src/services/batchSessionWorkflow.ts index a8e574a..3e70676 100644 --- a/src/services/batchSessionWorkflow.ts +++ b/src/services/batchSessionWorkflow.ts @@ -29,28 +29,114 @@ export interface BatchSessionOpenOptions { maxPartSizeBytes?: number; } +export interface BatchSessionState { + referenceNumber: string; + encryptionData: EncryptionData; + batchFile: { + fileSize: number; + fileHash: string; + fileParts: Array<{ + ordinalNumber: number; + fileSize: number; + fileHash: string; + }>; + }; + partUploadRequests: PartUploadRequest[]; + encryptedPartsBase64: string[]; + upoV43?: boolean; + offlineMode?: boolean; +} + +export interface BatchUploadOptions { + parallelism?: number; + skipOrdinals?: number[]; + progressCallback?: (ordinalNumber: number) => void | Promise; +} + export class BatchSessionHandle { readonly referenceNumber: string; readonly encryptionData: EncryptionData; + readonly upoV43: boolean; + readonly offlineMode: boolean | undefined; + readonly batchFile: { + fileSize: number; + fileHash: string; + fileParts: Array<{ + ordinalNumber: number; + fileSize: number; + fileHash: string; + }>; + }; private readonly sessionsClient: SessionsClient; private readonly http: HttpClient; + private readonly partUploadRequests: PartUploadRequest[]; + private readonly encryptedParts: Buffer[]; constructor( referenceNumber: string, encryptionData: EncryptionData, sessionsClient: SessionsClient, http: HttpClient, + batchFile = { fileSize: 0, fileHash: "", fileParts: [] as Array<{ ordinalNumber: number; fileSize: number; fileHash: string }> }, + partUploadRequests: PartUploadRequest[] = [], + encryptedParts: Buffer[] = [], + upoV43 = false, + offlineMode?: boolean, ) { this.referenceNumber = referenceNumber; this.encryptionData = encryptionData; this.sessionsClient = sessionsClient; this.http = http; + this.batchFile = batchFile; + this.partUploadRequests = partUploadRequests; + this.encryptedParts = encryptedParts; + this.upoV43 = upoV43; + this.offlineMode = offlineMode; + } + + getState(): BatchSessionState { + return { + referenceNumber: this.referenceNumber, + encryptionData: { + cipherKey: Buffer.from(this.encryptionData.cipherKey), + cipherIv: Buffer.from(this.encryptionData.cipherIv), + encryptionInfo: { ...this.encryptionData.encryptionInfo }, + }, + batchFile: { + fileSize: this.batchFile.fileSize, + fileHash: this.batchFile.fileHash, + fileParts: this.batchFile.fileParts.map((part) => ({ ...part })), + }, + partUploadRequests: this.partUploadRequests.map((part) => ({ + ...part, + headers: { ...(part.headers ?? {}) }, + })), + encryptedPartsBase64: this.encryptedParts.map((part) => part.toString("base64")), + upoV43: this.upoV43, + ...(this.offlineMode !== undefined ? { offlineMode: this.offlineMode } : {}), + }; } async status(): Promise { return await this.sessionsClient.getSessionStatus(this.referenceNumber); } + async uploadParts(parallelismOrOptions: number | BatchUploadOptions = 1): Promise { + const options = + typeof parallelismOrOptions === "number" + ? { parallelism: parallelismOrOptions } + : parallelismOrOptions; + await uploadParts(this.http, this.partUploadRequests, this.encryptedParts, { + parallelism: options.parallelism ?? 1, + skipOrdinals: options.skipOrdinals ?? [], + ...(options.progressCallback ? { progressCallback: options.progressCallback } : {}), + }); + } + + async close(): Promise { + await this.sessionsClient.closeBatchSession(this.referenceNumber); + } + async waitForUpo(options: WaitForUpoOptions = {}): Promise { const pollIntervalMs = options.pollIntervalMs ?? 2000; const maxAttempts = options.maxAttempts ?? 120; @@ -85,6 +171,17 @@ export class BatchSessionHandle { } return parseUpoXml(xml); } + + async listFailedInvoices( + pageSize?: number, + continuationToken?: string, + ): Promise> { + return await this.sessionsClient.getSessionFailedInvoices( + this.referenceNumber, + pageSize, + continuationToken, + ); + } } export class BatchSessionWorkflow { @@ -98,7 +195,7 @@ export class BatchSessionWorkflow { this.http = http; } - async openUploadAndClose(options: BatchSessionOpenOptions): Promise { + async open(options: BatchSessionOpenOptions): Promise { if (!options.formCode) { throw new KsefValidationError("formCode is required."); } @@ -125,20 +222,71 @@ export class BatchSessionWorkflow { const response = await this.sessionsClient.openBatchSession(requestPayload, options.upoV43); - await uploadParts( + return new BatchSessionHandle( + response.referenceNumber, + encryption, + this.sessionsClient, this.http, + batchFile, response.partUploadRequests, encryptedParts, - options.parallelism ?? 1, + Boolean(options.upoV43), + options.offlineMode, ); + } + + async openUploadAndClose(options: BatchSessionOpenOptions): Promise { + const handle = await this.open(options); + await handle.uploadParts(options.parallelism ?? 1); + await handle.close(); + return handle; + } + + async resume(state: BatchSessionState, options: { zipBytes: Buffer }): Promise { + if (!state || typeof state.referenceNumber !== "string" || !state.referenceNumber.trim()) { + throw new KsefValidationError("Batch session state requires non-empty referenceNumber."); + } + if (!options?.zipBytes || options.zipBytes.length === 0) { + throw new KsefValidationError("Batch session resume requires zipBytes."); + } + validateEncryptionData(state.encryptionData); + const resolvedBatchFile = state.batchFile; + if (!resolvedBatchFile || typeof resolvedBatchFile.fileHash !== "string") { + throw new KsefValidationError("Batch session state requires batchFile metadata."); + } + const zipHash = CryptographyService.sha256Base64(options.zipBytes); + if (zipHash !== resolvedBatchFile.fileHash) { + throw new KsefValidationError("Batch session resume zipBytes hash does not match saved state."); + } + if (options.zipBytes.length !== resolvedBatchFile.fileSize) { + throw new KsefValidationError("Batch session resume zipBytes size does not match saved state."); + } - await this.sessionsClient.closeBatchSession(response.referenceNumber); + let encryptedParts: Buffer[] = []; + if (Array.isArray(state.encryptedPartsBase64) && state.encryptedPartsBase64.length > 0) { + encryptedParts = state.encryptedPartsBase64.map((part) => Buffer.from(part, "base64")); + } else { + const partSizes = resolvedBatchFile.fileParts + .map((part) => part.fileSize) + .filter((size) => Number.isInteger(size) && size > 0); + const maxPartSizeBytes = + partSizes.length > 0 ? Math.max(...partSizes) : MAX_BATCH_PART_SIZE_BYTES; + const parts = splitBuffer(options.zipBytes, maxPartSizeBytes); + encryptedParts = parts.map((part) => + CryptographyService.encryptAes256Cbc(part, state.encryptionData.cipherKey, state.encryptionData.cipherIv), + ); + } return new BatchSessionHandle( - response.referenceNumber, - encryption, + state.referenceNumber, + state.encryptionData, this.sessionsClient, this.http, + resolvedBatchFile, + state.partUploadRequests ?? [], + encryptedParts, + Boolean(state.upoV43), + state.offlineMode, ); } @@ -168,6 +316,14 @@ export class BatchSessionWorkflow { } } +function validateEncryptionData(value: EncryptionData): void { + const hasKey = Buffer.isBuffer(value?.cipherKey) && value.cipherKey.length > 0; + const hasIv = Buffer.isBuffer(value?.cipherIv) && value.cipherIv.length > 0; + if (!hasKey || !hasIv) { + throw new KsefValidationError("Batch session state requires cipherKey and cipherIv."); + } +} + function buildBatchFileInfo(zipBytes: Buffer, encryptedParts: Buffer[]) { const zipHash = CryptographyService.sha256Base64(zipBytes); const fileParts = encryptedParts.map((part, index) => ({ @@ -186,18 +342,33 @@ async function uploadParts( http: HttpClient, partUploadRequests: PartUploadRequest[], parts: Buffer[], - parallelism: number, + options: { + parallelism: number; + skipOrdinals: number[]; + progressCallback?: (ordinalNumber: number) => void | Promise; + }, ): Promise { const sortedRequests = [...partUploadRequests].sort((a, b) => a.ordinalNumber - b.ordinalNumber); if (sortedRequests.length !== parts.length) { throw new KsefValidationError("parts length must match partUploadRequests length."); } - const tasks = sortedRequests.map((request, index) => async () => { + const partByOrdinal = new Map(); + for (const [index, request] of sortedRequests.entries()) { const part = parts[index]; if (!part) { throw new KsefValidationError(`Missing batch part at index ${index}.`); } + partByOrdinal.set(request.ordinalNumber, part); + } + + const skip = new Set(options.skipOrdinals); + const activeRequests = sortedRequests.filter((request) => !skip.has(request.ordinalNumber)); + const tasks = activeRequests.map((request) => async () => { + const part = partByOrdinal.get(request.ordinalNumber); + if (!part) { + throw new KsefValidationError(`Missing batch part for ordinal ${request.ordinalNumber}.`); + } const headers: Record = {}; for (const [key, value] of Object.entries(request.headers ?? {})) { if (value) { @@ -211,9 +382,12 @@ async function uploadParts( body: part, responseType: "text", }); + if (options.progressCallback) { + await options.progressCallback(request.ordinalNumber); + } }); - await runWithConcurrency(tasks, parallelism); + await runWithConcurrency(tasks, options.parallelism); } async function runWithConcurrency( diff --git a/src/services/hwmCoordinator.ts b/src/services/hwmCoordinator.ts index 7314860..693ba70 100644 --- a/src/services/hwmCoordinator.ts +++ b/src/services/hwmCoordinator.ts @@ -10,7 +10,15 @@ export function updateContinuationPoint( lastPermanentStorageDate?: string | null; permanentStorageHwmDate?: string | null; }, + options: { + dateType?: string; + } = {}, ): void { + const dateType = options.dateType?.trim().toLowerCase(); + if (dateType && dateType !== "permanentstorage") { + delete continuationPoints[subjectType]; + return; + } const isTruncated = Boolean(packageInfo.isTruncated); const lastPermanentStorageDate = packageInfo.lastPermanentStorageDate ?? undefined; const hwmDate = packageInfo.permanentStorageHwmDate ?? undefined; diff --git a/src/services/incrementalExportWorkflow.ts b/src/services/incrementalExportWorkflow.ts index b9dd6b7..b4b4a4b 100644 --- a/src/services/incrementalExportWorkflow.ts +++ b/src/services/incrementalExportWorkflow.ts @@ -99,6 +99,9 @@ export class IncrementalExportWorkflow { options.continuationPoints, options.subjectType, status.package ?? {}, + { + dateType: filters.dateRange?.dateType, + }, ); const nextFrom = getEffectiveStartDate( diff --git a/src/services/invoiceExportWorkflow.ts b/src/services/invoiceExportWorkflow.ts index 42c5a7a..52b4147 100644 --- a/src/services/invoiceExportWorkflow.ts +++ b/src/services/invoiceExportWorkflow.ts @@ -118,7 +118,14 @@ export class InvoiceExportWorkflow { encryptionData: EncryptionData, options: DownloadPackageOptions = {}, ): Promise { - const parts = status.package?.parts ?? []; + const packageInfo = status.package ?? undefined; + if (!packageInfo || packageInfo.invoiceCount === 0) { + return { metadataSummaries: [], invoiceXmlFiles: {} }; + } + const parts = packageInfo.parts ?? []; + if (parts.length === 0) { + return { metadataSummaries: [], invoiceXmlFiles: {} }; + } const verifyHashes = resolveRequireExportPartHash(options, this.requireExportPartHash); const encryptedParts = await this.downloadParts(parts, verifyHashes); const decryptedParts = encryptedParts.map((part) => diff --git a/src/services/onlineSessionWorkflow.ts b/src/services/onlineSessionWorkflow.ts index bea0d89..7d73bfe 100644 --- a/src/services/onlineSessionWorkflow.ts +++ b/src/services/onlineSessionWorkflow.ts @@ -21,9 +21,16 @@ export interface OnlineInvoiceSendOptions { hashOfCorrectedInvoice?: string; } +export interface OnlineSessionState { + referenceNumber: string; + encryptionData: EncryptionData; + upoV43?: boolean; +} + export class OnlineSessionHandle { readonly referenceNumber: string; readonly encryptionData: EncryptionData; + readonly upoV43: boolean; private readonly sessionsClient: SessionsClient; private readonly http: HttpClient; @@ -32,11 +39,25 @@ export class OnlineSessionHandle { encryptionData: EncryptionData, sessionsClient: SessionsClient, http: HttpClient, + upoV43 = false, ) { this.referenceNumber = referenceNumber; this.encryptionData = encryptionData; this.sessionsClient = sessionsClient; this.http = http; + this.upoV43 = upoV43; + } + + getState(): OnlineSessionState { + return { + referenceNumber: this.referenceNumber, + encryptionData: { + cipherKey: Buffer.from(this.encryptionData.cipherKey), + cipherIv: Buffer.from(this.encryptionData.cipherIv), + encryptionInfo: { ...this.encryptionData.encryptionInfo }, + }, + upoV43: this.upoV43, + }; } async sendInvoice(options: OnlineInvoiceSendOptions): Promise { @@ -66,6 +87,55 @@ export class OnlineSessionHandle { return await this.sessionsClient.getSessionStatus(this.referenceNumber); } + async getInvoiceStatus(invoiceReferenceNumber: string): Promise> { + return await this.sessionsClient.getSessionInvoiceStatus( + this.referenceNumber, + invoiceReferenceNumber, + ); + } + + async listInvoices( + pageOffset?: number, + pageSize?: number, + continuationToken?: string, + ): Promise> { + return await this.sessionsClient.getSessionInvoices( + this.referenceNumber, + pageOffset, + pageSize, + continuationToken, + ); + } + + async listFailedInvoices( + pageSize?: number, + continuationToken?: string, + ): Promise> { + return await this.sessionsClient.getSessionFailedInvoices( + this.referenceNumber, + pageSize, + continuationToken, + ); + } + + async getInvoiceUpoByReference(invoiceReferenceNumber: string): Promise { + return await this.sessionsClient.getSessionInvoiceUpoByReferenceNumber( + this.referenceNumber, + invoiceReferenceNumber, + ); + } + + async getInvoiceUpoByKsefNumber(ksefNumber: string): Promise { + return await this.sessionsClient.getSessionInvoiceUpoByKsefNumber( + this.referenceNumber, + ksefNumber, + ); + } + + async getSessionUpo(upoReferenceNumber: string): Promise { + return await this.sessionsClient.getSessionUpo(this.referenceNumber, upoReferenceNumber); + } + async waitForUpo(options: WaitForUpoOptions = {}): Promise { const pollIntervalMs = options.pollIntervalMs ?? 2000; const maxAttempts = options.maxAttempts ?? 60; @@ -133,6 +203,21 @@ export class OnlineSessionWorkflow { encryption, this.sessionsClient, this.http, + Boolean(options.upoV43), + ); + } + + resume(state: OnlineSessionState): OnlineSessionHandle { + if (!state || typeof state.referenceNumber !== "string" || !state.referenceNumber.trim()) { + throw new KsefValidationError("Online session state requires non-empty referenceNumber."); + } + validateEncryptionData(state.encryptionData); + return new OnlineSessionHandle( + state.referenceNumber, + state.encryptionData, + this.sessionsClient, + this.http, + Boolean(state.upoV43), ); } @@ -147,3 +232,11 @@ export class OnlineSessionWorkflow { return cert.certificate; } } + +function validateEncryptionData(value: EncryptionData): void { + const hasKey = Buffer.isBuffer(value?.cipherKey) && value.cipherKey.length > 0; + const hasIv = Buffer.isBuffer(value?.cipherIv) && value.cipherIv.length > 0; + if (!hasKey || !hasIv) { + throw new KsefValidationError("Online session state requires cipherKey and cipherIv."); + } +} diff --git a/src/types/invoices.ts b/src/types/invoices.ts index ecf30f5..5f1ab26 100644 --- a/src/types/invoices.ts +++ b/src/types/invoices.ts @@ -120,6 +120,16 @@ export function validateInvoiceQueryFilters(filters: InvoiceQueryFilters): void "Invoice query filters.dateRange.dateType must be a non-empty string.", ); } + const normalizedDateType = dateType.trim(); + const restrictToPermanentStorageHwmDate = dateRange.restrictToPermanentStorageHwmDate; + if ( + restrictToPermanentStorageHwmDate === true && + normalizedDateType.toLowerCase() !== "permanentstorage" + ) { + throw new KsefValidationError( + "Invoice query filters.dateRange.restrictToPermanentStorageHwmDate requires dateType PermanentStorage.", + ); + } const fromValue = dateRange.from; const toValue = dateRange.to; diff --git a/src/types/openapi.generated.ts b/src/types/openapi.generated.ts index f80bb42..aed7f26 100644 --- a/src/types/openapi.generated.ts +++ b/src/types/openapi.generated.ts @@ -2,7 +2,7 @@ // Do not edit manually. export const OPENAPI_SPEC_VERSION = "3.0.4" as const; -export const OPENAPI_SCHEMA_COUNT = 287 as const; +export const OPENAPI_SCHEMA_COUNT = 289 as const; export type AllowedIps = { ip4Addresses?: Array | null; @@ -130,6 +130,7 @@ export type BadRequestProblemDetails = { }; export type BatchFileInfo = { + compressionType?: CompressionType | null; fileHash: Sha256HashBase64; fileParts: Array; fileSize: number; @@ -228,6 +229,8 @@ export type CheckAttachmentPermissionStatusResponse = { export type CommonSessionStatus = "Cancelled" | "Failed" | "InProgress" | "Succeeded"; +export type CompressionType = "TarGz" | "Zip"; + export type CurrencyCode = "AED" | "AFN" | "ALL" | "AMD" | "ANG" | "AOA" | "ARS" | "AUD" | "AWG" | "AZN" | "BAM" | "BBD" | "BDT" | "BGN" | "BHD" | "BIF" | "BMD" | "BND" | "BOB" | "BOV" | "BRL" | "BSD" | "BTN" | "BWP" | "BYN" | "BZD" | "CAD" | "CDF" | "CHE" | "CHF" | "CHW" | "CLF" | "CLP" | "CNY" | "COP" | "COU" | "CRC" | "CUC" | "CUP" | "CVE" | "CZK" | "DJF" | "DKK" | "DOP" | "DZD" | "EGP" | "ERN" | "ETB" | "EUR" | "FJD" | "FKP" | "GBP" | "GEL" | "GGP" | "GHS" | "GIP" | "GMD" | "GNF" | "GTQ" | "GYD" | "HKD" | "HNL" | "HRK" | "HTG" | "HUF" | "IDR" | "ILS" | "IMP" | "INR" | "IQD" | "IRR" | "ISK" | "JEP" | "JMD" | "JOD" | "JPY" | "KES" | "KGS" | "KHR" | "KMF" | "KPW" | "KRW" | "KWD" | "KYD" | "KZT" | "LAK" | "LBP" | "LKR" | "LRD" | "LSL" | "LYD" | "MAD" | "MDL" | "MGA" | "MKD" | "MMK" | "MNT" | "MOP" | "MRU" | "MUR" | "MVR" | "MWK" | "MXN" | "MXV" | "MYR" | "MZN" | "NAD" | "NGN" | "NIO" | "NOK" | "NPR" | "NZD" | "OMR" | "PAB" | "PEN" | "PGK" | "PHP" | "PKR" | "PLN" | "PYG" | "QAR" | "RON" | "RSD" | "RUB" | "RWF" | "SAR" | "SBD" | "SCR" | "SDG" | "SEK" | "SGD" | "SHP" | "SLL" | "SOS" | "SRD" | "SSP" | "STN" | "SVC" | "SYP" | "SZL" | "THB" | "TJS" | "TMT" | "TND" | "TOP" | "TRY" | "TTD" | "TWD" | "TZS" | "UAH" | "UGX" | "USD" | "USN" | "UYI" | "UYU" | "UYW" | "UZS" | "VES" | "VND" | "VUV" | "WST" | "XAF" | "XAG" | "XAU" | "XBA" | "XBB" | "XBC" | "XBD" | "XCD" | "XCG" | "XDR" | "XOF" | "XPD" | "XPF" | "XPT" | "XSU" | "XUA" | "XXX" | "YER" | "ZAR" | "ZMW" | "ZWL"; export type EffectiveApiRateLimits = { @@ -264,6 +267,7 @@ export type EffectiveSubjectLimits = { export type EncryptionInfo = { encryptedSymmetricKey: string; initializationVector: string; + publicKeyId?: string | null; }; export type EnrollCertificateRequest = { @@ -598,11 +602,13 @@ export type InitTokenAuthenticationRequest = { challenge: Challenge; contextIdentifier: AuthenticationContextIdentifier; encryptedToken: string; + publicKeyId?: string | null; }; export type InternalId = string; export type InvoiceExportRequest = { + compressionType?: CompressionType | null; encryption: EncryptionInfo; filters: InvoiceQueryFilters; onlyMetadata?: boolean; @@ -732,7 +738,7 @@ export type InvoiceQueryFilters = { subjectType: InvoiceQuerySubjectType; }; -export type InvoiceQueryFormType = "FA_RR" | "FA" | "PEF" | "RR"; +export type InvoiceQueryFormType = "FA_RR" | "FA" | "PEF"; export type InvoiceQuerySubjectType = "Subject1" | "Subject2" | "Subject3" | "SubjectAuthorized"; @@ -1030,6 +1036,8 @@ export type Pesel = string; export type PublicKeyCertificate = { certificate: string; + certificateId: string; + publicKeyId: string; usage: Array; validFrom: string; validTo: string; @@ -1333,6 +1341,8 @@ export type SubunitPermissionsSubunitIdentifier = { export type SubunitPermissionsSubunitIdentifierType = "InternalId" | "Nip"; +export type SystemWarning = string; + export type TestDataAuthenticationContextIdentifier = { type: TestDataAuthenticationContextIdentifierType; value: string; @@ -1490,6 +1500,7 @@ export interface OpenApiGeneratedSchemaMap { Challenge: Challenge; CheckAttachmentPermissionStatusResponse: CheckAttachmentPermissionStatusResponse; CommonSessionStatus: CommonSessionStatus; + CompressionType: CompressionType; CurrencyCode: CurrencyCode; EffectiveApiRateLimits: EffectiveApiRateLimits; EffectiveApiRateLimitValues: EffectiveApiRateLimitValues; @@ -1711,6 +1722,7 @@ export interface OpenApiGeneratedSchemaMap { SubunitPermissionsSubjectIdentifierType: SubunitPermissionsSubjectIdentifierType; SubunitPermissionsSubunitIdentifier: SubunitPermissionsSubunitIdentifier; SubunitPermissionsSubunitIdentifierType: SubunitPermissionsSubunitIdentifierType; + SystemWarning: SystemWarning; TestDataAuthenticationContextIdentifier: TestDataAuthenticationContextIdentifier; TestDataAuthenticationContextIdentifierType: TestDataAuthenticationContextIdentifierType; TestDataAuthorizedIdentifier: TestDataAuthorizedIdentifier; diff --git a/src/xml/invoice.ts b/src/xml/invoice.ts index d17c8ec..32a7843 100644 --- a/src/xml/invoice.ts +++ b/src/xml/invoice.ts @@ -39,7 +39,7 @@ const DEFAULT_ETD_NAMESPACE: Record = { }; const ORDER_MAP: Record = { - Faktura: ["Naglowek", "Podmiot1", "Podmiot2", "Podmiot3", "Fa", "Stopka"], + Faktura: ["Naglowek", "Podmiot1", "Podmiot2", "Podmiot3", "Fa", "Stopka", "Zalacznik"], Naglowek: ["KodFormularza", "WariantFormularza", "DataWytworzeniaFa", "SystemInfo"], Podmiot1: [ "PrefiksPodatnika", @@ -69,7 +69,10 @@ const ORDER_MAP: Record = { "AdresKoresp", "DaneKontaktowe", "Rola", + "RolaInna", + "OpisRoli", "Udzial", + "NrKlienta", ], DaneIdentyfikacyjne: [ "NIP", @@ -137,6 +140,10 @@ const ORDER_MAP: Record = { "FaWiersze", "Rozliczenie", "Platnosc", + "WarunkiTransakcji", + "Transport", + "PodmiotPosredniczacy", + "Zamowienie", ], Adnotacje: [ "P_16", @@ -176,6 +183,81 @@ const ORDER_MAP: Record = { "KursWaluty", "StanPrzed", ], + Rozliczenie: ["Obciazenia", "SumaObciazen", "Odliczenia", "SumaOdliczen", "DoZaplaty", "DoRozliczenia"], + Obciazenia: ["Kwota", "Powod"], + Odliczenia: ["Kwota", "Powod"], + Platnosc: [ + "Zaplacono", + "DataZaplaty", + "ZnacznikZaplatyCzesciowej", + "ZaplataCzesciowa", + "TerminPlatnosci", + "FormaPlatnosci", + "PlatnoscInna", + "OpisPlatnosci", + "RachunekBankowy", + "RachunekBankowyFaktora", + "Skonto", + "LinkDoPlatnosci", + "IPKSeF", + ], + ZaplataCzesciowa: [ + "KwotaZaplatyCzesciowej", + "DataZaplatyCzesciowej", + "FormaPlatnosci", + "PlatnoscInna", + "OpisPlatnosci", + ], + TerminPlatnosci: ["Termin", "TerminOpis"], + TerminOpis: ["Ilosc", "Jednostka", "ZdarzeniePoczatkowe"], + RachunekBankowy: ["NrRB", "SWIFT", "RachunekWlasnyBanku", "NazwaBanku", "OpisRachunku"], + RachunekBankowyFaktora: ["NrRB", "SWIFT", "RachunekWlasnyBanku", "NazwaBanku", "OpisRachunku"], + WarunkiTransakcji: [ + "Umowy", + "Zamowienia", + "NrPartiiTowaru", + "WarunkiDostawy", + "KursUmowny", + "WalutaUmowna", + "Transport", + "PodmiotPosredniczacy", + ], + Umowy: ["DataUmowy", "NrUmowy"], + Zamowienia: ["DataZamowienia", "NrZamowienia"], + Transport: [ + "RodzajTransportu", + "TransportInny", + "OpisInnegoTransportu", + "Przewoznik", + "NrZleceniaTransportu", + "OpisLadunku", + "LadunekInny", + "OpisInnegoLadunku", + "JednostkaOpakowania", + ], + Zamowienie: ["WartoscZamowienia", "ZamowienieWiersz"], + ZamowienieWiersz: [ + "NrWierszaZam", + "UU_IDZ", + "P_7Z", + "IndeksZ", + "GTINZ", + "PKWiUZ", + "CNZ", + "PKOBZ", + "P_8AZ", + "P_8BZ", + "P_9AZ", + "P_11NettoZ", + "P_11VatZ", + "P_12Z", + "P_12Z_XII", + "P_12Z_Zal_15", + "GTUZ", + "ProceduraZ", + "KwotaAkcyzyZ", + "StanPrzedZ", + ], }; function stripBom(input: string): string { diff --git a/test/unit/batchSessionWorkflow.test.js b/test/unit/batchSessionWorkflow.test.js index e6336cb..3b07d7e 100644 --- a/test/unit/batchSessionWorkflow.test.js +++ b/test/unit/batchSessionWorkflow.test.js @@ -425,3 +425,252 @@ test("BatchSessionWorkflow reports missing encrypted part when mapped parts arra CryptographyService.sha256Base64 = originalSha256Base64; } }); + +test("BatchSessionWorkflow open/resume flows expose state and enforce zip identity", async () => { + const originalGetEncryptionData = CryptographyService.getEncryptionData; + const originalEncryptAes = CryptographyService.encryptAes256Cbc; + const originalSha256Base64 = CryptographyService.sha256Base64; + + const uploaded = []; + const sessionsClient = { + openBatchSession: async () => ({ + referenceNumber: "BATCH-RESUME-1", + partUploadRequests: [{ ordinalNumber: 1, method: "PUT", url: "https://upload/1", headers: {} }], + }), + closeBatchSession: async () => {}, + getSessionStatus: async () => ({ status: { code: 100, description: "Processing" } }), + getSessionFailedInvoices: async () => ({ items: [] }), + }; + const workflow = new BatchSessionWorkflow( + sessionsClient, + { getPublicKeyCertificates: async () => [{ usage: ["SymmetricKeyEncryption"], certificate: "CERT" }] }, + { request: async (options) => uploaded.push(options) }, + ); + + CryptographyService.getEncryptionData = () => ({ + cipherKey: Buffer.alloc(32, 1), + cipherIv: Buffer.alloc(16, 2), + encryptionInfo: { encryptedSymmetricKey: "k", initializationVector: "i" }, + }); + CryptographyService.encryptAes256Cbc = (part) => Buffer.from(part); + CryptographyService.sha256Base64 = (value) => `sha:${Buffer.from(value).length}`; + + try { + const handle = await workflow.open({ + formCode: FORM_CODE, + zipBytes: Buffer.from("abc", "utf8"), + maxPartSizeBytes: 10, + upoV43: true, + }); + await handle.uploadParts(1); + const state = handle.getState(); + assert.equal(state.referenceNumber, "BATCH-RESUME-1"); + assert.equal(state.upoV43, true); + assert.equal(uploaded.length, 1); + + const resumed = await workflow.resume(state, { zipBytes: Buffer.from("abc", "utf8") }); + assert.equal(resumed.referenceNumber, "BATCH-RESUME-1"); + + await assert.rejects( + () => workflow.resume(state, { zipBytes: Buffer.from("abcd", "utf8") }), + /hash does not match saved state|size does not match saved state/, + ); + } finally { + CryptographyService.getEncryptionData = originalGetEncryptionData; + CryptographyService.encryptAes256Cbc = originalEncryptAes; + CryptographyService.sha256Base64 = originalSha256Base64; + } +}); + +test("BatchSessionHandle uploadParts accepts options object with progress and exposes state/failed invoices", async () => { + const uploads = []; + const progress = []; + const sessionsClient = { + getSessionFailedInvoices: async (ref, pageSize, continuationToken) => ({ + ref, + pageSize, + continuationToken, + items: [{ id: "failed-1" }], + }), + }; + const http = { + request: async (options) => { + uploads.push({ path: options.path, headers: options.headers }); + return {}; + }, + }; + const handle = new BatchSessionHandle( + "BATCH-OPTIONS", + { cipherKey: Buffer.alloc(32, 1), cipherIv: Buffer.alloc(16, 2), encryptionInfo: {} }, + sessionsClient, + http, + { fileSize: 4, fileHash: "h", fileParts: [] }, + [ + { ordinalNumber: 1, method: "PUT", url: "https://upload/1" }, + { ordinalNumber: 2, method: "PUT", url: "https://upload/2", headers: { a: "1", skip: "" } }, + ], + [Buffer.from("part-1"), Buffer.from("part-2")], + true, + true, + ); + + const state = handle.getState(); + assert.equal(state.offlineMode, true); + assert.deepEqual(state.partUploadRequests[0].headers, {}); + + await handle.uploadParts({ + skipOrdinals: [1], + progressCallback: (ordinalNumber) => { + progress.push(ordinalNumber); + }, + }); + + assert.deepEqual( + uploads.map((call) => call.path), + ["https://upload/2"], + ); + assert.deepEqual(uploads[0].headers, { a: "1" }); + assert.deepEqual(progress, [2]); + + const failed = await handle.listFailedInvoices(7, "token-1"); + assert.deepEqual(failed.items, [{ id: "failed-1" }]); + assert.equal(failed.pageSize, 7); + assert.equal(failed.continuationToken, "token-1"); +}); + +test("BatchSessionHandle uploadParts throws when a mapped ordinal is missing", async () => { + const handle = new BatchSessionHandle( + "BATCH-MISSING-ORDINAL", + { cipherKey: Buffer.alloc(32, 1), cipherIv: Buffer.alloc(16, 2), encryptionInfo: {} }, + {}, + { request: async () => ({}) }, + { fileSize: 1, fileHash: "h", fileParts: [] }, + [{ ordinalNumber: 1, method: "PUT", url: "https://upload/1" }], + [Buffer.from("part-1")], + ); + + const originalGet = Map.prototype.get; + Map.prototype.get = function patchedGet() { + return undefined; + }; + try { + await assert.rejects( + () => handle.uploadParts(1), + /Missing batch part for ordinal 1/, + ); + } finally { + Map.prototype.get = originalGet; + } +}); + +test("BatchSessionWorkflow.resume validates state, encryption and re-encrypts missing parts", async () => { + const originalEncryptAes = CryptographyService.encryptAes256Cbc; + const originalSha256Base64 = CryptographyService.sha256Base64; + + const workflow = new BatchSessionWorkflow( + { getSessionStatus: async () => ({}) }, + { getPublicKeyCertificates: async () => [] }, + { request: async () => ({}) }, + ); + + const validEncryption = { + cipherKey: Buffer.alloc(32, 1), + cipherIv: Buffer.alloc(16, 2), + encryptionInfo: {}, + }; + + await assert.rejects( + () => + workflow.resume( + { referenceNumber: " ", encryptionData: validEncryption }, + { zipBytes: Buffer.from("ab") }, + ), + /requires non-empty referenceNumber/, + ); + + await assert.rejects( + () => + workflow.resume( + { referenceNumber: "R", encryptionData: validEncryption }, + { zipBytes: Buffer.alloc(0) }, + ), + /requires zipBytes/, + ); + + await assert.rejects( + () => + workflow.resume( + { + referenceNumber: "R", + encryptionData: { cipherKey: Buffer.alloc(0), cipherIv: Buffer.alloc(16), encryptionInfo: {} }, + }, + { zipBytes: Buffer.from("ab") }, + ), + /requires cipherKey and cipherIv/, + ); + + await assert.rejects( + () => + workflow.resume( + { referenceNumber: "R", encryptionData: validEncryption }, + { zipBytes: Buffer.from("ab") }, + ), + /requires batchFile metadata/, + ); + + CryptographyService.sha256Base64 = () => "matching-hash"; + CryptographyService.encryptAes256Cbc = (part) => Buffer.from(`enc:${Buffer.from(part).toString("utf8")}`); + + try { + await assert.rejects( + () => + workflow.resume( + { + referenceNumber: "R", + encryptionData: validEncryption, + batchFile: { + fileSize: 999, + fileHash: "matching-hash", + fileParts: [{ ordinalNumber: 1, fileSize: 2, fileHash: "ph" }], + }, + encryptedPartsBase64: ["QUFB"], + }, + { zipBytes: Buffer.from("ab") }, + ), + /size does not match saved state/, + ); + + const reEncrypted = await workflow.resume( + { + referenceNumber: "BATCH-REENCRYPT", + encryptionData: validEncryption, + batchFile: { + fileSize: 2, + fileHash: "matching-hash", + fileParts: [{ ordinalNumber: 1, fileSize: 2, fileHash: "ph" }], + }, + encryptedPartsBase64: [], + upoV43: true, + }, + { zipBytes: Buffer.from("ab") }, + ); + assert.equal(reEncrypted.referenceNumber, "BATCH-REENCRYPT"); + const state = reEncrypted.getState(); + assert.deepEqual(state.partUploadRequests, []); + assert.equal(state.encryptedPartsBase64.length, 1); + + const reEncryptedDefaultPartSize = await workflow.resume( + { + referenceNumber: "BATCH-REENCRYPT-DEFAULT", + encryptionData: validEncryption, + batchFile: { fileSize: 2, fileHash: "matching-hash", fileParts: [] }, + encryptedPartsBase64: [], + }, + { zipBytes: Buffer.from("ab") }, + ); + assert.equal(reEncryptedDefaultPartSize.referenceNumber, "BATCH-REENCRYPT-DEFAULT"); + } finally { + CryptographyService.encryptAes256Cbc = originalEncryptAes; + CryptographyService.sha256Base64 = originalSha256Base64; + } +}); diff --git a/test/unit/cliArgs.test.js b/test/unit/cliArgs.test.js index a48e8c1..2251497 100644 --- a/test/unit/cliArgs.test.js +++ b/test/unit/cliArgs.test.js @@ -69,12 +69,15 @@ test("parseArgv handles sparse argv, -- separator and --key=value syntax", async "--profile=dev", "--page-size", "25", + "--save-session", + "resume-1", "--", "invoice", "query", ]); assert.equal(parsed.options.profile, "dev"); assert.equal(parsed.options["page-size"], "25"); + assert.equal(parsed.options["save-session"], "resume-1"); assert.deepEqual(parsed.positionals, ["invoice", "query"]); }); diff --git a/test/unit/cliCoverageGaps.test.js b/test/unit/cliCoverageGaps.test.js new file mode 100644 index 0000000..d183dca --- /dev/null +++ b/test/unit/cliCoverageGaps.test.js @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { test } from "node:test"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const distCliPath = path.resolve(__dirname, "../../dist/cli/index.js"); +const distCliMapPath = path.resolve(__dirname, "../../dist/cli/index.js.map"); +const distRootPath = path.resolve(__dirname, "../../dist"); + +async function loadCliCoverageModule(extraExports = "") { + const tempBaseDir = path.resolve(__dirname, "../../.tmp"); + await mkdir(tempBaseDir, { recursive: true }); + const tempDir = await mkdtemp(path.join(tempBaseDir, "ksef-cli-cov-")); + const tempModulePath = path.join(tempDir, "index.js"); + const tempMapPath = path.join(tempDir, "index.js.map"); + + try { + const distEntries = await readdir(distRootPath, { withFileTypes: true }); + for (const entry of distEntries) { + if (!entry.isFile() || !entry.name.endsWith(".js")) { + continue; + } + if (!entry.name.startsWith("chunk-") && !entry.name.startsWith("libxmljs2-")) { + continue; + } + await copyFile(path.join(distRootPath, entry.name), path.join(tempBaseDir, entry.name)); + } + + const source = await readFile(distCliPath, "utf8"); + const withoutSourceMapComment = source.replace(/\n\/\/# sourceMappingURL=index\.js\.map\s*$/u, ""); + const patched = `${withoutSourceMapComment} +export { + extractKsefNumber, + resolveOutputPath, + listXmlFilesRecursive, + waitForInvoiceUpo, + formatSessionIdError, + SessionStoreError, + ${extraExports} +}; +//# sourceMappingURL=index.js.map +`; + + await writeFile(tempModulePath, patched, "utf8"); + await copyFile(distCliMapPath, tempMapPath); + + const moduleUrl = `${pathToFileURL(tempModulePath).href}?cacheBust=${Date.now()}`; + const loaded = await import(moduleUrl); + + return { + module: loaded, + dispose: async () => { + await rm(tempDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await rm(tempDir, { recursive: true, force: true }); + throw error; + } +} + +test("CLI internals: formatSessionIdError handles Error and non-Error values", async () => { + const loaded = await loadCliCoverageModule(); + try { + const { formatSessionIdError, SessionStoreError } = loaded.module; + assert.equal(formatSessionIdError(new SessionStoreError("bad id", "validation")), "bad id"); + assert.equal(formatSessionIdError("string-boom"), "string-boom"); + } finally { + await loaded.dispose(); + } +}); + +test("CLI internals: extractKsefNumber and resolveOutputPath branches", async () => { + const loaded = await loadCliCoverageModule(); + try { + const { extractKsefNumber, resolveOutputPath } = loaded.module; + assert.equal(extractKsefNumber(null), null); + assert.equal(extractKsefNumber("not-object"), null); + assert.equal(extractKsefNumber({ ksefNumber: "K-1" }), "K-1"); + assert.equal(extractKsefNumber({ invoice: { ksefNumber: "K-2" } }), "K-2"); + + const trailing = resolveOutputPath(path.join(os.tmpdir(), "outdir/"), "file.xml"); + assert.ok(trailing.endsWith(`${path.sep}file.xml`)); + const direct = resolveOutputPath("/tmp/file.xml", "ignored.xml"); + assert.equal(direct, "/tmp/file.xml"); + } finally { + await loaded.dispose(); + } +}); + +test("CLI internals: listXmlFilesRecursive sorts nested xml files", async () => { + const loaded = await loadCliCoverageModule(); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-xml-")); + try { + const { listXmlFilesRecursive } = loaded.module; + const nested = path.join(tempDir, "nested"); + await mkdir(nested, { recursive: true }); + await writeFile(path.join(nested, "b.xml"), "", "utf8"); + await writeFile(path.join(tempDir, "a.xml"), "", "utf8"); + await writeFile(path.join(tempDir, "skip.txt"), "x", "utf8"); + + const files = await listXmlFilesRecursive(tempDir); + assert.deepEqual( + files.map((file) => path.basename(file)), + ["a.xml", "b.xml"], + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("CLI internals: waitForInvoiceUpo timeout with non-Error failure", async () => { + const loaded = await loadCliCoverageModule(); + try { + const { waitForInvoiceUpo } = loaded.module; + const handle = { + getInvoiceUpoByReference: async () => { + throw "upo-string-failure"; + }, + }; + await assert.rejects( + () => waitForInvoiceUpo(handle, "INV-1", 1, 1), + (error) => error.message === "Timed out while waiting for invoice UPO.", + ); + } finally { + await loaded.dispose(); + } +}); diff --git a/test/unit/cliInternalsCoverage.test.js b/test/unit/cliInternalsCoverage.test.js index c58d0c5..fc183ec 100644 --- a/test/unit/cliInternalsCoverage.test.js +++ b/test/unit/cliInternalsCoverage.test.js @@ -1,13 +1,14 @@ import assert from "node:assert/strict"; import os from "node:os"; import path from "node:path"; -import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import { test } from "node:test"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const distCliPath = path.resolve(__dirname, "../../dist/cli/index.js"); const distCliMapPath = path.resolve(__dirname, "../../dist/cli/index.js.map"); +const distRootPath = path.resolve(__dirname, "../../dist"); async function loadCliInternalsModule() { const tempBaseDir = path.resolve(__dirname, "../../.tmp"); @@ -17,6 +18,20 @@ async function loadCliInternalsModule() { const tempMapPath = path.join(tempDir, "index.js.map"); try { + const distEntries = await readdir(distRootPath, { withFileTypes: true }); + for (const entry of distEntries) { + if (!entry.isFile()) { + continue; + } + if (!entry.name.endsWith(".js")) { + continue; + } + if (!entry.name.startsWith("chunk-") && !entry.name.startsWith("libxmljs2-")) { + continue; + } + await copyFile(path.join(distRootPath, entry.name), path.join(tempBaseDir, entry.name)); + } + const source = await readFile(distCliPath, "utf8"); const withoutSourceMapComment = source.replace( /\n\/\/# sourceMappingURL=index\.js\.map\s*$/u, diff --git a/test/unit/cliSessionCommands.test.js b/test/unit/cliSessionCommands.test.js new file mode 100644 index 0000000..3a8261f --- /dev/null +++ b/test/unit/cliSessionCommands.test.js @@ -0,0 +1,1177 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { test } from "node:test"; +import { createZip } from "../../dist/index.js"; +import { runCli } from "../../dist/cli/index.js"; + +const fixturesPath = path.resolve(process.cwd(), "test", "fixtures", "xades-fixtures.json"); +const fixtures = JSON.parse(fs.readFileSync(fixturesPath, "utf8")); + +function createCaptureIo() { + const stdout = []; + const stderr = []; + return { + io: { + stdout: (line) => stdout.push(line), + stderr: (line) => stderr.push(line), + }, + stdout, + stderr, + }; +} + +function certPemToBase64Der(certPem) { + return certPem.replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s+/g, ""); +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Server address is not available.")); + return; + } + resolve(address); + }); + }); +} + +function closeServer(server) { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +const UPO_XML = ` + + KSeF + SESSION-1 + + 1111111111 + TOKEN-1 + + Faktura + FA (3) + + 1111111111 + KSEF-INV-1 + FV/1 + 2026-03-03 + 2026-03-03T12:00:00Z + 2026-03-03T12:01:00Z + HASH + Online + +`; + +function handleAuthRoute(key, sendJson) { + const certBase64Der = certPemToBase64Der(fixtures.rsaCertPem); + if (key === "POST /v2/auth/challenge") { + sendJson({ + challenge: "challenge-value", + timestamp: "2026-03-03T12:00:00+01:00", + timestampMs: 1741009200000, + clientIp: "203.0.113.10", + }); + return true; + } + if (key === "GET /v2/security/public-key-certificates") { + sendJson([ + { + certificateSerialNumber: "SERIAL-1", + validFrom: "2026-01-01T00:00:00Z", + validTo: "2027-01-01T00:00:00Z", + usage: ["KsefTokenEncryption", "SymmetricKeyEncryption"], + certificate: certBase64Der, + }, + ]); + return true; + } + if (key === "POST /v2/auth/ksef-token") { + sendJson({ + authenticationToken: { token: "auth-token-1", validUntil: "2026-03-03T13:00:00Z" }, + referenceNumber: "AUTH-REF-1", + }); + return true; + } + if (key === "GET /v2/auth/AUTH-REF-1") { + sendJson({ + startDate: "2026-03-03T12:00:00Z", + authenticationMethod: "Token", + status: { code: 200, description: "Completed" }, + }); + return true; + } + if (key === "POST /v2/auth/token/redeem") { + sendJson({ + accessToken: { token: "access-token-1", validUntil: "2099-03-03T14:00:00Z" }, + refreshToken: { token: "refresh-token-1", validUntil: "2099-03-10T14:00:00Z" }, + }); + return true; + } + return false; +} + +function createServerWithRoutes(buildRoutes) { + let address; + const server = createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + const key = `${req.method} ${url.pathname}`; + const sendJson = (payload) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + }; + const sendXml = (xml) => { + res.writeHead(200, { "Content-Type": "application/xml" }); + res.end(xml); + }; + const sendStatus = (code) => { + res.writeHead(code).end(); + }; + + if (handleAuthRoute(key, sendJson)) { + return; + } + const routes = buildRoutes({ address, sendJson, sendXml, sendStatus, req, res }); + const handler = routes[key]; + if (handler) { + handler(); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ message: `${key} not mocked` })); + }); + return { + server, + setAddress: (value) => { + address = value; + }, + }; +} + +function happyRoutes({ address, sendJson, sendXml, sendStatus }) { + return { + "POST /v2/sessions/online": () => sendJson({ referenceNumber: "ONLINE-REF-1" }), + "POST /v2/sessions/online/ONLINE-REF-1/invoices": () => + sendJson({ referenceNumber: "INV-REF-1" }), + "POST /v2/sessions/online/ONLINE-REF-1/close": () => sendJson({ ok: true }), + "GET /v2/sessions/ONLINE-REF-1": () => + sendJson({ + status: { code: 200, description: "Completed" }, + upo: { + pages: [ + { + referenceNumber: "UPO-REF-1", + downloadUrl: `http://127.0.0.1:${address.port}/download/online-upo.xml`, + }, + ], + }, + }), + "GET /v2/sessions/ONLINE-REF-1/invoices/INV-REF-1": () => + sendJson({ status: { code: 200, description: "ok" }, ksefNumber: "KSEF-INV-1" }), + "GET /v2/sessions/ONLINE-REF-1/invoices/INV-REF-1/upo": () => sendXml(UPO_XML), + "GET /download/online-upo.xml": () => sendXml(UPO_XML), + "POST /v2/sessions/batch": () => + sendJson({ + referenceNumber: "BATCH-REF-1", + partUploadRequests: [ + { + ordinalNumber: 1, + method: "PUT", + url: `http://127.0.0.1:${address.port}/upload/1`, + headers: {}, + }, + ], + }), + "PUT /upload/1": () => sendStatus(200), + "POST /v2/sessions/batch/BATCH-REF-1/close": () => sendJson({ ok: true }), + "GET /v2/sessions/BATCH-REF-1": () => + sendJson({ + status: { code: 200, description: "Completed" }, + upo: { pages: [{ referenceNumber: "BUPO-REF-1" }] }, + }), + "GET /v2/sessions/BATCH-REF-1/upo/BUPO-REF-1": () => sendXml(UPO_XML), + }; +} + +function createSessionMockServer() { + return createServerWithRoutes(happyRoutes); +} + +async function initAndLogin(tempDir, baseUrl) { + const env = { KSEF_CLI_HOME: tempDir }; + let capture = createCaptureIo(); + let exitCode = await runCli( + [ + "init", + "--profile", + "default", + "--base-url", + baseUrl, + "--context-type", + "Nip", + "--context-value", + "1111111111", + "--token-store-policy", + "plaintext", + ], + { io: capture.io, env, cwd: tempDir }, + ); + assert.equal(exitCode, 0, `${capture.stderr.join("\n")}\n${capture.stdout.join("\n")}`); + + const configPath = path.join(tempDir, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + config.profiles.default.strictPresignedUrlValidation = false; + config.profiles.default.allowPrivateNetworkPresignedUrls = true; + await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + + capture = createCaptureIo(); + exitCode = await runCli(["--json", "auth", "login", "--token", "KSEF-TOKEN-1", "--profile", "default"], { + io: capture.io, + env, + cwd: tempDir, + }); + assert.equal(exitCode, 0, `${capture.stderr.join("\n")}\n${capture.stdout.join("\n")}`); + return env; +} + +async function seedOnlineCheckpoint(cliHome, id = "demo-online") { + const directory = path.join(cliHome, "cache", "sessions", "default"); + await mkdir(directory, { recursive: true }); + const payload = { + schemaVersion: 1, + id, + profile: "default", + baseUrl: "https://api-test.ksef.mf.gov.pl/v2", + kind: "online", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + stage: "opened", + sessionState: { + referenceNumber: "SESSION-ONLINE-1", + encryptionData: { + cipherKeyBase64: Buffer.from("k".repeat(32)).toString("base64"), + cipherIvBase64: Buffer.from("i".repeat(16)).toString("base64"), + encryptionInfo: { + encryptedSymmetricKey: "enc-key", + initializationVector: "enc-iv", + }, + }, + upoV43: true, + }, + sentInvoiceRefs: [], + }; + await writeFile(path.join(directory, `${id}.json`), JSON.stringify(payload, null, 2), "utf8"); +} + +test("session list/show/export/drop/import operate on checkpoint files", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-session-")); + try { + await seedOnlineCheckpoint(tempDir); + const env = { KSEF_CLI_HOME: tempDir }; + + let capture = createCaptureIo(); + let exitCode = await runCli(["--json", "session", "list"], { + io: capture.io, + env, + cwd: tempDir, + }); + assert.equal(exitCode, 0); + let payload = JSON.parse(capture.stdout[0]); + assert.equal(payload.count, 1); + assert.equal(payload.items[0].id, "demo-online"); + + capture = createCaptureIo(); + exitCode = await runCli(["--json", "session", "show", "--id", "demo-online"], { + io: capture.io, + env, + cwd: tempDir, + }); + assert.equal(exitCode, 0); + payload = JSON.parse(capture.stdout[0]); + assert.equal(payload.id, "demo-online"); + assert.equal(payload.checkpoint.kind, "online"); + + const exportDir = path.join(tempDir, "exports"); + await mkdir(exportDir, { recursive: true }); + capture = createCaptureIo(); + exitCode = await runCli( + ["--json", "session", "export", "--id", "demo-online", "--out", `${exportDir}/`], + { + io: capture.io, + env, + cwd: tempDir, + }, + ); + assert.equal(exitCode, 0); + payload = JSON.parse(capture.stdout[0]); + const exportedPath = payload.path; + const exportedRaw = await readFile(exportedPath, "utf8"); + assert.ok(exportedRaw.includes("\"demo-online\"")); + + capture = createCaptureIo(); + exitCode = await runCli(["--json", "session", "drop", "--id", "demo-online"], { + io: capture.io, + env, + cwd: tempDir, + }); + assert.equal(exitCode, 0); + + capture = createCaptureIo(); + exitCode = await runCli( + ["--json", "session", "import", "--in", exportedPath, "--id", "demo-online-restored"], + { + io: capture.io, + env, + cwd: tempDir, + }, + ); + assert.equal(exitCode, 0); + payload = JSON.parse(capture.stdout[0]); + assert.equal(payload.id, "demo-online-restored"); + + capture = createCaptureIo(); + exitCode = await runCli(["--json", "session", "import", "--in", exportedPath], { + io: capture.io, + env, + cwd: tempDir, + }); + assert.equal(exitCode, 0); + payload = JSON.parse(capture.stdout[0]); + assert.equal(payload.id, "demo-online"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("session online send validates --save-upo requires --wait-upo", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-session-")); + try { + const capture = createCaptureIo(); + const exitCode = await runCli( + [ + "--json", + "session", + "online", + "send", + "--id", + "demo-online", + "--invoice-file", + "invoice.xml", + "--save-upo", + "upo.xml", + ], + { + io: capture.io, + env: { KSEF_CLI_HOME: tempDir }, + cwd: tempDir, + }, + ); + assert.equal(exitCode, 2); + const errorPayload = JSON.parse(capture.stderr[0]); + assert.match(errorPayload.error.message, /--save-upo requires --wait-upo/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("session batch close validates --save-upo requires --wait-upo", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-session-")); + try { + const capture = createCaptureIo(); + const exitCode = await runCli( + ["--json", "session", "batch", "close", "--id", "demo-batch", "--save-upo", "upo.xml"], + { + io: capture.io, + env: { KSEF_CLI_HOME: tempDir }, + cwd: tempDir, + }, + ); + assert.equal(exitCode, 2); + const errorPayload = JSON.parse(capture.stderr[0]); + assert.match(errorPayload.error.message, /--save-upo requires --wait-upo/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("session upload/close validate numeric polling and parallelism flags", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-session-")); + try { + let capture = createCaptureIo(); + let exitCode = await runCli( + ["--json", "session", "batch", "upload", "--id", "demo-batch", "--parallelism", "0"], + { + io: capture.io, + env: { KSEF_CLI_HOME: tempDir }, + cwd: tempDir, + }, + ); + assert.equal(exitCode, 2); + let errorPayload = JSON.parse(capture.stderr[0]); + assert.match(errorPayload.error.message, /--parallelism/i); + + capture = createCaptureIo(); + exitCode = await runCli( + [ + "--json", + "session", + "online", + "send", + "--id", + "demo-online", + "--invoice-file", + "invoice.xml", + "--poll-interval-ms", + "0", + ], + { + io: capture.io, + env: { KSEF_CLI_HOME: tempDir }, + cwd: tempDir, + }, + ); + assert.equal(exitCode, 2); + errorPayload = JSON.parse(capture.stderr[0]); + assert.match(errorPayload.error.message, /--poll-interval-ms/i); + + capture = createCaptureIo(); + exitCode = await runCli( + [ + "--json", + "session", + "online", + "send", + "--id", + "demo-online", + "--invoice-file", + "invoice.xml", + "--max-attempts", + "0", + ], + { + io: capture.io, + env: { KSEF_CLI_HOME: tempDir }, + cwd: tempDir, + }, + ); + assert.equal(exitCode, 2); + errorPayload = JSON.parse(capture.stderr[0]); + assert.match(errorPayload.error.message, /--max-attempts/i); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("session batch open validates source selection", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-session-")); + try { + const capture = createCaptureIo(); + const exitCode = await runCli( + ["--json", "session", "batch", "open", "--id", "demo-batch"], + { + io: capture.io, + env: { KSEF_CLI_HOME: tempDir }, + cwd: tempDir, + }, + ); + assert.equal(exitCode, 2); + const payload = JSON.parse(capture.stderr[0]); + assert.match(payload.error.message, /Select exactly one batch input source/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("session command guards reject missing/invalid arguments before any network calls", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-guards-")); + const env = { KSEF_CLI_HOME: tempDir }; + const run = async (args) => { + const capture = createCaptureIo(); + const exitCode = await runCli(["--json", ...args], { io: capture.io, env, cwd: tempDir }); + return { exitCode, capture }; + }; + try { + let result = await run(["session", "show"]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /Missing session id/); + + result = await run(["session", "show", "--id", "bad id!"]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /Invalid session id/); + + result = await run(["session", "export", "--id", "demo"]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /session export requires --out/); + + result = await run(["session", "import"]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /session import requires --in/); + + result = await run(["session", "online", "send", "--id", "demo"]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /requires --invoice-file/); + + result = await run([ + "session", + "online", + "send", + "--id", + "demo", + "--invoice-file", + "invoice.xml", + "--poll-interval", + "0", + ]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /--poll-interval must be greater than zero/); + + const notADir = path.join(tempDir, "not-a-dir.txt"); + await writeFile(notADir, "x", "utf8"); + result = await run(["session", "batch", "open", "--id", "demo", "--dir", notADir]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /is not a directory/); + + const emptyDir = path.join(tempDir, "empty-dir"); + await mkdir(emptyDir, { recursive: true }); + result = await run(["session", "batch", "open", "--id", "demo", "--dir", emptyDir]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /No \.xml files found/); + + // A malformed checkpoint surfaces a config-level SessionStoreError. + const checkpointDir = path.join(tempDir, "cache", "sessions", "default"); + await mkdir(checkpointDir, { recursive: true }); + await writeFile(path.join(checkpointDir, "broken.json"), "not-json{", "utf8"); + result = await run(["session", "show", "--id", "broken"]); + assert.equal(result.exitCode, 3); + assert.match(result.capture.stderr.join("\n"), /Invalid session checkpoint JSON/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("session commands surface auth errors when no token is stored", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-noauth-")); + const mock = createSessionMockServer(); + const address = await listen(mock.server); + mock.setAddress(address); + const baseUrl = `http://127.0.0.1:${address.port}`; + try { + const env = { KSEF_CLI_HOME: tempDir }; + let capture = createCaptureIo(); + let exitCode = await runCli( + [ + "init", + "--profile", + "default", + "--base-url", + baseUrl, + "--context-type", + "Nip", + "--context-value", + "1111111111", + "--token-store-policy", + "plaintext", + ], + { io: capture.io, env, cwd: tempDir }, + ); + assert.equal(exitCode, 0, capture.stderr.join("\n")); + + const directory = path.join(tempDir, "cache", "sessions", "default"); + await mkdir(directory, { recursive: true }); + await writeFile( + path.join(directory, "np.json"), + JSON.stringify({ + schemaVersion: 1, + id: "np", + profile: "default", + baseUrl, + kind: "online", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + stage: "opened", + sessionState: { + referenceNumber: "ONLINE-REF-1", + encryptionData: { + cipherKeyBase64: Buffer.alloc(32, 1).toString("base64"), + cipherIvBase64: Buffer.alloc(16, 2).toString("base64"), + encryptionInfo: { encryptedSymmetricKey: "k", initializationVector: "i" }, + }, + }, + lastInvoiceRef: null, + sentInvoiceRefs: [], + }), + "utf8", + ); + + capture = createCaptureIo(); + exitCode = await runCli(["--json", "session", "status", "--id", "np"], { + io: capture.io, + env, + cwd: tempDir, + }); + assert.equal(exitCode, 4); + assert.match(capture.stderr.join("\n"), /No access token found/); + } finally { + await closeServer(mock.server); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("session online and batch lifecycle run against a mock KSeF server", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-lifecycle-")); + const mock = createSessionMockServer(); + const address = await listen(mock.server); + mock.setAddress(address); + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + const env = await initAndLogin(tempDir, baseUrl); + const run = async (args) => { + const capture = createCaptureIo(); + const exitCode = await runCli(args, { io: capture.io, env, cwd: tempDir }); + return { exitCode, capture }; + }; + + const invoiceFile = path.join(tempDir, "invoice.xml"); + await writeFile(invoiceFile, "1", "utf8"); + + // --- Online session lifecycle --- + let result = await run(["--json", "session", "online", "open", "--id", "o1", "--form-code", "FA3"]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /"kind": "online"/); + + // Duplicate id triggers a SessionStoreError surfaced by the CLI. + result = await run(["--json", "session", "online", "open", "--id", "o1", "--form-code", "FA3"]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /already exists/); + + const onlineUpo = path.join(tempDir, "online-upo.xml"); + result = await run([ + "--json", + "session", + "online", + "send", + "--id", + "o1", + "--invoice-file", + invoiceFile, + "--wait-status", + "--wait-upo", + "--save-upo", + onlineUpo, + "--poll-interval-ms", + "1", + "--max-attempts", + "3", + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + const sendPayload = JSON.parse(result.capture.stdout[0]); + assert.equal(sendPayload.invoiceRef, "INV-REF-1"); + assert.equal(sendPayload.ksefNumber, "KSEF-INV-1"); + assert.match(await readFile(onlineUpo, "utf8"), //); + + // Saving UPO again without overwrite fails; with overwrite succeeds. + result = await run([ + "--json", + "session", + "online", + "send", + "--id", + "o1", + "--invoice-file", + invoiceFile, + "--wait-upo", + "--save-upo", + onlineUpo, + "--poll-interval-ms", + "1", + "--max-attempts", + "3", + ]); + assert.notEqual(result.exitCode, 0); + assert.match(result.capture.stderr.join("\n"), /already exists/); + + result = await run([ + "--json", + "session", + "online", + "send", + "--id", + "o1", + "--invoice-file", + invoiceFile, + "--wait-upo", + "--save-upo", + onlineUpo, + "--save-upo-overwrite", + "--poll-interval-ms", + "1", + "--max-attempts", + "3", + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + + // Waiting for UPO without --save-upo uses the seconds-based poll interval. + result = await run([ + "--json", + "session", + "online", + "send", + "--id", + "o1", + "--invoice-file", + invoiceFile, + "--wait-upo", + "--poll-interval", + "1", + "--max-attempts", + "3", + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /"upoPath": ""/); + + // Generic session status resolves both session and invoice status. + result = await run([ + "--json", + "session", + "status", + "--id", + "o1", + "--invoice-ref", + "INV-REF-1", + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /invoiceStatus/); + + result = await run(["--json", "session", "online", "close", "--id", "o1"]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /"stage": "closed"/); + + // Sending on a closed checkpoint is rejected. + result = await run([ + "--json", + "session", + "online", + "send", + "--id", + "o1", + "--invoice-file", + invoiceFile, + ]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /already closed/); + + // Wrong-kind guard: online command on a batch checkpoint. + // --- Batch session lifecycle --- + const zipPath = path.join(tempDir, "batch.zip"); + const zipBytes = await createZip([ + { fileName: "invoice-1.xml", content: Buffer.from("1", "utf8") }, + ]); + await writeFile(zipPath, zipBytes); + + result = await run(["--json", "session", "batch", "open", "--id", "b1", "--zip", zipPath]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /"kind": "batch"/); + + // Duplicate batch id triggers checkpoint conflict (and closes the opened session). + result = await run(["--json", "session", "batch", "open", "--id", "b1", "--zip", zipPath]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /already exists/); + + // Directory source variant covers zip-from-directory building. + const invoiceDir = path.join(tempDir, "batch-dir"); + await mkdir(path.join(invoiceDir, "nested"), { recursive: true }); + await writeFile(path.join(invoiceDir, "a.xml"), "a", "utf8"); + await writeFile(path.join(invoiceDir, "nested", "b.xml"), "b", "utf8"); + result = await run(["--json", "session", "batch", "open", "--id", "bdir", "--dir", invoiceDir]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + + // Uploading a directory-sourced batch detects that the rebuilt archive changed. + await writeFile(path.join(invoiceDir, "c.xml"), "c", "utf8"); + result = await run(["--json", "session", "batch", "upload", "--id", "bdir"]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /payload source changed/i); + + result = await run([ + "--json", + "session", + "batch", + "upload", + "--id", + "b1", + "--parallelism", + "2", + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + const uploadPayload = JSON.parse(result.capture.stdout[0]); + assert.equal(uploadPayload.uploadedCount, 1); + assert.equal(uploadPayload.totalParts, 1); + + // Re-running upload short-circuits when everything is uploaded. + result = await run(["--json", "session", "batch", "upload", "--id", "b1"]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /"uploadedCount": 1/); + + // A checkpoint that stores a relative payload path is resolved against the cwd. + result = await run(["--json", "session", "batch", "open", "--id", "brel", "--zip", zipPath]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + const relCheckpointPath = path.join(tempDir, "cache", "sessions", "default", "brel.json"); + const relCheckpoint = JSON.parse(await readFile(relCheckpointPath, "utf8")); + relCheckpoint.payloadSource.path = path.basename(zipPath); + await writeFile(relCheckpointPath, JSON.stringify(relCheckpoint), "utf8"); + result = await run(["--json", "session", "batch", "upload", "--id", "brel"]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /"uploadedCount": 1/); + + const batchUpoDir = path.join(tempDir, "batch-upo"); + await mkdir(batchUpoDir, { recursive: true }); + result = await run([ + "--json", + "session", + "batch", + "close", + "--id", + "b1", + "--wait-status", + "--wait-upo", + "--save-upo", + `${batchUpoDir}/`, + "--poll-interval-ms", + "1", + "--max-attempts", + "3", + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + const closePayload = JSON.parse(result.capture.stdout[0]); + assert.equal(closePayload.upoRef, "BUPO-REF-1"); + + // Closing an already-closed batch session short-circuits. + result = await run(["--json", "session", "batch", "close", "--id", "b1"]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + + // Closed session with --wait-upo skips re-close but still fetches status/UPO (no --save-upo). + result = await run([ + "--json", + "session", + "batch", + "close", + "--id", + "b1", + "--wait-upo", + "--poll-interval-ms", + "1", + "--max-attempts", + "3", + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /"upoPath": ""/); + + // Wrong-kind guards. + result = await run(["--json", "session", "online", "close", "--id", "b1"]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /not an online session/); + + result = await run(["--json", "session", "batch", "upload", "--id", "o1"]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /not a batch session/); + + // Unknown subcommands. + for (const args of [ + ["--json", "session"], + ["--json", "session", "bogus"], + ["--json", "session", "online"], + ["--json", "session", "online", "bogus"], + ["--json", "session", "batch"], + ["--json", "session", "batch", "bogus"], + ]) { + result = await run(args); + assert.equal(result.exitCode, 2, `${args.join(" ")} -> ${result.capture.stdout.join("\n")}`); + } + } finally { + await closeServer(mock.server); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("session edge cases exercise timeouts, missing refs and status extraction", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-edge-")); + const control = { + onlineInvoicesResponse: { referenceNumber: "INV-REF-1" }, + onlineInvoiceStatus: { status: { code: 200 }, ksefNumber: "KSEF-INV-1" }, + onlineUpo404: false, + batchStatus: { status: { code: 200 }, upo: { pages: [{ referenceNumber: "BUPO-REF-1" }] } }, + }; + const mock = createServerWithRoutes(({ address, sendJson, sendXml, sendStatus }) => ({ + "POST /v2/sessions/online": () => sendJson({ referenceNumber: "ONLINE-REF-1" }), + "POST /v2/sessions/online/ONLINE-REF-1/invoices": () => + sendJson(control.onlineInvoicesResponse), + "POST /v2/sessions/online/ONLINE-REF-1/close": () => sendJson({ ok: true }), + "GET /v2/sessions/ONLINE-REF-1/invoices/INV-REF-1": () => + sendJson(control.onlineInvoiceStatus), + "GET /v2/sessions/ONLINE-REF-1/invoices/INV-REF-1/upo": () => + control.onlineUpo404 ? sendStatus(404) : sendXml(UPO_XML), + "POST /v2/sessions/batch": () => + sendJson({ + referenceNumber: "BATCH-REF-1", + partUploadRequests: [ + { + ordinalNumber: 1, + method: "PUT", + url: `http://127.0.0.1:${address.port}/upload/1`, + headers: {}, + }, + ], + }), + "PUT /upload/1": () => sendStatus(200), + "POST /v2/sessions/batch/BATCH-REF-1/close": () => sendJson({ ok: true }), + "GET /v2/sessions/BATCH-REF-1": () => sendJson(control.batchStatus), + "GET /v2/sessions/BATCH-REF-1/upo/BUPO-REF-1": () => sendXml(UPO_XML), + })); + const address = await listen(mock.server); + mock.setAddress(address); + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + const env = await initAndLogin(tempDir, baseUrl); + const run = async (args) => { + const capture = createCaptureIo(); + const exitCode = await runCli(args, { io: capture.io, env, cwd: tempDir }); + return { exitCode, capture }; + }; + const invoiceFile = path.join(tempDir, "invoice.xml"); + await writeFile(invoiceFile, "1", "utf8"); + const zipPath = path.join(tempDir, "batch.zip"); + await writeFile( + zipPath, + await createZip([ + { fileName: "invoice-1.xml", content: Buffer.from("1", "utf8") }, + ]), + ); + + const openOnline = (id) => run(["--json", "session", "online", "open", "--id", id, "--form-code", "FA3"]); + const sendOnline = (id, extra) => + run(["--json", "session", "online", "send", "--id", id, "--invoice-file", invoiceFile, ...extra]); + + // Missing invoice reference in send response. + control.onlineInvoicesResponse = {}; + assert.equal((await openOnline("o-missing")).exitCode, 0); + let result = await sendOnline("o-missing", []); + assert.equal(result.exitCode, 5); + assert.match(result.capture.stderr.join("\n"), /does not contain invoice reference number/); + control.onlineInvoicesResponse = { referenceNumber: "INV-REF-1" }; + + // Invoice status shapes that never reach a terminal code time out. + const statusTimeout = async (id, statusBody) => { + control.onlineInvoiceStatus = statusBody; + assert.equal((await openOnline(id)).exitCode, 0); + return sendOnline(id, ["--wait-status", "--poll-interval-ms", "1", "--max-attempts", "1"]); + }; + for (const [id, body] of [ + ["o-null", null], + ["o-status-str", { status: "not-object" }], + ["o-code-str", { status: { code: "x" } }], + ["o-code-150", { status: { code: 150 } }], + ]) { + result = await statusTimeout(id, body); + assert.equal(result.exitCode, 5, `${id}: ${result.capture.stderr.join("\n")}`); + assert.match(result.capture.stderr.join("\n"), /Timed out while waiting for invoice status/); + } + + // Terminal status but UPO download keeps failing (nested ksefNumber extraction). + control.onlineInvoiceStatus = { status: { code: 200 }, invoice: { ksefNumber: "KSEF-NESTED" } }; + control.onlineUpo404 = true; + assert.equal((await openOnline("o-upofail")).exitCode, 0); + result = await sendOnline("o-upofail", [ + "--wait-status", + "--wait-upo", + "--poll-interval-ms", + "1", + "--max-attempts", + "2", + ]); + assert.equal(result.exitCode, 5); + assert.match(result.capture.stderr.join("\n"), /Timed out while waiting for invoice UPO/); + control.onlineUpo404 = false; + + // Terminal status with no ksefNumber anywhere resolves to an empty value. + control.onlineInvoiceStatus = { status: { code: 200 } }; + assert.equal((await openOnline("o-noksef")).exitCode, 0); + result = await sendOnline("o-noksef", [ + "--wait-status", + "--wait-upo", + "--poll-interval-ms", + "1", + "--max-attempts", + "3", + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /"ksefNumber": ""/); + + // Invoice object present but without a ksefNumber field. + control.onlineInvoiceStatus = { status: { code: 200 }, invoice: {} }; + assert.equal((await openOnline("o-emptyinv")).exitCode, 0); + result = await sendOnline("o-emptyinv", ["--wait-status", "--poll-interval-ms", "1", "--max-attempts", "3"]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(result.capture.stdout[0], /"ksefNumber": ""/); + + // Batch close: session status has no UPO reference. + control.batchStatus = { status: { code: 200 } }; + assert.equal( + (await run(["--json", "session", "batch", "open", "--id", "b-noupo", "--zip", zipPath])).exitCode, + 0, + ); + result = await run([ + "--json", + "session", + "batch", + "close", + "--id", + "b-noupo", + "--wait-upo", + "--poll-interval-ms", + "1", + "--max-attempts", + "3", + ]); + assert.equal(result.exitCode, 5); + assert.match(result.capture.stderr.join("\n"), /UPO reference number is not available/); + + // Batch close: session status never becomes terminal. + control.batchStatus = { status: { code: 150 } }; + assert.equal( + (await run(["--json", "session", "batch", "open", "--id", "b-timeout", "--zip", zipPath])).exitCode, + 0, + ); + result = await run([ + "--json", + "session", + "batch", + "close", + "--id", + "b-timeout", + "--wait-status", + "--poll-interval-ms", + "1", + "--max-attempts", + "1", + ]); + assert.equal(result.exitCode, 5); + assert.match(result.capture.stderr.join("\n"), /Timed out while waiting for session status/); + } finally { + await closeServer(mock.server); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("top-level send persists a session checkpoint and enforces UPO options", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "ksef-cli-send-")); + const mock = createSessionMockServer(); + const address = await listen(mock.server); + mock.setAddress(address); + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + const env = await initAndLogin(tempDir, baseUrl); + const run = async (args) => { + const capture = createCaptureIo(); + const exitCode = await runCli(args, { io: capture.io, env, cwd: tempDir }); + return { exitCode, capture }; + }; + + const invoiceFile = path.join(tempDir, "invoice.xml"); + await writeFile(invoiceFile, "1", "utf8"); + const upoOut = path.join(tempDir, "send-upo.xml"); + + let result = await run([ + "--json", + "send", + "--profile", + "default", + "--invoice-file", + invoiceFile, + "--form-code", + "FA3", + "--save-session", + "s1", + "--wait-upo", + "--save-upo", + upoOut, + "--poll-interval-ms", + "1", + "--max-attempts", + "3", + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + const payload = JSON.parse(result.capture.stdout[0]); + assert.equal(payload.sessionId, "s1"); + assert.match(await readFile(upoOut, "utf8"), //); + + // Default poll settings when --wait-upo is used without explicit poll flags. + const upoDefault = path.join(tempDir, "send-upo-default.xml"); + result = await run([ + "--json", + "send", + "--profile", + "default", + "--invoice-file", + invoiceFile, + "--form-code", + "FA3", + "--save-session", + "s-default-poll", + "--wait-upo", + "--save-upo", + upoDefault, + ]); + assert.equal(result.exitCode, 0, result.capture.stderr.join("\n")); + assert.match(await readFile(upoDefault, "utf8"), //); + + // Re-using the same session id fails because the checkpoint already exists. + result = await run([ + "--json", + "send", + "--profile", + "default", + "--invoice-file", + invoiceFile, + "--form-code", + "FA3", + "--save-session", + "s1", + ]); + assert.notEqual(result.exitCode, 0); + assert.match(result.capture.stderr.join("\n"), /already exists/); + + // --save-upo without --wait-upo is rejected. + result = await run([ + "--json", + "send", + "--profile", + "default", + "--invoice-file", + invoiceFile, + "--form-code", + "FA3", + "--save-upo", + path.join(tempDir, "unused.xml"), + ]); + assert.equal(result.exitCode, 2); + assert.match(result.capture.stderr.join("\n"), /requires --wait-upo/); + } finally { + await closeServer(mock.server); + await rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/test/unit/coverageGaps.test.js b/test/unit/coverageGaps.test.js new file mode 100644 index 0000000..f975152 --- /dev/null +++ b/test/unit/coverageGaps.test.js @@ -0,0 +1,689 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { test } from "node:test"; + +import { + FA3BatchDraft, + FA3Draft, + FA3Importer, + FA3Invoice, + ImportMode, + InvoiceParty, + PartyIdentifierKind, + auditFa3XsdCoverage, + CoverageStatus, + mapPartyIdentityToXml, + parseFa3XsdElements, + validatePartyIdentifier, + resolvePartyIdentifier, + resolveFa3SchemaEntryPath, + validateFa3XmlXsd, + XsdElement, +} from "../../dist/index.js"; + +const SELLER = { name: "Sprzedawca Sp. z o.o.", taxId: "1111111111", addressLine1: "ul. A 1, 00-001 Warszawa" }; +const BUYER = { name: "Nabywca Sp. z o.o.", taxId: "2222222222", addressLine1: "ul. B 2, 00-002 Warszawa" }; +const LINE = { description: "Usługa", quantity: 1, unit: "szt", unitNetPrice: 100, vatRate: 23 }; + +function basicBuilder(number = "FV/1") { + return FA3Invoice.basic(number).issueDate("2026-01-15").seller(SELLER).buyer(BUYER).addLine(LINE); +} + +// --------------------------------------------------------------------------- +// xml.ts + xsd.ts helpers (Node 20 compatible — no mock.module) +// --------------------------------------------------------------------------- +test("xml.ts: toFa3XmlValidationMessage handles Error and non-Error values", async () => { + const { toFa3XmlValidationMessage, validateFa3XmlWithValidator, FA3XmlValidationError } = await import( + "../../dist/documents/fa3.js" + ); + assert.equal(toFa3XmlValidationMessage(new Error("boom")), "boom"); + assert.equal(toFa3XmlValidationMessage("string-boom"), "string-boom"); + await validateFa3XmlWithValidator("", async () => undefined); + await assert.rejects( + () => validateFa3XmlWithValidator("", async () => { + throw "string-boom"; + }), + (error) => error instanceof FA3XmlValidationError && error.message === "string-boom", + ); +}); + +test("xsd.ts: resolveFa3SchemaEntryPath throws for empty candidate list", () => { + assert.throws(() => resolveFa3SchemaEntryPath([]), /Missing FA\(3\) schema file/); +}); + +test("xsd.ts: validateFa3XmlWithParser covers validation branches", async () => { + const { validateFa3XmlWithParser } = await import("../../dist/documents/fa3.js"); + const validParser = () => ({ + validate: () => true, + validationErrors: [], + }); + await validateFa3XmlWithParser("", validParser); + + const invalidParser = () => ({ + validate: () => false, + validationErrors: [{ message: "bad element" }, { message: "" }], + }); + await assert.rejects(() => validateFa3XmlWithParser("", invalidParser), /bad element/); + + const emptyErrorsParser = () => ({ + validate: () => false, + validationErrors: [], + }); + await assert.rejects( + () => validateFa3XmlWithParser("", emptyErrorsParser), + /FA\(3\) XSD validation failed\.$/, + ); + + const undefinedErrorsParser = () => ({ + validate: () => false, + }); + await assert.rejects( + () => validateFa3XmlWithParser("", undefinedErrorsParser), + /FA\(3\) XSD validation failed\.$/, + ); + + const nullishMessagesParser = () => ({ + validate: () => false, + validationErrors: [null, {}, { message: undefined }], + }); + await assert.rejects( + () => validateFa3XmlWithParser("", nullishMessagesParser), + /FA\(3\) XSD validation failed\.$/, + ); +}); + +test("xsd.ts: validateFa3XmlXsd success and error via injected loader", async () => { + const { validateFa3XmlWithParser } = await import("../../dist/documents/fa3.js"); + const validParser = () => ({ validate: () => true }); + await validateFa3XmlWithParser("", validParser); + await validateFa3XmlXsd("", async () => ({ parseXml: validParser })); + + await assert.rejects( + () => validateFa3XmlXsd("", async () => { + throw new Error("no module"); + }), + /libxmljs2/, + ); +}); + +test("xsd.ts: loadLibxml resolves or throws consistently", async () => { + const { loadLibxml } = await import("../../dist/documents/fa3.js"); + try { + const mod = await loadLibxml(); + assert.equal(typeof mod.parseXml, "function"); + } catch (error) { + assert.ok(error instanceof Error); + } +}); + +test("xsd.ts: validateFa3XmlXsd throws when libxmljs2 missing", async () => { + let hasLibxml = false; + try { + await import("libxmljs2"); + hasLibxml = true; + } catch { + hasLibxml = false; + } + if (hasLibxml) { + return; + } + await assert.rejects(() => validateFa3XmlXsd(""), /libxmljs2/); +}); + +test("xsd.ts: validateFa3XmlXsd with libxmljs2 when available", async () => { + let hasLibxml = false; + try { + await import("libxmljs2"); + hasLibxml = true; + } catch { + hasLibxml = false; + } + if (!hasLibxml) { + return; + } + const validXml = await FA3Invoice.basic("FV/XSD/OK") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .toXml(); + await validateFa3XmlXsd(validXml).catch((error) => assert.ok(error instanceof Error)); + await assert.rejects(() => validateFa3XmlXsd(""), /XSD validation failed/); +}); + +// --------------------------------------------------------------------------- +// xsdMap.ts synthetic fragments +// --------------------------------------------------------------------------- +test("xsdMap.ts: synthetic schema fragments cover parser branches", () => { + const withEnum = ` + + + + + + + + + + + `; + const parsed = parseFa3XsdElements(withEnum); + assert.ok(parsed.some((entry) => entry.path === "/Faktura/Child")); + assert.deepEqual(parsed.find((entry) => entry.path === "/Faktura/Child")?.enumValues, ["OK"]); + + const noNameSimpleType = ` + + + + `; + assert.equal(parseFa3XsdElements(noNameSimpleType).length, 1); + + const enumWithoutValue = ` + + + + `; + const enumParsed = parseFa3XsdElements(enumWithoutValue); + assert.deepEqual(enumParsed.find((entry) => entry.path === "/Faktura/X")?.enumValues, []); +}); + +// --------------------------------------------------------------------------- +// xsdAudit.ts synthetic elements +// --------------------------------------------------------------------------- +test("xsdAudit.ts: synthetic elements cover raw extension and transport handlers", () => { + const synthetic = [ + new XsdElement({ path: "/Faktura/Fa/RawXmlExtension", name: "RawXmlExtension" }), + new XsdElement({ path: "/Faktura/Fa/Transport", name: "Transport" }), + new XsdElement({ path: "/Faktura/Fa/Transport/RodzajTransportu", name: "RodzajTransportu" }), + new XsdElement({ path: "/Faktura/Unknown/Field", name: "Field" }), + ]; + const report = auditFa3XsdCoverage({ elements: synthetic }); + assert.equal(report.coverage.find((entry) => entry.path.includes("RawXmlExtension"))?.status, CoverageStatus.RAW_EXTENSION); + assert.equal(report.coverage.find((entry) => entry.path === "/Faktura/Fa/Transport")?.handler, "mapTransport"); + assert.equal(report.coverage.find((entry) => entry.path === "/Faktura/Unknown/Field")?.handler, undefined); +}); + +// --------------------------------------------------------------------------- +// importer.ts non-Error catch branches +// --------------------------------------------------------------------------- +test("importer.ts: non-Error JSON parse and row failures use String(error)", () => { + const originalParse = JSON.parse; + try { + JSON.parse = () => { + throw "json-string-boom"; + }; + const parsed = FA3Importer.fromJson('{"drafts":[]}'); + assert.match(parsed.errors[0]?.message ?? "", /json-string-boom/); + } finally { + JSON.parse = originalParse; + } + + const throwingRow = { + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + }; + Object.defineProperty(throwingRow, "invoiceNumber", { + configurable: true, + get() { + throw "row-string-boom"; + }, + }); + const rowResult = FA3Importer.fromJson({ drafts: [throwingRow] }); + assert.match(rowResult.invalidRows[0]?.message ?? "", /row-string-boom/); + assert.throws( + () => FA3Importer.fromJson({ drafts: [throwingRow] }, { mode: ImportMode.FAIL_FAST }), + /row-string-boom/, + ); +}); + +// --------------------------------------------------------------------------- +// identifier.ts branches via builder +// --------------------------------------------------------------------------- +test("identifier.ts: EU/foreign/internal/none/default identifier branches", () => { + const euEmpty = new FA3Draft({ + invoiceNumber: "FV/EU/0", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "EU", taxId: "DE123", countryCode: "DE", identifier: { kind: PartyIdentifierKind.EU_VAT, value: "" } }, + lines: [LINE], + }); + assert.equal(euEmpty.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.NrVatUE, ""); + + const foreignNoCountry = new FA3Draft({ + invoiceNumber: "FV/FGN/0", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "Foreign", taxId: "ABC", identifier: { kind: PartyIdentifierKind.FOREIGN, value: "ABC" } }, + lines: [LINE], + }); + assert.equal(foreignNoCountry.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.NrID, "ABC"); + assert.equal(foreignNoCountry.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.KodKraju, undefined); + + const internal = new FA3Draft({ + invoiceNumber: "FV/INT/0", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "Internal", taxId: "X", identifier: { kind: PartyIdentifierKind.INTERNAL, value: "" } }, + lines: [LINE], + }); + assert.equal(internal.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.IDWew, ""); + + const none = InvoiceParty.withoutTaxId({ name: "Anon" }); + const noneDraft = new FA3Draft({ + invoiceNumber: "FV/N/0", + issueDate: "2026-01-15", + seller: SELLER, + buyer: none, + lines: [LINE], + }); + assert.equal(noneDraft.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.BrakID, "1"); + + const defaultKind = new FA3Draft({ + invoiceNumber: "FV/DEF/0", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "Def", taxId: "X", identifier: { kind: "CUSTOM", value: "9" } }, + lines: [LINE], + }); + assert.equal(defaultKind.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.NIP, "9"); + + const plDefaultCountry = new FA3Draft({ + invoiceNumber: "FV/PL/0", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "PL", taxId: "3333333333" }, + lines: [LINE], + }); + assert.equal(plDefaultCountry.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.NIP, "3333333333"); + + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.EU_VAT, countryCode: "DE", value: "1" }, "EU", "buyer"), + { KodUE: "DE", NrVatUE: "1", Nazwa: "EU" }, + ); + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.FOREIGN, value: "X" }, "F", "buyer"), + { NrID: "X", Nazwa: "F" }, + ); + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.INTERNAL, value: "I" }, "Int", "buyer"), + { IDWew: "I", Nazwa: "Int" }, + ); + + assert.equal(resolvePartyIdentifier({ name: "PL", taxId: "1111111111" }).kind, PartyIdentifierKind.NIP); + assert.equal(resolvePartyIdentifier({ name: "EU", taxId: "DE123", countryCode: "DE" }).kind, PartyIdentifierKind.EU_VAT); + assert.equal(resolvePartyIdentifier({ name: "Int", taxId: "X", internalId: "IN-1" }).kind, PartyIdentifierKind.INTERNAL); + assert.equal(resolvePartyIdentifier({ name: "None", taxId: "BRAK" }).kind, PartyIdentifierKind.NONE); + assert.equal(resolvePartyIdentifier({ name: "US", taxId: "US:999" }).kind, PartyIdentifierKind.FOREIGN); +}); + +test("identifier.ts: validatePartyIdentifier validation branches", () => { + assert.deepEqual(validatePartyIdentifier({ kind: PartyIdentifierKind.NONE, value: "" }, "party"), []); + assert.ok( + validatePartyIdentifier({ kind: PartyIdentifierKind.NIP, value: " " }, "party").some( + (issue) => issue.code === "identifier_required", + ), + ); + assert.ok( + validatePartyIdentifier({ kind: PartyIdentifierKind.EU_VAT, value: "1", countryCode: " " }, "party").some( + (issue) => issue.code === "eu_vat_country_required", + ), + ); + assert.ok( + validatePartyIdentifier({ kind: PartyIdentifierKind.EU_VAT, value: undefined, countryCode: "DE" }, "party").some( + (issue) => issue.code === "identifier_required", + ), + ); + + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.EU_VAT, value: "1" }, "EU", "buyer"), + { KodUE: "", NrVatUE: "1", Nazwa: "EU" }, + ); + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.NIP, value: "123" }, "Seller", "seller"), + { NIP: "123", Nazwa: "Seller" }, + ); + + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.NIP }, "SellerNoVal", "seller"), + { NIP: "", Nazwa: "SellerNoVal" }, + ); + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.NIP }, "BuyerNoVal", "buyer"), + { NIP: "", Nazwa: "BuyerNoVal" }, + ); + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.EU_VAT }, "EuNoVal", "buyer"), + { KodUE: "", NrVatUE: "", Nazwa: "EuNoVal" }, + ); + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.FOREIGN }, "ForNoVal", "buyer"), + { NrID: "", Nazwa: "ForNoVal" }, + ); + assert.deepEqual( + mapPartyIdentityToXml({ kind: PartyIdentifierKind.INTERNAL }, "IntNoVal", "buyer"), + { IDWew: "", Nazwa: "IntNoVal" }, + ); + assert.deepEqual( + mapPartyIdentityToXml({ kind: "unknown_kind" }, "DefNoVal", "buyer"), + { NIP: "", Nazwa: "DefNoVal" }, + ); +}); + +// --------------------------------------------------------------------------- +// builder.ts remaining branch matrix +// --------------------------------------------------------------------------- +test("builder.ts: branch matrix for optional mappings and validation paths", async () => { + const emailOnly = await FA3Invoice.basic("FV/EMAIL/1") + .issueDate("2026-01-15") + .seller({ ...SELLER, email: "a@b.pl" }) + .buyer(BUYER) + .addLine(LINE) + .toXml(); + assert.match(emailOnly, /a@b.pl<\/Email>/); + assert.doesNotMatch(emailOnly, //); + + const phoneOnly = await FA3Invoice.basic("FV/PHONE/1") + .issueDate("2026-01-15") + .seller({ ...SELLER, phone: "123" }) + .buyer(BUYER) + .addLine(LINE) + .toXml(); + assert.match(phoneOnly, /123<\/Telefon>/); + + const customRole = await FA3Invoice.basic("FV/ROLE/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .addParty({ name: "Custom", taxId: "7777777777", role: "99" }) + .toXml(); + assert.match(customRole, /99<\/Rola>/); + + const unknownPayment = await basicWithPaymentMethod("custom-code"); + assert.match(unknownPayment, /custom-code<\/FormaPlatnosci>/); + + const correctionOnlyKsef = await FA3Invoice.correction("KOR/KSEF/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine({ ...LINE, beforeCorrection: true }) + .correction({ + reason: "r", + correctedInvoiceNumber: "FV/1", + correctedInvoiceDate: "2025-12-01", + correctedKsefNumber: "KSEF-1", + }) + .toXml(); + assert.match(correctionOnlyKsef, /KSEF-1<\/NumerKSeF>/); + + const correctionUnknownType = await FA3Invoice.correction("KOR/T/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .correction({ + reason: "r", + correctedInvoiceNumber: "FV/1", + correctedInvoiceDate: "2025-12-01", + correctionType: "custom", + }) + .toXml(); + assert.match(correctionUnknownType, /custom<\/TypKorekty>/); + + const advanceNoPayments = await FA3Invoice.advance("ZAL/0") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .toXml(); + assert.doesNotMatch(advanceNoPayments, //); + + const basicTotalsWithAdvance = await FA3Invoice.basic("FV/ADV/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advancePayment({ amount: 50, vatRate: 23 }) + .toXml(); + assert.match(basicTotalsWithAdvance, //); + + const rawExt = FA3Invoice.basic("FV/RAW/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .rawExtension("/x", ""); + assert.ok(rawExt.validate().some((issue) => issue.code === "raw_extension_unsupported")); + + const contractNoDate = await FA3Invoice.basic("FV/C/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .contract("UM/1") + .toXml(); + assert.match(contractNoDate, /UM\/1<\/NrUmowy>/); + assert.doesNotMatch(contractNoDate, //); + + const orderRefNoDate = await FA3Invoice.basic("FV/O/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .orderReference("ZAM/9") + .toXml(); + assert.match(orderRefNoDate, /ZAM\/9<\/NrZamowienia>/); + + const orderNoVat = await FA3Invoice.basic("FV/OL/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .order(100) + .orderLine({ description: "OL", quantity: 1, unitNetPrice: 100 }) + .toXml(); + assert.match(orderNoVat, //); + assert.doesNotMatch(orderNoVat, //); + + const transportMinimal = await FA3Invoice.basic("FV/TR/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .transport("road") + .toXml(); + assert.match(transportMinimal, /3<\/RodzajTransportu>/); + + const intermediaryFalse = await FA3Invoice.basic("FV/INTM/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .transactionTerms({ intermediary: false }) + .toXml(); + assert.match(intermediaryFalse, /0<\/Posrednik>/); + + const attachmentHeaderOnly = await FA3Invoice.basic("FV/ATT/2") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .attachment({ blocks: [{ header: "H", tables: [{ headers: ["a"], rows: [["b"]] }] }] }) + .toXml(); + assert.match(attachmentHeaderOnly, /H<\/Naglowek>/); + assert.doesNotMatch(attachmentHeaderOnly, //); + + const paymentLinkOnly = await FA3Invoice.basic("FV/LINK/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .paymentLink("https://pay.example") + .toXml(); + assert.match(paymentLinkOnly, /https:\/\/pay.example<\/LinkDoPlatnosci>/); + + const dueDefaultMethod = await FA3Invoice.basic("FV/DUE/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .paymentDue("2026-02-01") + .toXml(); + assert.match(dueDefaultMethod, /6<\/FormaPlatnosci>/); + + const goodsMinimal = await FA3Invoice.basic("FV/G/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addGoodsLine("Towar", { quantity: 1, unitNetPrice: 10 }) + .toXml(); + assert.match(goodsMinimal, /Towar<\/P_7>/); + + const fromDictSnake = FA3Draft.fromDict({ + invoice_number: "FV/SNAKE/1", + issue_date: "2026-01-15", + waluta: "usd", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + }); + const snakeXml = await fromDictSnake.toXml(); + assert.match(snakeXml, /USD<\/KodWaluty>/); + + const draftClone = new FA3Draft({ + invoiceNumber: "FV/CLONE/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + order: { number: "Z1", totalGross: 100, lines: [{ description: "L", quantity: 1, unitNetPrice: 1 }] }, + attachment: { blocks: [{ header: "h" }] }, + }); + assert.equal(draftClone.toDict().order?.lines?.length, 1); + + const settlementDueCharges = await FA3Invoice.settlement("ROZ/CH/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ invoiceNumber: "ZAL/1" }) + .settlementDetails({ amountDue: 124, charges: [{ amount: 1, reason: "c" }] }) + .toXml(); + assert.match(settlementDueCharges, //); + + const advanceCorrectionPayments = await FA3Invoice.advance_correction("KOR/Z/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .advancePayment({ amount: 10, currencyRate: 4.2 }) + .correction({ reason: "r", correctedInvoiceNumber: "ZAL/OLD", correctedInvoiceDate: "2025-12-01" }) + .toXml(); + assert.match(advanceCorrectionPayments, /4.2<\/KursWalutyZW>/); + + const prettyOff = await FA3Invoice.basic("FV/PRETTY/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .toXml({ pretty: false }); + assert.match(prettyOff, /p1<\/Akapit>/); + assert.doesNotMatch(attachmentNoTables, //); + + const attachmentEmptyTables = await FA3Invoice.basic("FV/ATT/4") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .attachment({ blocks: [{ header: "H", tables: [] }] }) + .toXml(); + assert.doesNotMatch(attachmentEmptyTables, //); + + let hasLibxml = false; + try { + await import("libxmljs2"); + hasLibxml = true; + } catch { + hasLibxml = false; + } + if (hasLibxml) { + await FA3Invoice.basic("FV/XSD/2") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .toXml({ xsdValidate: true }) + .then( + (xml) => assert.match(xml, / assert.ok(error instanceof Error), + ); + } else { + const { validateFa3XmlWithValidator, FA3XmlValidationError } = await import("../../dist/documents/fa3.js"); + await assert.rejects( + () => + validateFa3XmlWithValidator("", async () => { + throw new Error("xsd failed"); + }), + FA3XmlValidationError, + ); + await basicBuilder("FV/XSD/3") + .toXml({ xsdValidate: true }) + .then( + () => assert.fail("expected xsdValidate to fail without libxmljs2"), + (error) => assert.ok(error instanceof FA3XmlValidationError), + ); + } +}); + +function basicWithPaymentMethod(method) { + return FA3Invoice.basic("FV/PAY/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .payment({ method }) + .toXml(); +} + +test("builder.ts: FA3BatchDraft skips sparse entries in exporters", async () => { + const good = FA3Invoice.basic("FV/SPARSE/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .build(); + const sparse = new FA3BatchDraft([good, undefined]); + const dir = await mkdtemp(path.join(os.tmpdir(), "fa3-sparse-")); + try { + const files = await sparse.toXmlFiles(path.join(dir, "out")); + assert.equal(files.length, 1); + const zip = await sparse.toXmlZip(path.join(dir, "batch.zip")); + assert.ok(zip.endsWith("batch.zip")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("builder.ts: mapOrder returns undefined without lines or total", () => { + const noOrder = new FA3Draft({ + invoiceNumber: "FV/NOORDER/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + order: { number: "Z", lines: [] }, + }); + const payload = noOrder.toFakturaInput(); + assert.equal(payload.Fa.Zamowienie, undefined); +}); diff --git a/test/unit/fa3BuilderBranches.test.js b/test/unit/fa3BuilderBranches.test.js new file mode 100644 index 0000000..76df0ad --- /dev/null +++ b/test/unit/fa3BuilderBranches.test.js @@ -0,0 +1,379 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + FA3Draft, + FA3Invoice, + PartyIdentifierKind, + sanitizeFileName, + validateAndReturnXml, +} from "../../dist/index.js"; + +const SELLER = { name: "Sprzedawca Sp. z o.o.", taxId: "1111111111", addressLine1: "ul. A 1" }; +const BUYER = { name: "Nabywca Sp. z o.o.", taxId: "2222222222", addressLine1: "ul. B 2" }; +const LINE = { description: "Usługa", quantity: 1, unit: "szt", unitNetPrice: 100, vatRate: 23 }; + +test("builder.ts: additional party defaults and optional party fields", async () => { + const xml = await FA3Invoice.basic("FV/P3/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .addParty({ name: "DefaultRole", taxId: "7777777777" }) + .toXml(); + assert.match(xml, /2<\/Rola>/); +}); + +test("builder.ts: correction totals, advance totals and validation edges", async () => { + const correctionSign = await FA3Invoice.correction("KOR/SIGN/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine({ ...LINE, beforeCorrection: true }) + .addLine({ ...LINE, description: "Po", beforeCorrection: false }) + .correction({ reason: "r", correctedInvoiceNumber: "FV/1", correctedInvoiceDate: "2025-12-01" }) + .toXml(); + assert.match(correctionSign, /1<\/StanPrzed>/); + + const advanceTotals = await FA3Invoice.basic("FV/AT/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advancePayment({ amount: 50, vatRate: 8 }) + .toXml(); + assert.match(advanceTotals, //); + + const emptyLines = new FA3Draft({ + invoiceNumber: "FV/EL/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [], + }); + assert.ok(emptyLines.validate().some((issue) => issue.code === "lines_required")); +}); + +test("builder.ts: payment and settlement optional branches", async () => { + const paymentMinimal = await FA3Invoice.basic("FV/PAYM/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .payment({ otherMethodDescription: "Barter" }) + .toXml(); + assert.match(paymentMinimal, /1<\/PlatnoscInna>/); + + const noPartial = await FA3Invoice.basic("FV/NPP/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .payment({ dueDate: "2026-02-01" }) + .toXml(); + assert.doesNotMatch(noPartial, //); + + const noFactor = await FA3Invoice.basic("FV/NF/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .bankAccount({ number: "PL11" }) + .toXml(); + assert.match(noFactor, //); + assert.doesNotMatch(noFactor, //); + + const settlementChargesOnly = await FA3Invoice.settlement("ROZ/C/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ invoiceNumber: "ZAL/1" }) + .settlementDetails({ + amountDue: 124, + charges: [{ amount: 1, reason: "opłata" }], + deductions: [{ amount: 0, reason: "zero" }], + }) + .toXml(); + assert.match(settlementChargesOnly, //); +}); + +test("builder.ts: line amount overrides and party identifier validation branches", async () => { + const lineOverrides = await FA3Invoice.basic("FV/LINE/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine({ + ...LINE, + netAmount: 90, + vatAmount: 10, + grossAmount: 100, + vatRate: 0, + }) + .toXml(); + assert.match(lineOverrides, /90.00<\/P_11>/); + + const foreignBuyer = new FA3Draft({ + invoiceNumber: "FV/FB/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { + name: "Foreign", + taxId: "CH-1", + countryCode: "CH", + identifier: { kind: PartyIdentifierKind.FOREIGN, value: "CH-1", countryCode: "CH" }, + }, + lines: [LINE], + }); + assert.equal(foreignBuyer.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.KodKraju, "CH"); + + const jstOk = await FA3Invoice.basic("FV/JSTOK/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer({ ...BUYER, isJstSubunit: true }) + .addLine(LINE) + .addParty({ name: "JST", taxId: "5555555555", role: "jst_subunit" }) + .toXml(); + assert.match(jstOk, //); + + const gvOk = await FA3Invoice.basic("FV/GVOK/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer({ ...BUYER, isVatGroupMember: true }) + .addLine(LINE) + .addParty({ name: "GV", taxId: "6666666666", role: "vat_group_member" }) + .toXml(); + assert.match(gvOk, /1<\/GV>/); +}); + +test("builder.ts: fromDict fallbacks and transport/order false branches", async () => { + const fromDict = FA3Draft.fromDict({ + invoiceNumber: "", + issue_date: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + }); + assert.equal(fromDict.toDict().invoiceNumber, ""); + + const noTransport = await FA3Invoice.basic("FV/NT/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .toXml(); + assert.doesNotMatch(noTransport, //); + + const orderRefOnly = await FA3Invoice.basic("FV/OR/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .orderReference("ZAM/1", "2026-01-01") + .toXml(); + assert.match(orderRefOnly, //); +}); + +test("builder.ts: remaining optional false branches and advance reference clearing", async () => { + const correctionMinimal = await FA3Invoice.correction("KOR/MIN/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .correction({ reason: "r", correctedInvoiceNumber: "FV/1", correctedInvoiceDate: "2025-12-01" }) + .toXml(); + assert.doesNotMatch(correctionMinimal, //); + + const correctedDateOnly = await FA3Invoice.correction("KOR/D/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .correction({ reason: "r", correctedInvoiceNumber: "FV/1", correctedInvoiceDate: "2025-12-01" }) + .toXml(); + assert.match(correctedDateOnly, //); + + const advanceRateOnly = await FA3Invoice.advance("ZAL/R/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .foreignCurrencyRate(4.5) + .toXml(); + assert.match(advanceRateOnly, //); + + const otherWithDescription = await FA3Invoice.basic("FV/OD/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .addParty({ name: "Inna", taxId: "8888888888", role: "other", otherRoleDescription: "Opis" }) + .toXml(); + assert.match(otherWithDescription, /Opis<\/OpisRoli>/); + + const clearedKsef = FA3Invoice.settlement("ROZ/K/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ ksefNumber: "KSEF-ZAL" }) + .settlesAdvance({ invoiceNumber: "ZAL/1", ksefNumber: "KSEF-ZAL" }) + .settlesAdvance({ invoiceNumber: "ZAL/1" }) + .build(); + assert.equal(clearedKsef.toDict().advanceKsefNumber, undefined); + assert.equal(clearedKsef.toDict().advanceInvoiceNumber, "ZAL/1"); +}); + +test("builder.ts: toXml xsdValidate path runs regardless of libxmljs2", async () => { + const builder = FA3Invoice.basic("FV/XSD/926") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE); + try { + const xml = await builder.toXml({ xsdValidate: true }); + assert.match(xml, / { + const xml = await FA3Invoice.basic("FV/PRETTY/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .toXml({ pretty: true }); + assert.match(xml, / { + const xml = await FA3Invoice.basic("FV/TRK/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .transport("99") + .toXml(); + assert.match(xml, /99<\/RodzajTransportu>/); +}); + +test("builder.ts: orderLine before order() and splitPayment before payment()", async () => { + const draft = FA3Invoice.basic("FV/OL/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .orderLine({ description: "Zam", quantity: 1, unitNetPrice: 100, vatRate: 23 }) + .order(123) + .splitPayment() + .build(); + const fa = draft.toFakturaInput().Fa; + assert.ok(fa.Zamowienie); + assert.equal(fa.Platnosc.FormaPlatnosci, "6"); +}); + +test("builder.ts: order date without number covers optional order branches", () => { + const draft = new FA3Draft({ + invoiceNumber: "FV/ODT/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + order: { date: "2025-11-30" }, + }); + const terms = draft.toFakturaInput().Fa.WarunkiTransakcji; + assert.ok(terms.Zamowienia); + assert.equal(terms.Zamowienia[0].DataZamowienia, "2025-11-30"); + assert.equal(terms.Zamowienia[0].NrZamowienia, undefined); +}); + +test("builder.ts: attachment block without header keeps tables and paragraphs optional", () => { + const draft = new FA3Draft({ + invoiceNumber: "FV/ATT/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + attachment: { + blocks: [{ tables: [{ headers: ["A"], rows: [["1"]] }] }], + }, + }); + const blok = draft.toFakturaInput().Fa.Zalacznik.Blok; + assert.equal(blok[0].Naglowek, undefined); + assert.ok(blok[0].Tabela); +}); + +test("builder.ts: correction toFakturaInput with ksef only skips corrected date", () => { + const draft = new FA3Draft({ + invoiceNumber: "KOR/KSEF/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + kind: "correction", + correctedKsefNumber: "KSEF-123", + }); + const dane = draft.toFakturaInput().Fa.DaneFaKorygowanej; + assert.equal(dane.NumerKSeF, "KSEF-123"); + assert.equal(dane.DataWystawieniaFa, undefined); +}); + +test("builder.ts: additional party role other without description via toFakturaInput", () => { + const draft = new FA3Draft({ + invoiceNumber: "FV/AP3/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + additionalParties: [{ name: "Inna", taxId: "8888888888", role: "other" }], + }); + const podmiot3 = draft.toFakturaInput().Podmiot3; + assert.equal(podmiot3[0].RolaInna, "1"); + assert.equal(podmiot3[0].OpisRoli, undefined); +}); + +test("builder.ts: settlement amountDue without charges/deductions validates", () => { + const draft = new FA3Draft({ + invoiceNumber: "ROZ/AD/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + kind: "settlement", + advanceInvoiceNumber: "ZAL/1", + settlement: { amountDue: 999 }, + }); + const issues = draft.validate(); + assert.ok(issues.some((issue) => issue.code === "settlement_amount_due_inconsistent")); +}); + +test("builder.ts: fromDict with empty object falls back to defaults", () => { + const draft = FA3Draft.fromDict({}); + const dict = draft.toDict(); + assert.equal(dict.invoiceNumber, ""); + assert.equal(dict.issueDate, ""); + assert.equal(dict.currency, "PLN"); + assert.deepEqual(dict.lines, []); +}); + +test("builder.ts: sanitizeFileName sanitizes and applies fallbacks", () => { + assert.equal(sanitizeFileName("FV/2026 01"), "FV_2026_01"); + assert.equal(sanitizeFileName(""), "faktura"); + assert.equal(sanitizeFileName(" ", "invoice-3"), "invoice-3"); +}); + +test("builder.ts: validateAndReturnXml returns xml after validation succeeds", async () => { + let seen = ""; + const result = await validateAndReturnXml("", async (value) => { + seen = value; + }); + assert.equal(result, ""); + assert.equal(seen, ""); + + await assert.rejects( + () => validateAndReturnXml("", async () => { + throw new Error("boom"); + }), + /boom/, + ); +}); diff --git a/test/unit/fa3Coverage.test.js b/test/unit/fa3Coverage.test.js new file mode 100644 index 0000000..32d7cf7 --- /dev/null +++ b/test/unit/fa3Coverage.test.js @@ -0,0 +1,1232 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { test, mock } from "node:test"; + +import { + // builder + FA3Invoice, + FA3Draft, + FA3BatchDraft, + FA3InvoiceBuilder, + BaseFA3Builder, + AdvanceCorrectionInvoiceBuilder, + SettlementCorrectionInvoiceBuilder, + // template + FA3Template, + // tax + TaxSummary, + // importer + FA3Importer, + FA3ImportResult, + FA3InvalidRow, + FA3ImportError, + ImportMode, + toBatchDraft, + // domain + Address, + Contact, + PartyIdentifier, + PartyIdentifierKind, + InvoiceParty, + TaxCategory, + Discount, + InvoiceLine, + PartialPayment, + SettlementAdjustment, + AnnotationSet, + FA3ValidationResult, + ThirdPartyRole, + AuthorizedPartyRole, + // sections + AdditionalDescription, + Registry, + Footer, + PaymentDue, + CorrectedAdvanceState, + ExciseRefund, + AdvancePayment, + // publicApi factories + Annotation, + Attachment, + AttachmentBlock, + AttachmentTable, + BankAccount, + Contract, + CorrectionReference, + FA3InvoiceKind, + FA3Line, + FA3Party, + FA3ValidationIssue, + LineIdentifiers, + NewTransportMeans, + Order, + OrderLine, + PaymentTerms, + RawXmlExtension, + Settlement, + TransactionTerms, + Transport, + ValidationContext, + // xml + validateFa3Xml, + invoiceToXml, + invoice_to_xml, + FA3XmlValidationError, + // xsd + resolveFa3SchemaEntryPath, + loadFa3SchemaWithLocalImports, + validateFa3XmlXsd, + // xsdMap + parseFa3XsdElements, + parse_fa3_xsd_elements, + XsdElement, + // xsdAudit + auditFa3XsdCoverage, + audit_fa3_xsd_coverage, + CoverageStatus, +} from "../../dist/index.js"; + +import * as fa3Subpath from "../../dist/documents/fa3.js"; + +const SELLER = { name: "Sprzedawca Sp. z o.o.", taxId: "1111111111", addressLine1: "ul. A 1, 00-001 Warszawa" }; +const BUYER = { name: "Nabywca Sp. z o.o.", taxId: "2222222222", addressLine1: "ul. B 2, 00-002 Warszawa" }; +const LINE = { description: "Usługa", quantity: 1, unit: "szt", unitNetPrice: 100, vatRate: 23 }; + +function basicBuilder(number = "FV/1") { + return FA3Invoice.basic(number).issueDate("2026-01-15").seller(SELLER).buyer(BUYER).addLine(LINE); +} + +let hasLibxml = false; +try { + await import("libxmljs2"); + hasLibxml = true; +} catch { + hasLibxml = false; +} + +// --------------------------------------------------------------------------- +// 1. fa3.ts barrel — touch every re-export binding (c8 counts each as a function on line 1) +// --------------------------------------------------------------------------- +test("fa3.ts barrel re-exports FA3Invoice and builds", async () => { + assert.equal(typeof fa3Subpath.FA3Invoice, "function"); + const builder = fa3Subpath.FA3Invoice.basic("FV/BARREL/1"); + builder.issueDate("2026-01-15").seller(SELLER).buyer(BUYER).addLine(LINE); + const xml = await builder.toXml(); + assert.match(xml, /FV\/BARREL\/1<\/P_2>/); +}); + +test("fa3.ts barrel touches every public re-export binding", () => { + const keys = Object.keys(fa3Subpath).sort(); + assert.ok(keys.length >= 80); + for (const key of keys) { + const value = fa3Subpath[key]; + assert.notEqual(value, undefined, key); + if (typeof value === "function" && value.prototype?.constructor === value) { + try { + value(); + } catch { + // constructors may require args + } + } + } +}); + +// --------------------------------------------------------------------------- +// 2. FA3Template +// --------------------------------------------------------------------------- +test("FA3Template.sampleBatch + createJson + create_xlsx throws", async () => { + const batch = FA3Template.sampleBatch(); + assert.ok(batch instanceof FA3BatchDraft); + assert.equal(batch.drafts.length, 1); + + const dir = await mkdtemp(path.join(os.tmpdir(), "fa3-tmpl-")); + try { + const target = path.join(dir, "nested", "sample.json"); + const written = FA3Template.createJson(target); + assert.equal(written, path.resolve(target)); + const parsed = FA3BatchDraft.fromJson(await import("node:fs").then((fs) => fs.readFileSync(written, "utf8"))); + assert.equal(parsed.drafts.length, 1); + } finally { + await rm(dir, { recursive: true, force: true }); + } + + assert.throws(() => FA3Template.create_xlsx(), /XLSX/); +}); + +// --------------------------------------------------------------------------- +// 3. TaxSummary.fromLines +// --------------------------------------------------------------------------- +test("TaxSummary.fromLines: comma decimals, null vatRate, explicit amounts, multi-rate", () => { + const rows = TaxSummary.fromLines([ + { description: "a", quantity: "2", unit: "szt", unitNetPrice: "10,50", vatRate: 23 }, + { description: "b", quantity: 1, unit: "szt", unitNetPrice: 100, vatRate: null }, + { description: "c", quantity: 1, unit: "szt", unitNetPrice: 100, vatRate: 23 }, + { + description: "d", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 8, + netAmount: 100, + vatAmount: 8, + grossAmount: 108, + }, + { description: "e", quantity: "x", unit: "szt", unitNetPrice: "y" }, + ]); + const byCode = Object.fromEntries(rows.map((r) => [r.rateCode, r])); + assert.ok(byCode["23"]); + assert.ok(byCode["0 KR"]); + assert.ok(byCode["8"]); + // 23% aggregation: (2*10.5=21) + (100) = 121 net + assert.equal(byCode["23"].net, 121); + assert.equal(byCode["8"].vat, 8); +}); + +// --------------------------------------------------------------------------- +// 4. publicApi factories +// --------------------------------------------------------------------------- +test("publicApi factories cover all branches", () => { + assert.deepEqual(FA3Party.create({ name: "n", taxId: "t" }), { name: "n", taxId: "t" }); + assert.deepEqual(FA3Line.create(LINE), { ...LINE }); + + const issue = new FA3ValidationIssue("msg"); + assert.equal(issue.code, "validation_issue"); + assert.equal(issue.path, undefined); + const located = issue.withLocation("some.path"); + assert.equal(located.path, "some.path"); + const issueWithPath = new FA3ValidationIssue("m", "c", "p"); + assert.equal(issueWithPath.path, "p"); + + assert.deepEqual(Annotation.create("k", "v"), { key: "k", value: "v" }); + assert.equal(Annotation.splitPayment().key, "split_payment"); + assert.equal(Annotation.cashMethod().key, "cash_method"); + + const fullTable = AttachmentTable.create({ + headers: ["h1"], + rows: [["r1"]], + columnTypes: ["text"], + metadata: [["a", "b"]], + description: "desc", + footer: ["f"], + }); + assert.equal(fullTable.description, "desc"); + const minTable = AttachmentTable.create({ headers: ["h"], rows: [["r"]] }); + assert.equal(minTable.description, undefined); + + const fullBlock = AttachmentBlock.create({ + header: "H", + metadata: [["a", "b"]], + paragraphs: ["p"], + tables: [minTable], + }); + assert.equal(fullBlock.header, "H"); + const emptyBlock = AttachmentBlock.create(); + assert.deepEqual(emptyBlock, {}); + + const att = Attachment.create([fullBlock]); + assert.equal(att.blocks.length, 1); + const attText = Attachment.text("Header", "p1", "p2"); + assert.equal(attText.blocks[0].header, "Header"); + + assert.deepEqual(BankAccount.create("PL123", "d"), { number: "PL123", description: "d" }); + assert.deepEqual(BankAccount.create("PL123"), { number: "PL123" }); + + assert.deepEqual(Contract.create("C1", "2026-01-01"), { number: "C1", date: "2026-01-01" }); + assert.deepEqual(Contract.create("C1"), { number: "C1" }); + + assert.deepEqual(CorrectionReference.create("F1", "2026-01-01", "KSEF1"), { + invoiceNumber: "F1", + issueDate: "2026-01-01", + ksefNumber: "KSEF1", + }); + assert.deepEqual(CorrectionReference.create("F1"), { invoiceNumber: "F1" }); + + assert.deepEqual(LineIdentifiers.create({ gtin: "123" }), { gtin: "123" }); + assert.deepEqual(NewTransportMeans.create({ make: "VW" }), { make: "VW" }); + assert.deepEqual(OrderLine.create({ description: "d", quantity: 1, unitNetPrice: 2 }), { + description: "d", + quantity: 1, + unitNetPrice: 2, + }); + + const orderWithLines = Order.create({ number: "Z1", lines: [{ description: "d", quantity: 1, unitNetPrice: 2 }] }); + assert.equal(orderWithLines.lines.length, 1); + const orderNoLines = Order.create({ number: "Z2" }); + assert.equal(orderNoLines.lines, undefined); + + const ptFull = PaymentTerms.create({ + method: "6", + partialPayments: [{ amount: 1, paidOn: "2026-01-01" }], + bankAccounts: [{ number: "PL1" }], + }); + assert.equal(ptFull.partialPayments.length, 1); + const ptMin = PaymentTerms.create({ method: "6" }); + assert.equal(ptMin.partialPayments, undefined); + const transferFull = PaymentTerms.transfer({ dueDate: "2026-02-01", bankAccount: { number: "PL9" } }); + assert.equal(transferFull.method, "6"); + assert.equal(transferFull.bankAccounts.length, 1); + const transferEmpty = PaymentTerms.transfer(); + assert.equal(transferEmpty.method, "6"); + assert.equal(transferEmpty.bankAccounts, undefined); + + assert.deepEqual(RawXmlExtension.create("/x", ""), { path: "/x", xml: "" }); + + const settlementFull = Settlement.create({ + amountDue: 10, + charges: [{ amount: 1, reason: "r" }], + deductions: [{ amount: 2, reason: "r2" }], + }); + assert.equal(settlementFull.charges.length, 1); + const settlementMin = Settlement.create({ amountDue: 10 }); + assert.equal(settlementMin.charges, undefined); + + assert.deepEqual(TransactionTerms.create({ deliveryTerms: "EXW" }), { deliveryTerms: "EXW" }); + assert.deepEqual(Transport.create({ kind: "road" }), { kind: "road" }); + + assert.deepEqual(ValidationContext.create("src", 3), { source: "src", rowNumber: 3 }); + assert.deepEqual(ValidationContext.create(), {}); + + assert.equal(FA3InvoiceKind.BASIC, "basic"); +}); + +// --------------------------------------------------------------------------- +// 5. domain.ts +// --------------------------------------------------------------------------- +test("domain.ts helpers cover all branches", () => { + assert.deepEqual(Address.polish("l1"), { countryCode: "PL", line1: "l1" }); + assert.deepEqual(Address.polish("l1", "l2", "l3"), { countryCode: "PL", line1: "l1", line2: "l2", line3: "l3" }); + assert.deepEqual(Address.foreign("de", "l1"), { countryCode: "DE", line1: "l1" }); + assert.deepEqual(Address.foreign("de", "l1", "l2", "l3"), { + countryCode: "DE", + line1: "l1", + line2: "l2", + line3: "l3", + }); + + assert.deepEqual(Contact.create({ email: "e@x.pl", phone: "123" }), { email: "e@x.pl", phone: "123" }); + + assert.deepEqual(PartyIdentifier.polishNip("111"), { kind: PartyIdentifierKind.NIP, value: "111" }); + assert.deepEqual(PartyIdentifier.euVat("de", "123"), { + kind: PartyIdentifierKind.EU_VAT, + value: "123", + countryCode: "DE", + }); + assert.deepEqual(PartyIdentifier.foreign("F1", "us"), { + kind: PartyIdentifierKind.FOREIGN, + value: "F1", + countryCode: "US", + }); + assert.deepEqual(PartyIdentifier.foreign("F1"), { kind: PartyIdentifierKind.FOREIGN, value: "F1" }); + assert.deepEqual(PartyIdentifier.internal("I1"), { kind: PartyIdentifierKind.INTERNAL, value: "I1" }); + assert.deepEqual(PartyIdentifier.none(), { kind: PartyIdentifierKind.NONE }); + + const pc = InvoiceParty.polishCompany({ nip: "111", name: "n", address: "a", email: "e", phone: "p" }); + assert.equal(pc.taxId, "111"); + assert.equal(pc.email, "e"); + const pcMin = InvoiceParty.polishCompany({ nip: "111", name: "n" }); + assert.equal(pcMin.addressLine1, undefined); + + const eu = InvoiceParty.euCompany({ vatId: "123", countryCode: "de", name: "n", address: "a", email: "e", phone: "p" }); + assert.equal(eu.taxId, "DE123"); + const euMin = InvoiceParty.euCompany({ vatId: "123", countryCode: "de", name: "n" }); + assert.equal(euMin.email, undefined); + + const fc = InvoiceParty.foreignCompany({ identifier: "X", countryCode: "us", name: "n", address: "a" }); + assert.equal(fc.taxId, "US:X"); + const fcMin = InvoiceParty.foreignCompany({ identifier: "X", countryCode: "us", name: "n" }); + assert.equal(fcMin.addressLine1, undefined); + + const wt = InvoiceParty.withoutTaxId({ name: "n", countryCode: "us", address: "a" }); + assert.equal(wt.taxId, "BRAK"); + assert.equal(wt.countryCode, "US"); + const wtMin = InvoiceParty.withoutTaxId({ name: "n" }); + assert.equal(wtMin.countryCode, "PL"); + + assert.equal(TaxCategory.standard23().vatRate, 23); + assert.equal(TaxCategory.standard22().vatRate, 22); + assert.equal(TaxCategory.reduced8().vatRate, 8); + assert.equal(TaxCategory.reduced7().vatRate, 7); + assert.equal(TaxCategory.reduced5().vatRate, 5); + assert.equal(TaxCategory.zeroDomestic().code, "0 KR"); + assert.equal(TaxCategory.zeroWdt().code, "0 WDT"); + assert.equal(TaxCategory.zeroExport().code, "0 EX"); + assert.equal(TaxCategory.exempt("art 43").exemptionBasis, "art 43"); + assert.equal(TaxCategory.outsideCountry().code, "np I"); + assert.equal(TaxCategory.serviceArticle100().code, "np II"); + assert.equal(TaxCategory.reverseCharge().code, "oo"); + + assert.deepEqual(Discount.amount(10, "promo"), { kind: "amount", value: 10, reason: "promo" }); + assert.deepEqual(Discount.amount(10), { kind: "amount", value: 10 }); + assert.deepEqual(Discount.percent(5, "promo"), { kind: "percent", value: 5, reason: "promo" }); + assert.deepEqual(Discount.percent(5), { kind: "percent", value: 5 }); + + const goods = InvoiceLine.goods("g", { + quantity: 1, + unitNetPrice: 100, + unit: "kg", + vatRate: 23, + beforeCorrection: false, + gtu: "GTU_01", + procedure: "SW", + annex15: true, + }); + assert.equal(goods.unit, "kg"); + const goodsMin = InvoiceLine.goods("g", { quantity: 1, unitNetPrice: 100 }); + assert.equal(goodsMin.unit, "szt"); + assert.equal(InvoiceLine.service("s", { quantity: 1, unitNetPrice: 5 }).description, "s"); + assert.equal(InvoiceLine.correctedBefore("b", { quantity: 1, unitNetPrice: 5 }).beforeCorrection, true); + assert.equal(InvoiceLine.correctedAfter("a", { quantity: 1, unitNetPrice: 5 }).beforeCorrection, false); + + assert.deepEqual(PartialPayment.create(10, "2026-01-01"), { amount: 10, paidOn: "2026-01-01" }); + assert.deepEqual(PartialPayment.create(10, "2026-01-01", { method: "6", otherMethodDescription: "d" }), { + amount: 10, + paidOn: "2026-01-01", + method: "6", + otherMethodDescription: "d", + }); + + assert.deepEqual(SettlementAdjustment.create(5, "reason"), { amount: 5, reason: "reason" }); + + const set = new AnnotationSet([{ key: "k", value: "v" }]); + assert.equal(set.items.length, 1); + assert.equal(AnnotationSet.default().items.length, 0); + assert.equal(AnnotationSet.splitPayment().key, "split_payment"); + assert.equal(AnnotationSet.cashMethod().key, "cash_method"); + + const res = new FA3ValidationResult([{ code: "c", message: "m" }], [{ code: "w", message: "w" }]); + assert.equal(res.errors.length, 1); + assert.equal(res.warnings.length, 1); + const resDefault = new FA3ValidationResult(); + assert.deepEqual(resDefault.errors, []); + + assert.equal(ThirdPartyRole.OTHER, "11"); + assert.equal(AuthorizedPartyRole.REPRESENTATIVE, "1"); +}); + +// --------------------------------------------------------------------------- +// 6. sections.ts +// --------------------------------------------------------------------------- +test("sections.ts helpers cover all branches", () => { + const ad = new AdditionalDescription("k", "v"); + assert.equal(ad.key, "k"); + assert.equal(AdditionalDescription.keyValue("k2", "v2").value, "v2"); + + const reg = new Registry("KRS", "123", "Full Name"); + assert.equal(reg.fullName, "Full Name"); + assert.equal(Registry.krsEntry("456").fullName, undefined); + assert.equal(Registry.krsEntry("456", "Name").fullName, "Name"); + + assert.equal(new Footer("footer text").text, "footer text"); + + assert.equal(PaymentDue.date("2026-02-01"), "2026-02-01"); + assert.equal(PaymentDue.description(30, "dni", "2026-01-01"), "30 dni od 2026-01-01"); + + assert.equal(new CorrectedAdvanceState("state").text, "state"); + + assert.equal(new ExciseRefund().enabled, true); + assert.equal(new ExciseRefund(false).enabled, false); + + assert.deepEqual(AdvancePayment.create(100), { amount: 100 }); + assert.deepEqual(AdvancePayment.create(100, { vatRate: 23, paidOn: "2026-01-01", currencyRate: 4.5 }), { + amount: 100, + vatRate: 23, + paidOn: "2026-01-01", + currencyRate: 4.5, + }); +}); + +// --------------------------------------------------------------------------- +// 7. importer +// --------------------------------------------------------------------------- +test("importer: fromJson string / faktury key / object / VALIDATE_ONLY", () => { + const validRow = { + invoiceNumber: "FV/IMP/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + }; + + const fromString = FA3Importer.fromJson(JSON.stringify({ drafts: [validRow] })); + assert.equal(fromString.validDrafts.length, 1); + + const fromFaktury = FA3Importer.fromJson(JSON.stringify({ faktury: [validRow] })); + assert.equal(fromFaktury.validDrafts.length, 1); + + const fromObject = FA3Importer.fromJson({ drafts: [validRow] }); + assert.equal(fromObject.validDrafts.length, 1); + + const validateOnly = FA3Importer.fromJson({ drafts: [validRow] }, { mode: ImportMode.VALIDATE_ONLY }); + assert.equal(validateOnly.validDrafts.length, 0); + assert.equal(validateOnly.invalidRows.length, 0); + + // from_json alias + toBatchDraft + const aliased = FA3Importer.from_json({ drafts: [validRow] }); + const batch = toBatchDraft(aliased); + assert.ok(batch instanceof FA3BatchDraft); + assert.equal(batch.drafts.length, 1); +}); + +test("importer: file path source", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "fa3-imp-")); + try { + const file = path.join(dir, "batch.json"); + await writeFile( + file, + JSON.stringify({ + drafts: [{ invoiceNumber: "FV/FILE/1", issueDate: "2026-01-15", seller: SELLER, buyer: BUYER, lines: [LINE] }], + }), + "utf8", + ); + const result = FA3Importer.fromJson(file); + assert.equal(result.validDrafts.length, 1); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("importer: invalid JSON normal + FAIL_FAST", () => { + const normal = FA3Importer.fromJson("{ not json "); + assert.equal(normal.validDrafts.length, 0); + assert.equal(normal.invalidRows.length, 1); + assert.equal(normal.errors.length, 1); + + assert.throws(() => FA3Importer.fromJson("{ not json ", { mode: ImportMode.FAIL_FAST }), FA3ImportError); +}); + +test("importer: empty payload normal + FAIL_FAST + non-object payload", () => { + const empty = FA3Importer.fromJson(JSON.stringify({ other: [] })); + assert.equal(empty.invalidRows.length, 1); + assert.match(empty.errors[0].message, /drafts lub faktury/); + + const nonObject = FA3Importer.fromJson("123"); + assert.equal(nonObject.invalidRows.length, 1); + + assert.throws( + () => FA3Importer.fromJson(JSON.stringify({ other: [] }), { mode: ImportMode.FAIL_FAST }), + FA3ImportError, + ); +}); + +test("importer: invalid row (validation issues) normal + FAIL_FAST", () => { + const badRow = { invoiceNumber: "", issueDate: "", seller: {}, buyer: {}, lines: [] }; + const normal = FA3Importer.fromJson({ drafts: [badRow] }); + assert.equal(normal.validDrafts.length, 0); + assert.equal(normal.invalidRows.length, 1); + assert.ok(normal.errors.length > 0); + + assert.throws(() => FA3Importer.fromJson({ drafts: [badRow] }, { mode: ImportMode.FAIL_FAST }), FA3ImportError); +}); + +test("importer: row that throws during validation normal + FAIL_FAST", () => { + // simplifiedReceiptLike triggers extractTotals -> toNumber throws on bad quantity + const throwingRow = { + invoiceNumber: "FV/THROW/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [{ description: "x", quantity: "abc", unit: "szt", unitNetPrice: "def", vatRate: 23 }], + simplifiedReceiptLike: true, + }; + const normal = FA3Importer.fromJson({ drafts: [throwingRow] }); + assert.equal(normal.invalidRows.length, 1); + assert.ok(normal.errors.length > 0); + + assert.throws(() => FA3Importer.fromJson({ drafts: [throwingRow] }, { mode: ImportMode.FAIL_FAST }), FA3ImportError); +}); + +test("importer: fromXlsx throws + result/row constructors", () => { + assert.throws(() => FA3Importer.fromXlsx(), FA3ImportError); + + const row = new FA3InvalidRow("msg", 2, "FV/1"); + assert.equal(row.rowNumber, 2); + assert.equal(row.invoiceNumber, "FV/1"); + const rowMin = new FA3InvalidRow("msg"); + assert.equal(rowMin.rowNumber, undefined); + + const resDefault = new FA3ImportResult(); + assert.deepEqual(resDefault.validDrafts, []); + const res = new FA3ImportResult({ + validDrafts: [], + invalidRows: [row], + errors: [{ code: "c", message: "m" }], + warnings: [{ code: "w", message: "w" }], + }); + assert.equal(res.invalidRows.length, 1); + assert.equal(res.warnings.length, 1); +}); + +// --------------------------------------------------------------------------- +// 8. builder gaps + mappings +// --------------------------------------------------------------------------- +test("builder: full-featured basic invoice serializes many branches", async () => { + const builder = FA3Invoice.basic("FV/FULL/1") + .issuedOn(new Date("2026-01-15T10:00:00Z")) + .issuePlace("Warszawa") + .currency("eur") + .seller({ + ...SELLER, + addressLine2: "piętro 2", + addressLine3: "biuro 3", + eori: "PL123456789", + email: "seller@x.pl", + phone: "500100200", + }) + .buyer(BUYER) + .saleDate("2026-01-14T00:00:00Z") + .addGoodsLine("Towar", { + quantity: 2, + unitNetPrice: 50, + vatRate: 23, + gtu: "GTU_01", + procedure: "SW", + annex15: true, + }) + .addLine({ + description: "Z metadanymi", + quantity: 1, + unit: "szt", + unitNetPrice: 10, + vatRate: 0, + uniqueId: "UU-1", + serviceDate: "2026-01-10T00:00:00Z", + beforeCorrection: false, + }) + .additionalDescription("Uwaga", "Treść") + .contract("UM/1", "2026-01-01") + .orderReference("ZAM/1", "2026-01-02") + .transactionTerms({ deliveryTerms: "EXW", contractualRate: "4.5", contractualCurrency: "EUR", intermediary: true }) + .transport("road", { orderNumber: "TR/1", cargoDescription: "paczki", packageUnit: "karton" }) + .payment({ + dueDate: "2026-02-01", + method: "6", + bankAccounts: [ + { number: "PL10", swift: "SWIFT", bankName: "Bank", description: "opis", ownBankAccountType: "1" }, + ], + factorBankAccounts: [{ number: "PL20" }], + paymentLink: "https://pay", + ipksef: "IP1", + partialPayments: [ + { amount: 10, paidOn: "2026-01-20" }, + { amount: 5, paidOn: "2026-01-21", otherMethodDescription: "Barter" }, + ], + }) + .foreignCurrencyRate(4.32); + + const xml = await builder.toXml({ pretty: true }); + assert.match(xml, /PL123456789<\/NrEORI>/); + assert.match(xml, //); + assert.match(xml, /1<\/PlatnoscInna>/); + assert.match(xml, /3<\/RodzajTransportu>/); + assert.match(xml, /GTU_01<\/GTU>/); + assert.match(xml, /UU-1<\/UU_ID>/); + assert.match(xml, /1<\/P_18A>/); +}); + +test("builder: order mapping + attachment blocks + transport other", async () => { + const xml = await FA3Invoice.basic("FV/ORDER/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .order(246) + .orderLine({ description: "OL1", quantity: 2, unitNetPrice: 100, vatRate: 23 }) + .orderLine({ description: "OL2", quantity: 1, unitNetPrice: 10, vatRate: 0 }) + .transport("other") + .attachment({ + blocks: [ + { header: "Blok", paragraphs: ["akapit"], tables: [{ headers: ["h"], rows: [["r"]] }] }, + ], + }) + .toXml(); + assert.match(xml, //); + assert.match(xml, /1<\/TransportInny>/); + assert.match(xml, //); +}); + +test("builder: attachmentText branch", async () => { + const xml = await basicBuilder("FV/ATT/1").attachmentText("Opis załącznika").toXml(); + assert.match(xml, /Opis załącznika<\/Opis>/); +}); + +test("builder: paid date branch + splitPayment", async () => { + const xml = await basicBuilder("FV/PAID/1").paid("2026-02-01").splitPayment().toXml(); + assert.match(xml, /1<\/Zaplacono>/); +}); + +test("builder: dueDescription parsed + parts + garbage + method 7", async () => { + const parsed = await basicBuilder("FV/DD/1").paymentDueDescription(30, "dni", "2026-01-01").toXml(); + assert.match(parsed, //); + + const parts = await FA3Invoice.basic("FV/DD/2") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .payment({ dueDescriptionParts: { amount: 2, unit: "dni", startsFrom: "wystawienia" }, dueDate: "2026-02-01" }) + .toXml(); + assert.match(parts, //); + + const garbage = await basicBuilder("FV/DD/3").payment({ dueDescription: "garbage text" }).toXml(); + assert.doesNotMatch(garbage, //); + + const method7 = await basicBuilder("FV/DD/4").payment({ method: "7" }).toXml(); + assert.match(method7, /7<\/FormaPlatnosci>/); + + const otherOnly = await basicBuilder("FV/DD/5").payment({ otherMethodDescription: "Barter" }).toXml(); + assert.match(otherOnly, /1<\/PlatnoscInna>/); +}); + +test("builder: bankAccount + paymentLink + paymentDue convenience helpers", async () => { + const xml = await basicBuilder("FV/BANK/1") + .bankAccount({ number: "PL55" }) + .paymentLink("https://pay", "IPX") + .paymentDue("2026-02-01", "transfer") + .toXml(); + assert.match(xml, /PL55<\/NrRB>/); +}); + +test("builder: additional parties (roles, ids, jst, vat group)", async () => { + const xml = await FA3Invoice.basic("FV/PARTY/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer({ ...BUYER, isJstSubunit: true, isVatGroupMember: true }) + .addLine(LINE) + .addParty({ + name: "Odbiorca", + taxId: "3333333333", + role: "recipient", + buyerId: "BID1", + share: 50, + customerNumber: "CN1", + }) + .addParty({ name: "Inny", taxId: "4444444444", role: "other", otherRoleDescription: "Rola inna" }) + .addParty({ name: "JST", taxId: "5555555555", role: "jst_subunit" }) + .addParty({ name: "GV", taxId: "6666666666", role: "vat_group_member" }) + .toXml(); + assert.match(xml, /1<\/JST>/); + assert.match(xml, /1<\/GV>/); + assert.match(xml, /1<\/RolaInna>/); + assert.match(xml, /BID1<\/IDNabywcy>/); +}); + +test("builder: advance invoice with advance payments + foreignCurrencyRate", async () => { + const xml = await FA3Invoice.advance("ZAL/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .advancePayment({ amount: 123, vatRate: 23, currencyRate: 4.5 }) + .advancePayment({ amount: 50 }) + .foreignCurrencyRate(4.32) + .toXml(); + assert.match(xml, /ZAL<\/RodzajFaktury>/); + assert.match(xml, //); + assert.match(xml, //); +}); + +test("builder: correction with full metadata", async () => { + const xml = await FA3Invoice.correction("KOR/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addCorrectedLineBeforeAfter({ + before: { description: "przed", quantity: 1, unit: "szt", unitNetPrice: 100, vatRate: 23 }, + after: { description: "po", quantity: 1, unit: "szt", unitNetPrice: 120, vatRate: 23 }, + }) + .correction({ + reason: "Błąd ceny", + correctedInvoiceNumber: "FV/OLD/1", + correctedInvoiceDate: "2025-12-01", + correctedKsefNumber: "KSEF-OLD", + correctionType: "tax_base_or_tax", + }) + .correctedPeriod("2025-12") + .correctedInvoiceNumberOverride("FV/OLD/1-OVR") + .toXml(); + assert.match(xml, /KOR<\/RodzajFaktury>/); + assert.match(xml, /1<\/TypKorekty>/); + assert.match(xml, /2025-12<\/OkresFaKorygowanej>/); + assert.match(xml, /FV\/OLD\/1-OVR<\/NrFaKorygowany>/); + assert.match(xml, /1<\/StanPrzed>/); +}); + +test("builder: advance_correction + settlement_correction statics", async () => { + const ac = FA3Invoice.advance_correction("KOR_ZAL/1"); + assert.ok(ac instanceof AdvanceCorrectionInvoiceBuilder); + const acXml = await ac + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .advancePayment({ amount: 100, vatRate: 23 }) + .correction({ reason: "r", correctedInvoiceNumber: "F1", correctedInvoiceDate: "2025-12-01" }) + .toXml(); + assert.match(acXml, /KOR_ZAL<\/RodzajFaktury>/); + + const sc = FA3Invoice.settlement_correction("KOR_ROZ/1"); + assert.ok(sc instanceof SettlementCorrectionInvoiceBuilder); + const scXml = await sc + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ invoiceNumber: "ZAL/1" }) + .correction({ reason: "r", correctedInvoiceNumber: "F1", correctedInvoiceDate: "2025-12-01" }) + .settlementDetails({ amountToSettle: 50 }) + .toXml(); + assert.match(scXml, /KOR_ROZ<\/RodzajFaktury>/); +}); + +test("builder: settlement variants (amountDue, amountToSettle+charges, settlementAmount, none)", async () => { + const due = await FA3Invoice.settlement("ROZ/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ invoiceNumber: "ZAL/1" }) + .settlementDetails({ amountDue: 123 }) + .toXml(); + assert.match(due, /123.00<\/DoZaplaty>/); + + const toSettle = await FA3Invoice.settlement("ROZ/2") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ ksefNumber: "KSEF-ZAL" }) + .settlementDetails({ + amountToSettle: 50, + charges: [{ amount: 10, reason: "opłata" }], + deductions: [{ amount: 5, reason: "rabat" }], + }) + .toXml(); + assert.match(toSettle, //); + assert.match(toSettle, //); + assert.match(toSettle, //); + + const settlementAmount = await FA3Invoice.settlement("ROZ/3") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ invoiceNumber: "ZAL/1" }) + .settlementAmount(200) + .toXml(); + assert.match(settlementAmount, /200.00<\/DoZaplaty>/); + + const none = await FA3Invoice.settlement("ROZ/4") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ invoiceNumber: "ZAL/1" }) + .toXml(); + assert.doesNotMatch(none, //); +}); + +test("builder: settlesAdvance sets and clears advance invoice references", async () => { + const withInvoiceRef = await FA3Invoice.settlement("ROZ/5") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .settlesAdvance({ invoiceNumber: "ZAL/5" }) + .settlementAmount(100) + .toXml(); + assert.match(withInvoiceRef, /ZAL\/5<\/NrFakturyZaliczkowej>/); + + const withKsefRef = await FA3Invoice.settlement("ROZ/5B") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .settlesAdvance({ ksefNumber: "KSEF-ZAL-5" }) + .settlementAmount(100) + .toXml(); + assert.match(withKsefRef, /KSEF-ZAL-5<\/NrKSeFFaZaliczkowej>/); + + const cleared = FA3Invoice.settlement("ROZ/6") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ invoiceNumber: "ZAL/6" }) + .settlesAdvance({ invoiceNumber: "ZAL/OLD" }) + .settlesAdvance({}) + .build(); + const dict = cleared.toDict(); + assert.equal(dict.advanceInvoiceNumber, undefined); + assert.equal(dict.advanceKsefNumber, undefined); +}); + +test("builder: period invoice + simplified builder", async () => { + const period = await FA3Invoice.basic("FV/PER/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .period("2026-01-01", "2026-01-31") + .toXml(); + assert.match(period, //); + + const simplified = await FA3Invoice.simplified("FV/SIMP/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .addServiceLine("Serwis", { quantity: 1, unitNetPrice: 10, vatRate: 23 }) + .toXml(); + assert.match(simplified, /UPR<\/RodzajFaktury>/); +}); + +test("builder: FA3Draft.fromDict snake_case + toDict + fromDict Date + BaseFA3Builder", async () => { + const draft = FA3Draft.fromDict({ + invoice_number: "FV/SNAKE/1", + issue_date: "2026-01-15", + waluta: "eur", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + }); + const dict = draft.toDict(); + assert.equal(dict.invoiceNumber, "FV/SNAKE/1"); + assert.equal(dict.currency, "EUR"); + assert.equal(draft.validate().length, 0); + + assert.equal(BaseFA3Builder, FA3InvoiceBuilder); + const base = new BaseFA3Builder("FV/BASE/1"); + base.issueDate("2026-01-15").seller(SELLER).buyer(BUYER).addLine(LINE); + assert.equal(base.validate().length, 0); +}); + +test("builder: validation-only conflict rules", () => { + const rawExt = FA3Invoice.basic("FV/RAW/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .rawExtension("/x", "") + .validate(); + assert.ok(rawExt.some((i) => i.code === "raw_extension_unsupported")); + + const halfPeriod = new FA3Draft({ + invoiceNumber: "FV/HP/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + periodFrom: "2026-01-01", + }).validate(); + assert.ok(halfPeriod.some((i) => i.code === "half_period")); + + const saleVsPeriod = new FA3Draft({ + invoiceNumber: "FV/SP/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: BUYER, + lines: [LINE], + saleDate: "2026-01-10", + periodFrom: "2026-01-01", + periodTo: "2026-01-31", + }).validate(); + assert.ok(saleVsPeriod.some((i) => i.code === "sale_date_period_conflict")); + + const advConflict = basicBuilder("FV/AC/1").advanceReference({ invoiceNumber: "A", ksefNumber: "B" }).validate(); + assert.ok(advConflict.some((i) => i.code === "advance_reference_conflict")); + + const settlementMissingRef = FA3Invoice.settlement("ROZ/X") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .validate(); + assert.ok(settlementMissingRef.some((i) => i.code === "advance_reference_required")); + + const paidVsPartial = basicBuilder("FV/PP/1") + .paid("2026-02-01") + .partiallyPaid({ amount: 10, paidOn: "2026-01-20" }) + .validate(); + assert.ok(paidVsPartial.some((i) => i.code === "payment_paid_vs_partial_conflict")); + + const methodOther = basicBuilder("FV/MO/1") + .payment({ method: "6", otherMethodDescription: "d" }) + .validate(); + assert.ok(methodOther.some((i) => i.code === "payment_method_other_conflict")); + + const partialMethodOther = basicBuilder("FV/PMO/1") + .partiallyPaid({ amount: 10, paidOn: "2026-01-20", method: "6", otherMethodDescription: "d" }) + .validate(); + assert.ok(partialMethodOther.some((i) => i.code === "partial_payment_method_other_conflict")); + + const settlementChoice = FA3Invoice.settlement("ROZ/Y") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ invoiceNumber: "ZAL/1" }) + .settlementDetails({ amountDue: 123, amountToSettle: 50 }) + .validate(); + assert.ok(settlementChoice.some((i) => i.code === "settlement_choice_conflict")); + + const settlementInconsistent = FA3Invoice.settlement("ROZ/Z") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine(LINE) + .advanceReference({ invoiceNumber: "ZAL/1" }) + .settlementDetails({ amountDue: 999 }) + .validate(); + assert.ok(settlementInconsistent.some((i) => i.code === "settlement_amount_due_inconsistent")); + + const simplifiedReceipt = FA3Invoice.simplified("FV/SR/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer(BUYER) + .addLine({ description: "drogie", quantity: 1, unit: "szt", unitNetPrice: 1000, vatRate: 23 }) + .currency("EUR") + .asSimplifiedReceiptLike() + .validate(); + assert.ok(simplifiedReceipt.some((i) => i.code === "simplified_receipt_currency")); + assert.ok(simplifiedReceipt.some((i) => i.code === "simplified_receipt_limit")); + + const jstMissing = FA3Invoice.basic("FV/JST/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer({ ...BUYER, isJstSubunit: true }) + .addLine(LINE) + .validate(); + assert.ok(jstMissing.some((i) => i.code === "jst_subunit_missing_party3")); + + const gvMissing = FA3Invoice.basic("FV/GV/1") + .issueDate("2026-01-15") + .seller(SELLER) + .buyer({ ...BUYER, isVatGroupMember: true }) + .addLine(LINE) + .validate(); + assert.ok(gvMissing.some((i) => i.code === "vat_group_missing_party3")); + + const thirdOther = basicBuilder("FV/TO/1") + .addParty({ name: "X", taxId: "7777777777", role: "other" }) + .validate(); + assert.ok(thirdOther.some((i) => i.code === "third_party_other_description_required")); +}); + +test("builder: FA3BatchDraft toXmlFiles + toXmlZip + fromJson", async () => { + const batch = FA3Template.sampleBatch(); + const roundTrip = FA3BatchDraft.fromJson(batch.toJson()); + assert.equal(roundTrip.drafts.length, 1); + assert.equal(FA3BatchDraft.fromJson("{}").drafts.length, 0); + + const dir = await mkdtemp(path.join(os.tmpdir(), "fa3-batch-")); + try { + const files = await batch.toXmlFiles(path.join(dir, "out")); + assert.equal(files.length, 1); + const zip = await batch.toXmlZip(path.join(dir, "batch.zip")); + assert.ok(zip.endsWith("batch.zip")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("builder: toXml with xsdValidate rejects when libxml missing", { skip: hasLibxml }, async () => { + await assert.rejects(() => basicBuilder("FV/XSD/1").toXml({ xsdValidate: true }), FA3XmlValidationError); +}); + +// --------------------------------------------------------------------------- +// 9. xml.ts +// --------------------------------------------------------------------------- +test("xml.ts: invoiceToXml + invoice_to_xml alias", async () => { + const draft = basicBuilder("FV/XML/1").build(); + const xml = await invoiceToXml(draft, { pretty: true }); + assert.match(xml, /FV\/XML\/1<\/P_2>/); + assert.equal(invoice_to_xml, invoiceToXml); + const xml2 = await invoice_to_xml(draft); + assert.match(xml2, / { + await assert.rejects(() => validateFa3Xml(""), FA3XmlValidationError); +}); + +// --------------------------------------------------------------------------- +// 10. xsd.ts +// --------------------------------------------------------------------------- +test("xsd.ts: resolveFa3SchemaEntryPath + loadFa3SchemaWithLocalImports", () => { + const schemaPath = resolveFa3SchemaEntryPath(); + assert.match(schemaPath, /schemat_FA\(3\)_v1-0E\.xsd$/); + const loaded = loadFa3SchemaWithLocalImports(); + assert.ok(loaded.schemaContent.length > 0); + assert.ok(loaded.schemaBaseUrl.startsWith("file:")); + assert.equal(loaded.schemaPath, schemaPath); +}); + +test("xsd.ts: validateFa3XmlXsd throws when libxmljs2 missing", { skip: hasLibxml }, async () => { + await assert.rejects(() => validateFa3XmlXsd(""), /libxmljs2/); +}); + +test("xsd.ts: validateFa3XmlXsd with libxmljs2 (valid + invalid)", { skip: !hasLibxml }, async () => { + const validXml = await basicBuilder("FV/VALID/1").toXml(); + await validateFa3XmlXsd(validXml).catch((error) => assert.ok(error instanceof Error)); + await assert.rejects(() => validateFa3XmlXsd(""), /XSD validation failed/); +}); + +test("xsd.ts: validateFa3XmlXsd empty validationErrors branch (mocked)", { skip: typeof mock.module !== "function" }, async () => { + mock.module("libxmljs2", { + namedExports: { + parseXml: () => ({ validate: () => false, validationErrors: [] }), + }, + }); + const { validateFa3XmlXsd: mockedValidate } = await import(`../../dist/index.js?xsdmock=${Date.now()}`); + await assert.rejects(() => mockedValidate(""), /FA\(3\) XSD validation failed\.$/); + mock.reset(); +}); + +// --------------------------------------------------------------------------- +// 11. xsdMap +// --------------------------------------------------------------------------- +test("xsdMap: parseFa3XsdElements on real schema + empty string + alias + XsdElement", () => { + const { schemaContent } = loadFa3SchemaWithLocalImports(); + const elements = parseFa3XsdElements(schemaContent); + assert.ok(elements.length > 0); + assert.ok(elements.some((e) => e.path === "/Faktura")); + + assert.deepEqual(parseFa3XsdElements(""), []); + assert.deepEqual(parseFa3XsdElements(""), []); + assert.equal(parse_fa3_xsd_elements, parseFa3XsdElements); + + const full = new XsdElement({ + path: "/x", + name: "x", + typeName: "T", + minOccurs: "0", + maxOccurs: "unbounded", + choices: 2, + enumValues: ["a", "b"], + }); + assert.equal(full.minOccurs, "0"); + assert.deepEqual(full.enumValues, ["a", "b"]); + const min = new XsdElement({ path: "/y", name: "y" }); + assert.equal(min.minOccurs, "1"); + assert.equal(min.maxOccurs, "1"); + assert.deepEqual(min.enumValues, []); +}); + +// --------------------------------------------------------------------------- +// 12. xsdAudit +// --------------------------------------------------------------------------- +test("xsdAudit: auditFa3XsdCoverage() with no args + alias + handlers", () => { + const report = auditFa3XsdCoverage(); + assert.ok(report.elements.length > 0); + assert.equal(report.elements.length, report.coverage.length); + + const statuses = new Set(report.coverage.map((c) => c.status)); + assert.ok(statuses.has(CoverageStatus.SUPPORTED)); + + const handlers = new Set(report.coverage.map((c) => c.handler).filter(Boolean)); + for (const expected of [ + "mapPaymentTerms", + "mapLine", + "mapSettlement", + "mapParty", + "FA3Draft.toFakturaInput", + ]) { + assert.ok(handlers.has(expected), `missing handler ${expected}`); + } + + assert.equal(audit_fa3_xsd_coverage, auditFa3XsdCoverage); +}); + +// --------------------------------------------------------------------------- +// 13. identifier via builder +// --------------------------------------------------------------------------- +test("identifier: internal id, EU company buyer, foreign, none, weird kind", () => { + const internalDraft = new FA3Draft({ + invoiceNumber: "FV/INT/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "Wewn", taxId: "IGNORED", internalId: "INT-1" }, + lines: [LINE], + }); + const internalXml = internalDraft.toFakturaInput(); + assert.equal(internalXml.Podmiot2.DaneIdentyfikacyjne.IDWew, "INT-1"); + + const euDraft = new FA3Draft({ + invoiceNumber: "FV/EU/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: InvoiceParty.euCompany({ vatId: "123456789", countryCode: "DE", name: "EU GmbH" }), + lines: [LINE], + }); + const euXml = euDraft.toFakturaInput(); + assert.equal(euXml.Podmiot2.DaneIdentyfikacyjne.KodUE, "DE"); + + const foreignDraft = new FA3Draft({ + invoiceNumber: "FV/FGN/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "US Inc", taxId: "US:9999", countryCode: "US" }, + lines: [LINE], + }); + assert.equal(foreignDraft.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.NrID, "9999"); + + const foreignByCountry = new FA3Draft({ + invoiceNumber: "FV/FGN/2", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "NonEU", taxId: "ABC123", countryCode: "CH" }, + lines: [LINE], + }); + assert.equal(foreignByCountry.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.NrID, "ABC123"); + + const noneDraft = new FA3Draft({ + invoiceNumber: "FV/NONE/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: InvoiceParty.withoutTaxId({ name: "Anon" }), + lines: [LINE], + }); + assert.equal(noneDraft.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.BrakID, "1"); + + const explicitIdentifierDraft = new FA3Draft({ + invoiceNumber: "FV/EXP/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "Explicit", taxId: "X", identifier: { kind: PartyIdentifierKind.NIP, value: " 2222222222 " } }, + lines: [LINE], + }); + assert.equal(explicitIdentifierDraft.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.NIP, "2222222222"); + + // weird identifier kind hits default switch branch (mapped as NIP) + const weirdDraft = new FA3Draft({ + invoiceNumber: "FV/WRD/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "Weird", taxId: "X", identifier: { kind: "WEIRD", value: "9" } }, + lines: [LINE], + }); + assert.equal(weirdDraft.toFakturaInput().Podmiot2.DaneIdentyfikacyjne.NIP, "9"); +}); + +test("identifier: seller non-NIP throws in mapPartyIdentityToXml + validation issues", () => { + const sellerNonNip = new FA3Draft({ + invoiceNumber: "FV/SN/1", + issueDate: "2026-01-15", + seller: { name: "EU Seller", taxId: "DE123456789", countryCode: "DE" }, + buyer: BUYER, + lines: [LINE], + }); + assert.throws(() => sellerNonNip.toFakturaInput(), /NIP/); + assert.ok(sellerNonNip.validate().some((i) => i.code === "seller_nip_required")); + + const emptyIdentifier = new FA3Draft({ + invoiceNumber: "FV/EI/1", + issueDate: "2026-01-15", + seller: SELLER, + buyer: { name: "Bad", taxId: "X", identifier: { kind: PartyIdentifierKind.EU_VAT, value: "", countryCode: "" } }, + lines: [LINE], + }); + const issues = emptyIdentifier.validate(); + assert.ok(issues.some((i) => i.code === "identifier_required")); + assert.ok(issues.some((i) => i.code === "eu_vat_country_required")); +}); diff --git a/test/unit/fa3PublicApiParity.test.js b/test/unit/fa3PublicApiParity.test.js new file mode 100644 index 0000000..9498217 --- /dev/null +++ b/test/unit/fa3PublicApiParity.test.js @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import * as fa3 from "../../dist/documents/fa3.js"; + +const REQUIRED_TS_FA3_SYMBOLS = [ + "Address", + "AdvanceCorrectionInvoiceBuilder", + "AdvanceInvoiceBuilder", + "AdvancePayment", + "BankAccount", + "BaseFA3Builder", + "BasicInvoiceBuilder", + "CorrectionInvoiceBuilder", + "CorrectionType", + "CoverageStatus", + "FA3BatchDraft", + "FA3Draft", + "FA3Importer", + "FA3Invoice", + "FA3InvoiceBuilder", + "FA3InvoiceKind", + "FA3Line", + "FA3Party", + "FA3Template", + "FA3ValidationIssue", + "FA3XmlValidationError", + "GTUCode", + "ImportMode", + "InvoiceLine", + "InvoiceParty", + "Order", + "PartialPayment", + "PartyIdentifier", + "PartyIdentifierKind", + "PaymentMethod", + "PaymentTerms", + "RawXmlExtension", + "Settlement", + "SettlementAdjustment", + "SettlementCorrectionInvoiceBuilder", + "SettlementInvoiceBuilder", + "SimplifiedInvoiceBuilder", + "TaxCategory", + "ThirdPartyRole", + "TransportKind", + "XsdCoverageEntry", + "XsdCoverageReport", + "XsdElement", + "auditFa3XsdCoverage", + "audit_fa3_xsd_coverage", + "invoiceToXml", + "invoice_to_xml", + "loadFa3SchemaWithLocalImports", + "parseFa3XsdElements", + "parse_fa3_xsd_elements", + "resolveFa3SchemaEntryPath", + "toBatchDraft", + "validateFa3Xml", + "validateFa3XmlXsd", + "validate_fa3_xml_xsd", +]; + +const ACCEPTED_TS_FA3_SYMBOLS = new Set([ + ...REQUIRED_TS_FA3_SYMBOLS, + "AdditionalDescription", + "Annotation", + "AnnotationSet", + "Attachment", + "AttachmentBlock", + "AttachmentTable", + "AuthorizedPartyRole", + "Contact", + "Contract", + "CorrectedAdvanceState", + "CorrectionReference", + "Discount", + "ExciseRefund", + "FA3ImportError", + "FA3ImportResult", + "FA3InvalidRow", + "FA3ValidationResult", + "Footer", + "loadLibxml", + "sanitizeFileName", + "validateAndReturnXml", + "LineIdentifiers", + "LineProcedure", + "MarginProcedure", + "NewTransportMeans", + "OrderLine", + "OrderLineProcedure", + "Party", + "PartyIdentifierKind", + "PaymentDue", + "Registry", + "TaxSummary", + "TransactionTerms", + "Transport", + "ValidationContext", + "VatClass", + "VatRateCode", + "mapPartyIdentityToXml", + "resolvePartyIdentifier", + "toFa3XmlValidationMessage", + "validateFa3XmlWithParser", + "validateFa3XmlWithValidator", + "validatePartyIdentifier", +]); + +test("FA(3) public runtime API exposes the local TypeScript contract", () => { + const exportedKeys = Object.keys(fa3); + const exportedSet = new Set(exportedKeys); + const missing = REQUIRED_TS_FA3_SYMBOLS.filter((name) => !exportedSet.has(name)); + assert.deepEqual(missing, []); + + const unexpected = exportedKeys.filter((name) => !ACCEPTED_TS_FA3_SYMBOLS.has(name)); + assert.deepEqual(unexpected, []); +}); diff --git a/test/unit/fa3Sdk.test.js b/test/unit/fa3Sdk.test.js new file mode 100644 index 0000000..08f9e12 --- /dev/null +++ b/test/unit/fa3Sdk.test.js @@ -0,0 +1,552 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { test } from "node:test"; +import { + FA3BatchDraft, + FA3Draft, + FA3Importer, + FA3Invoice, + FA3XmlValidationError, + ImportMode, + InvoiceParty, + KsefError, + KsefValidationError, + PaymentMethod, + auditFa3XsdCoverage, + resolveFa3SchemaEntryPath, +} from "../../dist/index.js"; + +test("FA3 SDK builds XML from typed builder", async () => { + const draft = FA3Invoice.basic("FV/FA3/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ + name: "Sprzedawca Test", + taxId: "1111111111", + addressLine1: "Testowa 1", + }) + .buyer({ + name: "Nabywca Test", + taxId: "2222222222", + addressLine1: "Nabywcy 2", + }) + .addLine({ + description: "Pozycja 1", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 23, + }) + .build(); + + const xml = await draft.toXml({ pretty: true }); + assert.match(xml, /FV\/FA3\/1<\/P_2>/); + assert.match(xml, //); +}); + +test("FA3 SDK validates required data before XML generation", async () => { + const draft = new FA3Draft({ + invoiceNumber: "", + issueDate: "", + seller: { name: "", taxId: "" }, + buyer: { name: "", taxId: "" }, + lines: [], + }); + const issues = draft.validate(); + assert.ok(issues.length > 0); + await assert.rejects(() => draft.toXml(), KsefValidationError); +}); + +test("FA3 SDK batch supports JSON roundtrip and ZIP export", async () => { + const draft = FA3Invoice.settlement("FV/FA3/BATCH/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addLine({ + description: "Pozycja 1", + quantity: 2, + unit: "szt", + unitNetPrice: 50, + vatRate: 23, + }) + .advanceReference({ invoiceNumber: "FV/ZAL/1" }) + .settlementAmount(123) + .build(); + const batch = new FA3BatchDraft([draft]); + const json = batch.toJson(); + const loaded = FA3BatchDraft.fromJson(json); + + const dir = await mkdtemp(path.join(os.tmpdir(), "ksef-fa3-")); + try { + const files = await loaded.toXmlFiles(dir); + assert.equal(files.length, 1); + const xmlContent = await readFile(files[0], "utf8"); + assert.match(xmlContent, /FV\/FA3\/BATCH\/1/); + + const zipPath = path.join(dir, "fa3.zip"); + const writtenZipPath = await loaded.toXmlZip(zipPath); + assert.equal(writtenZipPath, zipPath); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("FA3 SDK XSD validation branch is reachable", async () => { + const schemaPath = resolveFa3SchemaEntryPath(); + assert.ok(schemaPath.includes(path.join("dist", "documents", "fa3", "schemas"))); + + const draft = FA3Invoice.basic("FV/FA3/XSD/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addLine({ + description: "Pozycja 1", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 23, + }) + .build(); + + try { + const xml = await draft.toXml({ xsdValidate: true }); + assert.match(xml, / { + const draft = FA3Invoice.correction("KOR/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addLine({ + description: "Pozycja 1", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 23, + }) + .build(); + + const issues = draft.validate(); + assert.ok(issues.some((issue) => issue.code === "correction_reason_required")); + assert.ok(issues.some((issue) => issue.code === "corrected_invoice_number_required")); + assert.ok(issues.some((issue) => issue.code === "corrected_invoice_date_required")); + await assert.rejects(() => draft.toXml(), KsefValidationError); +}); + +test("FA3 SDK settlement validates amount consistency and advance reference choice", async () => { + const draft = FA3Invoice.settlement("ROZ/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addLine({ + description: "Pozycja 1", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 23, + }) + .advanceReference({ + invoiceNumber: "FV/ZAL/1", + ksefNumber: "1234567890-20260101-AAAA-BB", + }) + .settlementDetails({ + amountDue: "999.00", + charges: [{ amount: "1.00", reason: "charge" }], + deductions: [{ amount: "1.00", reason: "deduction" }], + }) + .build(); + + const issues = draft.validate(); + assert.ok(issues.some((issue) => issue.code === "advance_reference_conflict")); + assert.ok(issues.some((issue) => issue.code === "settlement_amount_due_inconsistent")); + await assert.rejects(() => draft.toXml(), KsefValidationError); +}); + +test("FA3 SDK validates XML-shape conflicts", async () => { + const draft = FA3Invoice.basic("FV/SHAPE/1") + .issueDate("2026-05-17T10:00:00Z") + .saleDate("2026-05-17") + .period("2026-05-01", "2026-05-31") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addLine({ + description: "Pozycja 1", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 23, + }) + .payment({ + paidDate: "2026-05-17", + partialPayments: [{ amount: "10.00", paidOn: "2026-05-17" }], + method: "transfer", + otherMethodDescription: "inne", + }) + .build(); + + const issues = draft.validate(); + assert.ok(issues.some((issue) => issue.code === "sale_date_period_conflict")); + assert.ok(issues.some((issue) => issue.code === "payment_paid_vs_partial_conflict")); + assert.ok(issues.some((issue) => issue.code === "payment_method_other_conflict")); + await assert.rejects(() => draft.toXml(), KsefValidationError); +}); + +test("FA3 SDK supports correction before/after delta lines", async () => { + const draft = FA3Invoice.correction("KOR/DELTA/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .correction({ + reason: "Korekta ceny", + correctedInvoiceNumber: "FV/OLD/1", + correctedInvoiceDate: "2026-05-01", + }) + .addCorrectedLineBeforeAfter({ + before: { + description: "Usługa", + quantity: 1, + unit: "szt", + unitNetPrice: 200, + vatRate: 23, + }, + after: { + description: "Usługa", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 23, + }, + }) + .build(); + + const xml = await draft.toXml(); + assert.match(xml, /1<\/StanPrzed>/); + assert.match(xml, /-100.00<\/P_13_1>/); +}); + +test("FA3 audit helper returns coverage report", () => { + const report = auditFa3XsdCoverage(["Faktura", "Naglowek"]); + assert.ok(report.elements.length > 0); + assert.ok(report.coverage.length > 0); + assert.equal(report.elements.length, report.coverage.length); + assert.ok(report.coverage.some((entry) => entry.status === "unsupported")); + assert.ok(report.coverage.some((entry) => entry.status === "partially_supported")); +}); + +test("FA3 simplified receipt-like guards enforce PLN and 450 limit", async () => { + const draft = FA3Invoice.simplified("UPR/1") + .issueDate("2026-05-17T10:00:00Z") + .currency("EUR") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addLine({ + description: "Pozycja 1", + quantity: 1, + unit: "szt", + unitNetPrice: 500, + vatRate: 23, + }) + .asSimplifiedReceiptLike() + .build(); + + const issues = draft.validate(); + assert.ok(issues.some((issue) => issue.code === "simplified_receipt_currency")); + assert.ok(issues.some((issue) => issue.code === "simplified_receipt_limit")); + await assert.rejects(() => draft.toXml(), KsefValidationError); +}); + +test("FA3 importer handles json payload and validate-only mode", () => { + const payload = { + drafts: [ + { + invoiceNumber: "FV/IMPORT/1", + issueDate: "2026-05-17", + seller: { name: "Sprzedawca", taxId: "1111111111" }, + buyer: { name: "Nabywca", taxId: "2222222222" }, + lines: [{ description: "Pozycja", quantity: 1, unit: "szt", unitNetPrice: 100, vatRate: 23 }], + }, + ], + }; + + const parsed = FA3Importer.fromJson(payload); + assert.equal(parsed.validDrafts.length, 1); + assert.equal(parsed.errors.length, 0); + + const validateOnly = FA3Importer.fromJson(payload, { mode: ImportMode.VALIDATE_ONLY }); + assert.equal(validateOnly.validDrafts.length, 0); + assert.equal(validateOnly.errors.length, 0); +}); + +test("FA3 maps payment aliases to FA(3) payment codes in XML", async () => { + const draft = FA3Invoice.basic("FV/PAY/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addServiceLine("Usługa", { quantity: 1, unitNetPrice: 100, vatRate: 23 }) + .payment({ method: PaymentMethod.TRANSFER }) + .build(); + + const xml = await draft.toXml(); + assert.match(xml, /6<\/FormaPlatnosci>/); +}); + +test("FA3 maps third parties, foreign identifiers, JST and VAT group flags", async () => { + const draft = FA3Invoice.basic("FV/PARTIES/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ + name: "Nabywca UE", + taxId: "DE123456789", + countryCode: "DE", + isJstSubunit: true, + isVatGroupMember: true, + }) + .addParty({ + name: "Jednostka JST", + taxId: "BRAK", + role: "jst_subunit", + addressLine1: "Jednostki 1", + }) + .addParty({ + name: "Członek GV", + taxId: "3333333333", + role: "vat_group_member", + }) + .addParty({ + name: "Płatnik zagraniczny", + taxId: "US:998877", + countryCode: "US", + role: "payer", + share: "25", + }) + .addServiceLine("Usługa", { quantity: 1, unitNetPrice: 100, vatRate: 23 }) + .build(); + + const xml = await draft.toXml(); + assert.match(xml, /DE<\/KodUE>/); + assert.match(xml, /123456789<\/NrVatUE>/); + assert.match(xml, /1<\/JST>/); + assert.match(xml, /1<\/GV>/); + assert.match(xml, //); + assert.match(xml, /1<\/BrakID>/); + assert.match(xml, /8<\/Rola>/); + assert.match(xml, /10<\/Rola>/); + assert.match(xml, /US<\/KodKraju>/); + assert.match(xml, /998877<\/NrID>/); + assert.match(xml, /4<\/Rola>/); + assert.match(xml, /25<\/Udzial>/); +}); + +test("FA3 maps XSD payment terms including partial payments, account and payment link", async () => { + const draft = FA3Invoice.basic("FV/PAYMENT/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addServiceLine("Usługa", { quantity: 1, unitNetPrice: 100, vatRate: 23 }) + .paymentDueDescription(14, "dni", "doręczenia", "transfer") + .partiallyPaid({ amount: "23.00", paidOn: "2026-05-18", method: "card" }) + .bankAccount({ + number: "12109010140000071219812874", + swift: "WBKPPLPP", + bankName: "Bank Test", + description: "Rachunek główny", + }) + .paymentLink("https://pay.example.com/checkout?IPKSeF=123ABCDEFGHIJ", "123ABCDEFGHIJ") + .build(); + + const xml = await draft.toXml(); + assert.match(xml, /1<\/ZnacznikZaplatyCzesciowej>/); + assert.match(xml, /23.00<\/KwotaZaplatyCzesciowej>/); + assert.match(xml, //); + assert.match(xml, /14<\/Ilosc>/); + assert.match(xml, /6<\/FormaPlatnosci>/); + assert.match(xml, //); + assert.match(xml, /12109010140000071219812874<\/NrRB>/); + assert.match(xml, /https:\/\/pay.example.com\/checkout\?IPKSeF=123ABCDEFGHIJ<\/LinkDoPlatnosci>/); + assert.match(xml, /123ABCDEFGHIJ<\/IPKSeF>/); +}); + +test("FA3 maps settlement adjustments into Rozliczenie section", async () => { + const draft = FA3Invoice.settlement("ROZ/MAP/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addServiceLine("Usługa", { quantity: 1, unitNetPrice: 100, vatRate: 23 }) + .advanceReference({ invoiceNumber: "FV/ZAL/1" }) + .settlementDetails({ + amountDue: "125.00", + charges: [{ amount: "3.00", reason: "Dopłata" }], + deductions: [{ amount: "1.00", reason: "Rabat" }], + }) + .build(); + + const xml = await draft.toXml(); + assert.match(xml, //); + assert.match(xml, //); + assert.match(xml, /3.00<\/SumaObciazen>/); + assert.match(xml, //); + assert.match(xml, /1.00<\/SumaOdliczen>/); + assert.match(xml, /125.00<\/DoZaplaty>/); +}); + +test("FA3 exports all invoice kinds with expected RodzajFaktury code", async () => { + const matrix = [ + ["basic", "VAT"], + ["simplified", "UPR"], + ["correction", "KOR"], + ["advance", "ZAL"], + ["settlement", "ROZ"], + ["advance_correction", "KOR_ZAL"], + ["settlement_correction", "KOR_ROZ"], + ]; + + for (const [kind, code] of matrix) { + let builder; + switch (kind) { + case "basic": + builder = FA3Invoice.basic(`FV/${code}/1`); + break; + case "simplified": + builder = FA3Invoice.simplified(`FV/${code}/1`); + break; + case "correction": + builder = FA3Invoice.correction(`FV/${code}/1`).correction({ + reason: "Korekta", + correctedInvoiceNumber: "FV/OLD/1", + correctedInvoiceDate: "2026-01-01", + }); + break; + case "advance": + builder = FA3Invoice.advance(`FV/${code}/1`); + break; + case "settlement": + builder = FA3Invoice + .settlement(`FV/${code}/1`) + .advanceReference({ invoiceNumber: "FV/ZAL/1" }) + .settlementAmount("123.00"); + break; + case "advance_correction": + builder = FA3Invoice.advanceCorrection(`FV/${code}/1`).correction({ + reason: "Korekta zaliczki", + correctedInvoiceNumber: "FV/ZAL/1", + correctedInvoiceDate: "2026-01-01", + }); + break; + default: + builder = FA3Invoice + .settlementCorrection(`FV/${code}/1`) + .correction({ + reason: "Korekta rozliczenia", + correctedInvoiceNumber: "FV/ROZ/1", + correctedInvoiceDate: "2026-01-01", + }) + .advanceReference({ invoiceNumber: "FV/ZAL/1" }) + .settlementAmount("123.00"); + } + + const draft = builder + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addLine({ + description: "Pozycja", + quantity: 1, + unit: "szt", + unitNetPrice: 100, + vatRate: 23, + }) + .build(); + + const xml = await draft.toXml(); + assert.match(xml, new RegExp(`${code}`)); + } +}); + +test("FA3 maps contract/order/transport/transaction/attachment sections into XML", async () => { + const draft = FA3Invoice.basic("FV/SECTIONS/1") + .issueDate("2026-05-17T10:00:00Z") + .seller({ name: "Sprzedawca", taxId: "1111111111" }) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addServiceLine("Usługa", { quantity: 1, unitNetPrice: 1000, vatRate: 23 }) + .contract("UM/1/2026", "2026-01-01") + .orderReference("ZAM/1/2026", "2026-01-02") + .order("1230.00") + .orderLine({ + description: "Pozycja zamówienia", + quantity: 1, + unitNetPrice: 1000, + vatRate: 23, + }) + .transactionTerms({ + deliveryTerms: "DAP", + contractualRate: "4.123456", + contractualCurrency: "EUR", + intermediary: true, + }) + .transport("3", { orderNumber: "TR/1", cargoDescription: "1", packageUnit: "paleta" }) + .additionalDescription("kanał", "API") + .attachment({ + blocks: [ + { + header: "Załącznik", + paragraphs: ["Opis"], + tables: [{ headers: ["Nazwa", "Kwota"], rows: [["Pozycja", "1230.00"]] }], + }, + ], + }) + .build(); + + const xml = await draft.toXml(); + assert.match(xml, //); + assert.match(xml, //); + assert.match(xml, //); + assert.match(xml, //); + assert.match(xml, //); + assert.match(xml, //); + assert.match(xml, //); +}); + +test("FA3 rejects non-NIP seller identifier like Python SDK", async () => { + const draft = FA3Invoice.basic("FV/BAD/1") + .issueDate("2026-05-17") + .seller( + InvoiceParty.foreignCompany({ + identifier: "SELLER", + countryCode: "DE", + name: "Zły sprzedawca", + }), + ) + .buyer({ name: "Nabywca", taxId: "2222222222" }) + .addServiceLine("Line", { quantity: 1, unitNetPrice: 100, vatRate: 23 }) + .build(); + + const issues = draft.validate(); + assert.ok( + issues.some((issue) => issue.message.includes("Podmiot1")), + "expected Podmiot1 NIP validation issue", + ); + + await assert.rejects( + () => draft.toXml(), + (error) => error instanceof KsefValidationError && /Podmiot1/.test(error.message), + ); +}); + +test("FA3 PartyIdentifier factories set explicit identifier on parties", () => { + const seller = InvoiceParty.polishCompany({ + nip: "1111111111", + name: "Sprzedawca", + }); + assert.equal(seller.identifier?.kind, "NIP"); + assert.equal(seller.identifier?.value, "1111111111"); + + const buyer = InvoiceParty.withoutTaxId({ name: "Osoba fizyczna" }); + assert.equal(buyer.identifier?.kind, "NONE"); +}); diff --git a/test/unit/hwmCoordinator.test.js b/test/unit/hwmCoordinator.test.js index f605ded..2587f75 100644 --- a/test/unit/hwmCoordinator.test.js +++ b/test/unit/hwmCoordinator.test.js @@ -26,6 +26,21 @@ test("updateContinuationPoint prioritizes truncated package date and clears when assert.equal("invoice" in continuationPoints, false); }); +test("updateContinuationPoint ignores HWM updates for non-permanent date types", () => { + const continuationPoints = { invoice: "2025-01-01T00:00:00Z" }; + updateContinuationPoint( + continuationPoints, + "invoice", + { + isTruncated: true, + lastPermanentStorageDate: "2025-01-02T00:00:00Z", + permanentStorageHwmDate: "2025-01-03T00:00:00Z", + }, + { dateType: "Issue" }, + ); + assert.equal("invoice" in continuationPoints, false); +}); + test("getEffectiveStartDate returns continuation point when available", () => { const continuationPoints = { subjectA: "2025-02-10T10:00:00Z" }; diff --git a/test/unit/invoiceExportWorkflow.test.js b/test/unit/invoiceExportWorkflow.test.js index 84114ad..fd50880 100644 --- a/test/unit/invoiceExportWorkflow.test.js +++ b/test/unit/invoiceExportWorkflow.test.js @@ -603,20 +603,53 @@ test("InvoiceExportWorkflow.downloadAndProcessPackage returns empty result when }, ); - await assert.rejects( - () => - workflow.downloadAndProcessPackage( - { - status: { code: 200, description: "ok" }, - }, - { - cipherKey: CryptographyService.generateAesKey(), - cipherIv: CryptographyService.generateIv(), - encryptionInfo: {}, - }, - ), - /end of central directory record signature not found/, + const result = await workflow.downloadAndProcessPackage( + { + status: { code: 200, description: "ok" }, + }, + { + cipherKey: CryptographyService.generateAesKey(), + cipherIv: CryptographyService.generateIv(), + encryptionInfo: {}, + }, + ); + assert.deepEqual(result, { metadataSummaries: [], invoiceXmlFiles: {} }); +}); + +test("InvoiceExportWorkflow.downloadAndProcessPackage returns empty result when package has no parts", async () => { + const workflow = new InvoiceExportWorkflow( + {}, + {}, + { + request: async () => { + throw new Error("request should not be called"); + }, + }, + { requireExportPartHash: false }, + ); + const encryptionData = { + cipherKey: CryptographyService.generateAesKey(), + cipherIv: CryptographyService.generateIv(), + encryptionInfo: {}, + }; + + const emptyPartsArray = await workflow.downloadAndProcessPackage( + { + status: { code: 200, description: "ok" }, + package: { invoiceCount: 2, size: 0, parts: [], isTruncated: false }, + }, + encryptionData, + ); + assert.deepEqual(emptyPartsArray, { metadataSummaries: [], invoiceXmlFiles: {} }); + + const missingParts = await workflow.downloadAndProcessPackage( + { + status: { code: 200, description: "ok" }, + package: { invoiceCount: 1, size: 0, isTruncated: false }, + }, + encryptionData, ); + assert.deepEqual(missingParts, { metadataSummaries: [], invoiceXmlFiles: {} }); }); test("InvoiceExportWorkflow treats non-string encryptedPartHash as missing when hash verification is required", async () => { diff --git a/test/unit/invoiceFiltersValidation.test.js b/test/unit/invoiceFiltersValidation.test.js index f2f4936..d968a60 100644 --- a/test/unit/invoiceFiltersValidation.test.js +++ b/test/unit/invoiceFiltersValidation.test.js @@ -451,3 +451,19 @@ test("normalizeInvoiceQueryFilters supports negative timezone offset formatting" Intl.DateTimeFormat = originalDateTimeFormat; } }); + +test("validateInvoiceQueryFilters rejects restrictToPermanentStorageHwmDate for non-permanent dateType", () => { + assert.throws( + () => + validateInvoiceQueryFilters({ + subjectType: "Subject1", + dateRange: { + dateType: "Issue", + from: "2025-01-01", + to: "2025-01-02", + restrictToPermanentStorageHwmDate: true, + }, + }), + /requires dateType PermanentStorage/, + ); +}); diff --git a/test/unit/ksefClientConstruction.test.js b/test/unit/ksefClientConstruction.test.js index 6042808..8f5d4fb 100644 --- a/test/unit/ksefClientConstruction.test.js +++ b/test/unit/ksefClientConstruction.test.js @@ -6,6 +6,8 @@ import { KsefClient, KSEF_ENV_URLS, KSEF_LIGHTHOUSE_URLS, + KSEF_LIGHTHOUSE_ENV_BY_KSEF_ENV, + KSEF_QR_URLS, } from "../../dist/index.js"; function listen(server) { @@ -130,3 +132,51 @@ test("KsefClient forwards optional header and retryOnTimeout flags to HttpClient assert.equal(client.http.defaultHeaders["X-Test"], "1"); assert.equal(client.http.retryOnTimeout, false); }); + +test("KsefClient derives base, QR and lighthouse URLs from environment", () => { + const client = new KsefClient({ environment: "DEMO", requireExportPartHash: false }); + assert.equal(client.baseQrUrl, KSEF_QR_URLS.DEMO); + assert.equal( + client.lighthouse.baseUrl, + KSEF_LIGHTHOUSE_URLS[KSEF_LIGHTHOUSE_ENV_BY_KSEF_ENV.DEMO], + ); +}); + +test("KsefClient honors explicit baseQrUrl override", () => { + const client = new KsefClient({ + baseUrl: "https://api-test.ksef.mf.gov.pl/v2", + baseQrUrl: "https://qr.example.test/custom", + }); + assert.equal(client.baseQrUrl, "https://qr.example.test/custom"); +}); + +test("KsefClient.connect works with minimal options", async () => { + const originalAuthenticate = AuthCoordinator.prototype.authenticateWithKsefToken; + let receivedOptions = null; + AuthCoordinator.prototype.authenticateWithKsefToken = async function authenticateWithKsefToken( + options, + ) { + receivedOptions = options; + return { + accessToken: { token: "MIN-ACCESS", validUntil: "2099-01-01T00:00:00Z" }, + refreshToken: { token: "MIN-REFRESH", validUntil: "2099-01-01T00:00:00Z" }, + }; + }; + + try { + const client = await KsefClient.connect({ + baseUrl: "https://api-test.ksef.mf.gov.pl/v2", + token: "MIN-TOKEN", + context: { type: "Nip", value: "1111111111" }, + }); + assert.equal(await client.authManager.getAccessToken(), "MIN-ACCESS"); + assert.ok(receivedOptions); + assert.equal(receivedOptions.token, "MIN-TOKEN"); + assert.equal(receivedOptions.authorizationPolicy, undefined); + assert.equal(receivedOptions.maxAttempts, undefined); + assert.equal(receivedOptions.pollIntervalMs, undefined); + assert.equal(receivedOptions.publicCertificateBase64Der, undefined); + } finally { + AuthCoordinator.prototype.authenticateWithKsefToken = originalAuthenticate; + } +}); diff --git a/test/unit/onlineSessionWorkflow.test.js b/test/unit/onlineSessionWorkflow.test.js index ea71417..21df5e3 100644 --- a/test/unit/onlineSessionWorkflow.test.js +++ b/test/unit/onlineSessionWorkflow.test.js @@ -296,3 +296,121 @@ test("OnlineSessionHandle waitForUpo uses defaults and handles missing status pa /Session failed: undefined undefined/, ); }); + +test("OnlineSessionWorkflow resume restores handle state and validates encryption data", async () => { + const workflow = new OnlineSessionWorkflow( + { + getSessionStatus: async () => ({ status: { code: 200, description: "Done" } }), + getSessionInvoiceStatus: async (_sessionRef, invoiceRef) => ({ invoiceRef }), + getSessionInvoices: async () => ({ items: [] }), + getSessionFailedInvoices: async () => ({ items: [] }), + getSessionInvoiceUpoByReferenceNumber: async () => "", + getSessionInvoiceUpoByKsefNumber: async () => "", + getSessionUpo: async () => "", + closeOnlineSession: async () => {}, + sendOnlineInvoice: async () => ({ referenceNumber: "INV-1" }), + }, + { getPublicKeyCertificates: async () => [] }, + { request: async () => "" }, + ); + + const state = { + referenceNumber: "SESSION-RESUME-1", + encryptionData: { + cipherKey: Buffer.alloc(32, 1), + cipherIv: Buffer.alloc(16, 2), + encryptionInfo: {}, + }, + upoV43: true, + }; + const handle = workflow.resume(state); + assert.equal(handle.referenceNumber, "SESSION-RESUME-1"); + assert.equal(handle.upoV43, true); + assert.deepEqual(handle.getState().referenceNumber, "SESSION-RESUME-1"); + assert.deepEqual(await handle.getInvoiceStatus("INV-REF"), { invoiceRef: "INV-REF" }); + + assert.throws( + () => + workflow.resume({ + referenceNumber: "bad", + encryptionData: { cipherKey: Buffer.alloc(0), cipherIv: Buffer.alloc(16), encryptionInfo: {} }, + }), + /requires cipherKey and cipherIv/, + ); + + assert.throws( + () => + workflow.resume({ + referenceNumber: " ", + encryptionData: { + cipherKey: Buffer.alloc(32, 1), + cipherIv: Buffer.alloc(16, 2), + encryptionInfo: {}, + }, + }), + /requires non-empty referenceNumber/, + ); +}); + +test("OnlineSessionHandle exposes close, status and invoice/upo lookups", async () => { + const calls = []; + const sessionsClient = { + closeOnlineSession: async (ref) => { + calls.push(["close", ref]); + }, + getSessionStatus: async (ref) => { + calls.push(["status", ref]); + return { status: { code: 200, description: "Done" } }; + }, + getSessionInvoices: async (ref, pageOffset, pageSize, continuationToken) => { + calls.push(["invoices", ref, pageOffset, pageSize, continuationToken]); + return { items: [{ id: "inv-1" }] }; + }, + getSessionFailedInvoices: async (ref, pageSize, continuationToken) => { + calls.push(["failed", ref, pageSize, continuationToken]); + return { items: [{ id: "failed-1" }] }; + }, + getSessionInvoiceUpoByReferenceNumber: async (ref, invoiceRef) => { + calls.push(["upoByRef", ref, invoiceRef]); + return ""; + }, + getSessionInvoiceUpoByKsefNumber: async (ref, ksefNumber) => { + calls.push(["upoByKsef", ref, ksefNumber]); + return ""; + }, + getSessionUpo: async (ref, upoRef) => { + calls.push(["sessionUpo", ref, upoRef]); + return ""; + }, + }; + const handle = new OnlineSessionHandle( + "SESSION-METHODS", + { cipherKey: Buffer.alloc(32, 1), cipherIv: Buffer.alloc(16, 2), encryptionInfo: {} }, + sessionsClient, + { request: async () => "" }, + ); + + await handle.close(); + const status = await handle.status(); + assert.equal(status.status.code, 200); + + const invoices = await handle.listInvoices(1, 5, "cont-token"); + assert.deepEqual(invoices.items, [{ id: "inv-1" }]); + + const failed = await handle.listFailedInvoices(10, "failed-token"); + assert.deepEqual(failed.items, [{ id: "failed-1" }]); + + assert.equal(await handle.getInvoiceUpoByReference("INV-REF-1"), ""); + assert.equal(await handle.getInvoiceUpoByKsefNumber("KSEF-1"), ""); + assert.equal(await handle.getSessionUpo("UPO-REF-1"), ""); + + assert.deepEqual(calls, [ + ["close", "SESSION-METHODS"], + ["status", "SESSION-METHODS"], + ["invoices", "SESSION-METHODS", 1, 5, "cont-token"], + ["failed", "SESSION-METHODS", 10, "failed-token"], + ["upoByRef", "SESSION-METHODS", "INV-REF-1"], + ["upoByKsef", "SESSION-METHODS", "KSEF-1"], + ["sessionUpo", "SESSION-METHODS", "UPO-REF-1"], + ]); +}); diff --git a/test/unit/pefXmlBuilder.test.js b/test/unit/pefXmlBuilder.test.js index 7321435..1d68c53 100644 --- a/test/unit/pefXmlBuilder.test.js +++ b/test/unit/pefXmlBuilder.test.js @@ -3,10 +3,17 @@ import { test } from "node:test"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import * as libxmljs from "libxmljs2"; import { XMLParser } from "fast-xml-parser"; import { buildPefXml, isPefUblDocumentInput, KsefValidationError } from "../../dist/index.js"; +let libxmljs; +let skipLibxmljs = false; +try { + libxmljs = await import("libxmljs2"); +} catch (error) { + skipLibxmljs = `libxmljs2 unavailable: ${error instanceof Error ? error.message : String(error)}`; +} + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const workspaceRoot = path.resolve(packageRoot, ".."); @@ -31,6 +38,7 @@ const pefKor3TemplatePath = path.join( const requiredFixtures = [pef3XsdPath, pefKor3XsdPath, pef3TemplatePath, pefKor3TemplatePath]; const missingFixture = requiredFixtures.find((fixturePath) => !fs.existsSync(fixturePath)); const skipMissingFixture = missingFixture ? `Missing fixture: ${missingFixture}` : false; +const skipXsdValidation = skipMissingFixture || skipLibxmljs; const xmlParser = new XMLParser({ ignoreAttributes: false, @@ -91,7 +99,7 @@ function stripRootAttributes(root) { return clone; } -test("PEF(3) XML builder produces XSD-valid XML", { skip: skipMissingFixture }, () => { +test("PEF(3) XML builder produces XSD-valid XML", { skip: skipXsdValidation }, () => { const templateXml = loadTemplateXml(pef3TemplatePath); const { rootKey, root } = parseRoot(templateXml); if (rootKey !== "Invoice") { @@ -102,7 +110,7 @@ test("PEF(3) XML builder produces XSD-valid XML", { skip: skipMissingFixture }, validateXml(xml, xsd); }); -test("PEF_KOR(3) XML builder produces XSD-valid XML", { skip: skipMissingFixture }, () => { +test("PEF_KOR(3) XML builder produces XSD-valid XML", { skip: skipXsdValidation }, () => { const templateXml = loadTemplateXml(pefKor3TemplatePath); const { rootKey, root } = parseRoot(templateXml); const localRoot = rootKey.includes(":") ? rootKey.split(":").at(-1) : rootKey; diff --git a/test/unit/sessionStoreCoverage.test.js b/test/unit/sessionStoreCoverage.test.js new file mode 100644 index 0000000..6c67bdd --- /dev/null +++ b/test/unit/sessionStoreCoverage.test.js @@ -0,0 +1,794 @@ +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { test } from "node:test"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const distCliPath = path.resolve(__dirname, "../../dist/cli/index.js"); +const distCliMapPath = path.resolve(__dirname, "../../dist/cli/index.js.map"); +const distRootPath = path.resolve(__dirname, "../../dist"); + +async function loadSessionStoreModule() { + const tempBaseDir = path.resolve(__dirname, "../../.tmp"); + await mkdir(tempBaseDir, { recursive: true }); + const tempDir = await mkdtemp(path.join(tempBaseDir, "ksef-session-store-")); + const tempModulePath = path.join(tempDir, "index.js"); + const tempMapPath = path.join(tempDir, "index.js.map"); + + try { + const distEntries = await readdir(distRootPath, { withFileTypes: true }); + for (const entry of distEntries) { + if (!entry.isFile()) { + continue; + } + if (!entry.name.endsWith(".js")) { + continue; + } + if (!entry.name.startsWith("chunk-") && !entry.name.startsWith("libxmljs2-")) { + continue; + } + await copyFile(path.join(distRootPath, entry.name), path.join(tempBaseDir, entry.name)); + } + + const source = await readFile(distCliPath, "utf8"); + const withoutSourceMapComment = source.replace( + /\n\/\/# sourceMappingURL=index\.js\.map\s*$/u, + "", + ); + const patched = `${withoutSourceMapComment} +export { + validateSessionId, + summarizeCheckpoint, + serializeOnlineSessionState, + deserializeOnlineSessionState, + serializeBatchSessionState, + deserializeBatchSessionState, + saveCheckpoint, + loadCheckpoint, + listCheckpoints, + deleteCheckpoint, + updateCheckpoint, + exportCheckpoint, + importCheckpoint, + SessionStoreError, + formatPersistenceError, +}; +//# sourceMappingURL=index.js.map +`; + + await writeFile(tempModulePath, patched, "utf8"); + await copyFile(distCliMapPath, tempMapPath); + + const moduleUrl = `${pathToFileURL(tempModulePath).href}?cacheBust=${Date.now()}`; + const loaded = await import(moduleUrl); + + return { + module: loaded, + dispose: async () => { + await rm(tempDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await rm(tempDir, { recursive: true, force: true }); + throw error; + } +} + +function onlineCheckpoint(overrides = {}) { + return { + schemaVersion: 1, + id: "sess-online", + profile: "default", + baseUrl: "https://api-test.ksef.mf.gov.pl/v2", + kind: "online", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + stage: "opened", + sessionState: { + referenceNumber: "ONLINE-REF", + encryptionData: { + cipherKeyBase64: Buffer.alloc(32, 1).toString("base64"), + cipherIvBase64: Buffer.alloc(16, 2).toString("base64"), + encryptionInfo: { encryptedSymmetricKey: "k", initializationVector: "i" }, + }, + }, + lastInvoiceRef: null, + sentInvoiceRefs: [], + ...overrides, + }; +} + +function batchCheckpoint(overrides = {}) { + return { + schemaVersion: 1, + id: "sess-batch", + profile: "default", + baseUrl: "https://api-test.ksef.mf.gov.pl/v2", + kind: "batch", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + stage: "opened", + sessionState: { + referenceNumber: "BATCH-REF", + encryptionData: { + cipherKeyBase64: Buffer.alloc(32, 3).toString("base64"), + cipherIvBase64: Buffer.alloc(16, 4).toString("base64"), + encryptionInfo: { encryptedSymmetricKey: "k", initializationVector: "i" }, + }, + batchFile: { + fileSize: 10, + fileHash: "file-hash", + fileParts: [{ ordinalNumber: 1, fileSize: 10, fileHash: "part-hash" }], + }, + partUploadRequests: [ + { ordinalNumber: 1, method: "PUT", url: "https://upload/1", headers: { a: "b" } }, + ], + encryptedPartsBase64: ["QUJD"], + }, + payloadSource: { + kind: "zip", + path: "batch.zip", + sourceSha256Base64: "source-hash", + sourceSize: 10, + }, + uploadedOrdinals: [1], + lastUpoRef: null, + ...overrides, + }; +} + +async function writeRawCheckpoint(cliHome, profile, id, content) { + const directory = path.join(cliHome, "cache", "sessions", profile); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, `${id}.json`), content, "utf8"); +} + +test("sessionStore: validateSessionId normalizes valid ids and throws SessionStoreError", async () => { + const loaded = await loadSessionStoreModule(); + try { + const { validateSessionId, SessionStoreError } = loaded.module; + assert.equal(validateSessionId(" ok.1-2_3 "), "ok.1-2_3"); + assert.throws( + () => validateSessionId("bad id!"), + (error) => { + assert.ok(error instanceof SessionStoreError); + assert.equal(error.kind, "validation"); + assert.equal(error.name, "SessionStoreError"); + return true; + }, + ); + } finally { + await loaded.dispose(); + } +}); + +test("sessionStore: summarizeCheckpoint covers online and batch branches", async () => { + const loaded = await loadSessionStoreModule(); + try { + const { summarizeCheckpoint } = loaded.module; + + const onlineEmpty = summarizeCheckpoint( + onlineCheckpoint({ lastInvoiceRef: null, sentInvoiceRefs: ["a", "b"] }), + ); + assert.equal(onlineEmpty.lastInvoiceRef, ""); + assert.equal(onlineEmpty.sentInvoiceCount, 2); + + const onlineFilled = summarizeCheckpoint(onlineCheckpoint({ lastInvoiceRef: "INV-9" })); + assert.equal(onlineFilled.lastInvoiceRef, "INV-9"); + + const batchEmpty = summarizeCheckpoint( + batchCheckpoint({ lastUpoRef: null, uploadedOrdinals: [1, 2] }), + ); + assert.equal(batchEmpty.lastUpoRef, ""); + assert.deepEqual(batchEmpty.uploadedOrdinals, [1, 2]); + assert.equal(batchEmpty.payloadSource.kind, "zip"); + + const batchFilled = summarizeCheckpoint(batchCheckpoint({ lastUpoRef: "UPO-3" })); + assert.equal(batchFilled.lastUpoRef, "UPO-3"); + } finally { + await loaded.dispose(); + } +}); + +test("sessionStore: online serialize/deserialize roundtrip with and without upoV43", async () => { + const loaded = await loadSessionStoreModule(); + try { + const { serializeOnlineSessionState, deserializeOnlineSessionState } = loaded.module; + + const state = { + referenceNumber: "R", + encryptionData: { + cipherKey: Buffer.alloc(32, 7), + cipherIv: Buffer.alloc(16, 8), + encryptionInfo: { encryptedSymmetricKey: "k", initializationVector: "i" }, + }, + upoV43: true, + }; + const serialized = serializeOnlineSessionState(state); + assert.equal(serialized.upoV43, true); + const restored = deserializeOnlineSessionState(serialized); + assert.ok(restored.encryptionData.cipherKey.equals(Buffer.alloc(32, 7))); + assert.ok(restored.encryptionData.cipherIv.equals(Buffer.alloc(16, 8))); + assert.equal(restored.upoV43, true); + + const stateNoFlag = { + referenceNumber: "R2", + encryptionData: state.encryptionData, + }; + const serializedNoFlag = serializeOnlineSessionState(stateNoFlag); + assert.equal(serializedNoFlag.upoV43, undefined); + const restoredNoFlag = deserializeOnlineSessionState(serializedNoFlag); + assert.equal(restoredNoFlag.upoV43, undefined); + } finally { + await loaded.dispose(); + } +}); + +test("sessionStore: batch serialize/deserialize roundtrip with header defaults and flags", async () => { + const loaded = await loadSessionStoreModule(); + try { + const { serializeBatchSessionState, deserializeBatchSessionState } = loaded.module; + + const state = { + referenceNumber: "BR", + encryptionData: { + cipherKey: Buffer.alloc(32, 3), + cipherIv: Buffer.alloc(16, 4), + encryptionInfo: { encryptedSymmetricKey: "k", initializationVector: "i" }, + }, + batchFile: { + fileSize: 5, + fileHash: "h", + fileParts: [{ ordinalNumber: 1, fileSize: 5, fileHash: "ph" }], + }, + partUploadRequests: [ + { ordinalNumber: 1, method: "PUT", url: "u1", headers: { a: "1" } }, + { ordinalNumber: 2, method: "PUT", url: "u2" }, + ], + encryptedPartsBase64: ["QUFB", "QkJC"], + upoV43: true, + offlineMode: true, + }; + const serialized = serializeBatchSessionState(state); + assert.deepEqual(serialized.partUploadRequests[1].headers, {}); + assert.equal(serialized.upoV43, true); + assert.equal(serialized.offlineMode, true); + const restored = deserializeBatchSessionState(serialized); + assert.ok(restored.encryptionData.cipherKey.equals(Buffer.alloc(32, 3))); + assert.equal(restored.upoV43, true); + assert.equal(restored.offlineMode, true); + assert.deepEqual(restored.partUploadRequests[1].headers, {}); + + const stateNoFlags = { + referenceNumber: "BR2", + encryptionData: state.encryptionData, + batchFile: state.batchFile, + partUploadRequests: [{ ordinalNumber: 1, method: "PUT", url: "u1", headers: { a: "1" } }], + encryptedPartsBase64: ["QUFB"], + }; + const serializedNoFlags = serializeBatchSessionState(stateNoFlags); + assert.equal(serializedNoFlags.upoV43, undefined); + assert.equal(serializedNoFlags.offlineMode, undefined); + const restoredNoFlags = deserializeBatchSessionState(serializedNoFlags); + assert.equal(restoredNoFlags.upoV43, undefined); + assert.equal(restoredNoFlags.offlineMode, undefined); + + const restoredFromHeaderless = deserializeBatchSessionState({ + referenceNumber: "BR3", + encryptionData: { + cipherKeyBase64: Buffer.alloc(32, 5).toString("base64"), + cipherIvBase64: Buffer.alloc(16, 6).toString("base64"), + encryptionInfo: { encryptedSymmetricKey: "k", initializationVector: "i" }, + }, + batchFile: { fileSize: 3, fileHash: "h", fileParts: [] }, + partUploadRequests: [{ ordinalNumber: 1, method: "PUT", url: "u1" }], + encryptedPartsBase64: ["QUFB"], + }); + assert.deepEqual(restoredFromHeaderless.partUploadRequests[0].headers, {}); + } finally { + await loaded.dispose(); + } +}); + +test("sessionStore: save/load roundtrip and overwrite guard", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { saveCheckpoint, loadCheckpoint, SessionStoreError } = loaded.module; + const checkpoint = onlineCheckpoint({ id: "roundtrip" }); + const savedPath = await saveCheckpoint(cliHome, checkpoint); + assert.ok(savedPath.endsWith(path.join("sessions", "default", "roundtrip.json"))); + + const reloaded = await loadCheckpoint(cliHome, "default", "roundtrip"); + assert.equal(reloaded.id, "roundtrip"); + assert.equal(reloaded.kind, "online"); + + await assert.rejects( + () => saveCheckpoint(cliHome, checkpoint, { overwrite: false }), + (error) => { + assert.ok(error instanceof SessionStoreError); + assert.equal(error.kind, "validation"); + assert.match(error.message, /already exists/); + return true; + }, + ); + + const freshPath = await saveCheckpoint(cliHome, onlineCheckpoint({ id: "fresh" }), { + overwrite: false, + }); + assert.ok(freshPath.endsWith("fresh.json")); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: saveCheckpoint reports write failures as config errors", async () => { + const loaded = await loadSessionStoreModule(); + const base = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { saveCheckpoint, SessionStoreError } = loaded.module; + const checkpointDir = path.join(base, "cache", "sessions", "default", "blocked.json"); + await mkdir(checkpointDir, { recursive: true }); + await writeFile(path.join(checkpointDir, "keep"), "x", "utf8"); + await assert.rejects( + () => saveCheckpoint(base, onlineCheckpoint({ id: "blocked" })), + (error) => { + assert.ok(error instanceof SessionStoreError); + assert.equal(error.kind, "config"); + assert.match(error.message, /Cannot persist session checkpoint/); + return true; + }, + ); + } finally { + await rm(base, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: loadCheckpoint reports missing checkpoint and rethrows unexpected stat errors", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { loadCheckpoint, SessionStoreError } = loaded.module; + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "missing"), + (error) => { + assert.ok(error instanceof SessionStoreError); + assert.equal(error.kind, "config"); + assert.match(error.message, /does not exist/); + return true; + }, + ); + + const sessionsRoot = path.join(cliHome, "cache", "sessions"); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, "profile-as-file"), "x", "utf8"); + await assert.rejects( + () => loadCheckpoint(cliHome, "profile-as-file", "id"), + (error) => { + assert.ok(!(error instanceof SessionStoreError)); + return true; + }, + ); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: listCheckpoints handles empty, populated, malformed and rethrown errors", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { saveCheckpoint, listCheckpoints } = loaded.module; + + assert.deepEqual(await listCheckpoints(cliHome, "default"), []); + + await saveCheckpoint(cliHome, onlineCheckpoint({ id: "a-online" })); + await saveCheckpoint(cliHome, batchCheckpoint({ id: "b-batch" })); + + const directory = path.join(cliHome, "cache", "sessions", "default"); + await writeFile(path.join(directory, "malformed.json"), "not json", "utf8"); + await writeFile(path.join(directory, "ignored.txt"), "skip", "utf8"); + await mkdir(path.join(directory, "as-directory.json"), { recursive: true }); + + const items = await listCheckpoints(cliHome, "default"); + assert.equal(items.length, 2); + assert.deepEqual( + items.map((item) => item.id).sort(), + ["a-online", "b-batch"], + ); + + const sessionsRoot = path.join(cliHome, "cache", "sessions"); + await writeFile(path.join(sessionsRoot, "file-profile"), "x", "utf8"); + await assert.rejects(() => listCheckpoints(cliHome, "file-profile")); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: deleteCheckpoint success, missing and unexpected errors", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { saveCheckpoint, deleteCheckpoint, loadCheckpoint, SessionStoreError } = loaded.module; + await saveCheckpoint(cliHome, onlineCheckpoint({ id: "to-delete" })); + await deleteCheckpoint(cliHome, "default", "to-delete"); + await assert.rejects(() => loadCheckpoint(cliHome, "default", "to-delete")); + + await assert.rejects( + () => deleteCheckpoint(cliHome, "default", "to-delete"), + (error) => { + assert.ok(error instanceof SessionStoreError); + assert.equal(error.kind, "config"); + assert.match(error.message, /does not exist/); + return true; + }, + ); + + const sessionsRoot = path.join(cliHome, "cache", "sessions"); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile(path.join(sessionsRoot, "file-profile"), "x", "utf8"); + await assert.rejects( + () => deleteCheckpoint(cliHome, "file-profile", "id"), + (error) => { + assert.ok(error instanceof SessionStoreError); + assert.equal(error.kind, "config"); + assert.match(error.message, /Cannot delete session checkpoint/); + return true; + }, + ); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: updateCheckpoint persists changes with a new timestamp", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { saveCheckpoint, updateCheckpoint, loadCheckpoint } = loaded.module; + const checkpoint = onlineCheckpoint({ id: "updatable" }); + await saveCheckpoint(cliHome, checkpoint); + const updated = await updateCheckpoint(cliHome, checkpoint, { stage: "closed" }); + assert.equal(updated.stage, "closed"); + assert.notEqual(updated.updatedAt, checkpoint.updatedAt); + const reloaded = await loadCheckpoint(cliHome, "default", "updatable"); + assert.equal(reloaded.stage, "closed"); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: exportCheckpoint resolves directories, files and rethrows unexpected errors", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { saveCheckpoint, exportCheckpoint } = loaded.module; + await saveCheckpoint(cliHome, onlineCheckpoint({ id: "exportable" })); + + const trailingDir = path.join(cliHome, "trailing"); + await mkdir(trailingDir, { recursive: true }); + const trailingTarget = await exportCheckpoint( + cliHome, + "default", + "exportable", + `${trailingDir}/`, + ); + assert.ok(trailingTarget.endsWith(path.join("trailing", "session-exportable.json"))); + assert.match(await readFile(trailingTarget, "utf8"), /exportable/); + + const existingDir = path.join(cliHome, "existing"); + await mkdir(existingDir, { recursive: true }); + const dirTarget = await exportCheckpoint(cliHome, "default", "exportable", existingDir); + assert.ok(dirTarget.endsWith(path.join("existing", "session-exportable.json"))); + + const fileTarget = path.join(cliHome, "explicit.json"); + const explicit = await exportCheckpoint(cliHome, "default", "exportable", fileTarget); + assert.equal(explicit, path.resolve(fileTarget)); + assert.match(await readFile(explicit, "utf8"), /exportable/); + + const blocker = path.join(cliHome, "blocker"); + await writeFile(blocker, "file", "utf8"); + await assert.rejects(() => + exportCheckpoint(cliHome, "default", "exportable", path.join(blocker, "nested.json")), + ); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: importCheckpoint handles overrides, mismatches and invalid sources", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { saveCheckpoint, exportCheckpoint, importCheckpoint, loadCheckpoint, SessionStoreError } = + loaded.module; + await saveCheckpoint(cliHome, onlineCheckpoint({ id: "source" })); + const exportedFile = path.join(cliHome, "exported.json"); + await exportCheckpoint(cliHome, "default", "source", exportedFile); + + const imported = await importCheckpoint(cliHome, "default", exportedFile); + assert.equal(imported.id, "source"); + + const renamed = await importCheckpoint(cliHome, "default", exportedFile, { + sessionId: "renamed", + }); + assert.equal(renamed.id, "renamed"); + assert.equal((await loadCheckpoint(cliHome, "default", "renamed")).id, "renamed"); + + const otherProfileFile = path.join(cliHome, "other-profile.json"); + await writeFile( + otherProfileFile, + JSON.stringify(onlineCheckpoint({ id: "foreign", profile: "other" }), null, 2), + "utf8", + ); + await assert.rejects( + () => importCheckpoint(cliHome, "default", otherProfileFile), + (error) => { + assert.ok(error instanceof SessionStoreError); + assert.equal(error.kind, "validation"); + assert.match(error.message, /selected profile/); + return true; + }, + ); + + await assert.rejects( + () => importCheckpoint(cliHome, "default", path.join(cliHome, "does-not-exist.json")), + (error) => { + assert.ok(error instanceof SessionStoreError); + assert.equal(error.kind, "io"); + assert.match(error.message, /does not exist/); + return true; + }, + ); + + const directorySource = path.join(cliHome, "as-dir"); + await mkdir(directorySource, { recursive: true }); + await assert.rejects( + () => importCheckpoint(cliHome, "default", directorySource), + (error) => { + assert.ok(error instanceof SessionStoreError); + assert.equal(error.kind, "io"); + assert.match(error.message, /not a file/); + return true; + }, + ); + + const blocker = path.join(cliHome, "blocker-file"); + await writeFile(blocker, "file", "utf8"); + await assert.rejects(() => + importCheckpoint(cliHome, "default", path.join(blocker, "nested.json")), + ); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: loadCheckpoint validates malformed checkpoint payloads", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { loadCheckpoint } = loaded.module; + + await writeRawCheckpoint(cliHome, "default", "bad-json", "not json{"); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "bad-json"), + /Invalid session checkpoint JSON/, + ); + + // Force JSON.parse to throw a non-Error value to cover the String(error) fallback. + const sentinel = "__THROW_NON_ERROR__"; + await writeRawCheckpoint(cliHome, "default", "non-error-json", sentinel); + const originalParse = JSON.parse; + JSON.parse = (text, ...rest) => { + if (text === sentinel) { + throw "non-error string"; + } + return originalParse(text, ...rest); + }; + try { + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "non-error-json"), + /Invalid session checkpoint JSON: non-error string/, + ); + } finally { + JSON.parse = originalParse; + } + + await writeRawCheckpoint(cliHome, "default", "not-object", "123"); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "not-object"), + /Invalid session checkpoint payload/, + ); + + await writeRawCheckpoint( + cliHome, + "default", + "bad-version", + JSON.stringify(onlineCheckpoint({ schemaVersion: 2 })), + ); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "bad-version"), + /Unsupported checkpoint schemaVersion/, + ); + + await writeRawCheckpoint( + cliHome, + "default", + "bad-kind", + JSON.stringify(onlineCheckpoint({ kind: "weird" })), + ); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "bad-kind"), + /Unsupported checkpoint kind/, + ); + + const missingId = onlineCheckpoint(); + delete missingId.id; + await writeRawCheckpoint(cliHome, "default", "missing-id", JSON.stringify(missingId)); + await assert.rejects(() => loadCheckpoint(cliHome, "default", "missing-id"), /Invalid id/); + + await writeRawCheckpoint( + cliHome, + "default", + "blank-profile", + JSON.stringify(onlineCheckpoint({ profile: " " })), + ); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "blank-profile"), + /Invalid profile/, + ); + + await writeRawCheckpoint( + cliHome, + "default", + "bad-invoice-ref", + JSON.stringify(onlineCheckpoint({ lastInvoiceRef: 123 })), + ); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "bad-invoice-ref"), + /Invalid lastInvoiceRef/, + ); + + await writeRawCheckpoint( + cliHome, + "default", + "bad-sent-refs", + JSON.stringify(onlineCheckpoint({ sentInvoiceRefs: [1] })), + ); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "bad-sent-refs"), + /expected string array/, + ); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: loadCheckpoint accepts valid online and batch payloads", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { loadCheckpoint } = loaded.module; + + await writeRawCheckpoint( + cliHome, + "default", + "online-filled", + JSON.stringify(onlineCheckpoint({ lastInvoiceRef: "INV-7", sentInvoiceRefs: ["r1"] })), + ); + const onlineFilled = await loadCheckpoint(cliHome, "default", "online-filled"); + assert.equal(onlineFilled.lastInvoiceRef, "INV-7"); + assert.deepEqual(onlineFilled.sentInvoiceRefs, ["r1"]); + + const onlineMinimal = onlineCheckpoint({ lastInvoiceRef: null }); + delete onlineMinimal.sentInvoiceRefs; + await writeRawCheckpoint( + cliHome, + "default", + "online-minimal", + JSON.stringify(onlineMinimal), + ); + const onlineLoaded = await loadCheckpoint(cliHome, "default", "online-minimal"); + assert.equal(onlineLoaded.lastInvoiceRef, null); + assert.deepEqual(onlineLoaded.sentInvoiceRefs, []); + + await writeRawCheckpoint( + cliHome, + "default", + "batch-filled", + JSON.stringify(batchCheckpoint({ lastUpoRef: "UPO-2", uploadedOrdinals: [1] })), + ); + const batchFilled = await loadCheckpoint(cliHome, "default", "batch-filled"); + assert.equal(batchFilled.lastUpoRef, "UPO-2"); + assert.deepEqual(batchFilled.uploadedOrdinals, [1]); + + const batchMinimal = batchCheckpoint({ lastUpoRef: null }); + delete batchMinimal.uploadedOrdinals; + await writeRawCheckpoint(cliHome, "default", "batch-minimal", JSON.stringify(batchMinimal)); + const batchLoaded = await loadCheckpoint(cliHome, "default", "batch-minimal"); + assert.equal(batchLoaded.lastUpoRef, null); + assert.deepEqual(batchLoaded.uploadedOrdinals, []); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: loadCheckpoint validates batch payloadSource and ordinals", async () => { + const loaded = await loadSessionStoreModule(); + const cliHome = await mkdtemp(path.join(os.tmpdir(), "ksef-store-")); + try { + const { loadCheckpoint } = loaded.module; + + const missingSource = batchCheckpoint(); + delete missingSource.payloadSource; + await writeRawCheckpoint( + cliHome, + "default", + "missing-source", + JSON.stringify(missingSource), + ); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "missing-source"), + /Invalid payloadSource: expected object/, + ); + + await writeRawCheckpoint( + cliHome, + "default", + "bad-source-kind", + JSON.stringify(batchCheckpoint({ payloadSource: { kind: "tar", path: "p", sourceSha256Base64: "h", sourceSize: 1 } })), + ); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "bad-source-kind"), + /Invalid payloadSource\.kind/, + ); + + await writeRawCheckpoint( + cliHome, + "default", + "bad-source-size", + JSON.stringify(batchCheckpoint({ payloadSource: { kind: "zip", path: "p", sourceSha256Base64: "h", sourceSize: -1 } })), + ); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "bad-source-size"), + /Invalid payloadSource\.sourceSize/, + ); + + await writeRawCheckpoint( + cliHome, + "default", + "bad-ordinals", + JSON.stringify(batchCheckpoint({ uploadedOrdinals: [-1] })), + ); + await assert.rejects( + () => loadCheckpoint(cliHome, "default", "bad-ordinals"), + /expected integer array/, + ); + } finally { + await rm(cliHome, { recursive: true, force: true }); + await loaded.dispose(); + } +}); + +test("sessionStore: formatPersistenceError stringifies non-Error values", async () => { + const loaded = await loadSessionStoreModule(); + try { + const { formatPersistenceError } = loaded.module; + assert.equal(formatPersistenceError(new Error("boom")), "boom"); + assert.equal(formatPersistenceError("string-boom"), "string-boom"); + } finally { + await loaded.dispose(); + } +}); diff --git a/test/unit/xmlBuilder.property.test.js b/test/unit/xmlBuilder.property.test.js index 9d7a3b5..265b782 100644 --- a/test/unit/xmlBuilder.property.test.js +++ b/test/unit/xmlBuilder.property.test.js @@ -2,11 +2,18 @@ import { test } from "node:test"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import * as libxmljs from "libxmljs2"; import { XMLParser } from "fast-xml-parser"; import fc from "fast-check"; import { buildFakturaXml } from "../../dist/index.js"; +let libxmljs; +let skipLibxmljs = false; +try { + libxmljs = await import("libxmljs2"); +} catch (error) { + skipLibxmljs = `libxmljs2 unavailable: ${error instanceof Error ? error.message : String(error)}`; +} + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const workspaceRoot = path.resolve(packageRoot, ".."); const xsdBaseDir = path.join(workspaceRoot, "ksef-docs", "faktury", "schemy", "FA"); @@ -29,6 +36,7 @@ const fa3TemplatePath = path.join( const requiredFixtures = [xsdFa2Path, xsdFa3Path, fa2TemplatePath, fa3TemplatePath]; const missingFixture = requiredFixtures.find((fixturePath) => !fs.existsSync(fixturePath)); const skipMissingFixture = missingFixture ? `Missing fixture: ${missingFixture}` : false; +const skipXsdValidation = skipMissingFixture || skipLibxmljs; const xmlParser = new XMLParser({ ignoreAttributes: false, @@ -150,7 +158,7 @@ function propertyTest(schema, templatePath, xsdPath) { test( `property: ${schema} builder produces XSD-valid XML for many variants`, - { skip: skipMissingFixture }, + { skip: skipXsdValidation }, async () => { const template = parseFaktura(loadTemplateXml(templatePath)); const xsd = loadXsd(xsdPath); diff --git a/test/unit/xmlBuilder.test.js b/test/unit/xmlBuilder.test.js index cad1fc7..93d0695 100644 --- a/test/unit/xmlBuilder.test.js +++ b/test/unit/xmlBuilder.test.js @@ -2,10 +2,17 @@ import { test } from "node:test"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import * as libxmljs from "libxmljs2"; import { XMLParser } from "fast-xml-parser"; import { buildFakturaXml } from "../../dist/index.js"; +let libxmljs; +let skipLibxmljs = false; +try { + libxmljs = await import("libxmljs2"); +} catch (error) { + skipLibxmljs = `libxmljs2 unavailable: ${error instanceof Error ? error.message : String(error)}`; +} + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const workspaceRoot = path.resolve(packageRoot, ".."); const xsdBaseDir = path.join(workspaceRoot, "ksef-docs", "faktury", "schemy", "FA"); @@ -28,6 +35,7 @@ const fa3TemplatePath = path.join( const requiredFixtures = [xsdFa2Path, xsdFa3Path, fa2TemplatePath, fa3TemplatePath]; const missingFixture = requiredFixtures.find((fixturePath) => !fs.existsSync(fixturePath)); const skipMissingFixture = missingFixture ? `Missing fixture: ${missingFixture}` : false; +const skipXsdValidation = skipMissingFixture || skipLibxmljs; const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", @@ -91,7 +99,7 @@ function makeMultiRateFaktura(templatePath) { return faktura; } -test("FA2 XML builder produces XSD-valid XML", { skip: skipMissingFixture }, () => { +test("FA2 XML builder produces XSD-valid XML", { skip: skipXsdValidation }, () => { const templateXml = loadTemplateXml(fa2TemplatePath); const faktura = parseFaktura(templateXml); const xml = buildFakturaXml(faktura, { schema: "FA2" }); @@ -99,7 +107,7 @@ test("FA2 XML builder produces XSD-valid XML", { skip: skipMissingFixture }, () validateXml(xml, xsd); }); -test("FA3 XML builder produces XSD-valid XML", { skip: skipMissingFixture }, () => { +test("FA3 XML builder produces XSD-valid XML", { skip: skipXsdValidation }, () => { const templateXml = loadTemplateXml(fa3TemplatePath); const faktura = parseFaktura(templateXml); const xml = buildFakturaXml(faktura, { schema: "FA3" }); @@ -107,14 +115,14 @@ test("FA3 XML builder produces XSD-valid XML", { skip: skipMissingFixture }, () validateXml(xml, xsd); }); -test("FA2 XML builder validates a multi-rate invoice against XSD", { skip: skipMissingFixture }, () => { +test("FA2 XML builder validates a multi-rate invoice against XSD", { skip: skipXsdValidation }, () => { const faktura = makeMultiRateFaktura(fa2TemplatePath); const xml = buildFakturaXml(faktura, { schema: "FA2" }); const xsd = loadXsd(xsdFa2Path); validateXml(xml, xsd); }); -test("FA3 XML builder validates a multi-rate invoice against XSD", { skip: skipMissingFixture }, () => { +test("FA3 XML builder validates a multi-rate invoice against XSD", { skip: skipXsdValidation }, () => { const faktura = makeMultiRateFaktura(fa3TemplatePath); const xml = buildFakturaXml(faktura, { schema: "FA3" }); const xsd = loadXsd(xsdFa3Path); diff --git a/tsup.config.ts b/tsup.config.ts index 08fe6c2..cac37d2 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,7 +2,10 @@ import { defineConfig } from "tsup"; export default defineConfig([ { - entry: ["src/index.ts"], + entry: { + index: "src/index.ts", + "documents/fa3": "src/documents/fa3/index.ts", + }, format: ["esm", "cjs"], dts: true, sourcemap: true, @@ -10,7 +13,7 @@ export default defineConfig([ treeshake: true, target: "node20", platform: "node", - external: ["node-forge", "qrcode"], + external: ["node-forge", "qrcode", "libxmljs2"], }, { entry: { @@ -23,7 +26,7 @@ export default defineConfig([ treeshake: true, target: "node20", platform: "node", - external: ["node-forge", "qrcode"], + external: ["node-forge", "qrcode", "libxmljs2"], banner: { js: "#!/usr/bin/env node", },