Skip to main content

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 TaxRule seed.
  • 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

FieldTypeMeaning
lowerBoundSantimbigintinclusive
upperBoundSantimbigint | nullexclusive; null = open-topped
rateBpsintbasis points; 1500 = 15%
fixedDeductionSantimbigintthe 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)
060,00000
60,000165,00010006,000
165,000320,000150014,250
320,000525,000200030,250
525,000760,000250056,500
760,0001,090,000300094,500
1,090,000— (open)3500149,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

FieldTypeMeaning
employeeContributionBpsintbasis points
employerContributionBpsintbasis points
minContributoryBaseSantimbigint?exempt floor; null = no floor
maxContributoryBaseSantimbigint?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)

FieldValue
employeeContributionBps700 (7%)
employerContributionBps1100 (11%)
minContributoryBaseSantimNULL
maxContributoryBaseSantimNULL

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)

GapStatutory expectationPilot behaviourPhase to close
Pension is subtracted from taxable base BEFORE PITrequired by 979/2016 §65pilot taxes the entire grossPayroll-E2
Non-taxable allowances (transport, per diem, housing within limits)required by 979/2016 §10pilot treats all allowances as taxable; only baseSalary is read today, so allowances are absent altogetherPayroll-E2 (also needs Employee-table modelling)
"Basic salary" vs "total gross" for pension baseprivate-sector pension applies to "basic salary" onlypilot applies to grossPayroll-E2
Overtime multipliers (×1.25, ×1.5, ×2 weekend/holiday)Labour Proclamation 1156/2019not modelledPayroll-E2
Court-ordered deductions take priority over voluntary deductionscivil procedurepilot has no priority order; deductions sum without rankingPayroll-E2
Minimum-net-salary protectionimplicit in Labour Proclamationpilot fails calculation on negative net; reviewer must intervenePayroll-E2
Monthly remittance to ERCA (tax) + POESSA (pension)required end of monthengine computes liability but no remittance integrationPayroll-E4
Pension contribution caps for very high earnersper fund regulationsmin/max fields exist on PensionRule; default seed sets both to NULLconfigurable today; no enforcement of statutory caps
Public-sector vs private-sector rates714/2011 vs 715/2011pilot ships only private-sector seednew 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:

  1. Author a new TaxRule DRAFT.
  2. Simulate against last period (Payroll-E2 will add a SimulatePayrollUnderRules use case).
  3. 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.