Skip to content

South Africa (ZA) tax regime - #896

Open
apayne185 wants to merge 5 commits into
invopop:mainfrom
apayne185:south-africa-regime
Open

South Africa (ZA) tax regime#896
apayne185 wants to merge 5 commits into
invopop:mainfrom
apayne185:south-africa-regime

Conversation

@apayne185

Copy link
Copy Markdown

Summary

Adds South Africa (ZA) as a new GOBL tax regime: VAT categories (15% general, 0% zero-rated, with full rate history back to VAT's 1991 introduction), VAT number format validation, a CIPC company registration identity, credit/debit note corrections, and three example invoices covering the general/zero-rate split, an unregistered (sub-threshold) supplier, and a credit note.

This PR description is long on purpose — per the assignment instructions, I'd rather overexplain my reasoning than underexplain it. I included headings below so it's skimmable if you want the shortened version.


Why South Africa

I wanted a country with a real and slightly unusual tax-ID validation process, a single national flat VAT rate (no federal/provincial patchwork like Canada's), a genuine legally grounded secondary identifier, and a live, dated regulatory question. Overall, something that would require actual scoping judgment rather than just simply transcribing a spec sheet into Go structs. South Africa turned out to have all four:

  • SARS has never published a VAT-number check-digit algorithm. Most regimes in this codebase validate a real checksum, so I had be the one to decide what correct validation even means when there isn't one.
  • VAT is a single national rate with no subnational variation, but with three well documented historical rate changes (10% → 14% → 15%) to model correctly.
  • The Companies Act legally requires a second, non-tax identifier (CIPC registration number) on invoices, which was a good test of the org_identities.go pattern, not just tax_identity.go.
  • South Africa is currently mid-rollout on an e-invoicing mandate (legal framework published April 2026, full rollout targeted 2028), so I had a live "is this in scope yet?" question with a defensible, dated answer.

Research and sources

I read through the primary sources before beginning to write any code:

What I read in the existing codebase before writing anything

CONTRIBUTING.md essentially specs out the whole task, so I started there. I also read several existing regimes to understand the actual conventions, since sometimes the written docs and the code don't always agree on the current way to do something:

  • regimes/fi (Finland, the most recently merged regime) — primary structural template: the init() pattern (tax.RegisterRegimeDef, rules.Register("fi", rules.GOBL.Add(...), ...), norm.Register), and general file organization.
  • regimes/ca (Canada) — precedent for modeling a zero rate with no percentage history (Since omitted when a rate's never changed). I initially over-specified this for South Africa and fixed it after noticing CA didn't.
  • regimes/gb and regimes/dk — precedent for a secondary, non-tax org.Identity using the Type field (not Key) for a country-specific registry number (GB's CRN, DK's CVR) — the direct template for this PR's CIPC identity.
  • regimes/de and regimes/se — a stricter variant of the same pattern, wrapping identity rules in is.InContext(tax.RegimeIn(CountryCode)) so they only fire inside the right regime context. DK's own CVR rule doesn't do this. I went with the DE/SE version deliberately where a Type code is just a string and could theoretically collide with another regime's type code, and the extra guard costs nothing.
  • regimes/no — precedent for shipping a credit-note example even when the Corrections config has no special constraints (no stamps, just allowed types). I originally skipped this, on the theory that CA/US (which have the same plain setup) don't bother either but I went back and matched NO's more thorough precedent instead once I noticed the config was declared but never actually exercised by an example (see the section of Self-review below).
  • regimes/mx and regimes/gr — the other kind of Corrections setup, requiring Stamps for e-invoicing clearance systems. This was useful as a contrast, confirming South Africa's corrections don't need this (no clearance system), rather than just me assuming.
  • regimes/ae and regimes/in — checked whether their scenarios.go (which auto injects a "Simplified Tax Invoice" note when tax.TagSimplified is set) was something I needed too, for South Africa's abridged invoice concept. Concluded no, because SARS doesn't require different document wording for abridged vs. full invoices, only different mandatory fields, and GOBL core already makes the customer optional without any regime code (see below). This let me confirm a scope cut instead of second-guessing it.
  • regimes/us — the minimal end of the spectrum (no VAT/tax-ID system at all), useful for understanding just how little a RegimeDef strictly requires.
  • regimes/ie — closest in overall size/shape to what I expected ZA to end up as, I used it to sanity check file organization once everything was written.

Key design decisions

VAT number: format-only validation, no invented checksum

South Africa's VAT number is 10 digits, always starting with 4. SARS has never published a check-digit algorithm for it, which I confirmed explicitly rather than assuming, since every other regime I'd just read (Finland, Ireland, Sweden) does have a real checksum ( I didn't want to miss one that existed). The only authoritative check is a live lookup against SARS's own VAT Vendor Search service, which is a runtime/network concern, not something a static schema library should be doing. I validate shape only (^4\d{9}$) and said so explicitly in the code comment, specifically so a future contributor doesn't attempt to fix this by inventing a checksum that doesn't actually exist. Personally, I'd rather underclaim what this library validates than overclaim it.

Zero-rated vs. exempt: two legally distinct things, modeled as two different things

The VAT Act treats these very differently: zero-rated vendors can still recover input VAT, but exempt vendors can't. GOBL's core already has the right primitive for this: tax.GlobalVATKeys() provides exempt, reverse-charge, export, intra-community, and outside-scope as combo keys flagged NoPercent: true (no rate at all), separately from rated keys like zero. I reused tax.GlobalVATKeys() wholesale rather than redeclaring the list locally, so this regime only needs to define the two rates that actually carry a percentage: 15% general and 0% zero. Exempt supplies need zero regime specific code.

Historical VAT rates: three tiers, not one

VAT was introduced at 10% (30 Sept 1991), raised to 14% (7 April 1993), raised to 15% (1 April 2018). I initially planned to only model the 2018 change and wave off the earlier ones as "diminishing returns" but then I decided to do one more search specifically to check whether the 1993 date was well-documented enough to cite with confidence. It was (corroborated by a 2011 Parliamentary Monitoring Group briefing independently of the SARS 2018 documentation), so I upgraded the decision rather than settling for the easier one. Also confirmed a 2025 budget proposal to raise the rate to 15.5%/16% was announced and then withdrawn before taking effect — the rate has stayed at 15% throughout.

CIPC company registration number as a secondary identity

Not a nice-to-have: Companies Act 71 of 2008, s32(4) makes displaying this number on invoices a legal requirement, with a fine/imprisonment penalty for non-compliance. This is a stronger basis than most "optional" regime features get. Modeled directly on GB's CRN / DK's CVR pattern (org.Identity.Type, not .Key).

While reviewing this, I checked whether the underlying regex (YYYY/NNNNNN/XX) would reject Close Corporations. This is a legacy South African entity type (Close Corporations Act 69 of 1984) that can't be newly registered since 1 May 2011, but where existing CCs still trade today and still owe the same disclosure duty as companies. It doesn't reject them: CC numbers use the identical 12-character shape with suffix 23 (vs. 06 public company, 07 private company, 08 non-profit). I broadened the doc comment to say this explicitly and added a test case locking in the coverage.

One thing I could not fully resolve: I found conflicting secondary sources on whether CC numbers sometimes carry a literal CK prefix baked into the number itself (as opposed to CK just naming the physical CK1/CK2 registration form). I did not encode an unverified guess into the regex to fix this, so I am flagging it here as an open question because it is more honest than quietly patching based on any shaky sourcing. If this would matter in practice, then it needs checking against an actual CIPC certificate or a direct CIPC source, not the secondary sites I was able to find.

What I deliberately did not build and why

  • No bill_invoices.go mandatory-tax-ID rule. Some regimes (consider Denmark) require some identity on every invoice unconditionally. South Africa doesn't work that way, where only registered VAT vendors need a VAT number, and plenty of legitimate invoices come from businesses below the registration threshold. A hard requirement here would reject valid real world documents. I didn't just assert this, I decided to read bill/invoice.go's core rules directly and confirmed the only unconditional requirement is that a supplier be present with a name, never a tax ID. Then I built examples/za/invoice-unregistered-supplier.yaml to prove it, not just argue it: here, a supplier with no tax_id and no VAT category on its lines validates through the full pipeline, with totals showing tax: "0.00" and no taxes breakdown at all.
  • No e-invoicing/Peppol addon. Not mandatory, as of today. The legal framework (Tax Administration Laws Amendment Act) was only published April 2026, the technical format isn't finalized, and full rollout is targeted for 2028 with the large taxpayers phased in first. This directly parallels a precedent already in this repo's own CHANGELOG.md: dk-oioubl-v2 was approved once as an external addon, then was pulled because the companion module "is not yet ready for release." So, I applied the same standard proactively.
  • No abridged-invoice-specific rule or scenarios.go. SARS permits an abridged tax invoice (supplier details only) for supplies between R50–R5,000, but unlike AE/IN and how it auto-inject a "Simplified Tax Invoice" note via scenarios.go when tax.TagSimplified is set, SARS doesn't require different document wording for the abridged case, only different mandatory fields. GOBL core already makes the customer optional and only requires a customer name when a customer tax ID is set, so this is already handled for free.
  • No regional/provincial VAT variation. Unlike Canada (HST/PST per province) or the US (sales tax per state), South Africa has one VAT rate nationally, full stop. I am stating this here explicitly so it doesn't read as an oversight because in reality it's a genuine simplification the tax system itself provides.

Points of confusion that I had to work through

  • rate: "standard" vs. tax.RateGeneral ("general"). Every current example I read (FI, GB, NO, IE) writes rate: "standard" in YAML for the general-rate line, but the Go constant tax.RateGeneral is literally "general". These didn't match and I didn't want to guess. So I traced it to normalizeCombo in tax/combo.go, which maps a legacy "standard" rate spelling onto the current key/rate model as a backward-compatibility alias. Matched existing convention (rate: standard) in my own examples rather than "correcting" it to rate: general, since diverging would be inconsistent with every other example in the repo for no benefit.
  • GOBL's rate-value ordering requirement. I wrote the three historical rate values ascending (1991, 1993, 2018) which is the natural way to read a history and go test rejected it: [GOBL-TAX-RATEDEF-03] rate values must be in descending chronological order. GOBL resolves the applicable rate by "most recent Since ≤ document date," so it wants most-recent-first. Fixed this by reversing the order, kept the fix as its own commit rather than squashing it away, since it's real evidence of the validation pipeline doing its job.
  • org.Identity.Type vs. .Key. These are mutually exclusive fields serving different purposes (Type for country/context-specific registry identifiers, Key for the generic cross-regime vocabulary like SKU/ISBN/GTIN) — easy to conflate at first glance, resolved by reading org/identity.go directly rather than pattern-matching off one example.
  • Whether Since is required on a RateValueDef. It's optional (*cal.Date, omitempty). I initially added one to the Zero rate ("since VAT's introduction") on the theory that more historical precision is always better then noticed Canada's equivalent zero-rate definition omits Since entirely when a rate's never changed, and simplified mine to match. More precision isn't better if the extra data point doesn't carry any actual information.

Self-review pass (what I did even after I thought this assignment was done)

I went back through everything adversarially, specifically hunting for gaps between what I'd argued in reasoning and what the code actually proved:

  1. Unnecessary Since on the Zero rate — removed, see above.
  2. Close Corporation coverage — broadened the CIPC doc comment and added a test case, see above.
  3. The core-handles-unregistered-suppliers claim was argued, not demonstrated — added examples/za/invoice-unregistered-supplier.yaml to prove it end-to-end.
  4. The Corrections config (credit/debit notes) was declared but never exercised — found this by checking Norway's precedent (identical plain setup, but NO ships a credit-note example while CA/US don't). Added examples/za/credit-note.yaml a partial credit note against the main example invoice, 20 of 100 zero-rated units returned, validated end-to-end (R500.00, zero VAT). Deliberately did not add a matching debit-note example, it's the mechanical inverse at the GOBL-core level, exercises no new regime-specific code, and no comparably-simple regime bothers with one either.

Testing performed

  • go test ./regimes/za/... and go test -race ./regimes/za/... — full pass, -cover shows 100.0% of statements.
  • go test . -run TestConvertExamplesToJSON -update — all three example invoices convert and validate through the real Calculate()Validate() pipeline (confirmed this isn't just marshaling by reading pkg/examples/run.go directly), with hand verified totals for each.
  • go generate . — regenerates data/regimes/za.json, data/rules/za.json, and the ZA entry in data/schemas/tax/regime-code.json confirmed idempotent (zero diff on a second run) in a completely fresh clone, not just the working copy, so the committed generated files aren't accidentally dependent on local state.
  • golangci-lint run ./... — 0 issues, repo-wide, run against the actual .golangci.yaml config (confirmed it loaded 14 real linters, not a fallback empty config).
  • Manually reviewed every pointer dereference and type assertion in the package for nil-safety (there's exactly one, in isCIPCIdentity, it's guarded).
  • Ran CI's exact commands (go test -race -coverprofile=... -covermode=atomic -coverpkg=./... ./..., golangci-lint run) in a fresh clone, matching .github/workflows/test.yaml and lint.yaml precisely rather than approximating them.
  • Verified all six cited source URLs resolve with a real browser User-Agent.

One unrelated finding, just noted for transparency: cal.TestDateToday (pre-existing, not touched by this PR) has a latent bug where it compares cal.Today() (which is computed from time.Now().UTC()) against a second, separate time.Now() call in local time, which can disagree by a day depending on the runner's timezone and the time of day. Ran into it once during development, it's timezone/clock-dependent so it may or may not reproduce in CI. I am flagging it now in case a reviewer sees it flake and wonders if this PR caused it. It didn't, I left it alone since it's out of scope for a regime PR.

Open questions and notes for reviewers

  • The Close Corporation CK-prefix question above - I am happy to dig further if it's actually load bearing for real invoices you're processing.
  • No e-invoicing addon by design (see above) - if there's an internal timeline for South Africa I don't have visibility into, let me know and I can then scope that as a followup.

apayne185 added 5 commits July 11, 2026 01:44
Adds a new GOBL regime for South Africa, administered by SARS under the
VAT Act No. 89 of 1991.

- za.go: regime metadata, 15% standard VAT, credit/debit note corrections
  per VAT Act s21.
- tax_categories.go: VAT category reusing tax.GlobalVATKeys() so exempt,
  reverse-charge, export, intra-community, and outside-scope supplies are
  covered for free; only the general (15%) and zero (0%) rates need local
  definitions, since those are the only ones that carry a percentage.
  Zero-rated and exempt are legally distinct under the VAT Act (zero-rated
  vendors can still recover input VAT, exempt vendors cannot) and that
  distinction is preserved rather than collapsed.
- tax_identity.go: format-only VAT number validation (10 digits, always
  starting with 4). SARS has never published a check-digit algorithm for
  this number, unlike most countries GOBL already covers, so this
  deliberately validates shape only rather than fabricating a checksum.
- org_identities.go: CIPC company registration number as a secondary,
  non-tax identity. Not a nice-to-have - Companies Act 71 of 2008 s32(4)
  makes displaying it on invoices a legal requirement with criminal
  penalties for non-compliance.

Deliberately not included yet: bill_invoices.go, scenarios.go,
corrections.go, an e-invoicing addon. See follow-up commits and the PR
description for why each was scoped out.
go test caught this immediately: [GOBL-TAX-RATEDEF-03] rate values must
be in descending chronological order. I'd listed the historical VAT rate
values ascending (1991 -> 1993 -> 2018), which is the natural way to read
a history but not what GOBL's own validation expects - it resolves the
applicable rate by "most recent Since <= document date", so it wants
most-recent-first.

Reordered to 2018, 1993, 1991. Confirmed against gb and ca's existing
tax_categories.go, which use the same most-recent-first ordering - I just
hadn't consciously registered the convention on first read.
- Registered the za package via blank import in regimes/regimes.go so it
  loads into the global tax regime registry alongside every other regime.
- Added examples/za/invoice-b2b.yaml: a two-line B2B invoice deliberately
  covering both rate paths - consulting services at the 15% general rate
  and a basic foodstuff (maize meal) at the 0% zero rate - so the example
  actually exercises the zero-vs-exempt modeling, not just the trivial
  single-rate case. Supplier carries both the VAT tax_id and a CIPC
  identity, to prove out both identity paths in one document.
- Ran go generate, which produced data/regimes/za.json, data/rules/za.json,
  and the ZA entry in data/schemas/tax/regime-code.json - confirmed
  idempotent by running it twice.
- Added the CHANGELOG.md entry under Unreleased > Added, matching the
  existing fi entry's style.

Hand-verified the generated totals: 15h x R850 = R12,750 base -> R1,912.50
VAT at 15%; 100 x R25 = R2,500 base -> R0.00 VAT at 0%; payable
R17,162.50. Confirms the zero-rate category modeling is wired correctly
end to end, not just internally consistent.
demonstrate the unregistered-supplier path

Went back through every file adversarially before considering this done.
Three things came out of it:

- tax_categories.go: the Zero rate carried an unnecessary Since date. It
  has never changed, so per Canada's regime (the closest precedent for a
  similarly-modeled zero rate), Since should be omitted entirely rather
  than implying more historical precision than the fact needs.

- org_identities.go: Companies Act s32(4) (the basis for the CIPC identity)
  technically only governs companies. South Africa also has legacy Close
  Corporations - none newly registered since 1 May 2011, but existing ones
  still trade and still owe the same registration-number disclosure duty
  under the Close Corporations Act 69 of 1984. Checked whether the CIPC
  regex would reject their numbers: it already accepts them, since CC
  numbers use the same YYYY/NNNNNN/XX shape with suffix 23 (vs 06 public,
  07 private, 08 non-profit). Broadened the doc comment to say so
  explicitly and added a test case locking in the coverage. I also found
  conflicting secondary sources on whether CC numbers sometimes carry a
  literal "CK" prefix in the number itself - couldn't resolve that with
  confidence, so deliberately did not encode an unverified guess into the
  regex. Flagging an open question honestly beats quietly "fixing" it on
  shaky sourcing.

- examples/za/invoice-unregistered-supplier.yaml: the PR's central scoping
  argument (no bill_invoices.go forcing a tax ID, because businesses below
  the VAT registration threshold trade without a VAT number) was argued
  from reading GOBL core, not demonstrated. Before writing this example I
  checked bill/invoice.go's rules directly and confirmed core only
  requires a supplier to be present with a name, never a tax_id. The
  example - a supplier with no tax_id and no VAT category on its lines -
  validates through the full Calculate+Validate pipeline on the first try,
  and the generated totals show tax: "0.00" with no taxes breakdown at
  all, which is the real proof.

Also ran go test -race and golangci-lint across the package: clean.
za.go's Corrections allows both credit and debit notes with no stamps or
extra constraints, but nothing exercised that path - the config was
declared and unit-tested indirectly (via TestValidate on the regime
definition) but never actually driven through a real correction.

Checked precedent: Norway has the identical plain (types-only, no stamps)
Corrections setup and still ships a credit-note example, while CA/US
(which also have plain setups) don't bother. Matched the more thorough
precedent rather than the minimal one, since this is cheap to add and
closes a real gap between what's configured and what's demonstrated.

examples/za/credit-note.yaml: a partial credit note against
invoice-b2b.yaml (20 of the 100 zero-rated maize meal units returned),
referencing the original via `preceding`. Validated through the full
pipeline on the first try; totals correctly show R500.00 with zero VAT.

Did not add a debit-note example alongside it - debit notes are the
mechanical inverse of credit notes at the GOBL core level and exercise no
additional regime-specific logic, and no comparable simple-correction
regime (CA, US, NO, GB) bothers with one either. Only ar's ARCA addon has
a debit-note example, tied to addon-specific behavior this regime doesn't
have.
@apayne185
apayne185 marked this pull request as ready for review July 13, 2026 16:01

@samlown samlown left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this! Looks good from the outset! I've added a comment in-line, and I think we lost the main PR checklist that should be there.

Comment thread regimes/za/za.go
Name: i18n.String{
i18n.EN: "South Africa",
},
Description: i18n.String{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general for the description I'd try to avoid too much detail that is likely to require revisiting in the future, such as percentage rates, classifications, etc. Ideally we just want the basics details.

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.76%. Comparing base (458d352) to head (da7a5ea).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #896      +/-   ##
==========================================
+ Coverage   94.73%   94.76%   +0.03%     
==========================================
  Files         316      319       +3     
  Lines       17198    17315     +117     
==========================================
+ Hits        16292    16409     +117     
  Misses        560      560              
  Partials      346      346              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@samlown samlown added the regime Related to a specific regime. label Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

regime Related to a specific regime.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants