DEV Community

Peter Hallander
Peter Hallander

Posted on

Poland's e-invoicing system has no JavaScript SDK, so I published the validation layer

Poland runs a national e-invoicing system called KSeF (Krajowy System e-Faktur). Business-to-business invoices are submitted to a government API in a schema called FA(3), and the system hands back an official confirmation of receipt. If you sell software to Polish companies, you will meet it.

The Ministry of Finance publishes official SDKs for Java and .NET. There is nothing for JavaScript.

A full client is a real project: authentication, session handling, certificates, XML signing. But a large share of rejected invoices have nothing to do with any of that. They are structural. A tax ID with a bad checksum. Net plus VAT that does not add up to gross. A date that does not exist. Those are worth catching on your side, before you build a session with anyone.

So I pulled that layer out of a product I work on, rewrote it standalone, and published it: ksef-invoice-validate. Zero dependencies, no network calls, runs in the browser.

npm i ksef-invoice-validate
Enter fullscreen mode Exit fullscreen mode
import { validateInvoiceForKsef } from "ksef-invoice-validate";

const result = validateInvoiceForKsef({
  invoice_number: "FV/2026/07/1",
  issue_date: "2026-07-01",
  seller_nip: "1111111111",
  buyer_nip: "1111111111",
  amount_net: 1000,
  amount_vat: 230,
  amount_gross: 1230,
});
Enter fullscreen mode Exit fullscreen mode

Three things in it were more interesting than I expected.

The NIP checksum

A Polish tax identification number (NIP) is ten digits. The tenth is a checksum over the first nine, each weighted and reduced modulo 11.

const weights = [6, 5, 7, 2, 3, 4, 5, 6, 7];
const digits = cleaned.split("").map(Number);
const checksum = weights.reduce((sum, w, i) => sum + w * digits[i], 0) % 11;

if (checksum !== digits[9]) {
  // invalid
}
Enter fullscreen mode Exit fullscreen mode

There is a small elegance here. The remainder can be 10, and no single digit equals 10, so those numbers simply cannot exist as valid NIPs. You do not need a special case. The comparison rejects them on its own.

This alone catches a surprising amount. Most bad tax IDs in the wild are transposed digits from manual entry, and a mod-11 check catches every single-digit transposition.

Never compare money as floats

The original implementation checked that net plus VAT equals gross with a tolerance:

if (Math.abs(net + vat - gross) > 0.01) { /* mismatch */ }
Enter fullscreen mode Exit fullscreen mode

That is the bug everyone writes once. 0.1 + 0.2 === 0.30000000000000004, and on invoice arithmetic the drift lands exactly where you cannot afford it: a comparison against a rounding tolerance. Values that should pass fail, and it is not reproducible in a way that looks like a pattern.

The fix is to leave floating point entirely and compare in integer grosze (Polish cents):

const grosze = (n: number) => Math.round(n * 100);
const diff = Math.abs(grosze(net) + grosze(vat) - grosze(gross));
if (diff > 1) { /* mismatch */ }
Enter fullscreen mode Exit fullscreen mode

The tolerance is now literally "one grosz", which is a statement about money rather than about IEEE 754.

Dates that do not exist

This one is quiet and nasty:

new Date("2026-02-31"); // no error. Rolls over to March 3rd.
Enter fullscreen mode Exit fullscreen mode

Date will happily accept the 31st of February and silently move you into March. On an invoice, an issue date that shifts by three days is not a cosmetic problem.

The cheapest reliable check is a round trip:

const date = new Date(value);
if (date.toISOString().slice(0, 10) !== value) {
  // not a real calendar date
}
Enter fullscreen mode Exit fullscreen mode

If the string you get back is not the string you put in, Date corrected you, which means the input was wrong.

Codes, not sentences

The internal version returned i18n message keys, which are fine inside one app and useless in a library. Every error now carries a stable code you can switch on:

nip.format | nip.checksum | date.format | date.invalid
date.future | amount.negative | amount.mismatch | field.required
Enter fullscreen mode Exit fullscreen mode

The English message is there for humans reading logs. The code is the contract.

What it deliberately does not do

It is not a substitute for validating against the official FA(3) XSD, and it cannot check anything that requires the Ministry's systems: duplicate detection, counterparty status, authorisation, session handling. Think of it as the cheap check you run first, so that the expensive one has less to complain about.

It also does not know your business rules. Buyer tax ID is required by default because the common case is B2B, but consumers and foreign buyers legitimately have no Polish NIP, so that is a flag rather than a law.


Package: ksef-invoice-validate on npm.
Source: github.com/TrendTweekers/ksef-invoice-validate, MIT licensed.

It was extracted from the validation layer of FakturaFlow, a KSeF tool for Polish accounting offices. The checks themselves are general enough to be useful well outside it, which is why they are now their own package.

If you have implemented FA(3) in TypeScript and solved parts of this differently, I would genuinely like to hear it. The JS side of KSeF is thin enough that most of us are solving the same problems alone.

Top comments (0)