From 3c34063768142b3e1af2f920b4ed556beaa52cb6 Mon Sep 17 00:00:00 2001 From: Nik Divjak Date: Mon, 25 May 2026 07:08:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20P2=20FURS=20fiscal=20verification=20(ZO?= =?UTF-8?q?I/EOR,=20JSON/JWS)=20=E2=80=94=20unit-verified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS/Effect port of the FURS JSON/JWS protocol (src/lib/furs/), cross-checked vs node-furs, jurgenwerk (Ruby), and python-furs-fiscal: - cert: load taxpayer PKCS#12 → key + identity (serial as exact BigInt; real test-cert serials exceed 2^53). - zoi: MD5(RSA-SHA256 / PKCS#1 v1.5( taxNo + dd-MM-yyyy HH:mm:ss + … )). PKCS#1 v1.5 (deterministic) + dashes date — resolves the node-furs (dots) vs python-furs (PSS) divergence toward the deterministic, 2-of-3 form. - jws: RS256 + FURS custom header (serial as exact integer literal). - messages: InvoiceRequest + immovable BusinessPremiseRequest. - client: Effect echo / registerBusinessPremise / reportInvoice (→ ZOI+EOR+printable), mutual-TLS to blagajne-test.fu.gov.si:9002. Tests: 40 pass / 1 skip (ZOI cross-validated vs independent node:crypto; JWS verifies; loads the real FURS demo test cert). tsc clean. Live test-env round-trip deferred: bun 1.3.6 doesn't present an outbound mTLS client cert, and the legacy FURS endpoint rejects modern-OpenSSL TLS from a proxied net — runtime/env, not the code. Opt-in furs-live.test.ts runs it under a Node/unproxied runtime. See ROADMAP + CLAUDE. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 14 +++ README.md | 42 +++++++- ROADMAP.md | 35 ++++++- bun.lock | 6 ++ package.json | 3 + src/index.ts | 1 + src/lib/furs/cert.ts | 77 +++++++++++++++ src/lib/furs/client.ts | 195 +++++++++++++++++++++++++++++++++++++ src/lib/furs/datetime.ts | 41 ++++++++ src/lib/furs/errors.ts | 39 ++++++++ src/lib/furs/index.ts | 7 ++ src/lib/furs/jws.ts | 48 +++++++++ src/lib/furs/messages.ts | 139 ++++++++++++++++++++++++++ src/lib/furs/zoi.ts | 69 +++++++++++++ src/test/furs-fixtures.ts | 45 +++++++++ src/test/furs-live.test.ts | 35 +++++++ src/test/furs.test.ts | 133 +++++++++++++++++++++++++ 17 files changed, 920 insertions(+), 9 deletions(-) create mode 100644 src/lib/furs/cert.ts create mode 100644 src/lib/furs/client.ts create mode 100644 src/lib/furs/datetime.ts create mode 100644 src/lib/furs/errors.ts create mode 100644 src/lib/furs/index.ts create mode 100644 src/lib/furs/jws.ts create mode 100644 src/lib/furs/messages.ts create mode 100644 src/lib/furs/zoi.ts create mode 100644 src/test/furs-fixtures.ts create mode 100644 src/test/furs-live.test.ts create mode 100644 src/test/furs.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index eab661f..d98ca8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,8 +35,22 @@ wrap it as a leaf dependency. Do not couple it to any framework. - `src/lib/einvoice/` — bridge to `@e-invoice-eu/core`: maps the domain model → the lib's UBL-shaped internal JSON (`ubl:Invoice` / `cbc:`/`cac:`) → UBL / CII output, plus EN16931 validation via the lib's `invoiceSchema`. +- `src/lib/furs/` (P2) — FURS fiscal verification (ZOI/EOR), the JSON/JWS + protocol. `cert` (p12 load), `zoi` (`MD5(RSA-SHA256/PKCS#1 v1.5(…))`), `jws` + (RS256 + FURS header), `messages`, `client` (mutual-TLS). Cert serial is kept + as a BigInt string (real serials exceed 2^53) and emitted as an exact integer + literal in the JWS header. - `src/lib/foundation/` — minimal Effect house helpers (tagged errors, runSafe). +### Runtime requirement: FURS needs Node-mTLS, not bun-in-sandbox + +The FURS client uses **outbound mutual-TLS client certs**, which **bun 1.3.6 does +not support** (fetch `tls.cert/key` and `node:https` cert/key both fail with +ECONNRESET; `node:https` rejects `pfx`). Run FURS calls under a **Node** runtime +on a non-proxied network. The non-FURS code (e-invoice/e-SLOG) runs fine under +bun. This is a real deployment constraint for the eventual service / Medusa +plugin — don't schedule FURS calls on bun-in-sandbox. + ### Derive, don't fork We **depend on** `@e-invoice-eu/core` (WTFPL, npm) for the EN16931 model + diff --git a/README.md b/README.md index 64f27ca..811d103 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ 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). +> **Status — P1 + P2.** Covers the **e-invoice core** (generate + validate an +> EN16931 core invoice as **e-SLOG 2.0** / **UBL** / **CII**, with official-XSD +> validation) and **FURS fiscal verification** (ZOI/EOR — unit-verified; live +> test-env round-trip deferred, see below). Medusa integration and the +> service/MCP surface are later phases — see [`ROADMAP.md`](./ROADMAP.md). ## Why this shape @@ -120,6 +121,39 @@ yield* validateEslogXml(someEslogXml); (`eSLOG20_INVOIC_v200.xsd` + `xmldsig-core-schema.xsd`, from the epos.si Aug-2020 package), using xmllint compiled to WebAssembly — no native bindings. +## FURS fiscal verification (P2) + +For Slovenian cash-register fiscalization (distinct from the e-invoice document): +compute the **ZOI** and obtain the **EOR** from FURS. + +```ts +import { Effect } from "effect"; +import { makeFursClient } from "@grunt-it/fiscalize/furs"; + +const program = Effect.gen(function* () { + const furs = yield* makeFursClient({ p12, passphrase, production: false }); + const { zoi, eor, printable } = yield* furs.reportInvoice({ + taxNumber: 10489185, + issueDateTime: new Date(), + invoiceNumber: "11", + businessPremiseId: "BP101", + electronicDeviceId: "0001", + invoiceAmount: 19.15, + vat: [{ taxRate: 22, taxableAmount: 15.7, taxAmount: 3.45 }], + }); + return { zoi, eor, printable }; // ZOI + FURS EOR + the QR/PDF417 string +}); +``` + +`p12` is the taxpayer's certificate (a FURS test cert for the test env; the +shop's eDavki cert in production). ZOI is `MD5(RSA-SHA256/PKCS#1 v1.5(…))`; +messages are RS256 JWS over mutual TLS. + +> **Runtime note:** outbound mutual-TLS client certs do **not** work under bun +> 1.3.6 — run the FURS client under **Node** until bun supports them. The logic +> is unit-verified; the live FURS-test round-trip is the one deferred step +> (see [`ROADMAP.md`](./ROADMAP.md)). + ## The model `Invoice` is a clean, EN16931-aligned **core invoice**: header (BT-1/2/3/5/9/72), diff --git a/ROADMAP.md b/ROADMAP.md index ab18945..ddb07ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,11 +41,36 @@ EN16931 core invoice → **e-SLOG 2.0** + **UBL / CII**, with validation. - **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). +## P2 — FURS fiscal verification ✅ (built; live-verify deferred) + +Cash-register receipts → ZOI/EOR (`src/lib/furs/`). TS/Effect port of the +JSON/JWS FURS protocol; cross-checked against `node-furs-fiscal-verification`, +`jurgenwerk/furs_fiscal_verification`, and `boris-savic/python-furs-fiscal`. + +- **Cert** — load taxpayer PKCS#12 → key + identity (subject/issuer/serial; + serial kept as an exact BigInt — real test cert serials exceed 2^53). +- **ZOI** — `MD5(RSA-SHA256 / PKCS#1 v1.5( taxNo + dd-MM-yyyy HH:mm:ss + invNo + + premiseID + deviceID + amount ))`. PKCS#1 v1.5 (deterministic), date with + dashes — resolved the divergence between the reference clients (node-furs used + dots; python-furs used PSS) in favour of the deterministic, 2-of-3 form. +- **JWS** — RS256 with FURS's custom header (`subject_name`/`issuer_name`/`serial`, + serial emitted as an exact integer literal). +- **Messages** — `InvoiceRequest` + immovable `BusinessPremiseRequest`; client + `echo` / `registerBusinessPremise` / `reportInvoice` (→ ZOI + EOR + printable). + +Unit-verified (ZOI cross-validated vs independent `node:crypto`; JWS signature +verifies; loads the real FURS demo test cert). + +### Deferred / blocked + +- **Live test-env round-trip.** Could not be completed from the build env: + **bun 1.3.6 does not present an outbound mTLS client certificate**, and the + legacy FURS test endpoint rejects modern-OpenSSL TLS from a proxied network. + Run the opt-in `furs-live.test.ts` under a **Node** runtime on an unproxied + network (with a FURS test p12) to confirm end-to-end — see the test's header. +- **Verify FURS's response JWS signature** against the FURS public cert (the + reference clients skip this; currently decoded without verification). +- **Refunds / corrective receipts**, movable premises (A/B/C), sales-book mode. ## P3 — integration surface diff --git a/bun.lock b/bun.lock index a0931ce..9baf6a6 100644 --- a/bun.lock +++ b/bun.lock @@ -9,12 +9,14 @@ "ajv": "^8.18.0", "ajv-formats": "^3.0.1", "effect": "^3.18.4", + "node-forge": "^1.4.0", "valibot": "^1.2.0", "xmlbuilder2": "^4.0.3", "xmllint-wasm": "^5.2.0", }, "devDependencies": { "@types/bun": "latest", + "@types/node-forge": "^1.3.14", "typescript": "^5", }, }, @@ -50,6 +52,8 @@ "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="], + "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=="], @@ -88,6 +92,8 @@ "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-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + "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=="], diff --git a/package.json b/package.json index 91830f4..9f3681c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "./eslog": "./src/lib/eslog/index.ts", "./einvoice": "./src/lib/einvoice/index.ts", "./invoice": "./src/lib/invoice/index.ts", + "./furs": "./src/lib/furs/index.ts", "./foundation": "./src/lib/foundation/index.ts" }, "files": [ @@ -47,6 +48,7 @@ }, "devDependencies": { "@types/bun": "latest", + "@types/node-forge": "^1.3.14", "typescript": "^5" }, "dependencies": { @@ -54,6 +56,7 @@ "ajv": "^8.18.0", "ajv-formats": "^3.0.1", "effect": "^3.18.4", + "node-forge": "^1.4.0", "valibot": "^1.2.0", "xmlbuilder2": "^4.0.3", "xmllint-wasm": "^5.2.0" diff --git a/src/index.ts b/src/index.ts index a1fa9bf..d4da8af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ export function createEInvoice( export * from "./lib/foundation"; export * from "./lib/invoice"; export * from "./lib/einvoice"; +export * from "./lib/furs"; export { type EslogOptions, serializeEslog } from "./lib/eslog/serialize"; export { validateEslogXml } from "./lib/eslog/validate-eslog"; export * as eslogCodes from "./lib/eslog/codes"; diff --git a/src/lib/furs/cert.ts b/src/lib/furs/cert.ts new file mode 100644 index 0000000..ad600f4 --- /dev/null +++ b/src/lib/furs/cert.ts @@ -0,0 +1,77 @@ +import { Effect } from "effect"; +import forge from "node-forge"; +import { FursCertError } from "./errors"; + +/** + * A loaded FURS signing certificate: the RSA private key (PEM) plus the + * identity fields FURS matches the JWS against. + */ +export interface FursCert { + /** RSA private key, PKCS#1 PEM — used for ZOI + JWS signing. */ + privateKeyPem: string; + /** Certificate, PEM — used as the mutual-TLS client certificate. */ + certPem: string; + /** Subject DN as `CN=…,O=…,…` (comma-joined RDNs), for the JWS header. */ + subjectName: string; + /** Issuer DN, same format. */ + issuerName: string; + /** Certificate serial number as an exact decimal string (may exceed 2^53). */ + serial: string; +} + +/** + * Load a taxpayer PKCS#12 (.p12/.pfx) — as issued by FURS (test) or eDavki + * (production) — and extract the signing key + identity. Fails with + * `FursCertError` (e.g. wrong passphrase, no key in the bundle). + */ +export const loadP12 = Effect.fn("loadP12")(function* (p12: Uint8Array, passphrase: string) { + return yield* Effect.try({ + try: (): FursCert => parseP12(p12, passphrase), + catch: (cause) => new FursCertError("Failed to load PKCS#12 certificate", cause), + }); +}); + +function parseP12(p12: Uint8Array, passphrase: string): FursCert { + const oids = forge.pki.oids; + const shroudedKeyBagOid = oids.pkcs8ShroudedKeyBag as string; + const keyBagOid = oids.keyBag as string; + const certBagOid = oids.certBag as string; + + const der = forge.util.createBuffer(toBinaryString(p12)); + const asn1 = forge.asn1.fromDer(der); + const p12obj = forge.pkcs12.pkcs12FromAsn1(asn1, passphrase); + + const keyBag = + p12obj.getBags({ bagType: shroudedKeyBagOid })[shroudedKeyBagOid]?.[0] ?? + p12obj.getBags({ bagType: keyBagOid })[keyBagOid]?.[0]; + const certBag = p12obj.getBags({ bagType: certBagOid })[certBagOid]?.[0]; + + if (!keyBag?.key) throw new Error("no private key in PKCS#12 bundle"); + if (!certBag?.cert) throw new Error("no certificate in PKCS#12 bundle"); + + const cert = certBag.cert; + return { + privateKeyPem: forge.pki.privateKeyToPem(keyBag.key), + certPem: forge.pki.certificateToPem(cert), + subjectName: formatDn(cert.subject.attributes), + issuerName: formatDn(cert.issuer.attributes), + serial: hexToDecimalString(cert.serialNumber), + }; +} + +/** `[{shortName|name, value}]` → `CN=…,O=…` matching the reference FURS clients. */ +function formatDn(attributes: forge.pki.CertificateField[]): string { + return attributes + .map((a) => `${a.shortName ?? a.name ?? ""}=${a.value ?? ""}`) + .join(","); +} + +function hexToDecimalString(hex: string): string { + return BigInt(`0x${hex}`).toString(10); +} + +function toBinaryString(bytes: Uint8Array): string { + let s = ""; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!); + return s; +} diff --git a/src/lib/furs/client.ts b/src/lib/furs/client.ts new file mode 100644 index 0000000..5658065 --- /dev/null +++ b/src/lib/furs/client.ts @@ -0,0 +1,195 @@ +import { randomUUID } from "node:crypto"; +import { Effect } from "effect"; +import * as v from "valibot"; +import { InvalidInvoiceError } from "../foundation/errors"; +import { type FursCert, loadP12 } from "./cert"; +import { DEFAULT_TIMEZONE, formatFursDateTime } from "./datetime"; +import { FursConnectionError, FursError } from "./errors"; +import { decodeJwsPayload, signFursJws } from "./jws"; +import { + buildBusinessPremiseRequest, + buildInvoiceRequest, + FursBusinessPremise, + FursInvoice, +} from "./messages"; +import { calculateZoi, zoiToPrintable } from "./zoi"; + +const ENDPOINTS = { + test: "https://blagajne-test.fu.gov.si:9002", + production: "https://blagajne.fu.gov.si:9003", +} as const; + +const PATHS = { + echo: "/v1/cash_registers/echo", + register: "/v1/cash_registers/invoices/register", + invoice: "/v1/cash_registers/invoices", +} as const; + +export interface FursClientConfig { + /** Taxpayer PKCS#12 bytes (test cert for the test env; eDavki cert in prod). */ + p12: Uint8Array; + passphrase: string; + /** Target the production endpoint instead of test. Default false (test). */ + production?: boolean; + /** Wall-clock time zone for ZOI/IssueDateTime. Default Europe/Ljubljana. */ + timeZone?: string; + /** + * Verify FURS's server TLS certificate. Default false — the FURS test env + * presents a self-signed CA, matching the reference clients. Set true (and + * supply the CA out of band) for hardened production use. + */ + rejectUnauthorized?: boolean; + requestTimeoutMs?: number; +} + +export interface InvoiceResult { + /** Issuer protective mark. */ + zoi: string; + /** FURS unique invoice ID (EOR). */ + eor: string; + /** Printable verification string for the QR / PDF417 / Code128. */ + printable: string; +} + +export interface FursClient { + /** Connectivity check (unsigned, but still over mutual TLS). Returns the echoed text. */ + echo(message?: string): Effect.Effect; + /** Register an immovable business premise. Resolves `true` on success. */ + registerBusinessPremise( + premise: FursBusinessPremise, + ): Effect.Effect; + /** Fiscally verify an invoice → ZOI + EOR + printable mark. */ + reportInvoice( + invoice: FursInvoice, + ): Effect.Effect; + /** The loaded certificate's identity (subject/issuer/serial). */ + readonly cert: FursCert; +} + +/** Construct a FURS client (loads + validates the certificate once). */ +export const makeFursClient = Effect.fn("makeFursClient")(function* (config: FursClientConfig) { + const cert = yield* loadP12(config.p12, config.passphrase); + const baseUrl = config.production ? ENDPOINTS.production : ENDPOINTS.test; + const timeZone = config.timeZone ?? DEFAULT_TIMEZONE; + const timeout = config.requestTimeoutMs ?? 10_000; + + const postRaw = (path: string, body: unknown) => + Effect.tryPromise({ + try: async () => { + const res = await fetch(`${baseUrl}${path}`, { + method: "POST", + headers: { "content-type": "application/json; charset=UTF-8" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout), + // bun extension: mutual-TLS client cert + server-verify toggle. + tls: { + cert: cert.certPem, + key: cert.privateKeyPem, + rejectUnauthorized: config.rejectUnauthorized ?? false, + }, + } as RequestInit); + if (!res.ok) throw new Error(`FURS HTTP ${res.status} ${res.statusText}`); + return (await res.json()) as Record; + }, + catch: (cause) => new FursConnectionError(`FURS request to ${path} failed`, cause), + }); + + /** Post a signed (JWS) request and decode the response envelope, raising FursError on a FURS error. */ + const postSigned = (path: string, message: Record) => + Effect.gen(function* () { + const token = signFursJws(message, cert.privateKeyPem, { + subjectName: cert.subjectName, + issuerName: cert.issuerName, + serial: cert.serial, + }); + const json = yield* postRaw(path, { token }); + const responseToken = json.token; + if (typeof responseToken !== "string") { + return yield* Effect.fail(new FursConnectionError("FURS response missing token")); + } + const decoded = decodeJwsPayload>>(responseToken); + const envelope = decoded[Object.keys(decoded)[0] ?? ""]; + if (envelope?.Error) { + return yield* Effect.fail(new FursError(envelope.Error.ErrorCode, envelope.Error.ErrorMessage)); + } + return decoded; + }); + + const echo: FursClient["echo"] = (message = "ping") => + Effect.gen(function* () { + const json = yield* postRaw(PATHS.echo, { EchoRequest: message }); + return String(json.EchoResponse ?? ""); + }); + + const registerBusinessPremise: FursClient["registerBusinessPremise"] = (premiseInput) => + Effect.gen(function* () { + const premise = yield* parse(FursBusinessPremise, premiseInput); + const now = formatFursDateTime(new Date(), timeZone); + const validity = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(premise.validityDate); // en-CA → yyyy-MM-dd + const message = buildBusinessPremiseRequest(premise, { + messageId: randomUUID(), + headerIso: now.iso, + validityDateYmd: validity, + }); + yield* postSigned(PATHS.register, message); + return true as const; + }); + + const reportInvoice: FursClient["reportInvoice"] = (invoiceInput) => + Effect.gen(function* () { + const invoice = yield* parse(FursInvoice, invoiceInput); + const issued = formatFursDateTime(invoice.issueDateTime, timeZone); + const now = formatFursDateTime(new Date(), timeZone); + + const zoi = calculateZoi( + { + taxNumber: invoice.taxNumber, + issueDateTime: issued.zoi, + invoiceNumber: invoice.invoiceNumber, + businessPremiseId: invoice.businessPremiseId, + electronicDeviceId: invoice.electronicDeviceId, + invoiceAmount: invoice.invoiceAmount, + }, + cert.privateKeyPem, + ); + + const message = buildInvoiceRequest(invoice, { + zoi, + issueIso: issued.iso, + messageId: randomUUID(), + headerIso: now.iso, + }); + + const decoded = yield* postSigned(PATHS.invoice, message); + const eor = decoded?.InvoiceResponse?.UniqueInvoiceID; + if (typeof eor !== "string" || !eor) { + return yield* Effect.fail(new FursConnectionError("FURS response missing UniqueInvoiceID (EOR)")); + } + return { + zoi, + eor, + printable: zoiToPrintable(zoi, invoice.issueDateTime, invoice.taxNumber, timeZone), + } satisfies InvoiceResult; + }); + + return { echo, registerBusinessPremise, reportInvoice, cert } satisfies FursClient; +}); + +const parse = Effect.fn("parseFursInput")(function* (schema: T, input: unknown) { + const result = v.safeParse(schema, input, { abortPipeEarly: false }); + if (result.success) return result.output; + return yield* Effect.fail( + new InvalidInvoiceError( + `FURS input failed validation (${result.issues.length} issue${result.issues.length === 1 ? "" : "s"}).`, + result.issues.map((i) => ({ + path: i.path?.map((p) => String((p as { key: unknown }).key)).join(".") ?? "", + message: i.message, + })), + ), + ); +}); diff --git a/src/lib/furs/datetime.ts b/src/lib/furs/datetime.ts new file mode 100644 index 0000000..7b565a3 --- /dev/null +++ b/src/lib/furs/datetime.ts @@ -0,0 +1,41 @@ +/** + * FURS uses wall-clock (Slovenian) time in two formats that must agree for the + * same instant: the ZOI input (`dd-MM-yyyy HH:mm:ss`) and the message + * `IssueDateTime` (`yyyy-MM-ddTHH:mm:ss`). We derive both from one `Date` via a + * fixed time zone so the result is independent of the server's local zone. + */ +export const DEFAULT_TIMEZONE = "Europe/Ljubljana"; + +export interface FursDateTime { + /** `dd-MM-yyyy HH:mm:ss` — the ZOI date component. */ + zoi: string; + /** `yyyy-MM-ddTHH:mm:ss` — the message IssueDateTime / DateTime. */ + iso: string; +} + +export function formatFursDateTime(date: Date, timeZone: string = DEFAULT_TIMEZONE): FursDateTime { + const parts = new Intl.DateTimeFormat("en-GB", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(date); + + const p = (type: Intl.DateTimeFormatPartTypes) => parts.find((x) => x.type === type)?.value ?? ""; + const yyyy = p("year"); + const MM = p("month"); + const dd = p("day"); + // Intl may emit "24" for midnight in some engines; normalise to "00". + const HH = p("hour") === "24" ? "00" : p("hour"); + const mm = p("minute"); + const ss = p("second"); + + return { + zoi: `${dd}-${MM}-${yyyy} ${HH}:${mm}:${ss}`, + iso: `${yyyy}-${MM}-${dd}T${HH}:${mm}:${ss}`, + }; +} diff --git a/src/lib/furs/errors.ts b/src/lib/furs/errors.ts new file mode 100644 index 0000000..46b3afa --- /dev/null +++ b/src/lib/furs/errors.ts @@ -0,0 +1,39 @@ +import { Data } from "effect"; + +/** Could not load / decrypt the taxpayer PKCS#12 certificate. */ +export class FursCertError extends Data.TaggedError("FursCertError")<{ + message: string; + status: number; + cause?: unknown; +}> { + constructor(message: string, cause?: unknown) { + super({ message, status: 400, cause }); + } +} + +/** Network/TLS failure talking to FURS (unreachable, timeout, handshake). */ +export class FursConnectionError extends Data.TaggedError("FursConnectionError")<{ + message: string; + status: number; + cause?: unknown; +}> { + constructor(message: string, cause?: unknown) { + super({ message, status: 503, cause }); + } +} + +/** + * FURS accepted the request but returned a business error in the response + * envelope (e.g. `S002` schema error, VAT mismatch). Carries the FURS code. + */ +export class FursError extends Data.TaggedError("FursError")<{ + message: string; + status: number; + errorCode: string; +}> { + constructor(errorCode: string, message: string) { + super({ message: `FURS error ${errorCode}: ${message}`, status: 422, errorCode }); + } +} + +export type FursFailure = FursCertError | FursConnectionError | FursError; diff --git a/src/lib/furs/index.ts b/src/lib/furs/index.ts new file mode 100644 index 0000000..5c515a3 --- /dev/null +++ b/src/lib/furs/index.ts @@ -0,0 +1,7 @@ +export * from "./cert"; +export * from "./client"; +export * from "./datetime"; +export * from "./errors"; +export * from "./jws"; +export * from "./messages"; +export * from "./zoi"; diff --git a/src/lib/furs/jws.ts b/src/lib/furs/jws.ts new file mode 100644 index 0000000..d18a652 --- /dev/null +++ b/src/lib/furs/jws.ts @@ -0,0 +1,48 @@ +import { createSign } from "node:crypto"; + +export interface JwsIdentity { + subjectName: string; + issuerName: string; + /** Decimal serial string (exact; emitted as a raw JSON integer). */ + serial: string; +} + +/** + * Sign a FURS message as a compact JWS (RS256) with FURS's custom protected + * header (`subject_name`, `issuer_name`, `serial`). The header is assembled by + * hand so the (potentially > 2^53) certificate serial is emitted as an exact + * integer literal rather than a lossy JS number. + */ +export function signFursJws(payload: unknown, privateKeyPem: string, id: JwsIdentity): string { + if (!/^\d+$/.test(id.serial)) throw new Error(`invalid certificate serial: ${id.serial}`); + + const headerJson = + `{"alg":"RS256",` + + `"subject_name":${JSON.stringify(id.subjectName)},` + + `"issuer_name":${JSON.stringify(id.issuerName)},` + + `"serial":${id.serial}}`; + + const signingInput = `${b64url(headerJson)}.${b64url(JSON.stringify(payload))}`; + + const signer = createSign("RSA-SHA256"); + signer.update(signingInput, "utf8"); + signer.end(); + const signature = signer.sign(privateKeyPem).toString("base64url"); + + return `${signingInput}.${signature}`; +} + +/** + * Decode a compact JWS payload without verifying the signature. The reference + * FURS clients do not verify FURS's response signature; verifying it against the + * FURS public cert is a hardening follow-up (see ROADMAP). + */ +export function decodeJwsPayload(token: string): T { + const segment = token.split(".")[1]; + if (!segment) throw new Error("malformed JWS: missing payload segment"); + return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as T; +} + +function b64url(s: string): string { + return Buffer.from(s, "utf8").toString("base64url"); +} diff --git a/src/lib/furs/messages.ts b/src/lib/furs/messages.ts new file mode 100644 index 0000000..073e086 --- /dev/null +++ b/src/lib/furs/messages.ts @@ -0,0 +1,139 @@ +import * as v from "valibot"; + +// ── VAT line within TaxesPerSeller ────────────────────────────────────────── +export const FursVat = v.object({ + /** VAT rate, percent. */ + taxRate: v.number(), + /** Net base for this rate. */ + taxableAmount: v.number(), + /** VAT amount for this rate. */ + taxAmount: v.number(), +}); +export type FursVat = v.InferOutput; + +/** Numbering structure: per-device (`B`) or central (`C`). */ +export const NumberingStructure = v.picklist(["B", "C"]); + +// ── Invoice (fiscal verification, cash-register receipt — not the e-invoice) ── +export const FursInvoice = v.object({ + /** Issuer tax number (8 digits). */ + taxNumber: v.pipe(v.number(), v.integer()), + /** Invoice issue instant. */ + issueDateTime: v.date(), + /** Sequential invoice number (the per-premise/device counter). */ + invoiceNumber: v.pipe(v.string(), v.minLength(1)), + businessPremiseId: v.pipe(v.string(), v.minLength(1)), + electronicDeviceId: v.pipe(v.string(), v.minLength(1)), + numberingStructure: v.optional(NumberingStructure), + /** Invoice total (gross). */ + invoiceAmount: v.number(), + /** Amount paid; defaults to invoiceAmount. */ + paymentAmount: v.optional(v.number()), + /** Tax number of the operator (cashier) who issued the invoice. */ + operatorTaxNumber: v.optional(v.pipe(v.number(), v.integer())), + /** VAT breakdown by rate. */ + vat: v.array(FursVat), +}); +export type FursInvoice = v.InferOutput; + +// ── Business premise registration (immovable) ──────────────────────────────── +export const FursBusinessPremise = v.object({ + taxNumber: v.pipe(v.number(), v.integer()), + premiseId: v.pipe(v.string(), v.minLength(1)), + cadastralNumber: v.pipe(v.number(), v.integer()), + buildingNumber: v.pipe(v.number(), v.integer()), + buildingSectionNumber: v.pipe(v.number(), v.integer()), + street: v.string(), + houseNumber: v.string(), + houseNumberAdditional: v.optional(v.string()), + community: v.string(), + city: v.string(), + postalCode: v.string(), + /** Date the premise started issuing invoices. */ + validityDate: v.date(), + softwareSupplierTaxNumber: v.optional(v.pipe(v.number(), v.integer())), + foreignSoftwareSupplierName: v.optional(v.string()), + specialNotes: v.optional(v.string()), +}); +export type FursBusinessPremise = v.InferOutput; + +// ── Builders ───────────────────────────────────────────────────────────────── + +function header(messageId: string, dateTimeIso: string) { + return { MessageID: messageId, DateTime: dateTimeIso }; +} + +export function buildInvoiceRequest( + invoice: FursInvoice, + opts: { zoi: string; issueIso: string; messageId: string; headerIso: string }, +): Record { + return { + InvoiceRequest: { + Header: header(opts.messageId, opts.headerIso), + Invoice: { + TaxNumber: invoice.taxNumber, + IssueDateTime: opts.issueIso, + NumberingStructure: invoice.numberingStructure ?? "B", + InvoiceIdentifier: { + BusinessPremiseID: invoice.businessPremiseId, + ElectronicDeviceID: invoice.electronicDeviceId, + InvoiceNumber: invoice.invoiceNumber, + }, + InvoiceAmount: invoice.invoiceAmount, + PaymentAmount: invoice.paymentAmount ?? invoice.invoiceAmount, + TaxesPerSeller: [ + { + VAT: invoice.vat.map((x) => ({ + TaxRate: x.taxRate, + TaxableAmount: x.taxableAmount, + TaxAmount: x.taxAmount, + })), + }, + ], + ...(invoice.operatorTaxNumber != null ? { OperatorTaxNumber: invoice.operatorTaxNumber } : {}), + ProtectedID: opts.zoi, + }, + }, + }; +} + +export function buildBusinessPremiseRequest( + premise: FursBusinessPremise, + opts: { messageId: string; headerIso: string; validityDateYmd: string }, +): Record { + const address: Record = { + Street: premise.street, + HouseNumber: premise.houseNumber, + Community: premise.community, + City: premise.city, + PostalCode: premise.postalCode, + }; + if (premise.houseNumberAdditional) address.HouseNumberAdditional = premise.houseNumberAdditional; + + return { + BusinessPremiseRequest: { + Header: header(opts.messageId, opts.headerIso), + BusinessPremise: { + TaxNumber: premise.taxNumber, + BusinessPremiseID: premise.premiseId, + BPIdentifier: { + RealEstateBP: { + PropertyID: { + CadastralNumber: premise.cadastralNumber, + BuildingNumber: premise.buildingNumber, + BuildingSectionNumber: premise.buildingSectionNumber, + }, + Address: address, + }, + }, + ValidityDate: opts.validityDateYmd, + SoftwareSupplier: [ + premise.softwareSupplierTaxNumber != null + ? { TaxNumber: premise.softwareSupplierTaxNumber } + : { NameForeign: premise.foreignSoftwareSupplierName ?? "" }, + ], + SpecialNotes: premise.specialNotes ?? "", + }, + }, + }; +} diff --git a/src/lib/furs/zoi.ts b/src/lib/furs/zoi.ts new file mode 100644 index 0000000..c6bf776 --- /dev/null +++ b/src/lib/furs/zoi.ts @@ -0,0 +1,69 @@ +import { createHash, createSign } from "node:crypto"; + +export interface ZoiInput { + /** Issuer tax number (8 digits). */ + taxNumber: number | string; + /** Invoice issue datetime, already formatted as `dd-MM-yyyy HH:mm:ss`. */ + issueDateTime: string; + /** Sequential invoice number. */ + invoiceNumber: string; + businessPremiseId: string; + electronicDeviceId: string; + /** Invoice total amount (string or number, formatted as it appears on the invoice). */ + invoiceAmount: number | string; +} + +/** + * Compute the **ZOI** (Zaščitna oznaka izdajatelja — issuer's protective mark). + * + * Per the FURS spec: concatenate the fields, sign with the issuer's private key + * using **RSA-SHA256 (RSASSA-PKCS#1 v1.5)**, then take the **MD5** of the + * signature as a 32-char lowercase hex string. PKCS#1 v1.5 (not PSS) → the ZOI + * is deterministic, matching the spec's worked example and the production-proven + * reference clients (node-furs, jurgenwerk). + * + * `privateKeyPem` is the taxpayer key (see {@link loadP12}). + */ +export function calculateZoi(input: ZoiInput, privateKeyPem: string): string { + const content = + `${input.taxNumber}${input.issueDateTime}${input.invoiceNumber}` + + `${input.businessPremiseId}${input.electronicDeviceId}${input.invoiceAmount}`; + + const signer = createSign("RSA-SHA256"); + signer.update(content, "utf8"); + signer.end(); + const signature = signer.sign(privateKeyPem); // PKCS#1 v1.5 padding (default) + + return createHash("md5").update(signature).digest("hex"); +} + +/** + * Build the printable verification string (for the QR / PDF417 / Code128 on the + * invoice): zero-padded decimal ZOI (39 digits) + `YYMMDDHHmmss` + tax number + + * a mod-10 control digit over the whole string. + */ +export function zoiToPrintable(zoiHex: string, issueDate: Date, taxNumber: number | string, timeZone?: string): string { + let value = BigInt(`0x${zoiHex}`).toString(10).padStart(39, "0"); + + const parts = new Intl.DateTimeFormat("en-GB", { + timeZone: timeZone ?? "Europe/Ljubljana", + year: "2-digit", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(issueDate); + const p = (t: Intl.DateTimeFormatPartTypes) => parts.find((x) => x.type === t)?.value ?? ""; + const hh = p("hour") === "24" ? "00" : p("hour"); + value += `${p("year")}${p("month")}${p("day")}${hh}${p("minute")}${p("second")}`; + + value += String(taxNumber); + + let control = 0; + for (const ch of value) control += Number(ch); + value += String(control % 10); + + return value; +} diff --git a/src/test/furs-fixtures.ts b/src/test/furs-fixtures.ts new file mode 100644 index 0000000..f62dc2a --- /dev/null +++ b/src/test/furs-fixtures.ts @@ -0,0 +1,45 @@ +import forge from "node-forge"; + +export interface TestCert { + /** PKCS#12 bytes (RSA key + self-signed cert), for loadP12. */ + p12: Uint8Array; + passphrase: string; + privateKeyPem: string; + publicKeyPem: string; + serialDecimal: string; +} + +/** + * Generate an ephemeral RSA key + self-signed certificate, packaged as a PKCS#12. + * Used by the FURS tests so no real certificate material is committed. The key is + * a genuine RSA key, so signatures (ZOI, JWS) are real and verifiable. + */ +export function makeTestCert(): TestCert { + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = "0a1b2c3d4e5f6071"; // hex; exercises BigInt serial handling + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(Date.now() + 365 * 24 * 3600 * 1000); + const attrs = [ + { shortName: "CN", value: "10489185" }, + { shortName: "O", value: "grunt-it test" }, + { shortName: "C", value: "SI" }, + ]; + cert.setSubject(attrs); + cert.setIssuer([{ shortName: "CN", value: "Test CA" }, { shortName: "C", value: "SI" }]); + cert.sign(keys.privateKey, forge.md.sha256.create()); + + const passphrase = "test-pass"; + const asn1 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, [cert], passphrase, { algorithm: "3des" }); + const der = forge.asn1.toDer(asn1).getBytes(); + const p12 = Uint8Array.from(der, (c) => c.charCodeAt(0)); + + return { + p12, + passphrase, + privateKeyPem: forge.pki.privateKeyToPem(keys.privateKey), + publicKeyPem: forge.pki.publicKeyToPem(keys.publicKey), + serialDecimal: BigInt("0x0a1b2c3d4e5f6071").toString(10), + }; +} diff --git a/src/test/furs-live.test.ts b/src/test/furs-live.test.ts new file mode 100644 index 0000000..4c28ff6 --- /dev/null +++ b/src/test/furs-live.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; +import { makeFursClient } from "../lib/furs/client"; + +/** + * Opt-in live integration against the FURS TEST environment. Skipped by default. + * + * Provide a FURS test PKCS#12 to run it: + * FISCALIZE_FURS_TEST_P12=/abs/path/demo_podjetje.p12 \ + * FISCALIZE_FURS_TEST_PASSPHRASE='…' bun test + * + * NOTE (2026-05): a live round-trip could not be completed from the build + * environment — bun 1.3.6 does not present an outbound mTLS client certificate, + * and the legacy FURS test endpoint also rejects modern-OpenSSL TLS from a + * proxied network. Run this under a Node runtime on an unproxied network to + * verify end-to-end (the implementation is otherwise unit-verified). See ROADMAP. + */ +const p12Path = process.env.FISCALIZE_FURS_TEST_P12; +const passphrase = process.env.FISCALIZE_FURS_TEST_PASSPHRASE; +const live = Boolean(p12Path && passphrase); +const suite = live ? describe : describe.skip; + +suite("FURS test-env live integration (opt-in)", () => { + test("echo round-trips against the FURS test endpoint", async () => { + const p12 = new Uint8Array(readFileSync(p12Path as string)); + const echoed = await Effect.runPromise( + Effect.gen(function* () { + const furs = yield* makeFursClient({ p12, passphrase: passphrase as string, production: false }); + return yield* furs.echo("fiscalize-live"); + }), + ); + expect(echoed).toBe("fiscalize-live"); + }); +}); diff --git a/src/test/furs.test.ts b/src/test/furs.test.ts new file mode 100644 index 0000000..597ea2c --- /dev/null +++ b/src/test/furs.test.ts @@ -0,0 +1,133 @@ +import { createHash, createSign, createVerify } from "node:crypto"; +import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; +import * as v from "valibot"; +import { loadP12 } from "../lib/furs/cert"; +import { formatFursDateTime } from "../lib/furs/datetime"; +import { decodeJwsPayload, signFursJws } from "../lib/furs/jws"; +import { buildInvoiceRequest, FursInvoice } from "../lib/furs/messages"; +import { calculateZoi, zoiToPrintable } from "../lib/furs/zoi"; +import { makeTestCert } from "./furs-fixtures"; + +const certFx = makeTestCert(); + +describe("loadP12", () => { + test("extracts key, cert PEM and identity from a PKCS#12", async () => { + const cert = await Effect.runPromise(loadP12(certFx.p12, certFx.passphrase)); + expect(cert.privateKeyPem).toContain("PRIVATE KEY"); + expect(cert.certPem).toContain("BEGIN CERTIFICATE"); + expect(cert.subjectName).toContain("CN=10489185"); + expect(cert.serial).toBe(certFx.serialDecimal); // exact, via BigInt + }); + + test("wrong passphrase fails with FursCertError", async () => { + const exit = await Effect.runPromiseExit(loadP12(certFx.p12, "wrong")); + expect(exit._tag).toBe("Failure"); + const err = exit._tag === "Failure" ? (exit.cause as any).error : undefined; + expect(err?._tag).toBe("FursCertError"); + }); +}); + +describe("calculateZoi", () => { + const input = { + taxNumber: 10489185, + issueDateTime: "25-05-2026 14:30:00", + invoiceNumber: "11", + businessPremiseId: "BP101", + electronicDeviceId: "0001", + invoiceAmount: 19.15, + }; + + test("is a 32-char lowercase hex MD5 and deterministic (PKCS#1 v1.5)", () => { + const a = calculateZoi(input, certFx.privateKeyPem); + const b = calculateZoi(input, certFx.privateKeyPem); + expect(a).toMatch(/^[0-9a-f]{32}$/); + expect(a).toBe(b); // deterministic — confirms PKCS#1 v1.5, not PSS + }); + + test("matches an independent RSA-SHA256 + MD5 computation", () => { + const content = `${input.taxNumber}${input.issueDateTime}${input.invoiceNumber}${input.businessPremiseId}${input.electronicDeviceId}${input.invoiceAmount}`; + const sig = createSign("RSA-SHA256").update(content, "utf8").sign(certFx.privateKeyPem); + const expected = createHash("md5").update(sig).digest("hex"); + expect(calculateZoi(input, certFx.privateKeyPem)).toBe(expected); + }); + + test("changes when any field changes", () => { + const base = calculateZoi(input, certFx.privateKeyPem); + expect(calculateZoi({ ...input, invoiceAmount: 19.16 }, certFx.privateKeyPem)).not.toBe(base); + }); +}); + +describe("zoiToPrintable", () => { + test("39-digit ZOI block + YYMMDDHHmmss + tax number + valid mod-10 check digit", () => { + const zoi = "a".repeat(32); // arbitrary 32-hex + const printable = zoiToPrintable(zoi, new Date("2026-05-25T12:30:00Z"), 10489185, "UTC"); + expect(printable).toMatch(/^\d+$/); + const sumExceptLast = [...printable.slice(0, -1)].reduce((s, c) => s + Number(c), 0); + expect(Number(printable.at(-1))).toBe(sumExceptLast % 10); + }); +}); + +describe("signFursJws", () => { + test("produces a verifiable RS256 JWS with the FURS custom header", () => { + const payload = { InvoiceRequest: { hello: "world" } }; + const token = signFursJws(payload, certFx.privateKeyPem, { + subjectName: "CN=10489185,O=grunt-it test,C=SI", + issuerName: "CN=Test CA,C=SI", + serial: certFx.serialDecimal, + }); + + const [h, p, s] = token.split("."); + expect(h && p && s).toBeTruthy(); + + const header = JSON.parse(Buffer.from(h!, "base64url").toString()); + expect(header.alg).toBe("RS256"); + expect(header.subject_name).toBe("CN=10489185,O=grunt-it test,C=SI"); + expect(typeof header.serial).toBe("number"); // emitted as an integer literal + + // signature verifies against the public key + const ok = createVerify("RSA-SHA256").update(`${h}.${p}`).verify(certFx.publicKeyPem, s!, "base64url"); + expect(ok).toBe(true); + + expect(decodeJwsPayload(token)).toEqual(payload); + }); + + test("rejects a non-numeric serial (guards raw-JSON injection)", () => { + expect(() => + signFursJws({}, certFx.privateKeyPem, { subjectName: "x", issuerName: "y", serial: "12a" }), + ).toThrow(); + }); +}); + +describe("buildInvoiceRequest + schema", () => { + test("maps the FURS invoice envelope with ProtectedID", () => { + const msg = buildInvoiceRequest( + { + taxNumber: 10489185, + issueDateTime: new Date(), + invoiceNumber: "11", + businessPremiseId: "BP101", + electronicDeviceId: "0001", + invoiceAmount: 19.15, + vat: [{ taxRate: 22, taxableAmount: 15.7, taxAmount: 3.45 }], + }, + { zoi: "deadbeef", issueIso: "2026-05-25T14:30:00", messageId: "m1", headerIso: "2026-05-25T14:30:01" }, + ) as any; + expect(msg.InvoiceRequest.Invoice.ProtectedID).toBe("deadbeef"); + expect(msg.InvoiceRequest.Invoice.TaxesPerSeller[0].VAT[0].TaxRate).toBe(22); + expect(msg.InvoiceRequest.Invoice.NumberingStructure).toBe("B"); + }); + + test("schema rejects an invoice with no VAT lines structurally validated upstream", () => { + const bad = v.safeParse(FursInvoice, { taxNumber: "nope" }); + expect(bad.success).toBe(false); + }); +}); + +describe("formatFursDateTime", () => { + test("derives consistent ZOI + ISO strings from one instant", () => { + const { zoi, iso } = formatFursDateTime(new Date("2026-05-25T12:30:45Z"), "UTC"); + expect(zoi).toBe("25-05-2026 12:30:45"); + expect(iso).toBe("2026-05-25T12:30:45"); + }); +});