Skip to content

P2: FURS fiscal verification (ZOI/EOR, JSON/JWS) — unit-verified, live-verify deferred#4

Merged
terrxo merged 1 commit into
mainfrom
p2-furs-fiscal-verification
May 25, 2026
Merged

P2: FURS fiscal verification (ZOI/EOR, JSON/JWS) — unit-verified, live-verify deferred#4
terrxo merged 1 commit into
mainfrom
p2-furs-fiscal-verification

Conversation

@terrxo

@terrxo terrxo commented May 25, 2026

Copy link
Copy Markdown
Contributor

What this adds

P2 — Slovenian FURS fiscal verification (cash-register receipts → ZOI/EOR), src/lib/furs/. A TS/Effect port of the FURS JSON/JWS protocol, cross-checked against three reference clients: node-furs-fiscal-verification (JS), jurgenwerk/furs_fiscal_verification (Ruby, production-proven), boris-savic/python-furs-fiscal (the canonical one).

Standalone & framework-agnostic, like the rest of fiscalize — the Medusa plugin track wraps it.

Module Role
cert.ts Load taxpayer PKCS#12 → key PEM + cert PEM + identity (subject/issuer/serial)
zoi.ts ZOI = MD5(RSA-SHA256/PKCS#1 v1.5(content)) + the QR/PDF417 printable string
jws.ts RS256 JWS with FURS's custom header; payload decode
messages.ts Typed (valibot) InvoiceRequest + immovable BusinessPremiseRequest
client.ts Effect client: echo / registerBusinessPremise / reportInvoice (→ ZOI + EOR + printable), mutual-TLS

Decisions / gotchas worth knowing

  • ZOI padding fork resolved → PKCS#1 v1.5. The references diverged: node-furs uses PKCS#1 v1.5 (SHA256withRSA), python-furs uses PSS (randomized!). PKCS#1 v1.5 is deterministic (matches the spec's worked example) and is what node-furs + jurgenwerk (Ruby RSA#sign default) use — 2 of 3, and the right one. FURS doesn't recompute the ZOI, so PSS "works" too, but v1.5 is correct.
  • ZOI date format → dashes dd-MM-yyyy HH:mm:ss (python + jurgenwerk agree; node-furs's dots were the outlier — and node-furs also has a md5(sig.sign) bug passing the function).
  • Cert serial is a BigInt. The real FURS demo test cert serial is 6051472362733128891 (> 2^53) — JS number would corrupt it. Kept as an exact decimal string and emitted as a raw integer literal in the hand-built JWS header.
  • Demo test cert passphrase is Geslo123# (the README "SCREWFURS" in jurgenwerk is a placeholder; the real one is in its spec).

Verification

  • bun test40 pass / 1 skip / 0 fail. FURS-specific: ZOI cross-validated against an independent node:crypto sign+MD5, deterministic; JWS signature verifies against the public key; loadP12 loads the real FURS demo test cert ("TESTNO PODJETJE", DavPotRacTEST). bunx tsc --noEmit clean.

⚠️ Blocker — live test-env round-trip deferred (runtime/env, not code)

A live call to blagajne-test.fu.gov.si:9002 could not be completed from the build environment:

  1. bun 1.3.6 does not present an outbound mTLS client certificatefetch tls.cert/key and node:https cert/key both fail ECONNRESET (the server requires a client cert; bun never sends it). node:https even rejects pfx ("pfx is not supported"). Real bun limitation — candidate line for bun.md (deferred, not editing the global rule here).
  2. Under Node v24 the cert is presented but the legacy FURS endpoint fails TLS (BAD_RECORD_MAC) even with SECLEVEL=0/minVersion TLSv1 — legacy server and/or this sandbox's HTTPS proxy breaking raw mTLS.

The cert is valid and self-provisionable; the implementation is unit-proven. The gap is purely runtime + network.

  • Opt-in furs-live.test.ts runs the live round-trip wherever mTLS works (set FISCALIZE_FURS_TEST_P12 + FISCALIZE_FURS_TEST_PASSPHRASE); skipped by default.
  • Runtime requirement (deployment): FURS calls (test and prod) need a Node-mTLS-capable, non-proxied runtime — documented in CLAUDE.md/ROADMAP.md. Don't schedule FURS on bun-in-sandbox. The one-shot live EOR confirmation is deferred to such a runtime (you/Nik).

Also deferred (ROADMAP)

Verify FURS's response JWS signature (refs skip it); refunds/corrective receipts; movable premises (A/B/C); sales-book mode.

Rebased onto main (includes the merged #3 privacy/bunfig corrections), so the diff is P2 only.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@terrxo terrxo merged commit 4afba33 into main May 25, 2026
2 checks passed
@terrxo terrxo deleted the p2-furs-fiscal-verification branch May 25, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant