Ethiopian Tax Engine
This document explains how DemozPay implements Ethiopian personal income
tax (PIT) and pension within packages/payroll. It also flags every
known gap vs. the published statutes so the engineering and compliance
sides keep a single honest view.
Source-of-truth statutes (informational)
- Federal Income Tax Proclamation 979/2016 — the personal income
tax schedule shipped as the pilot's GLOBAL
TaxRuleseed. - Public Servants' Pension Proclamation 714/2011 — public-sector pension scheme.
- Private Organization Employees Pension Proclamation 715/2011 — private-sector pension scheme. The pilot seed defaults to the private-sector 7% / 11% rates because all DemozPay launch employers are private organisations.
DemozPay does not mirror these statutes in code. The pilot rules
ship as DATA in migration
20260529400000_payroll_tax_pension_rules/migration.sql. Compliance
can supersede them with a tenant-specific or newer GLOBAL rule via the
normal create + activate flow.
Bracket model
A TaxBracket row stores
| Field | Type | Meaning |
|---|---|---|
lowerBoundSantim | bigint | inclusive |
upperBoundSantim | bigint | null | exclusive; null = open-topped |
rateBps | int | basis points; 1500 = 15% |
fixedDeductionSantim | bigint | the published "rebate" |
The bracket the gross falls into computes:
tax_owed = floor(gross * rateBps / 10000) − fixedDeductionSantim
clamped at 0. The single subtraction at the end matches the way the Ethiopian Revenues & Customs Authority publishes the schedule and avoids the floating-point hazard of summing marginal portions.
Pilot seed (FITP 979/2016)
Verbatim, as it ships in the seed migration. Santim, not ETB.
| Lower (santim) | Upper (santim) | Rate (bps) | Fixed deduction (santim) |
|---|---|---|---|
| 0 | 60,000 | 0 | 0 |
| 60,000 | 165,000 | 1000 | 6,000 |
| 165,000 | 320,000 | 1500 | 14,250 |
| 320,000 | 525,000 | 2000 | 30,250 |
| 525,000 | 760,000 | 2500 | 56,500 |
| 760,000 | 1,090,000 | 3000 | 94,500 |
| 1,090,000 | — (open) | 3500 | 149,000 |
Worked example: gross = 200,000 santim (2,000 ETB).
tax = floor(200,000 * 1500 / 10,000) − 14,250
= 30,000 − 14,250
= 15,750 santim (157.50 ETB)
The unit test tax-rule.spec.ts → applies the 15% bracket with the 14,250 rebate
asserts exactly this number.
Pension model
A PensionRule row stores
| Field | Type | Meaning |
|---|---|---|
employeeContributionBps | int | basis points |
employerContributionBps | int | basis points |
minContributoryBaseSantim | bigint? | exempt floor; null = no floor |
maxContributoryBaseSantim | bigint? | ceiling; null = uncapped |
Computation:
base = clamp(gross, [min, max]) // 0 if gross < min
employeeShare = floor(base * employeeBps / 10000)
employerShare = floor(base * employerBps / 10000)
Pilot seed (Proclamation 715/2011, private sector)
| Field | Value |
|---|---|
| employeeContributionBps | 700 (7%) |
| employerContributionBps | 1100 (11%) |
| minContributoryBaseSantim | NULL |
| maxContributoryBaseSantim | NULL |
Only the employee contribution flows into the payslip as a
PENSION deduction. The employer contribution is recorded as a
liability total on the calculate result (employerPensionLiabilitySantim)
and will become its own outbox event
payroll.employer_pension_recognised.v1 in Payroll-E3 (cross-domain
consumers + accounting hooks). Today the employer liability is observable
on the calc result but not yet remitted; that's a Payroll-E4 item.
Known statutory gaps (HONEST list)
| Gap | Statutory expectation | Pilot behaviour | Phase to close |
|---|---|---|---|
| Pension is subtracted from taxable base BEFORE PIT | required by 979/2016 §65 | pilot taxes the entire gross | Payroll-E2 |
| Non-taxable allowances (transport, per diem, housing within limits) | required by 979/2016 §10 | pilot treats all allowances as taxable; only baseSalary is read today, so allowances are absent altogether | Payroll-E2 (also needs Employee-table modelling) |
| "Basic salary" vs "total gross" for pension base | private-sector pension applies to "basic salary" only | pilot applies to gross | Payroll-E2 |
| Overtime multipliers (×1.25, ×1.5, ×2 weekend/holiday) | Labour Proclamation 1156/2019 | not modelled | Payroll-E2 |
| Court-ordered deductions take priority over voluntary deductions | civil procedure | pilot has no priority order; deductions sum without ranking | Payroll-E2 |
| Minimum-net-salary protection | implicit in Labour Proclamation | pilot fails calculation on negative net; reviewer must intervene | Payroll-E2 |
| Monthly remittance to ERCA (tax) + POESSA (pension) | required end of month | engine computes liability but no remittance integration | Payroll-E4 |
| Pension contribution caps for very high earners | per fund regulations | min/max fields exist on PensionRule; default seed sets both to NULL | configurable today; no enforcement of statutory caps |
| Public-sector vs private-sector rates | 714/2011 vs 715/2011 | pilot ships only private-sector seed | new GLOBAL or tenant rule via the normal create + activate flow |
Why we don't hardcode bracket values
If the FITP schedule changes (raised top rate, indexed brackets, employer-specific exemption for a free-zone tenant), the change is:
- Author a new
TaxRuleDRAFT. - Simulate against last period (Payroll-E2 will add a
SimulatePayrollUnderRulesuse case). - Activate it. The aggregate refuses to activate if its effective range overlaps another ACTIVE rule for the same tenant.
The previous version stays in the database as RETIRED. Payroll runs
calculated under it still resolve their snapshot pointer correctly.
There is therefore no code path that needs editing when Ethiopia changes its tax law. Compliance authors, engineering reviews, both sides keep the timeline of rule versions in the audit trail.