Building Financial Software for Zambian Businesses

What actually changes when you write financial software for Zambia: statutory deductions that are not what most payroll engines assume, mobile money as a first-class payment rail, and decimal arithmetic you cannot afford to get wrong.

Fintech Payroll Zambia NAPSA NHIMA PAYE

Most financial software sold into Zambia was designed somewhere else. It usually works, right up to the point where it meets a statutory rule its authors never had to think about, and then it quietly produces a number that is wrong in a way nobody notices until a return is filed.

This is a note on the specifics: the things we have had to get right building payroll and financial systems here, and the places where an imported assumption produces a defensible-looking but incorrect figure.

Three deductions, three different bases

A Zambian payslip carries three statutory deductions. The rates are easy to look up. The part that catches people is that each is charged on a different base, so a single "gross × percentage" loop through all three will be wrong twice.

DeductionCharged onCeiling
PAYEGross emolumentsNone, progressive bands
NAPSAGross earningsYes, capped monthly
NHIMABasic salary onlyNone

NHIMA is the one implemented incorrectly most often. The Third Schedule of the National Health Insurance (General) Regulations, SI No. 63 of 2019, sets the contribution at 1% of basic salary, so allowances sit outside the base. A system that charges it on gross over-deducts from every employee whose package includes a housing or transport allowance, which in practice is most of them.

The error is small per payslip and invisible on a summary report. It compounds monthly, across every employee, and surfaces at reconciliation.

The pension deduction is not pre-tax

This deserves its own heading because the intuition from other jurisdictions is so strong, and because it was true here once.

Until 1 January 2018, the allowable pension contribution was deducted before arriving at chargeable pay. Since then it is not. PAYE is charged on gross emoluments without subtracting NAPSA; the two are calculated independently against the same gross figure.

Any engine still carrying the pre-2018 rule under-declares PAYE on every payslip it produces. It is worth checking explicitly rather than assuming, because the output looks entirely plausible either way. The numbers are all the right shape, just slightly too small.

Bands tax slices, not salaries

PAYE runs across four monthly bands: nothing on the first K5,100, then 20%, then 30%, then 37% above K9,200. Each rate applies only to the portion of income inside its own band.

Worked through on a K12,000 monthly salary:

BandIncome in bandRateTax
First K5,100K5,100.000%K0.00
K5,101 – K7,100K2,000.0020%K400.00
K7,101 – K9,200K2,100.0030%K630.00
Above K9,200K2,800.0037%K1,036.00
TotalK12,000.00K2,066.00

The effective rate is about 17.2%, well under the 37% headline. This matters for software because it is the number employees argue with. A payslip showing only a total invites the question; one showing the band-by-band working answers it before it is asked. Our net pay calculator exists largely because that working is worth showing.

Money is not a float

Binary floating point cannot represent 0.1 exactly. In most software that is a curiosity. In payroll it is a defect, because the rounding error accumulates across thousands of line items and totals stop reconciling by a few ngwee, exactly the kind of discrepancy an auditor pulls a thread on.

Use a decimal type with an explicit scale and an explicit rounding mode, everywhere, from the database column through to the rendered payslip. BigDecimal in Java, NUMERIC in PostgreSQL. Decide the rounding rule once and write it down, because half-up and half-even give different answers and both are defensible until two systems disagree.

The rounding boundary has to be deliberate too. Rounding each band's tax and then summing gives a different total from summing and rounding once. Neither is wrong in principle; silently doing both in different parts of one system is.

Mobile money is the payment rail, not a fallback

Card penetration is low here and mobile money penetration is high. A product that treats mobile money as a secondary option bolted on after cards has the priority backwards for this market.

What that means concretely:

  • Payment is asynchronous. A mobile money charge is a request, then a customer approval on a handset, then a callback. There is a real interval where the payment is neither successful nor failed, and the interface has to represent that honestly rather than spinning.
  • Webhooks arrive more than once. Fulfilment has to be idempotent, keyed on the provider's reference, so a repeated callback finds the work already done instead of doing it twice.
  • The customer may close the tab. Delivery cannot depend on the browser still being open when the callback lands. If a purchase produces a document, it needs a route to the customer that does not involve a page they have already navigated away from.
  • Failure modes are specific. Insufficient funds, limit exceeded, unauthorised, timeout. Collapsing them into "payment failed" throws away the one piece of information that would let the customer fix it.

Statutory rates are data, not code

Rates change. The NAPSA ceiling is revised annually against National Average Earnings; bands move with the national budget. If those figures are literals scattered through a codebase, every change is a code change, a deployment, and an opportunity to update four of the five places.

Keep them in one versioned, effective-dated place that the calculation reads. Two things follow that are hard to retrofit later:

  • A recalculation for a past period resolves the rules that applied then, so a correction or back-pay run computes against historical law rather than today's.
  • The rate a figure was computed with becomes auditable. "Why is this different from last March?" has an answer that is not archaeology.

The corollary is that a customer should never be typing a tax band into a settings screen. A typo there is a wrong statutory return, discovered late.

Constraints worth designing for

Two local realities shape architecture more than they should have to.

Power. Load-shedding is a scheduling input, not an edge case. Long-running batch work that assumes uninterrupted execution will be interrupted. Make it resumable and idempotent, so a half-finished payroll run can be restarted without double-posting.

Bandwidth costs money. Users are paying per megabyte, often on a phone. A multi-megabyte JavaScript bundle to render a form is a real cost passed to the customer. That is a large part of why we default to server-rendered HTML and reach for a client framework only when an interface genuinely needs one.

What to check in a system you already run

If you have payroll software in production, these are worth verifying directly rather than assuming:

  1. Is NHIMA charged on basic salary, or on gross? Test with an employee who has an allowance.
  2. Is NAPSA being subtracted before PAYE is calculated? It should not be.
  3. Does the NAPSA cap apply, at the correct ceiling for the current year?
  4. Do the band figures on a payslip sum exactly to the stated total, or is there a rounding gap?
  5. When a rate changes, how many files have to change with it?

Each takes a few minutes to check with one test payslip, and each is the kind of thing that stays wrong for years because the output never looks obviously broken.

We build this sort of system: payroll, expense management, and the financial plumbing around them. If you would like a second pair of eyes on one you already run, get in touch.

All posts

Want this done properly?

We build the systems we write about: Java and Spring Boot on the inside, considered interfaces on the outside.

eLucive Software · Lusaka, Zambia