DEV Community

Peter Hallander
Peter Hallander

Posted on

Splitting Polish statutory interest across central bank rate changes

If you invoice a business in Poland and they pay late, you are entitled to statutory interest. Computing it looks like a one-liner:

interest = amount × rate × days / 365
Enter fullscreen mode Exit fullscreen mode

That is wrong often enough to matter, for a reason that has nothing to do with rounding: the rate changes while the debt is outstanding, and you have to split the delay into segments.

Two regimes, two different clocks

Poland has two separate statutory interest rates, and mixing them up is the single most common error I see.

Odsetki ustawowe za opóźnienie (Civil Code) apply to ordinary obligations. The rate is defined relative to the National Bank of Poland reference rate, so it changes whenever the central bank moves rates, effective from the date of the decision.

Odsetki za opóźnienie w transakcjach handlowych apply to business-to-business commercial transactions. This rate is fixed per half-year: it is set from the NBP reference rate on 1 January for the first half, and on 1 July for the second, and it does not move in between even if the central bank cuts rates in March.

So for the same 200-day delay, one regime may need five segments and the other exactly two. Picking the wrong regime does not just change a number, it changes the shape of the calculation.

The segmentation

Once you accept that the rate is a function of time rather than a constant, the calculation becomes a fold over intervals:

type RatePeriod = { from: Date; to: Date; ratePercent: number };

function interestForPeriods(
  amountGrosze: number,
  periods: RatePeriod[],
): number {
  return periods.reduce((total, p) => {
    const days = daysBetween(p.from, p.to);
    // integer grosze throughout; never accumulate in floats
    return total + Math.round((amountGrosze * p.ratePercent * days) / (365 * 100));
  }, 0);
}
Enter fullscreen mode Exit fullscreen mode

Three things in that small function are load-bearing.

Money stays in integer grosze. 0.1 + 0.2 !== 0.3, and interest is a sum of many small terms. Accumulating in floats produces drift that shows up as a one-grosz disagreement with the other side's accountant, which is exactly the kind of thing that turns a payment discussion into an argument.

Rounding happens per segment, not once at the end. This is a modelling decision rather than a mathematical one: each segment is a distinct legally-defined period, so it is the natural unit to round. Whichever convention you choose, choose it deliberately and document it, because the two approaches differ by a grosz or two and someone will eventually ask why.

The day count is exclusive of one endpoint. Off-by-one over a 400-day delay is not a rounding error, it is a whole day of interest.

Building the period list

The segmentation itself is a merge between the delay window and the rate timeline:

function splitByRateChanges(
  from: Date,
  to: Date,
  rateTable: { effectiveFrom: Date; ratePercent: number }[],
): RatePeriod[] {
  const sorted = [...rateTable].sort((a, b) => +a.effectiveFrom - +b.effectiveFrom);
  const out: RatePeriod[] = [];

  for (let i = 0; i < sorted.length; i++) {
    const start = sorted[i].effectiveFrom;
    const end = sorted[i + 1]?.effectiveFrom ?? to;
    // clip the rate window to the delay window
    const segFrom = start > from ? start : from;
    const segTo = end < to ? end : to;
    if (segFrom < segTo) {
      out.push({ from: segFrom, to: segTo, ratePercent: sorted[i].ratePercent });
    }
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

For the commercial-transactions regime you feed it a rate table with two entries per year. For the Civil Code regime you feed it every NBP decision. Same fold, different input, which is the point: the regime difference belongs in the data, not in the control flow.

Why not just use the current rate

Because the answer is wrong, and wrong in a direction that is easy to spot. During a rate-cutting cycle, applying today's rate to the whole delay understates what you are owed. During a hiking cycle it overstates it, which is worse, because now your demand letter contains a number the debtor can dispute.

Worth knowing if you build this

  • Rate tables need a source of truth with effective dates, and they change with no notice on your schedule.
  • Store the segment breakdown, not just the total. When someone challenges the figure, "here are the five periods and the rate in each" ends the conversation; a single number does not.
  • Test around the boundaries: a delay that starts on the day of a rate change, one that ends on it, and one entirely inside a single period.

I maintain WezwaniePro, a free generator for Polish payment demands and interest notes that does this segmentation automatically. The arithmetic above is the part that took longest to get right, which is why it seemed worth writing down.

If you have implemented statutory interest for another jurisdiction and handled the rate-change problem differently, I would be interested to hear it.

Top comments (0)