ksef-invoice-validate
v0.2.0
Published
Zero-dependency validation for Polish invoices before KSeF submission: NIP checksum, timezone-correct dates, buyer-type detection and net + VAT = gross consistency.
Downloads
19
Maintainers
Readme
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-31is 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.3behaves 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); // renders UTCPoland 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); // "2026-09-01" (00:00 in Warsaw)
// the naive version gives "2026-08-31"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 }); // true
mayNeedBuyerNip({ buyer_name: "Jan Kowalski", buyer_nip: null }); // falseUse 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"); // true
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; // trueA 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 });
// {
// valid: false,
// errors: [
// { field: "invoice_number", code: "field.required", message: "Invoice number is required." },
// { field: "amount_gross", code: "amount.mismatch", message: "Net plus VAT does not equal gross.",
// details: { differencePln: "876.00" } },
// ...
// ]
// }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
| Export | Purpose |
| --- | --- |
| 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.
