ksef-invoice-validate
Pre-submission checks for Polish invoices headed for KSeF (Krajowy System e-Faktur).
Zero dependencies, no network calls, no file I/O. Everything runs locally, including in the browser.
The Ministry of Finance publishes SDKs for Java and .NET. This is a small piece of the
same job for the TypeScript side: catching the mistakes that get an invoice rejected
before it is worth talking to KSeF at all.
What it checks
- NIP checksum - ten digits, weighted
[6,5,7,2,3,4,5,6,7] modulo 11. Separators tolerated.
- Dates -
YYYY-MM-DD, real calendar dates only (2026-02-31 is rejected), future dates
flagged against the Polish calendar rather than UTC.
- Amounts - non-negative, and net + VAT equals gross. Compared in integer grosze, so
0.1 + 0.2 === 0.3 behaves the way an accountant expects.
- Buyer type - whether a missing NIP is a consumer sale or a company you forgot to fill in.
Dates are Polish calendar dates
The obvious way to turn a timestamp into an invoice date is wrong:
new Date(unixSeconds * 1000).toISOString().slice(0, 10);
Poland runs UTC+2 in summer and UTC+1 in winter, so any instant between midnight and the
offset falls on the previous UTC day. A billing platform that issues invoices at 00:00
Europe/Warsaw therefore dates every one of them a day early, and at the turn of a month
that is the wrong VAT period, not a cosmetic slip.
import { polishDateFromUnix } from "ksef-invoice-validate";
polishDateFromUnix(1788213600);
The offset comes from the timezone database, so winter works too. validateDate uses the
same rule, which means an invoice correctly dated today is no longer reported as being in
the future when you run it just after midnight.
A missing buyer NIP is not always a mistake
FA(3) carries BrakID for a consumer, and a large share of a real Polish invoice book has
no buyer NIP at all. It is wrong for a company, though, and filing a business sale as a
consumer sale needs a correcting invoice afterwards. Nothing in the invoice data separates
the two; the name sometimes does.
import { mayNeedBuyerNip } from "ksef-invoice-validate";
mayNeedBuyerNip({ buyer_name: "Bookinghost Sp. z o.o.", buyer_nip: null });
mayNeedBuyerNip({ buyer_name: "Jan Kowalski", buyer_nip: null });
Use it to decide whether a missing NIP deserves a human look, never to reject an invoice.
It deliberately ignores generic words like "Usługi" or "Group": measured against a
1,142-invoice production book it flagged 6 of 733 NIP-less invoices and every one was a
genuine company. A sole trader is a business whose name is a person's name, so no pattern
finds those.
What it 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, or session handling. Think of it as the cheap check you run first.
Usage
import { validateInvoiceForKsef, isValidNip } from "ksef-invoice-validate";
isValidNip("111-111-11-11");
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,
});
result.valid;
A failure returns stable codes you can switch on, rather than prose you have to parse:
validateInvoiceForKsef({ amount_net: 100, amount_vat: 23, amount_gross: 999 });
Buyer NIP is required by default, since the common case is a B2B invoice. Waive it for
consumers and foreign buyers:
validateInvoiceForKsef(invoice, { requireBuyerNip: false });
API
validateInvoiceForKsef(invoice, options?) | Runs every check, returns { valid, errors } |
validateNip(nip, field?) | NIP format and checksum |
isValidNip(nip) | Boolean convenience wrapper |
validateDate(value, field, options?) | Format, real-date and future checks |
validateAmounts(net, vat, gross) | Sign and net + VAT = gross |
polishDate(date) | Polish calendar date of an instant, YYYY-MM-DD |
polishDateFromUnix(seconds) | Same, from a unix timestamp in seconds |
polishToday(now?) | Today's date in Poland |
looksLikeCompany(name) | Buyer name carries a registered legal form |
mayNeedBuyerNip(invoice) | Company name, but no NIP supplied |
Error codes: nip.format, nip.checksum, date.format, date.invalid, date.future,
amount.negative, amount.mismatch, field.required.
Tests
node --test "test/**/*.test.ts"
Requires Node 22 or newer, which strips TypeScript types natively.
Origin
Extracted from the validation layer of FakturaFlow, a KSeF tool for
Polish accounting offices, and released separately because NIP and invoice-arithmetic checks
are useful well beyond it.
MIT licensed.