Rates belong in settings, with a date on them
Published 2026-11-03 by Arif Hossen
Source: https://arifhossen.dev/blog/rates-belong-in-settings-with-a-date-on-them
Rates belong in settings, with a date on them
Statutory payroll arrived as a spreadsheet from the finance team. I built a calculator that matched it, then moved every band, cap and relief out of the code and into settings HR can change, each with the date it takes effect. The same rule applies to any table that sets what someone pays.
A spreadsheet is a specification
The rules came the way business rules usually come: a workbook the finance and HR people trusted, because they had built it and checked it by hand. Progressive income tax bands. A two-tier social security contribution. A housing levy. Pension caps. Exemptions for interns.
The first job was to reproduce that sheet exactly, because it was the only definition of correct anyone had. Not an improved version, not a cleaner one. The same numbers for the same inputs, so the people who owned the sheet could check the system against something they already believed.
That gets you a calculator. It does not get you a payroll the business can run, because the rules in that sheet change, and in the first version every change was a developer ticket.
Numbers in code are a release you have to remember
A tax band in a config file looks harmless. Then the authority moves a threshold, and the change needs a developer, a review and a deployment, on a date the business did not choose. Worse, the old value is gone. Recalculate last year's payroll and it comes out under this year's rules.
So the bands, limits and reliefs moved into payroll settings HR edits on a screen. Each setting carries an effective date. A payroll for a past month resolves the rules that were in force that month, not the ones in force today.
Rates belong in settings, with a date on them
A rate without a date is only correct until it changes.
The settings decide, the code walks them
Moving numbers out of code does not make the code trivial. It makes it a walker over data, and the walker still has to be right.
- Bands: The tax walker uses inclusive band arithmetic that tracks the previous band's ceiling. That is where the classic off-by-one in progressive tax code lives.
- Relief: Personal relief applies after the bands, and the result floors at zero.
- Breakdown: Every run keeps a per-band breakdown, so a payslip, or a dispute, can show its working.
- No cap means no cap: An empty maximum is stored as empty, not as zero, so it reads as unlimited.
- New deductions: A new statutory deduction attaches itself to every eligible employee, instead of waiting for someone to add it to each record.
Settings resolve in a fixed order: a row for the company's own country, then a global row, then the config file as a last fallback. The group ran payroll in two countries, so the engines take a country code, and one country's settings never overwrite the other's. The lead engineer later added the second country's engine on top of this design rather than replacing it.
Refuse to run rather than guess
The most useful behaviour is the one that stops. The contribution engine refuses to run when its tiers are not configured, and the message names what is missing. A wrong statutory deduction computed quietly is far worse than a payroll that will not start, because the first one reaches someone's bank account.
The same idea runs through the guards around the payroll run. Approval checks that every ledger account the payroll will post to exists before anything posts. A closed accounting period refuses payroll. An advance is capped on net pay, so its repayment can never exceed what the employee takes home.
[!CAUTION]
The rates, thresholds and the client's own figures are deliberately left out. The engines are described by their shape, not their numbers.
What I would do differently
The tests came too late. Roughly 2,030 test cases now guard HR and payroll, but most of them were written near the end of the year, not in the first month. The sums ran for months on careful manual checks with the accountants, which protected that month's payroll and not the next developer.
What's next
- Read the case study for the ledger this payroll posts into
- Put effective dates on any rate table before its first change, not after
The same shape as an underwriting rule
Payroll is where I built this, but the pattern is not about payroll. Commission schemes, levies and rating tables all have the same three properties: the business owns the numbers, the numbers change on dates the business decides, and a past calculation has to be reproducible under the rules of its own time.
The underwriting engine on the same platform follows the same rule. A change of appetite is a data change, not a developer ticket. Encode the rules, prove them against the business's own artefact, then hand control to the people who own them.