diff --git a/README.md b/README.md index 495881d..2ac4590 100644 --- a/README.md +++ b/README.md @@ -154,10 +154,12 @@ replies — the client then verifies each response's JWS signature before trusti the EOR (raising `FursResponseSignatureError` on a spoofed/tampered response). Strongly recommended for production. -> **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)). +> **⚠ Runtime requirement — live FURS needs Node, not bun.** Outbound mutual-TLS +> client certs do **not** work under bun 1.3.6, so the live FURS submission path +> (`echo` / `reportInvoice` / `registerBusinessPremise`) must run under a **Node** +> runtime on a non-proxied network. The ZOI / JWS / verification crypto runs +> anywhere (incl. bun). This is a real deployment constraint, documented in full +> with the opt-in path: **[`docs/FURS-RUNTIME.md`](./docs/FURS-RUNTIME.md)**. ## The model @@ -176,6 +178,18 @@ contacts, multiple payment means, and the long tail of optional BTs. The e-SLOG mapping is grounded in the official spec (epos.si) and cross-checked against the MIT-licensed reference generator `Media24si/eslog2`. +## Examples + +Runnable end-to-end scripts in [`examples/`](./examples): + +```bash +bun run examples/e-invoice.ts # model → e-SLOG 2.0 (XSD-valid) + UBL +bun run examples/furs-offline.ts # FURS ZOI + request-JWS + response-verify (offline) +``` + +`examples/furs-offline.ts` shows the FURS crypto without a live call (runs under +bun). For a live submission, see [`docs/FURS-RUNTIME.md`](./docs/FURS-RUNTIME.md). + ## Development ```bash diff --git a/docs/FURS-RUNTIME.md b/docs/FURS-RUNTIME.md new file mode 100644 index 0000000..8e76c2d --- /dev/null +++ b/docs/FURS-RUNTIME.md @@ -0,0 +1,78 @@ +# FURS runtime requirement (read before deploying FURS calls) + +The e-invoice side of fiscalize (e-SLOG / UBL / CII generation + XSD validation) +runs anywhere. **The live FURS submission path has a hard runtime requirement.** + +## The constraint + +FURS fiscal verification posts to `blagajne(-test).fu.gov.si` over **mutual TLS**: +the client must present the taxpayer certificate during the TLS handshake. + +- **bun (≤ 1.3.6) does not present an outbound mTLS client certificate.** Both + `fetch({ tls: { cert, key } })` and `node:https` with `cert`/`key` fail with + `ECONNRESET` against FURS (which requires the client cert); `node:https` also + rejects `pfx` outright (`"pfx is not supported"`). This is a bun runtime gap, + not a fiscalize bug. +- Therefore **live FURS calls (test *and* production) must run under Node**, on a + network that doesn't MITM/terminate TLS (a transparent HTTPS proxy breaks the + mTLS session — observed in CI/sandbox environments). + +What this means in practice: don't schedule `reportInvoice` / `registerBusinessPremise` +/ `echo` on bun-in-sandbox. Run the FURS-calling component under Node. + +## What still works under bun + +Everything except the live network call: `loadP12`, `calculateZoi`, +`zoiToPrintable`, `signFursJws`, `verifyFursResponse`. See +[`examples/furs-offline.ts`](../examples/furs-offline.ts) — runnable under bun. + +## Running live FURS under Node (the opt-in path) + +```ts +// run with: node --experimental-strip-types furs-live.ts (Node 22+/24) +// or compile the TS first; the key point is the *runtime* is Node, not bun. +import { readFileSync } from "node:fs"; +import { Effect } from "effect"; +import { makeFursClient } from "@grunt-it/fiscalize/furs"; + +const p12 = new Uint8Array(readFileSync(process.env.FURS_P12!)); +const eor = await Effect.runPromise( + Effect.gen(function* () { + const furs = yield* makeFursClient({ + p12, + passphrase: process.env.FURS_PASSPHRASE!, + production: false, + // Recommended: authenticate FURS's response signature. + fursResponseCertPem: readFileSync(process.env.FURS_RESPONSE_CERT!, "utf8"), + }); + yield* furs.echo(); // connectivity (mutual TLS) + return yield* furs.reportInvoice({ /* … */ }); + }), +); +``` + +### Opt-in live integration test + +`src/test/furs-live.test.ts` is skipped unless you provide a cert. Run it from a +Node-mTLS-capable, non-proxied environment: + +```bash +FISCALIZE_FURS_TEST_P12=/abs/demo_podjetje.p12 \ +FISCALIZE_FURS_TEST_PASSPHRASE='Geslo123#' \ + bun test src/test/furs-live.test.ts # (under bun it will still hit the mTLS gap; run the equivalent under Node to confirm) +``` + +A public FURS **test** certificate (`demo_podjetje.p12`, passphrase `Geslo123#`) +ships with the reference clients (`jurgenwerk/furs_fiscal_verification`, +`boris-savic/python-furs-fiscal`). Production uses the shop's own eDavki cert. + +## Status + +The FURS protocol implementation is **unit-verified** (ZOI cross-checked against +an independent `node:crypto` computation; request JWS verifies; response +verification accepts genuine and rejects spoofed/tampered/wrong-key responses; +the real FURS demo test cert loads). The single outstanding item is a **one-shot +live EOR confirmation** from a Node-mTLS runtime — tracked in `ROADMAP.md`. + +> Candidate addition to the global `bun.md` rule: "bun (≤1.3.6) cannot present an +> outbound mTLS client certificate — run mTLS clients under Node." Left to Nik. diff --git a/examples/e-invoice.ts b/examples/e-invoice.ts new file mode 100644 index 0000000..26631ff --- /dev/null +++ b/examples/e-invoice.ts @@ -0,0 +1,54 @@ +/** + * Runnable e-invoice example: domain model → e-SLOG 2.0 (XSD-validated) + UBL. + * + * In your project, import from the package: import { createEInvoice } from "@grunt-it/fiscalize"; + * Run in this repo: bun run examples/e-invoice.ts + */ +import { Effect } from "effect"; +import { createEInvoice, type Invoice, validateEslogXml } from "../src/index"; + +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: "Ekološki detergent 1L", quantity: 10, netPrice: 5.0, lineNetAmount: 50.0, taxRate: 22 }, + { id: "2", name: "Naravno milo 100g", quantity: 2, netPrice: 20.0, lineNetAmount: 40.0, taxRate: 9.5 }, + ], + taxBreakdown: [ + { taxableAmount: 50.0, taxAmount: 11.0, taxRate: 22 }, + { taxableAmount: 40.0, taxAmount: 3.8, taxRate: 9.5 }, + ], + totals: { lineExtensionAmount: 90, taxExclusiveAmount: 90, taxAmount: 14.8, taxInclusiveAmount: 104.8 }, +}; + +// 1. e-SLOG 2.0, validated against the official XSD on the way out. +const eslog = await createEInvoice(invoice, { format: "eslog", validateOutput: true }); +if (!eslog.ok) throw new Error(`e-SLOG failed: ${eslog.error.message}`); +console.log("✓ e-SLOG 2.0 generated + XSD-valid:"); +console.log(`${eslog.data.split("\n").slice(0, 4).join("\n")}\n …(${eslog.data.length} bytes)\n`); + +// 2. UBL — EN16931-validated before generation. +const ubl = await createEInvoice(invoice, { format: "ubl" }); +if (!ubl.ok) throw new Error(`UBL failed: ${ubl.error.message}`); +console.log(`✓ UBL generated (${ubl.data.length} bytes, EN16931-validated)\n`); + +// 3. Re-validate an e-SLOG string explicitly against the official XSD. +await Effect.runPromise(validateEslogXml(eslog.data)); +console.log("✓ validateEslogXml: passed"); + +// 4. Invalid input never throws — it returns a 400 result. +const bad = await createEInvoice({ invoiceNumber: 123 }, { format: "ubl" }); +console.log(`✓ bad input rejected cleanly: ok=${bad.ok}, status=${bad.ok ? "-" : bad.error.status}`); diff --git a/examples/furs-offline.ts b/examples/furs-offline.ts new file mode 100644 index 0000000..e228876 --- /dev/null +++ b/examples/furs-offline.ts @@ -0,0 +1,75 @@ +/** + * Runnable OFFLINE FURS example — demonstrates the ZOI, request-JWS and + * response-verification crypto WITHOUT a live FURS call. + * + * A live round-trip (echo / reportInvoice) additionally needs a real FURS + * certificate AND a Node-mTLS-capable, non-proxied runtime — see + * docs/FURS-RUNTIME.md. This example uses a throwaway self-signed cert so it + * runs anywhere (bun included) and shows exactly what the client computes. + * + * Run in this repo: bun run examples/furs-offline.ts + * In your project: import { calculateZoi, signFursJws, verifyFursResponse } from "@grunt-it/fiscalize/furs"; + */ +import { Effect } from "effect"; +import forge from "node-forge"; +import { loadP12 } from "../src/lib/furs/cert"; +import { formatFursDateTime } from "../src/lib/furs/datetime"; +import { signFursJws, verifyFursResponse } from "../src/lib/furs/jws"; +import { calculateZoi, zoiToPrintable } from "../src/lib/furs/zoi"; + +// — Mint a throwaway certificate (stands in for the taxpayer's FURS cert) — +function makeDemoP12(): { p12: Uint8Array; passphrase: string } { + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = "0a1b2c3d4e5f6071"; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(Date.now() + 365 * 24 * 3600 * 1000); + const dn = [{ shortName: "CN", value: "10489185" }, { shortName: "O", value: "DEMO" }, { shortName: "C", value: "SI" }]; + cert.setSubject(dn); + cert.setIssuer([{ shortName: "CN", value: "Demo CA" }]); + cert.sign(keys.privateKey, forge.md.sha256.create()); + const der = forge.asn1.toDer(forge.pkcs12.toPkcs12Asn1(keys.privateKey, [cert], "demo", { algorithm: "3des" })).getBytes(); + return { p12: Uint8Array.from(der, (c) => c.charCodeAt(0)), passphrase: "demo" }; +} + +const { p12, passphrase } = makeDemoP12(); +const issuedAt = new Date("2026-05-25T14:30:00Z"); + +const result = await Effect.runPromise( + Effect.gen(function* () { + const cert = yield* loadP12(p12, passphrase); + console.log(`✓ cert loaded — subject: ${cert.subjectName}, serial: ${cert.serial}`); + + // 1. ZOI — the issuer's protective mark. + const { zoi: zoiDate } = formatFursDateTime(issuedAt, "Europe/Ljubljana"); + const zoi = calculateZoi( + { taxNumber: 10489185, issueDateTime: zoiDate, invoiceNumber: "11", businessPremiseId: "BP101", electronicDeviceId: "0001", invoiceAmount: 19.15 }, + cert.privateKeyPem, + ); + console.log(`✓ ZOI: ${zoi}`); + console.log(`✓ printable (QR/PDF417): ${zoiToPrintable(zoi, issuedAt, 10489185)}`); + + // 2. Request JWS — what the client posts to FURS over mutual TLS. + const token = signFursJws({ InvoiceRequest: { ProtectedID: zoi } }, cert.privateKeyPem, { + subjectName: cert.subjectName, + issuerName: cert.issuerName, + serial: cert.serial, + }); + console.log(`✓ request JWS signed (${token.split(".").length}-part compact JWS)`); + + // 3. Response verification — here we mint a response signed by our demo cert + // standing in for FURS, then verify it (in production: FURS's real cert). + const fakeFursResponse = signFursJws( + { InvoiceResponse: { UniqueInvoiceID: "demo-eor-1234" } }, + cert.privateKeyPem, + { subjectName: "CN=FURS", issuerName: "CN=Tax CA", serial: "1" }, + ); + const verified = verifyFursResponse<{ InvoiceResponse: { UniqueInvoiceID: string } }>(fakeFursResponse, cert.certPem); + console.log(`✓ response verified — EOR: ${verified.InvoiceResponse.UniqueInvoiceID}`); + + return zoi; + }), +); + +console.log(`\nOffline FURS crypto demo complete (ZOI ${result}). Live submission → see docs/FURS-RUNTIME.md.`);