polish-statutory-interest
Zero-dependency calculation of Polish statutory late-payment interest, correctly segmented across
rate changes.
Computing this looks like a one-liner:
interest = amount × rate × days / 365
That is wrong often enough to matter, because the rate changes while the debt is outstanding, and
Poland has two separate statutory regimes that change on two different schedules.
The two regimes
Odsetki ustawowe za opóźnienie (art. 481 § 2 Kodeksu cywilnego) apply to ordinary obligations.
The rate is the NBP reference rate plus 5.5 pp, so it moves whenever the central bank moves rates,
effective from the date of the decision.
Odsetki za opóźnienie w transakcjach handlowych apply to B2B commercial transactions. Per
art. 11b, this rate is pinned per half-year: it is set from the NBP reference rate in force on
1 January for interest accruing in the first half of the year, and on 1 July for the second half. It
does not move if the central bank cuts rates in March.
So the same 200-day delay may need five segments under one regime and exactly two under the other.
Picking the wrong regime does not just change a number, it changes the shape of the calculation.
Install
npm install polish-statutory-interest
Node 22 or newer. No runtime dependencies.
Usage
import { calculateInterest } from 'polish-statutory-interest';
const result = calculateInterest({
amountGrosze: 1_230_000,
dueDate: '2026-05-31',
calcDate: '2026-08-31',
regime: 'commercial',
});
{
"regime": "commercial",
"totalDays": 92,
"segments": [
{ "from": "2026-06-01", "to": "2026-06-30", "days": 30, "ratePercent": 14, "interestGrosze": 14153 },
{ "from": "2026-07-01", "to": "2026-08-31", "days": 62, "ratePercent": 13.75, "interestGrosze": 28728 }
],
"totalInterestGrosze": 42881,
"commercial": { "rate": 13.75, "halfYearStart": "2026-07-01", "halfYearLabel": "II polrocze 2026", "healthcareDebtor": false }
}
The delay crossed 1 July, so the commercial rate stepped from 14,00% to 13,75% on that exact day and
the calculation split in two. Under the civil regime the same window would split on NBP decision
dates instead.
Keep the segments, not just the total. When someone disputes the figure, "here are the periods
and the rate in each" ends the conversation. A single number does not.
Design decisions worth knowing
Money is integer grosze. 0.1 + 0.2 !== 0.3, and interest is a sum of many small terms.
Accumulating in floats produces drift that surfaces as a one-grosz disagreement with the other side's
accountant. amountGrosze must be a positive integer or the call throws.
Rounding happens per segment. Each segment is a distinct legally defined period, so it is the
natural unit to round on. This is a modelling decision rather than a mathematical one, and it is
documented rather than incidental. Whichever convention you pick, pick it deliberately.
Interest starts the day after the due date. An off-by-one over a 400-day delay is a whole day of
interest, not a rounding error.
Dates are ISO strings and all arithmetic is UTC, so a delay spanning the spring clock change does
not silently lose or gain a day.
Unknown dates throw rather than guess. The reference-rate history starts 2020-05-29
(EARLIEST_KNOWN_DATE); asking about an earlier date raises a RangeError instead of quietly
returning a wrong rate.
Formatting is not included. Polish CLDR sets minimumGroupingDigits=2, so four-digit numbers are
not grouped: 1234,56, but 100 000,00. Hand-rolled formatters get this wrong. Use the platform:
new Intl.NumberFormat('pl-PL', { minimumFractionDigits: 2 }).format(result.totalInterestGrosze / 100);
API
calculateInterest(input) | Accrued interest with a per-segment breakdown |
referenceRateOn(iso) | NBP reference rate in force on a date |
civilLateRateOn(iso) | Odsetki ustawowe za opóźnienie, art. 481 § 2 KC |
maxLateRateOn(iso) | Odsetki maksymalne, twice the civil rate |
commercialRateOn(iso, opts) | Commercial rate with its half-year label; healthcareDebtor uses the 8 pp margin |
recoveryCompensationEur(amountGrosze) | Rekompensata tier: 40, 70 or 100 EUR |
daysBetween, addDays, halfYearStart | ISO date helpers used internally |
NBP_REFERENCE_RATES, OFFSETS, EARLIEST_KNOWN_DATE | The underlying configuration |
Rekompensata
Commercial transactions also carry a fixed recovery-cost compensation under art. 10 ustawy o
przeciwdziałaniu nadmiernym opóźnieniom, owed by operation of law with no need to prove costs:
40 EUR below 5 000 zł, 70 EUR from 5 000 zł, 100 EUR from 50 000 zł.
Converting it to złoty requires the NBP mid rate from the last working day of the month preceding
the payment due date. That is left to the caller so this library makes no network calls.
Keeping rates current
NBP_REFERENCE_RATES is the single source of truth. When the Rada Polityki Pieniężnej moves the
reference rate, add one entry; every statutory rate is derived, so no percentage is hardcoded
anywhere. Pull requests updating it are welcome.
Accuracy
Rates are checked against the published obwieszczenia: II półrocze 2026 = 13,75%, I półrocze 2026 =
14,00%, II półrocze 2022 = 16,00%, I półrocze 2022 = 11,75%. The suite also covers half-year pinning,
the mid-half-year central-bank move that must not be followed, segment tiling with no gaps or
overlaps, and DST boundaries.
This is a calculation library, not legal advice.
Licence
MIT. Built while working on WezwaniePro, a free generator for Polish
payment demands and interest notes, where this arithmetic took the longest to get right.