From e9162ca88874b7ee003e41a8063708b141745d27 Mon Sep 17 00:00:00 2001 From: Nik Divjak Date: Mon, 25 May 2026 06:34:19 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20P1=20e-invoice=20core=20=E2=80=94=20EN1?= =?UTF-8?q?6931=20=E2=86=92=20e-SLOG=202.0=20/=20UBL=20generate=20+=20vali?= =?UTF-8?q?date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public EN16931 core-invoice model (valibot, BT-annotated) → three outputs: - e-SLOG 2.0 (owned serializer; UN/EDIFACT-INVOIC XML, urn:eslog:2.00) - UBL / CII via @e-invoice-eu/core (model → ubl:Invoice internal JSON) - EN16931 structural validation via the lib's JSON Schema (Ajv 2019-09) Effect-native API (parseInvoice / validateEn16931 / generateEInvoice) plus a Promise boundary (createEInvoice) for non-Effect hosts. Framework-agnostic — no Medusa coupling; the medusa plugin track wraps this as a leaf dep. e-SLOG mapping grounded in the epos.si spec + cross-checked vs the MIT Media24si/eslog2 reference. 25 tests pass; tsc clean. Coverage/roadmap in ROADMAP.md (e-SLOG XSD/schematron output validation + allowances deferred). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 123 +++++++++++++++++- ROADMAP.md | 54 ++++++++ bun.lock | 114 +++++++++++++++++ src/index.ts | 44 +++++++ src/lib/einvoice/formats.ts | 16 +++ src/lib/einvoice/generate.ts | 59 +++++++++ src/lib/einvoice/index.ts | 4 + src/lib/einvoice/to-internal.ts | 156 ++++++++++++++++++++++ src/lib/einvoice/validate.ts | 47 +++++++ src/lib/eslog/codes.ts | 98 ++++++++++++++ src/lib/eslog/index.ts | 2 + src/lib/eslog/serialize.ts | 220 ++++++++++++++++++++++++++++++++ src/lib/foundation/errors.ts | 75 +++++++++++ src/lib/foundation/index.ts | 2 + src/lib/foundation/runtime.ts | 38 ++++++ src/lib/invoice/index.ts | 2 + src/lib/invoice/model.ts | 199 +++++++++++++++++++++++++++++ src/lib/invoice/validate.ts | 28 ++++ src/test/einvoice.test.ts | 67 ++++++++++ src/test/eslog.test.ts | 88 +++++++++++++ src/test/fixtures.ts | 79 ++++++++++++ src/test/invoice.test.ts | 48 +++++++ src/test/pipeline.test.ts | 23 ++++ 23 files changed, 1584 insertions(+), 2 deletions(-) create mode 100644 ROADMAP.md create mode 100644 bun.lock create mode 100644 src/index.ts create mode 100644 src/lib/einvoice/formats.ts create mode 100644 src/lib/einvoice/generate.ts create mode 100644 src/lib/einvoice/index.ts create mode 100644 src/lib/einvoice/to-internal.ts create mode 100644 src/lib/einvoice/validate.ts create mode 100644 src/lib/eslog/codes.ts create mode 100644 src/lib/eslog/index.ts create mode 100644 src/lib/eslog/serialize.ts create mode 100644 src/lib/foundation/errors.ts create mode 100644 src/lib/foundation/index.ts create mode 100644 src/lib/foundation/runtime.ts create mode 100644 src/lib/invoice/index.ts create mode 100644 src/lib/invoice/model.ts create mode 100644 src/lib/invoice/validate.ts create mode 100644 src/test/einvoice.test.ts create mode 100644 src/test/eslog.test.ts create mode 100644 src/test/fixtures.ts create mode 100644 src/test/invoice.test.ts create mode 100644 src/test/pipeline.test.ts diff --git a/README.md b/README.md index ecd0403..eea7053 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,125 @@ Open-source Slovenian **fiscalization + e-invoicing** toolkit on the grunt-it TS/Effect stack — the compliance-hard sliver of what Minimax does, as a reusable, -framework-agnostic engine (not a full accounting suite). +**framework-agnostic** engine (not a full accounting suite). -> Scaffold. Implementation lands in the P1 PR (e-invoice core). See `ROADMAP.md`. +It knows nothing about Medusa, HTTP frameworks, or any host: build an invoice, +get conformant XML out. Consumers (e.g. a Medusa fiscalization plugin) wrap it as +a leaf dependency. + +> **Status — P1.** This release covers the **e-invoice core**: generate + +> validate an EN16931 core invoice as **e-SLOG 2.0** (Slovenian) and **UBL / CII**. +> FURS fiscal verification (ZOI/EOR), Medusa integration, and the service/MCP +> surface are later phases — see [`ROADMAP.md`](./ROADMAP.md). + +## Why this shape + +Slovenia's e-SLOG 2.0 is EN16931-compliant but is its **own** XML syntax +(UN/EDIFACT INVOIC-derived, namespace `urn:eslog:2.00`) — not UBL. So fiscalize: + +- **depends on** [`@e-invoice-eu/core`](https://github.com/gflohr/e-invoice-eu) + (WTFPL) for the EN16931 model, validation, and UBL / CII / Peppol / Factur-X + serialization — the hard, maintained part; and +- **owns** the thin Slovenian delta: an **e-SLOG 2.0 serializer**. + +Staying on upstream is deliberate — EN16931 / Peppol rules move (the SI B2B +mandate lands **Jan 2028**), and we want to inherit those updates rather than +fork away from them. Rule-change monitoring is tracked via `upkeep`. + +## Install + +Published to GitHub Packages. Create a `bunfig.toml`: + +```toml +[install.scopes] +"@grunt-it" = { token = "$REGISTRY_TOKEN", url = "https://npm.pkg.github.com" } +``` + +```bash +export REGISTRY_TOKEN="ghp_…" # a PAT with read:packages +bun add @grunt-it/fiscalize +``` + +## Quick start + +Promise-friendly, for any host (never throws): + +```ts +import { createEInvoice, type Invoice } from "@grunt-it/fiscalize"; + +const invoice: Invoice = { + invoiceNumber: "2026-000123", + issueDate: "2026-05-25", + dueDate: "2026-06-08", + seller: { + name: "Zelena Japka d.o.o.", + vatId: "SI12345678", + address: { street: "Slovenska cesta 1", city: "Ljubljana", postalZone: "1000", countryCode: "SI" }, + iban: "SI56020170014356205", + bic: "LJBASI2X", + }, + buyer: { + name: "Kupec d.o.o.", + vatId: "SI87654321", + address: { city: "Ljubljana", postalZone: "1000", countryCode: "SI" }, + }, + lines: [ + { id: "1", name: "Detergent 1L", quantity: 10, netPrice: 5.0, lineNetAmount: 50.0, taxRate: 22 }, + ], + taxBreakdown: [{ taxableAmount: 50.0, taxAmount: 11.0, taxRate: 22 }], + totals: { lineExtensionAmount: 50.0, taxExclusiveAmount: 50.0, taxAmount: 11.0, taxInclusiveAmount: 61.0 }, +}; + +const result = await createEInvoice(invoice, { format: "eslog" }); +if (result.ok) { + console.log(result.data); // e-SLOG 2.0 XML +} else { + console.error(result.error.message, result.error.status); +} +``` + +`format` is `"eslog" | "ubl" | "cii"`. For `ubl`/`cii`, EN16931 validation runs +before generation by default (`validate: false` to skip). + +### Effect-native API + +```ts +import { Effect } from "effect"; +import { parseInvoice, validateEn16931, generateEInvoice, serializeEslog } from "@grunt-it/fiscalize"; + +const program = Effect.gen(function* () { + const invoice = yield* parseInvoice(rawInput); // valibot — structural gate + yield* validateEn16931(invoice); // Ajv vs EN16931 schema + return yield* generateEInvoice(invoice, { format: "ubl" }); +}); +``` + +`serializeEslog(invoice)` is a pure synchronous escape hatch for e-SLOG only. + +## The model + +`Invoice` is a clean, EN16931-aligned **core invoice**: header (BT-1/2/3/5/9/72), +seller & buyer (BG-4/BG-7, with VAT, address, bank), lines (BG-25), VAT breakdown +(BG-23), and totals (BG-22). Every field is annotated with its Business Term so +the mapping to each syntax stays auditable. Validated with `valibot`. + +## What's covered vs deferred + +P1 maps the **mandatory + common core**. Document-level allowances/charges, +line-level allowances, contacts, multiple payment means, e-SLOG XSD/schematron +output validation, and the long tail of optional BTs are deferred — see +[`ROADMAP.md`](./ROADMAP.md). The e-SLOG mapping is grounded in the official +spec (epos.si) and cross-checked against the MIT-licensed reference generator +`Media24si/eslog2`. + +## Development + +```bash +bun install +bun test +bunx tsc --noEmit +``` + +## License + +MIT — see [`LICENSE`](./LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..3ea4eff --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,54 @@ +# Roadmap + +Phasing mirrors the ticket (`grunt-it/tickets/fiscalization-einvoicing-toolkit.md`). + +## P1 — e-invoice core ✅ (this release) + +EN16931 core invoice → **e-SLOG 2.0** + **UBL / CII**, with validation. + +- Clean EN16931-aligned `Invoice` domain model (valibot), BT-annotated. +- Owned **e-SLOG 2.0** serializer (`urn:eslog:2.00`, UN/EDIFACT-INVOIC structure): + header, dates, parties (+ bank + VAT), currency, payment terms, lines, document + totals, VAT breakdown. +- **UBL / CII** via `@e-invoice-eu/core` (model → internal `ubl:Invoice` JSON). +- EN16931 structural validation via the lib's JSON Schema (Ajv 2019-09). +- Effect-native API + Promise boundary (`createEInvoice`). + +### Deferred within the e-invoice core (next P1.x slices) + +- **e-SLOG XSD + schematron validation** of *output*. P1 validates the EN16931 + model structurally (UBL schema); it does not yet validate the produced e-SLOG + XML against the official e-SLOG 2.0 XSD or business-rule schematron. Vendor the + XSD (incl. `xmldsig-core-schema.xsd`) and add an output-validation pass. +- **Document-level allowances/charges** (BG-20/BG-21) → e-SLOG `G_SG16`, UBL + `cac:AllowanceCharge`. Model has the totals (BT-107/108) but not the detail. +- **Line-level allowances** (BG-27/BG-28) → e-SLOG `G_SG39`. +- **Contacts** (BG-6/BG-9), **delivery address** (BG-15), **multiple payment + means**, item identifiers (EAN/buyer/seller), `cbc:Note` granularity. +- **Cross-border**: EAS scheme defaults beyond SI VAT (`9949`); a country→EAS map. +- **XML digital signature** (XAdES over e-SLOG) — required for some exchange paths. +- **Credit notes / corrected invoices** end-to-end (type codes are modelled; + the negative-amount + reference-to-original rules are not yet exercised). + +## P2 — FURS fiscal verification + +Cash-register receipts → ZOI/EOR. Derive from `node-furs-fiscal-verification` +(TS-ify; cert handling, FURS tax-API calls). Cross-language refs: SLOTax (.NET), +jurgenwerk/furs_fiscal_verification (Ruby). + +## P3 — integration surface + +`order.placed` → fiscalize (FURS) + e-invoice (e-SLOG/UBL). Consumed by a +separate `@grunt-it/medusa-plugin-si-fiscalization` track — fiscalize stays +framework-agnostic; the plugin wraps it. + +## P4 — service + MCP + +Optional long-running service + MCP-accessible surface (sibling to secret-tap, +upkeep). Package for reuse. + +## Ongoing — compliance tracking + +FURS / e-SLOG / EN16931 / Peppol rules change (incl. the moving Jan-2028 SI B2B +mandate). Tracked via `upkeep`'s AI compliance check; fiscalize is its first +consumer. Rule changes become mapping updates here. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ddfb788 --- /dev/null +++ b/bun.lock @@ -0,0 +1,114 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@grunt-it/fiscalize", + "dependencies": { + "@e-invoice-eu/core": "^3.1.1", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", + "effect": "^3.18.4", + "valibot": "^1.2.0", + "xmlbuilder2": "^4.0.3", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5", + }, + }, + }, + "packages": { + "@cantoo/pdf-lib": ["@cantoo/pdf-lib@2.6.5", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "color": "^4.2.3", "crypto-js": "^4.2.0", "node-html-better-parser": ">=1.4.0", "pako": "^1.0.11", "tslib": ">=2" } }, "sha512-3eMHEaqKHt/G/q+6QjT06A3lz0S/a8x3+myiSN7FNeL3uWcedO0lpfs6TWofa4C03Z1wz3tWeHoa4CsI7DrTSA=="], + + "@e-invoice-eu/core": ["@e-invoice-eu/core@3.1.1", "", { "dependencies": { "@cantoo/pdf-lib": "^2.6.5", "@e965/xlsx": "^0.20.3", "@esgettext/runtime": "^1.3.10", "ajv": "^8.18.0", "jsonpath-plus": "^10.4.0", "tmp-promise": "^3.0.3", "tslib": "^2.8.1", "xmlbuilder2": "^4.0.3" } }, "sha512-SCy4VQO95H5wLmiPx6e959cW6/ujGbYTeHOalYsUYYbmgXhbFcWOry8Ge3ceRJWC3mfFKBVbdP2NteGVyKvTLA=="], + + "@e965/xlsx": ["@e965/xlsx@0.20.3", "", { "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w=="], + + "@esgettext/runtime": ["@esgettext/runtime@1.3.10", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-thIUBFoim5wv3e/HLz0Gums8X+/lpoXXb48Nycfre/fSH+RGmzNjYXQwXVNl9AhkazVMuBmY8nKiJS9IBlg9SQ=="], + + "@jsep-plugin/assignment": ["@jsep-plugin/assignment@1.3.0", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ=="], + + "@jsep-plugin/regex": ["@jsep-plugin/regex@1.0.4", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg=="], + + "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], + + "@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="], + + "@oozcitak/url": ["@oozcitak/url@3.0.0", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0" } }, "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ=="], + + "@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="], + + "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="], + + "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + + "effect": ["effect@3.21.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], + + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "jsonpath-plus": ["jsonpath-plus@10.4.0", "", { "dependencies": { "@jsep-plugin/assignment": "^1.3.0", "@jsep-plugin/regex": "^1.0.4", "jsep": "^1.4.0" }, "bin": { "jsonpath": "bin/jsonpath-cli.js", "jsonpath-plus": "bin/jsonpath-cli.js" } }, "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA=="], + + "node-html-better-parser": ["node-html-better-parser@1.5.8", "", { "dependencies": { "html-entities": "^2.3.2" } }, "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], + + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + + "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "valibot": ["valibot@1.4.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g=="], + + "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..27655df --- /dev/null +++ b/src/index.ts @@ -0,0 +1,44 @@ +import { Effect } from "effect"; +import { generateEInvoice, type GenerateOptions } from "./lib/einvoice/generate"; +import { validateEn16931 } from "./lib/einvoice/validate"; +import { type EffectResult, runSafe } from "./lib/foundation/runtime"; +import { parseInvoice } from "./lib/invoice/validate"; + +export interface CreateEInvoiceOptions extends GenerateOptions { + /** + * Run EN16931 business-rule validation before generating. Defaults to `true` + * for `ubl`/`cii` and `false` for `eslog` (e-SLOG has its own XSD/schematron + * validation path, deferred — see ROADMAP.md). + */ + validate?: boolean; +} + +/** + * The full pipeline as an Effect: parse unknown input against the invoice model + * → optionally validate against EN16931 → generate XML in the requested format. + */ +export const createEInvoiceEffect = (input: unknown, options: CreateEInvoiceOptions) => + Effect.gen(function* () { + const invoice = yield* parseInvoice(input); + const shouldValidate = options.validate ?? options.format !== "eslog"; + if (shouldValidate) yield* validateEn16931(invoice); + return yield* generateEInvoice(invoice, options); + }); + +/** + * Promise-friendly wrapper of {@link createEInvoiceEffect} for non-Effect hosts + * (e.g. a Medusa plugin). Never throws — returns `{ ok: true, data }` with the + * XML or `{ ok: false, error: { message, status } }`. + */ +export function createEInvoice( + input: unknown, + options: CreateEInvoiceOptions, +): Promise> { + return runSafe(createEInvoiceEffect(input, options)); +} + +export * from "./lib/foundation"; +export * from "./lib/invoice"; +export * from "./lib/einvoice"; +export { type EslogOptions, serializeEslog } from "./lib/eslog/serialize"; +export * as eslogCodes from "./lib/eslog/codes"; diff --git a/src/lib/einvoice/formats.ts b/src/lib/einvoice/formats.ts new file mode 100644 index 0000000..d0f65ec --- /dev/null +++ b/src/lib/einvoice/formats.ts @@ -0,0 +1,16 @@ +/** Output formats fiscalize can produce. */ +export const FORMATS = ["eslog", "ubl", "cii"] as const; +export type Format = (typeof FORMATS)[number]; + +/** + * Map a fiscalize format to the `@e-invoice-eu/core` format string. `eslog` is + * handled by our own serializer and is intentionally absent here. + */ +export const LIB_FORMAT: Record, string> = { + ubl: "UBL", + cii: "CII", +}; + +export function isFormat(value: string): value is Format { + return (FORMATS as readonly string[]).includes(value); +} diff --git a/src/lib/einvoice/generate.ts b/src/lib/einvoice/generate.ts new file mode 100644 index 0000000..07aa2be --- /dev/null +++ b/src/lib/einvoice/generate.ts @@ -0,0 +1,59 @@ +import { type Invoice as EInvoiceEUInvoice, InvoiceService } from "@e-invoice-eu/core"; +import { Effect } from "effect"; +import { EInvoiceGenerationError, UnsupportedFormatError } from "../foundation/errors"; +import type { EslogOptions } from "../eslog/serialize"; +import { serializeEslog } from "../eslog/serialize"; +import type { Invoice } from "../invoice/model"; +import { FORMATS, type Format, isFormat, LIB_FORMAT } from "./formats"; +import { toEInvoiceInternal } from "./to-internal"; + +export interface GenerateOptions extends EslogOptions { + format: Format; + /** + * Language tag (e.g. `sl-si`) — only used by `@e-invoice-eu/core` for + * Factur-X PDF metadata; irrelevant to the pure-XML formats here. Default `sl`. + */ + lang?: string; +} + +// `InvoiceService` is stateless across calls; build once. +let service: InvoiceService | undefined; +function getService(): InvoiceService { + if (!service) service = new InvoiceService(console); + return service; +} + +/** + * Generate an e-invoice XML string in the requested {@link Format}. + * + * - `eslog` → our owned e-SLOG 2.0 serializer. + * - `ubl` / `cii` → `@e-invoice-eu/core`, via the internal-format mapping. + * + * Assumes a structurally-valid invoice (run `parseInvoice` first). For EN16931 + * business-rule checking ahead of generation, run `validateEn16931`. + */ +export const generateEInvoice = Effect.fn("generateEInvoice")(function* ( + invoice: Invoice, + options: GenerateOptions, +) { + const format = options.format; + if (!isFormat(format)) { + return yield* Effect.fail(new UnsupportedFormatError(format, FORMATS)); + } + + if (format === "eslog") { + return yield* Effect.try({ + try: () => serializeEslog(invoice, options), + catch: (cause) => new EInvoiceGenerationError("eslog", cause), + }); + } + + const internal = toEInvoiceInternal(invoice) as unknown as EInvoiceEUInvoice; + const rendered = yield* Effect.tryPromise({ + try: () => getService().generate(internal, { format: LIB_FORMAT[format], lang: options.lang ?? "sl" }), + catch: (cause) => new EInvoiceGenerationError(format, cause), + }); + + // Pure-XML formats return the XML as a string. + return typeof rendered === "string" ? rendered : String(rendered); +}); diff --git a/src/lib/einvoice/index.ts b/src/lib/einvoice/index.ts new file mode 100644 index 0000000..0cd14b1 --- /dev/null +++ b/src/lib/einvoice/index.ts @@ -0,0 +1,4 @@ +export * from "./formats"; +export * from "./generate"; +export * from "./to-internal"; +export * from "./validate"; diff --git a/src/lib/einvoice/to-internal.ts b/src/lib/einvoice/to-internal.ts new file mode 100644 index 0000000..9901096 --- /dev/null +++ b/src/lib/einvoice/to-internal.ts @@ -0,0 +1,156 @@ +import { DEFAULTS, type Invoice, type InvoiceLine, type Party, type TaxBreakdown } from "../invoice/model"; + +/** + * Map the fiscalize {@link Invoice} model to the `@e-invoice-eu/core` internal + * format — a UBL-shaped JSON tree (`ubl:Invoice` with `cbc:`/`cac:` keys and + * `@attr` siblings). The lib renders this to UBL / CII / Peppol and validates it + * against EN16931. Element *ordering* is handled by the lib's templates, so keys + * here are in authoring order, not UBL sequence. + * + * Covers the EN16931 core invoice. The long tail (contacts, endpoints, document + * allowances/charges, line allowances) is deferred — see ROADMAP.md. + */ +export function toEInvoiceInternal(invoice: Invoice): { "ubl:Invoice": Record } { + const currency = invoice.currency ?? DEFAULTS.currency; + + const ubl: Record = { + "cbc:CustomizationID": "urn:cen.eu:en16931:2017", + "cbc:ProfileID": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0", + "cbc:ID": invoice.invoiceNumber, + "cbc:IssueDate": invoice.issueDate, + ...(invoice.dueDate ? { "cbc:DueDate": invoice.dueDate } : {}), + "cbc:InvoiceTypeCode": invoice.invoiceTypeCode ?? DEFAULTS.invoiceTypeCode, + ...(invoice.note ? { "cbc:Note": [invoice.note] } : {}), + "cbc:DocumentCurrencyCode": currency, + ...(invoice.buyerReference ? { "cbc:BuyerReference": invoice.buyerReference } : {}), + ...(invoice.orderReference ? { "cac:OrderReference": { "cbc:ID": invoice.orderReference } } : {}), + "cac:AccountingSupplierParty": { "cac:Party": party(invoice.seller, "supplier") }, + "cac:AccountingCustomerParty": { "cac:Party": party(invoice.buyer, "customer") }, + ...(invoice.deliveryDate + ? { "cac:Delivery": { "cbc:ActualDeliveryDate": invoice.deliveryDate } } + : {}), + "cac:PaymentMeans": [paymentMeans(invoice)], + "cac:TaxTotal": [taxTotal(invoice, currency)], + "cac:LegalMonetaryTotal": legalMonetaryTotal(invoice, currency), + "cac:InvoiceLine": invoice.lines.map((line) => invoiceLine(line, currency)), + }; + + return { "ubl:Invoice": ubl }; +} + +function party(p: Party, side: "supplier" | "customer"): Record { + const endpointId = p.endpointId ?? p.vatId; + const out: Record = {}; + + // BT-34 / BT-49 — electronic address (mandatory in EN16931/UBL). + if (endpointId) { + out["cbc:EndpointID"] = endpointId; + out["cbc:EndpointID@schemeID"] = p.endpointScheme ?? DEFAULTS.endpointScheme; + } + + out["cac:PartyName"] = { "cbc:Name": p.name }; + out["cac:PostalAddress"] = { + ...(p.address.street ? { "cbc:StreetName": p.address.street } : {}), + "cbc:CityName": p.address.city, + "cbc:PostalZone": p.address.postalZone, + "cac:Country": { "cbc:IdentificationCode": p.address.countryCode }, + }; + + if (p.vatId) { + // The internal schema mirrors the EN16931 sample's asymmetry: supplier + // PartyTaxScheme is an array, customer is a single object. + const taxScheme = { "cbc:CompanyID": p.vatId, "cac:TaxScheme": { "cbc:ID": "VAT" } }; + out["cac:PartyTaxScheme"] = side === "supplier" ? [taxScheme] : taxScheme; + } + + out["cac:PartyLegalEntity"] = { + "cbc:RegistrationName": p.name, + ...(p.registrationId ? { "cbc:CompanyID": p.registrationId } : {}), + }; + + return out; +} + +function paymentMeans(invoice: Invoice): Record { + const out: Record = { + "cbc:PaymentMeansCode": invoice.paymentMeansCode ?? DEFAULTS.paymentMeansCode, + "cbc:PaymentID": `Invoice ${invoice.invoiceNumber}`, + }; + if (invoice.seller.iban) { + const account: Record = { "cbc:ID": invoice.seller.iban }; + if (invoice.seller.bankName) account["cbc:Name"] = invoice.seller.bankName; + if (invoice.seller.bic) { + account["cac:FinancialInstitutionBranch"] = { "cbc:ID": invoice.seller.bic }; + } + out["cac:PayeeFinancialAccount"] = account; + } + return out; +} + +function taxTotal(invoice: Invoice, currency: string): Record { + return { + ...amount("cbc:TaxAmount", invoice.totals.taxAmount, currency), + "cac:TaxSubtotal": invoice.taxBreakdown.map((t) => taxSubtotal(t, currency)), + }; +} + +function taxSubtotal(t: TaxBreakdown, currency: string): Record { + const category: Record = { + "cbc:ID": t.category ?? DEFAULTS.taxCategory, + "cbc:Percent": String(t.taxRate), + ...(t.exemptionReason ? { "cbc:TaxExemptionReason": t.exemptionReason } : {}), + "cac:TaxScheme": { "cbc:ID": "VAT" }, + }; + return { + ...amount("cbc:TaxableAmount", t.taxableAmount, currency), + ...amount("cbc:TaxAmount", t.taxAmount, currency), + "cac:TaxCategory": category, + }; +} + +function legalMonetaryTotal(invoice: Invoice, currency: string): Record { + const t = invoice.totals; + return { + ...amount("cbc:LineExtensionAmount", t.lineExtensionAmount, currency), + ...amount("cbc:TaxExclusiveAmount", t.taxExclusiveAmount, currency), + ...amount("cbc:TaxInclusiveAmount", t.taxInclusiveAmount, currency), + ...(t.allowanceTotal != null ? amount("cbc:AllowanceTotalAmount", t.allowanceTotal, currency) : {}), + ...(t.chargeTotal != null ? amount("cbc:ChargeTotalAmount", t.chargeTotal, currency) : {}), + ...(t.paidAmount != null ? amount("cbc:PrepaidAmount", t.paidAmount, currency) : {}), + ...(t.roundingAmount != null ? amount("cbc:PayableRoundingAmount", t.roundingAmount, currency) : {}), + ...amount("cbc:PayableAmount", t.payableAmount ?? t.taxInclusiveAmount, currency), + }; +} + +function invoiceLine(line: InvoiceLine, currency: string): Record { + return { + "cbc:ID": line.id, + "cbc:InvoicedQuantity": String(line.quantity), + "cbc:InvoicedQuantity@unitCode": line.unitCode ?? DEFAULTS.unitCode, + ...amount("cbc:LineExtensionAmount", line.lineNetAmount, currency), + "cac:Item": { + "cbc:Name": line.name, + ...(line.description ? { "cbc:Description": line.description } : {}), + "cac:ClassifiedTaxCategory": { + "cbc:ID": line.taxCategory ?? DEFAULTS.taxCategory, + "cbc:Percent": String(line.taxRate), + "cac:TaxScheme": { "cbc:ID": "VAT" }, + }, + }, + "cac:Price": { + ...amount("cbc:PriceAmount", line.netPrice, currency), + }, + }; +} + +/** Emit a UBL amount as the `{ key, key@currencyID }` pair the internal format uses. */ +function amount(key: string, value: number, currency: string): Record { + return { + [key]: round(value).toFixed(2), + [`${key}@currencyID`]: currency, + }; +} + +function round(n: number): number { + return Math.round((n + Number.EPSILON) * 100) / 100; +} diff --git a/src/lib/einvoice/validate.ts b/src/lib/einvoice/validate.ts new file mode 100644 index 0000000..a568791 --- /dev/null +++ b/src/lib/einvoice/validate.ts @@ -0,0 +1,47 @@ +import { invoiceSchema } from "@e-invoice-eu/core"; +// `@e-invoice-eu/core`'s invoiceSchema is JSON Schema draft 2019-09 → use Ajv2019. +import AjvImport, { type ValidateFunction } from "ajv/dist/2019.js"; +import addFormatsImport from "ajv-formats"; +import { Effect } from "effect"; +import { InvalidInvoiceError, type ValidationIssue } from "../foundation/errors"; +import type { Invoice } from "../invoice/model"; +import { toEInvoiceInternal } from "./to-internal"; + +// Ajv & ajv-formats ship CJS; normalise the interop default across runtimes. +const Ajv = ((AjvImport as unknown as { default?: unknown }).default ?? AjvImport) as typeof AjvImport; +const addFormats = ((addFormatsImport as unknown as { default?: unknown }).default ?? + addFormatsImport) as typeof addFormatsImport; + +let validator: ValidateFunction | undefined; + +function getValidator(): ValidateFunction { + if (!validator) { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + validator = ajv.compile(invoiceSchema); + } + return validator; +} + +/** + * Validate an {@link Invoice} against EN16931 business rules, via the JSON Schema + * shipped by `@e-invoice-eu/core`. Render-free: maps to the internal format and + * checks it without producing XML. Succeeds with the mapped internal document; + * fails with `InvalidInvoiceError` carrying per-field issues. + */ +export const validateEn16931 = Effect.fn("validateEn16931")(function* (invoice: Invoice) { + const internal = toEInvoiceInternal(invoice); + const validate = getValidator(); + if (validate(internal)) return internal; + + const issues: ValidationIssue[] = (validate.errors ?? []).map((e) => ({ + path: e.instancePath || e.schemaPath, + message: e.message ?? "invalid", + })); + return yield* Effect.fail( + new InvalidInvoiceError( + `Invoice failed EN16931 validation (${issues.length} issue${issues.length === 1 ? "" : "s"}).`, + issues, + ), + ); +}); diff --git a/src/lib/eslog/codes.ts b/src/lib/eslog/codes.ts new file mode 100644 index 0000000..481cd2f --- /dev/null +++ b/src/lib/eslog/codes.ts @@ -0,0 +1,98 @@ +/** + * e-SLOG 2.0 is a Slovenian national e-invoice syntax: UN/EDIFACT INVOIC mapped + * into XML (namespace `urn:eslog:2.00`), semantically EN16931-compliant. Elements + * are EDIFACT segment (`S_*`) / group (`G_SG*`) / composite (`C_*`) / data-element + * (`D_*`) codes; the *values* of qualifier data-elements come from UN/EDIFACT code + * lists. These constants name the qualifiers this serializer emits, mapped to the + * EN16931 Business Term they carry. + * + * Grounded in: the official e-SLOG 2.0 spec (epos.si) and the MIT-licensed + * reference generator Media24si/eslog2 (structure cross-checked against its + * sample `inv.xml`). + */ + +/** Document name code — S_BGM/C_C002/D_1001 (UNCL1001). */ +export const DOC_TYPE = { + INVOICE: "380", + CREDIT_NOTE: "381", + CORRECTED_INVOICE: "384", + PREPAYMENT_INVOICE: "386", +} as const; + +/** Date/time qualifier — S_DTM/C_C507/D_2005 (UNCL2005). */ +export const DTM = { + ISSUE: "137", // BT-2 document/message date + DELIVERY: "35", // BT-72 actual delivery date + DUE: "13", // BT-9 payment due date (within G_SG8) +} as const; + +/** Monetary amount qualifier — S_MOA/C_C516/D_5025 (UNCL5025). */ +export const MOA = { + LINE_AMOUNT_WITH_TAX: "38", // line amount incl. VAT (e-SLOG national, NBT-031) + LINE_NET_AMOUNT: "203", // BT-131 invoice line net amount + TAX_AMOUNT: "124", // VAT amount of a tax category / line + TAXABLE_AMOUNT: "125", // VAT category taxable base + SUM_LINE_NET: "79", // BT-106 sum of invoice line net amounts + ALLOWANCES_TOTAL: "260", // BT-107 sum of document allowances + CHARGES_TOTAL: "259", // BT-108 sum of document charges + TAX_EXCLUSIVE: "389", // BT-109 invoice total without VAT + TAX_TOTAL: "176", // BT-110 invoice total VAT amount + TAX_INCLUSIVE: "388", // BT-112 invoice total with VAT + PAID: "113", // BT-113 paid (prepaid) amount + ROUNDING: "366", // BT-114 rounding amount + PAYABLE: "9", // BT-115 amount due for payment + ITEM_DISCOUNT: "509", // BT-147 item price discount +} as const; + +/** Price qualifier — S_PRI/C_C509/D_5125 (UNCL5125). */ +export const PRI = { + NET: "AAA", // BT-146 item net price + GROSS: "AAB", // BT-148 item gross price +} as const; + +/** Party function qualifier — S_NAD/D_3035 (UNCL3035). */ +export const NAD = { + BUYER: "BY", // BG-7 buyer + SELLER: "SE", // BG-4 seller +} as const; + +/** Financial-institution qualifier — S_FII/D_3035 (UNCL3035). */ +export const FII = { + PAYEE_BANK: "RB", // seller's account (where payment is made) + BENEFICIARY_BANK: "BB", +} as const; + +/** Reference qualifier — S_RFF/C_C506/D_1153 (UNCL1153). */ +export const RFF = { + VAT: "VA", // BT-31 / BT-48 VAT identifier + ORDER: "ON", // BT-13 purchase order reference +} as const; + +/** Free-text subject qualifier — S_FTX/D_4451 (UNCL4451). */ +export const FTX = { + GENERAL_INFO: "AAI", // BT-22 invoice note +} as const; + +/** Item description type — S_IMD/D_7077 (UNCL7077). */ +export const IMD = { + ITEM_NAME: "F", // BT-153 (free-form short) + DESCRIPTION: "A", // BT-154 +} as const; + +/** Quantity qualifier — S_QTY/C_C186/D_6063 (UNCL6063). */ +export const QTY_INVOICED = "47"; // BT-129 invoiced quantity + +/** Duty/tax/fee function qualifier — S_TAX/D_5283 (UNCL5283). 7 = tax. */ +export const TAX_FUNCTION = "7"; +/** Duty/tax/fee type — S_TAX/C_C241/D_5153. */ +export const TAX_TYPE_VAT = "VAT"; + +/** Currency usage qualifier — S_CUX/C_C504/D_6347 (UNCL6347). 2 = reference currency. */ +export const CUX_REFERENCE = "2"; + +/** Default unit of measure — S_QTY/D_6411, S_PRI/D_6411 (UN/ECE Rec 20). C62 = unit. */ +export const DEFAULT_UNIT = "C62"; + +/** Namespaces of the e-SLOG 2.0 invoice document. */ +export const ESLOG_NS = "urn:eslog:2.00"; +export const XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"; diff --git a/src/lib/eslog/index.ts b/src/lib/eslog/index.ts new file mode 100644 index 0000000..c54d05f --- /dev/null +++ b/src/lib/eslog/index.ts @@ -0,0 +1,2 @@ +export * from "./serialize"; +export * as eslogCodes from "./codes"; diff --git a/src/lib/eslog/serialize.ts b/src/lib/eslog/serialize.ts new file mode 100644 index 0000000..2cd8cf3 --- /dev/null +++ b/src/lib/eslog/serialize.ts @@ -0,0 +1,220 @@ +import { create } from "xmlbuilder2"; +import { DEFAULTS, type Invoice, type InvoiceLine, type Party, type TaxBreakdown } from "../invoice/model"; +import { + CUX_REFERENCE, + DEFAULT_UNIT, + DTM, + ESLOG_NS, + FII, + FTX, + IMD, + MOA, + NAD, + PRI, + QTY_INVOICED, + RFF, + TAX_FUNCTION, + TAX_TYPE_VAT, + XSI_NS, +} from "./codes"; + +export interface EslogOptions { + /** Pretty-print with indentation. Default `true`. Set `false` for compact output. */ + pretty?: boolean; +} + +/** + * Serialize a {@link Invoice} to an e-SLOG 2.0 invoice XML string. + * + * Pure and synchronous — the input is assumed already structurally valid + * (run it through `parseInvoice` first). Covers the EN16931 core invoice: + * header, dates, parties (+ bank + VAT), currency, payment terms, lines, + * document totals, and the VAT breakdown. See ROADMAP.md for what's deferred. + */ +export function serializeEslog(invoice: Invoice, options: EslogOptions = {}): string { + const currency = invoice.currency ?? DEFAULTS.currency; + const typeCode = invoice.invoiceTypeCode ?? DEFAULTS.invoiceTypeCode; + + // M_INVOIC children, built in document order. Repeated segments use arrays. + const mInvoic: Record = { + "@Id": "data", + S_UNH: { + D_0062: invoice.invoiceNumber, + C_S009: { D_0065: "INVOIC", D_0052: "D", D_0054: "01B", D_0051: "UN" }, + }, + S_BGM: { + C_C002: { D_1001: typeCode }, + C_C106: { D_1004: invoice.invoiceNumber }, + }, + S_DTM: [ + dtmContent(DTM.ISSUE, invoice.issueDate), + ...(invoice.deliveryDate ? [dtmContent(DTM.DELIVERY, invoice.deliveryDate)] : []), + ], + }; + + if (invoice.note) { + mInvoic.S_FTX = [{ D_4451: FTX.GENERAL_INFO, C_C108: { D_4440: invoice.note } }]; + } + + if (invoice.orderReference) { + mInvoic.G_SG1 = [{ S_RFF: { C_C506: { D_1153: RFF.ORDER, D_1154: invoice.orderReference } } }]; + } + + // EN16931 / e-SLOG order parties buyer-first (matches the reference generator). + mInvoic.G_SG2 = [ + partyGroup(invoice.buyer, NAD.BUYER, FII.BENEFICIARY_BANK), + partyGroup(invoice.seller, NAD.SELLER, FII.PAYEE_BANK), + ]; + + mInvoic.G_SG7 = { S_CUX: { C_C504: { D_6347: CUX_REFERENCE, D_6345: currency } } }; + + mInvoic.G_SG8 = paymentTerms(invoice); + + mInvoic.G_SG26 = invoice.lines.map(lineGroup); + + mInvoic.G_SG50 = summaryAmounts(invoice).map(([code, amount]) => ({ + S_MOA: { C_C516: { D_5025: code, D_5004: money(amount) } }, + })); + + mInvoic.G_SG52 = invoice.taxBreakdown.map((t) => + taxGroup(t.taxRate, t.category ?? DEFAULTS.taxCategory, t.taxAmount, t.taxableAmount), + ); + + const doc = create( + { version: "1.0", encoding: "UTF-8" }, + { + Invoice: { + "@xmlns": ESLOG_NS, + "@xmlns:xsi": XSI_NS, + M_INVOIC: mInvoic, + }, + }, + ); + + return doc.end({ prettyPrint: options.pretty ?? true }); +} + +// ── Builders ────────────────────────────────────────────────────────────────── + +function dtmContent(code: string, date: string) { + return { C_C507: { D_2005: code, D_2380: date } }; +} + +function partyGroup(party: Party, qualifier: string, bankQualifier: string): Record { + const nad: Record = { + D_3035: qualifier, + C_C080: { D_3036: party.name }, + }; + if (party.address.street) nad.C_C059 = { D_3042: party.address.street }; + nad.D_3164 = party.address.city; + if (party.address.countryName) nad.C_C819 = { D_3228: party.address.countryName }; + nad.D_3251 = party.address.postalZone; + nad.D_3207 = party.address.countryCode; + + const group: Record = { S_NAD: nad }; + + if (party.iban) { + const c078: Record = { D_3194: party.iban }; + if (party.bankName) c078.D_3192 = party.bankName; + const fii: Record = { D_3035: bankQualifier, C_C078: c078 }; + if (party.bic) fii.C_C088 = { D_3433: party.bic }; + group.S_FII = fii; + } + + if (party.vatId) { + group.G_SG3 = { S_RFF: { C_C506: { D_1153: RFF.VAT, D_1154: party.vatId } } }; + } + + return group; +} + +function paymentTerms(invoice: Invoice): Record { + const terms: Record = { S_PAT: { D_4279: "1" } }; + if (invoice.dueDate) terms.S_DTM = dtmContent(DTM.DUE, invoice.dueDate); + terms.S_PAI = { C_C534: { D_4461: invoice.paymentMeansCode ?? DEFAULTS.paymentMeansCode } }; + return terms; +} + +function lineGroup(line: InvoiceLine): Record { + const unit = line.unitCode ?? DEFAULT_UNIT; + const gross = line.grossPrice ?? line.netPrice; + const category = line.taxCategory ?? DEFAULTS.taxCategory; + const lineTax = round(line.lineNetAmount * (line.taxRate / 100)); + const lineWithTax = round(line.lineNetAmount + lineTax); + + const imd: Array> = [ + { D_7077: IMD.ITEM_NAME, C_C273: { D_7008: clamp(line.name, 35) } }, + ]; + if (line.description) { + imd.push({ D_7077: IMD.DESCRIPTION, C_C273: { D_7008: clamp(line.description, 256) } }); + } + + return { + S_LIN: { D_1082: line.id }, + S_IMD: imd, + S_QTY: { C_C186: { D_6063: QTY_INVOICED, D_6060: num(line.quantity), D_6411: unit } }, + G_SG27: [ + { S_MOA: { C_C516: { D_5025: MOA.LINE_AMOUNT_WITH_TAX, D_5004: money(lineWithTax) } } }, + { S_MOA: { C_C516: { D_5025: MOA.LINE_NET_AMOUNT, D_5004: money(line.lineNetAmount) } } }, + ], + G_SG29: [ + { S_PRI: { C_C509: { D_5125: PRI.NET, D_5118: money(line.netPrice), D_5284: "1", D_6411: DEFAULT_UNIT } } }, + { S_PRI: { C_C509: { D_5125: PRI.GROSS, D_5118: money(gross), D_5284: "1", D_6411: DEFAULT_UNIT } } }, + ], + G_SG34: taxGroup(line.taxRate, category, lineTax, line.lineNetAmount), + }; +} + +/** S_TAX + the two S_MOA (tax amount, taxable base) — shared by line (G_SG34) and summary (G_SG52). */ +function taxGroup(rate: number, category: string, taxAmount: number, baseAmount: number): Record { + return { + S_TAX: { + D_5283: TAX_FUNCTION, + C_C241: { D_5153: TAX_TYPE_VAT }, + C_C243: { D_5278: num(rate) }, + D_5305: category, + }, + S_MOA: [ + { C_C516: { D_5025: MOA.TAX_AMOUNT, D_5004: money(taxAmount) } }, + { C_C516: { D_5025: MOA.TAXABLE_AMOUNT, D_5004: money(baseAmount) } }, + ], + }; +} + +function summaryAmounts(invoice: Invoice): Array<[string, number]> { + const t = invoice.totals; + const out: Array<[string, number]> = [ + [MOA.SUM_LINE_NET, t.lineExtensionAmount], + [MOA.ALLOWANCES_TOTAL, t.allowanceTotal ?? 0], + [MOA.CHARGES_TOTAL, t.chargeTotal ?? 0], + [MOA.TAX_EXCLUSIVE, t.taxExclusiveAmount], + [MOA.TAX_TOTAL, t.taxAmount], + [MOA.TAX_INCLUSIVE, t.taxInclusiveAmount], + ]; + if (t.paidAmount) out.push([MOA.PAID, t.paidAmount]); + if (t.roundingAmount) out.push([MOA.ROUNDING, t.roundingAmount]); + out.push([MOA.PAYABLE, t.payableAmount ?? t.taxInclusiveAmount]); + return out; +} + +// ── Number / text formatting ──────────────────────────────────────────────── + +/** Round to 2 decimals, float-safe. */ +function round(n: number): number { + return Math.round((n + Number.EPSILON) * 100) / 100; +} + +/** Money → 2-decimal string with dot separator (e-SLOG requires `.`). */ +function money(n: number): string { + return round(n).toFixed(2); +} + +/** Generic number → minimal decimal string (no forced trailing zeros). */ +function num(n: number): string { + return String(round(n)); +} + +/** e-SLOG limits some text fields; clamp defensively. */ +function clamp(s: string, max: number): string { + return s.length > max ? s.slice(0, max) : s; +} diff --git a/src/lib/foundation/errors.ts b/src/lib/foundation/errors.ts new file mode 100644 index 0000000..6eaa10c --- /dev/null +++ b/src/lib/foundation/errors.ts @@ -0,0 +1,75 @@ +import { Data } from "effect"; + +/** + * Shape shared by every fiscalize error: a human message, an HTTP-ish status + * (so a host can map failures to responses without unwrapping the union), and + * the underlying cause. + */ +export interface FiscalizeErrorParams { + message: string; + status: number; + cause?: unknown; +} + +/** + * A single schema/validation issue, normalised across valibot (input model) + * and Ajv (EN16931 business rules) so callers see one shape. + */ +export interface ValidationIssue { + /** Dotted path to the offending value, e.g. `seller.address.countryCode`. */ + path: string; + message: string; +} + +/** + * The invoice failed validation — the input model (valibot) or the EN16931 + * business rules (Ajv via `@e-invoice-eu/core`). 400-class: caller's data. + */ +export class InvalidInvoiceError extends Data.TaggedError("InvalidInvoiceError")< + FiscalizeErrorParams & { issues: ValidationIssue[] } +> { + constructor(message: string, issues: ValidationIssue[], cause?: unknown) { + super({ message, status: 400, issues, cause }); + } +} + +/** + * Serialization/generation failed after the invoice was accepted — e.g. the + * underlying renderer threw. 500-class: our problem, not the caller's. + */ +export class EInvoiceGenerationError extends Data.TaggedError("EInvoiceGenerationError")< + FiscalizeErrorParams & { format: string } +> { + constructor(format: string, cause: unknown) { + super({ + message: `Failed to generate ${format} e-invoice: ${describe(cause)}`, + status: 500, + format, + cause, + }); + } +} + +/** An unsupported output format was requested. */ +export class UnsupportedFormatError extends Data.TaggedError("UnsupportedFormatError")< + FiscalizeErrorParams & { format: string } +> { + constructor(format: string, supported: readonly string[]) { + super({ + message: `Unsupported format "${format}". Supported: ${supported.join(", ")}.`, + status: 400, + format, + }); + } +} + +export type FiscalizeError = + | InvalidInvoiceError + | EInvoiceGenerationError + | UnsupportedFormatError; + +function describe(cause: unknown): string { + if (cause instanceof Error) return cause.message; + if (typeof cause === "string") return cause; + return String(cause); +} diff --git a/src/lib/foundation/index.ts b/src/lib/foundation/index.ts new file mode 100644 index 0000000..32cce61 --- /dev/null +++ b/src/lib/foundation/index.ts @@ -0,0 +1,2 @@ +export * from "./errors"; +export * from "./runtime"; diff --git a/src/lib/foundation/runtime.ts b/src/lib/foundation/runtime.ts new file mode 100644 index 0000000..c63e2af --- /dev/null +++ b/src/lib/foundation/runtime.ts @@ -0,0 +1,38 @@ +import { Cause, Effect, Exit } from "effect"; + +/** + * Discriminated result for running an Effect at a Promise boundary — lets a + * non-Effect host (a Medusa plugin, an HTTP handler) consume fiscalize without + * adopting Effect. Mirrors `@grunt-it/utility-belt`'s `runSafe`. + */ +export type EffectSuccess = { ok: true; data: R }; +export type EffectFailure = { + ok: false; + error: { message: string; status: number; [key: string]: unknown }; +}; +export type EffectResult = EffectSuccess | EffectFailure; + +export function handleCause(cause: Cause.Cause): { message: string; status: number } { + let message = Cause.pretty(cause); + let status = 500; + + if (Cause.isFailType(cause)) { + const error = cause.error as { status?: number; message?: string }; + if (typeof error.status === "number") status = error.status; + if (typeof error.message === "string") message = error.message; + } + + return { message, status }; +} + +/** + * Run an Effect to a plain `{ ok }` result — never throws. Failures (including + * defects) collapse into `{ ok: false, error: { message, status } }`. + */ +export async function runSafe(effect: Effect.Effect): Promise> { + const result = await Effect.runPromiseExit(effect); + return Exit.match(result, { + onSuccess: (data) => ({ ok: true, data }), + onFailure: (cause) => ({ ok: false, error: handleCause(cause) }), + }); +} diff --git a/src/lib/invoice/index.ts b/src/lib/invoice/index.ts new file mode 100644 index 0000000..7a7bfa5 --- /dev/null +++ b/src/lib/invoice/index.ts @@ -0,0 +1,2 @@ +export * from "./model"; +export * from "./validate"; diff --git a/src/lib/invoice/model.ts b/src/lib/invoice/model.ts new file mode 100644 index 0000000..8563f27 --- /dev/null +++ b/src/lib/invoice/model.ts @@ -0,0 +1,199 @@ +import * as v from "valibot"; + +/** + * The fiscalize public invoice model — a clean, EN16931-aligned *core invoice*. + * + * This is the single input type consumers construct. It is deliberately + * decoupled from any output syntax: the same model serializes to e-SLOG 2.0 + * (Slovenian) and, via `@e-invoice-eu/core`, to UBL / CII / Peppol. + * + * Fields are annotated with their EN16931 Business Term (BT) / Business Group + * (BG) so the mapping to each syntax stays auditable as the standard evolves. + * + * Scope (P1): the mandatory + common core. Document-level allowances/charges, + * line-level allowances, contacts, multiple payment means, and the long tail of + * optional BTs are intentionally out — see ROADMAP.md. + */ + +// ── Code lists ────────────────────────────────────────────────────────────── + +/** EN16931 VAT category code (UNCL5305 subset). */ +export const TaxCategoryCode = v.picklist( + ["S", "Z", "E", "AE", "K", "G", "O", "L", "M"], + "Invalid VAT category code (expected one of S, Z, E, AE, K, G, O, L, M).", +); +export type TaxCategoryCode = v.InferOutput; + +/** ISO 8601 calendar date, `YYYY-MM-DD`. */ +const IsoDate = v.pipe( + v.string(), + v.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be ISO 8601 YYYY-MM-DD."), +); + +/** ISO 3166-1 alpha-2 country code, e.g. `SI`. */ +const CountryCode = v.pipe( + v.string(), + v.regex(/^[A-Z]{2}$/, "Country code must be ISO 3166-1 alpha-2 (two uppercase letters)."), +); + +/** ISO 4217 currency code, e.g. `EUR`. */ +const CurrencyCode = v.pipe( + v.string(), + v.regex(/^[A-Z]{3}$/, "Currency code must be ISO 4217 (three uppercase letters)."), +); + +const NonEmpty = v.pipe(v.string(), v.minLength(1)); +const Money = v.number(); // amounts in the invoice currency; rounding handled at serialization + +// ── Sub-objects ─────────────────────────────────────────────────────────────── + +export const PostalAddress = v.object({ + /** BT-35 / BT-50 — address line. */ + street: v.optional(v.string()), + /** BT-37 / BT-52 — city. */ + city: NonEmpty, + /** BT-38 / BT-53 — post code. */ + postalZone: NonEmpty, + /** BT-40 / BT-55 — country code (ISO 3166-1 alpha-2). */ + countryCode: CountryCode, + /** Display name of the country (non-EN16931, used by e-SLOG NAD). */ + countryName: v.optional(v.string()), +}); +export type PostalAddress = v.InferOutput; + +/** Seller (BG-4/BG-5) or Buyer (BG-7/BG-8). */ +export const Party = v.object({ + /** BT-27 (seller) / BT-44 (buyer) — registered legal name. */ + name: NonEmpty, + /** BT-31 (seller) / BT-48 (buyer) — VAT identifier, e.g. `SI12345678`. */ + vatId: v.optional(v.string()), + /** + * BT-34 (seller) / BT-49 (buyer) — electronic address (Peppol endpoint). + * Mandatory for EN16931/UBL output; defaults to {@link Party.vatId} when omitted. + */ + endpointId: v.optional(v.string()), + /** + * BT-34-1 / BT-49-1 — electronic address scheme (Peppol EAS code). Defaults to + * `9949` (Slovenia VAT). Cross-border senders must set their country's EAS code. + */ + endpointScheme: v.optional(v.string()), + /** BT-30 (seller) / BT-47 (buyer) — legal registration identifier. */ + registrationId: v.optional(v.string()), + address: PostalAddress, + /** BT-84 — payment account identifier (IBAN). Seller-side; used for e-SLOG FII. */ + iban: v.optional(v.string()), + /** BT-86 — payment service provider identifier (BIC/SWIFT). */ + bic: v.optional(v.string()), + /** Account holder / bank name (non-EN16931, used by e-SLOG FII). */ + bankName: v.optional(v.string()), +}); +export type Party = v.InferOutput; + +/** Invoice line (BG-25). */ +export const InvoiceLine = v.object({ + /** BT-126 — invoice line identifier. */ + id: NonEmpty, + /** BT-153 — item name. */ + name: NonEmpty, + /** BT-154 — item description. */ + description: v.optional(v.string()), + /** BT-129 — invoiced quantity. */ + quantity: v.number(), + /** BT-130 — unit of measure code (UN/ECE Rec 20), default `C62` (unit). */ + unitCode: v.optional(NonEmpty), + /** BT-146 — item net price (per unit, after item discount). */ + netPrice: Money, + /** BT-148 — item gross price (per unit, before item discount). Defaults to net. */ + grossPrice: v.optional(Money), + /** BT-131 — invoice line net amount. */ + lineNetAmount: Money, + /** BT-152 — invoiced item VAT rate (percent). */ + taxRate: v.number(), + /** BT-151 — invoiced item VAT category code. Default `S`. */ + taxCategory: v.optional(TaxCategoryCode), +}); +export type InvoiceLine = v.InferOutput; + +/** VAT breakdown entry (BG-23) — one per (category, rate). */ +export const TaxBreakdown = v.object({ + /** BT-116 — VAT category taxable amount. */ + taxableAmount: Money, + /** BT-117 — VAT category tax amount. */ + taxAmount: Money, + /** BT-119 — VAT category rate (percent). */ + taxRate: v.number(), + /** BT-118 — VAT category code. Default `S`. */ + category: v.optional(TaxCategoryCode), + /** BT-120 — VAT exemption reason text (for E/AE/K/G/O categories). */ + exemptionReason: v.optional(v.string()), +}); +export type TaxBreakdown = v.InferOutput; + +/** Document totals (BG-22). */ +export const Totals = v.object({ + /** BT-106 — sum of invoice line net amounts. */ + lineExtensionAmount: Money, + /** BT-107 — sum of allowances on document level. */ + allowanceTotal: v.optional(Money), + /** BT-108 — sum of charges on document level. */ + chargeTotal: v.optional(Money), + /** BT-109 — invoice total amount without VAT. */ + taxExclusiveAmount: Money, + /** BT-110 — invoice total VAT amount. */ + taxAmount: Money, + /** BT-112 — invoice total amount with VAT. */ + taxInclusiveAmount: Money, + /** BT-113 — paid amount. */ + paidAmount: v.optional(Money), + /** BT-114 — rounding amount. */ + roundingAmount: v.optional(Money), + /** BT-115 — amount due for payment. Defaults to `taxInclusiveAmount`. */ + payableAmount: v.optional(Money), +}); +export type Totals = v.InferOutput; + +// ── Invoice ─────────────────────────────────────────────────────────────────── + +export const Invoice = v.object({ + /** BT-1 — invoice number. */ + invoiceNumber: NonEmpty, + /** BT-2 — invoice issue date. */ + issueDate: IsoDate, + /** BT-3 — invoice type code (UNCL1001). Default `380` (commercial invoice). */ + invoiceTypeCode: v.optional(NonEmpty), + /** BT-5 — invoice currency code. Default `EUR`. */ + currency: v.optional(CurrencyCode), + /** BT-9 — payment due date. */ + dueDate: v.optional(IsoDate), + /** BT-72 — actual delivery date. */ + deliveryDate: v.optional(IsoDate), + /** BT-10 — buyer reference. */ + buyerReference: v.optional(v.string()), + /** BT-13 — purchase order reference. */ + orderReference: v.optional(v.string()), + /** BT-22 — invoice note. */ + note: v.optional(v.string()), + /** BT-81 — payment means code (UNCL4461). Default `30` (credit transfer). */ + paymentMeansCode: v.optional(NonEmpty), + seller: Party, + buyer: Party, + lines: v.pipe(v.array(InvoiceLine), v.minLength(1, "An invoice needs at least one line.")), + taxBreakdown: v.pipe( + v.array(TaxBreakdown), + v.minLength(1, "An invoice needs at least one VAT breakdown entry."), + ), + totals: Totals, +}); +export type Invoice = v.InferOutput; +/** Input form (before defaults/transforms) — what a caller passes in. */ +export type InvoiceInput = v.InferInput; + +export const DEFAULTS = { + invoiceTypeCode: "380", + currency: "EUR", + unitCode: "C62", + taxCategory: "S" as TaxCategoryCode, + paymentMeansCode: "30", + /** Peppol EAS code for Slovenia VAT — the default electronic-address scheme. */ + endpointScheme: "9949", +} as const; diff --git a/src/lib/invoice/validate.ts b/src/lib/invoice/validate.ts new file mode 100644 index 0000000..91321fb --- /dev/null +++ b/src/lib/invoice/validate.ts @@ -0,0 +1,28 @@ +import { Effect } from "effect"; +import * as v from "valibot"; +import { InvalidInvoiceError, type ValidationIssue } from "../foundation/errors"; +import { Invoice } from "./model"; + +/** + * Parse + validate caller input against the {@link Invoice} model. + * + * Succeeds with a fully-typed, defaults-resolved `Invoice`; fails with + * `InvalidInvoiceError` carrying normalised per-field issues. This is the + * *structural* gate (shape, types, formats); EN16931 business-rule validation + * lives in the einvoice module (Ajv via `@e-invoice-eu/core`). + */ +export const parseInvoice = Effect.fn("parseInvoice")(function* (input: unknown) { + const result = v.safeParse(Invoice, input, { abortPipeEarly: false }); + if (result.success) return result.output; + return yield* Effect.fail( + new InvalidInvoiceError( + `Invoice failed validation (${result.issues.length} issue${result.issues.length === 1 ? "" : "s"}).`, + result.issues.map(toIssue), + ), + ); +}); + +function toIssue(issue: v.BaseIssue): ValidationIssue { + const path = issue.path?.map((p) => String((p as { key: unknown }).key)).join(".") ?? ""; + return { path, message: issue.message }; +} diff --git a/src/test/einvoice.test.ts b/src/test/einvoice.test.ts new file mode 100644 index 0000000..5ea277f --- /dev/null +++ b/src/test/einvoice.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; +import { generateEInvoice } from "../lib/einvoice/generate"; +import { toEInvoiceInternal } from "../lib/einvoice/to-internal"; +import { validateEn16931 } from "../lib/einvoice/validate"; +import { UnsupportedFormatError } from "../lib/foundation/errors"; +import { sampleInvoice } from "./fixtures"; + +describe("toEInvoiceInternal", () => { + test("produces a ubl:Invoice tree with EN16931 customization", () => { + const internal = toEInvoiceInternal(sampleInvoice()); + const ubl = internal["ubl:Invoice"]; + expect(ubl["cbc:CustomizationID"]).toBe("urn:cen.eu:en16931:2017"); + expect(ubl["cbc:ID"]).toBe("2026-000123"); + expect(ubl["cac:InvoiceLine"]).toHaveLength(2); + }); + + test("emits a mandatory EndpointID, defaulting scheme to SI VAT (9949)", () => { + const ubl = toEInvoiceInternal(sampleInvoice())["ubl:Invoice"] as Record; + const seller = ubl["cac:AccountingSupplierParty"]["cac:Party"]; + expect(seller["cbc:EndpointID"]).toBe("SI12345678"); + expect(seller["cbc:EndpointID@schemeID"]).toBe("9949"); + }); + + test("supplier PartyTaxScheme is an array, customer is an object (schema asymmetry)", () => { + const ubl = toEInvoiceInternal(sampleInvoice())["ubl:Invoice"] as Record; + expect(Array.isArray(ubl["cac:AccountingSupplierParty"]["cac:Party"]["cac:PartyTaxScheme"])).toBe(true); + expect(Array.isArray(ubl["cac:AccountingCustomerParty"]["cac:Party"]["cac:PartyTaxScheme"])).toBe(false); + }); +}); + +describe("validateEn16931", () => { + test("a valid invoice passes and returns the mapped internal document", async () => { + const internal = await Effect.runPromise(validateEn16931(sampleInvoice())); + expect(internal["ubl:Invoice"]).toBeDefined(); + }); +}); + +describe("generateEInvoice", () => { + test("e-SLOG → e-SLOG 2.0 XML", async () => { + const xml = await Effect.runPromise(generateEInvoice(sampleInvoice(), { format: "eslog" })); + expect(xml).toContain('xmlns="urn:eslog:2.00"'); + }); + + test("UBL → UBL XML via @e-invoice-eu/core", async () => { + const xml = await Effect.runPromise(generateEInvoice(sampleInvoice(), { format: "ubl" })); + expect(xml).toContain("Invoice"); + expect(xml).toContain("cbc:"); + expect(xml).toContain("2026-000123"); + }); + + test("CII → CII XML via @e-invoice-eu/core", async () => { + const xml = await Effect.runPromise(generateEInvoice(sampleInvoice(), { format: "cii" })); + expect(xml.length).toBeGreaterThan(0); + expect(xml).toContain("CrossIndustryInvoice"); + }); + + test("an unknown format fails with UnsupportedFormatError", async () => { + const result = await Effect.runPromiseExit( + // deliberately bypass the type to exercise the runtime guard + generateEInvoice(sampleInvoice(), { format: "json" as never }), + ); + expect(result._tag).toBe("Failure"); + const error = result._tag === "Failure" ? (result.cause as any).error : undefined; + expect(error).toBeInstanceOf(UnsupportedFormatError); + }); +}); diff --git a/src/test/eslog.test.ts b/src/test/eslog.test.ts new file mode 100644 index 0000000..e410cf7 --- /dev/null +++ b/src/test/eslog.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { serializeEslog } from "../lib/eslog/serialize"; +import { sampleInvoice } from "./fixtures"; + +describe("serializeEslog", () => { + const xml = serializeEslog(sampleInvoice()); + + test("emits the e-SLOG 2.0 root and INVOIC message", () => { + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain("INVOIC"); + }); + + test("maps the document header (BGM 380 + invoice number)", () => { + expect(xml).toContain("380"); + expect(xml).toContain("2026-000123"); + }); + + test("maps issue (137) and delivery (35) dates", () => { + expect(xml).toContain("137"); + expect(xml).toContain("35"); + expect(xml).toContain("2026-05-25"); + }); + + test("emits both parties with VAT references, buyer (BY) before seller (SE)", () => { + expect(xml).toContain("BY"); + expect(xml).toContain("SE"); + expect(xml.indexOf("BY")).toBeLessThan(xml.indexOf("SE")); + expect(xml).toContain("SI12345678"); // seller VAT + expect(xml).toContain("SI87654321"); // buyer VAT + }); + + test("emits the seller bank account (FII RB + IBAN + BIC)", () => { + expect(xml).toContain("RB"); + expect(xml).toContain("SI56020170014356205"); + expect(xml).toContain("LJBASI2X"); + }); + + test("maps currency and payment due date", () => { + expect(xml).toContain("EUR"); + expect(xml).toContain("13"); // due date qualifier + expect(xml).toContain("2026-06-08"); + }); + + test("maps line items with quantity, prices and line VAT", () => { + expect(xml).toContain("Ekološka detergent koncentrat 1L"); + expect(xml).toContain("H87"); // unit override on line 1 + expect(xml).toContain("AAA"); // net price + expect(xml).toContain("AAB"); // gross price + // line 1 amount incl. VAT (50 * 1.22) and net + expect(xml).toContain("61.00"); + expect(xml).toContain("50.00"); + }); + + test("maps document totals to the right MOA qualifiers", () => { + // 79 sum-of-lines, 389 tax-exclusive, 176 tax-total, 388 tax-inclusive, 9 payable + for (const [code, amount] of [ + ["79", "90.00"], + ["389", "90.00"], + ["176", "14.80"], + ["388", "104.80"], + ["9", "104.80"], + ] as const) { + expect(xml).toContain(`${code}`); + expect(xml).toContain(`${amount}`); + } + }); + + test("emits a VAT breakdown (G_SG52) per rate", () => { + expect(xml).toContain("22"); + expect(xml).toContain("9.5"); + expect((xml.match(//g) ?? []).length).toBe(2); + }); + + test("compact mode drops indentation", () => { + const compact = serializeEslog(sampleInvoice(), { pretty: false }); + expect(compact).not.toContain("\n "); + expect(compact).toContain(' { + const inv = sampleInvoice(); + inv.note = "Plačilo & sklic"; + const out = serializeEslog(inv); + expect(out).toContain("<ref> & sklic"); + }); +}); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts new file mode 100644 index 0000000..968ee31 --- /dev/null +++ b/src/test/fixtures.ts @@ -0,0 +1,79 @@ +import type { Invoice } from "../lib/invoice/model"; + +/** + * A realistic Slovenian B2B invoice: two lines at the SI standard (22%) and + * reduced (9.5%) VAT rates, with totals that satisfy the EN16931 arithmetic + * (BR-CO / BR-S) rules. + * + * line 1: 10 × 5.00 = 50.00 net, 22% → 11.00 VAT + * line 2: 2 × 20.00 = 40.00 net, 9.5% → 3.80 VAT + * net 90.00, VAT 14.80, gross 104.80 + */ +export function sampleInvoice(): Invoice { + return { + invoiceNumber: "2026-000123", + issueDate: "2026-05-25", + dueDate: "2026-06-08", + deliveryDate: "2026-05-25", + currency: "EUR", + orderReference: "NAR-2026-77", + note: "Plačilo z navedbo sklica na številko računa.", + seller: { + name: "Zelena Japka d.o.o.", + vatId: "SI12345678", + registrationId: "8765432000", + address: { + street: "Slovenska cesta 1", + city: "Ljubljana", + postalZone: "1000", + countryCode: "SI", + countryName: "Slovenija", + }, + iban: "SI56020170014356205", + bic: "LJBASI2X", + bankName: "NLB d.d.", + }, + buyer: { + name: "Kupec d.o.o.", + vatId: "SI87654321", + address: { + street: "Dunajska cesta 100", + city: "Ljubljana", + postalZone: "1000", + countryCode: "SI", + }, + }, + lines: [ + { + id: "1", + name: "Ekološka detergent koncentrat 1L", + quantity: 10, + unitCode: "H87", + netPrice: 5.0, + lineNetAmount: 50.0, + taxRate: 22, + taxCategory: "S", + }, + { + id: "2", + name: "Naravno milo 100g", + quantity: 2, + netPrice: 20.0, + lineNetAmount: 40.0, + taxRate: 9.5, + taxCategory: "S", + }, + ], + taxBreakdown: [ + { taxableAmount: 50.0, taxAmount: 11.0, taxRate: 22, category: "S" }, + { taxableAmount: 40.0, taxAmount: 3.8, taxRate: 9.5, category: "S" }, + ], + totals: { + lineExtensionAmount: 90.0, + taxExclusiveAmount: 90.0, + taxAmount: 14.8, + taxInclusiveAmount: 104.8, + payableAmount: 104.8, + }, + }; +} diff --git a/src/test/invoice.test.ts b/src/test/invoice.test.ts new file mode 100644 index 0000000..2cf18a3 --- /dev/null +++ b/src/test/invoice.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; +import { InvalidInvoiceError } from "../lib/foundation/errors"; +import { parseInvoice } from "../lib/invoice/validate"; +import { sampleInvoice } from "./fixtures"; + +describe("parseInvoice", () => { + test("accepts a valid invoice and returns it typed", async () => { + const out = await Effect.runPromise(parseInvoice(sampleInvoice())); + expect(out.invoiceNumber).toBe("2026-000123"); + expect(out.lines).toHaveLength(2); + }); + + test("rejects malformed input with InvalidInvoiceError + per-field issues", async () => { + const result = await Effect.runPromiseExit( + parseInvoice({ + invoiceNumber: "x", + issueDate: "not-a-date", + seller: {}, + buyer: {}, + lines: [], + taxBreakdown: [], + totals: {}, + }), + ); + expect(result._tag).toBe("Failure"); + const error = result._tag === "Failure" ? extractError(result.cause) : undefined; + expect(error).toBeInstanceOf(InvalidInvoiceError); + expect(error?.status).toBe(400); + expect((error?.issues.length ?? 0)).toBeGreaterThan(0); + // an empty `lines` array must be flagged + expect(error?.issues.some((i) => i.path === "lines")).toBe(true); + }); + + test("rejects a bad country code", async () => { + const inv = sampleInvoice(); + const bad = { ...inv, seller: { ...inv.seller, address: { ...inv.seller.address, countryCode: "Slovenia" } } }; + const result = await Effect.runPromiseExit(parseInvoice(bad)); + expect(result._tag).toBe("Failure"); + }); +}); + +// Pull the typed failure value out of an Effect Cause without importing Cause internals everywhere. +function extractError(cause: unknown): InvalidInvoiceError | undefined { + const c = cause as { _tag?: string; error?: unknown }; + if (c?._tag === "Fail") return c.error as InvalidInvoiceError; + return undefined; +} diff --git a/src/test/pipeline.test.ts b/src/test/pipeline.test.ts new file mode 100644 index 0000000..c0e5187 --- /dev/null +++ b/src/test/pipeline.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { createEInvoice } from "../index"; +import { sampleInvoice } from "./fixtures"; + +describe("createEInvoice (Promise boundary)", () => { + test("valid input → { ok: true } with e-SLOG XML", async () => { + const result = await createEInvoice(sampleInvoice(), { format: "eslog" }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data).toContain('xmlns="urn:eslog:2.00"'); + }); + + test("valid input → { ok: true } with UBL XML (with EN16931 validation)", async () => { + const result = await createEInvoice(sampleInvoice(), { format: "ubl" }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data).toContain("cbc:"); + }); + + test("malformed input → { ok: false } with status 400, never throws", async () => { + const result = await createEInvoice({ invoiceNumber: 123 }, { format: "ubl" }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.status).toBe(400); + }); +});