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
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
78 changes: 78 additions & 0 deletions docs/FURS-RUNTIME.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions examples/e-invoice.ts
Original file line number Diff line number Diff line change
@@ -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}`);
75 changes: 75 additions & 0 deletions examples/furs-offline.ts
Original file line number Diff line number Diff line change
@@ -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.`);
Loading