South Africa (ZA) tax regime - #896
Open
apayne185 wants to merge 5 commits into
Open
Conversation
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
marked this pull request as ready for review
July 13, 2026 16:01
samlown
requested changes
Jul 13, 2026
samlown
left a comment
Collaborator
There was a problem hiding this comment.
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.
| Name: i18n.String{ | ||
| i18n.EN: "South Africa", | ||
| }, | ||
| Description: i18n.String{ |
Collaborator
There was a problem hiding this comment.
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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
org_identities.gopattern, not justtax_identity.go.Research and sources
I read through the primary sources before beginning to write any code:
VAT Act No. 89 of 1991 (the governing law)
SARS — Value-Added Tax and SARS — Tax Invoices
SARS — VAT rate increase from 1 April 2018 and the Rates and Monetary Amounts and Amendment of Revenue Laws Act 21 of 2018 for the 14% to15% change
A Parliamentary Monitoring Group briefing for the definitive zero-rated vs. exempt supply lists
Companies Act 71 of 2008, Section 32 for the CIPC registration-number disclosure duty
Secondary sources (lookuptax.com, vatcalc.com, accounter.co.za, and others) used only to cross-check facts against the primary sources above, never as the sole citation for anything that ended up in code comments or
Sourcesfields — I checked each fact against at least two independent sources before committing to it in code, and reverified the two dates I was least sure of (the 1993 rate change, the exact VAT-registration-threshold change date) with an extra search rather than shipping a best guess.Checked all six cited URLs actually resolve (HTTP 200) with a real browser user-agent before finalizing. One came back 403 through my own tooling at first, which I didn't take at face value, as it turned out to be an anti-bot block, not a dead link. I didn't want to ship a citation I hadn't actually confirmed was live.
What I read in the existing codebase before writing anything
CONTRIBUTING.mdessentially 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: theinit()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 (Sinceomitted when a rate's never changed). I initially over-specified this for South Africa and fixed it after noticing CA didn't.regimes/gbandregimes/dk— precedent for a secondary, non-taxorg.Identityusing theTypefield (notKey) for a country-specific registry number (GB's CRN, DK's CVR) — the direct template for this PR's CIPC identity.regimes/deandregimes/se— a stricter variant of the same pattern, wrapping identity rules inis.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 aTypecode 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 theCorrectionsconfig 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/mxandregimes/gr— the other kind ofCorrectionssetup, requiringStampsfor 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/aeandregimes/in— checked whether theirscenarios.go(which auto injects a "Simplified Tax Invoice" note whentax.TagSimplifiedis 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 aRegimeDefstrictly 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()providesexempt,reverse-charge,export,intra-community, andoutside-scopeas combo keys flaggedNoPercent: true(no rate at all), separately from rated keys likezero. I reusedtax.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 suffix23(vs.06public company,07private company,08non-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
CKprefix baked into the number itself (as opposed toCKjust 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
bill_invoices.gomandatory-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 readbill/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 builtexamples/za/invoice-unregistered-supplier.yamlto prove it, not just argue it: here, a supplier with notax_idand no VAT category on its lines validates through the full pipeline, with totals showingtax: "0.00"and notaxesbreakdown at all.CHANGELOG.md:dk-oioubl-v2was 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.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 viascenarios.gowhentax.TagSimplifiedis 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.Points of confusion that I had to work through
rate: "standard"vs.tax.RateGeneral("general"). Every current example I read (FI, GB, NO, IE) writesrate: "standard"in YAML for the general-rate line, but the Go constanttax.RateGeneralis literally"general". These didn't match and I didn't want to guess. So I traced it tonormalizeCombointax/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 torate: general, since diverging would be inconsistent with every other example in the repo for no benefit.go testrejected it:[GOBL-TAX-RATEDEF-03] rate values must be in descending chronological order. GOBL resolves the applicable rate by "most recentSince≤ 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.Typevs..Key. These are mutually exclusive fields serving different purposes (Typefor country/context-specific registry identifiers,Keyfor the generic cross-regime vocabulary like SKU/ISBN/GTIN) — easy to conflate at first glance, resolved by readingorg/identity.godirectly rather than pattern-matching off one example.Sinceis required on aRateValueDef. 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 omitsSinceentirely 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:
Sinceon the Zero rate — removed, see above.examples/za/invoice-unregistered-supplier.yamlto prove it end-to-end.Correctionsconfig (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). Addedexamples/za/credit-note.yamla 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/...andgo test -race ./regimes/za/...— full pass,-covershows 100.0% of statements.go test . -run TestConvertExamplesToJSON -update— all three example invoices convert and validate through the realCalculate()→Validate()pipeline (confirmed this isn't just marshaling by readingpkg/examples/run.godirectly), with hand verified totals for each.go generate .— regeneratesdata/regimes/za.json,data/rules/za.json, and theZAentry indata/schemas/tax/regime-code.jsonconfirmed 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.yamlconfig (confirmed it loaded 14 real linters, not a fallback empty config).isCIPCIdentity, it's guarded).go test -race -coverprofile=... -covermode=atomic -coverpkg=./... ./...,golangci-lint run) in a fresh clone, matching.github/workflows/test.yamlandlint.yamlprecisely rather than approximating them.One unrelated finding, just noted for transparency:
cal.TestDateToday(pre-existing, not touched by this PR) has a latent bug where it comparescal.Today()(which is computed fromtime.Now().UTC()) against a second, separatetime.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
CK-prefix question above - I am happy to dig further if it's actually load bearing for real invoices you're processing.