Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down
35 changes: 30 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -47,13 +48,15 @@
},
"devDependencies": {
"@types/bun": "latest",
"@types/node-forge": "^1.3.14",
"typescript": "^5"
},
"dependencies": {
"@e-invoice-eu/core": "^3.1.1",
"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"
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
77 changes: 77 additions & 0 deletions src/lib/furs/cert.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading