diff --git a/CHANGELOG.md b/CHANGELOG.md index 62df62ec4..433ec6d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- `za`: added the South Africa (ZA) tax regime. + ## [v0.502.2] - 2026-07-06 ### Changed diff --git a/data/regimes/za.json b/data/regimes/za.json new file mode 100644 index 000000000..b3024e159 --- /dev/null +++ b/data/regimes/za.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/regime-def", + "name": { + "en": "South Africa" + }, + "description": { + "en": "South Africa's tax system is administered by the South African\nRevenue Service (SARS) under the Value-Added Tax Act No. 89 of 1991.\n\nVAT is levied at a standard rate of 15%, in effect since 1 April 2018\n(previously 14% since 1993). A 2025 budget proposal to raise the rate\nfurther to 15.5% and then 16% was withdrawn before taking effect, and\nthe rate has remained at 15%.\n\nZero-rated supplies (0%, with input VAT still recoverable) include\nbasic foodstuffs, fuel, exports, international transport, and\nservices consumed outside South Africa. Exempt supplies (no VAT\ncharged, no input VAT recovery) include financial services,\nresidential rental, non-international passenger transport, and\neducation. The VAT Act treats these very differently, and this\nregime preserves the distinction rather than collapsing both into a\nsingle \"0%\" concept.\n\nVendors must register for VAT once taxable supplies exceed the\ncompulsory threshold (ZAR 2.3 million over 12 months, since 1 April\n2026; ZAR 1 million previously) and may register voluntarily above a\nlower threshold (ZAR 120,000, since 1 April 2026; ZAR 50,000\npreviously). Businesses below these thresholds may trade without a\nVAT number, so one is not always present.\n\nBusinesses are identified for VAT purposes by a 10-digit VAT\nregistration number that always starts with the digit 4 (e.g.\n4480152117). Unlike most countries covered by GOBL, SARS does not\npublish a check digit algorithm for this number, so only its format\ncan be validated here. Authoritative verification requires a live\nlookup against SARS's VAT Vendor Search service, which is outside\nthe scope of this library.\n\nRegistered companies must additionally display their CIPC company\nregistration number (format YYYY/NNNNNN/XX) on invoices and other\nbusiness correspondence, per section 32(4) of the Companies Act 71\nof 2008.\n\nBoth credit and debit notes are supported for invoice corrections,\nper section 21 of the VAT Act.\n\nAs of mid-2026, e-invoicing is not yet mandatory in South Africa.\nThe Tax Administration Laws Amendment Act (published April 2026)\nestablishes a legal framework for a phased mandate expected to\ncomplete by 2028, but the technical format has not yet been\nfinalized, so no e-invoicing addon is implemented here." + }, + "sources": [ + { + "title": { + "en": "SARS - Value-Added Tax" + }, + "url": "https://www.sars.gov.za/types-of-tax/value-added-tax/" + }, + { + "title": { + "en": "Value-Added Tax Act No. 89 of 1991" + }, + "url": "https://www.gov.za/sites/default/files/gcis_document/201505/act-89-1991s.pdf" + }, + { + "title": { + "en": "SARS - VAT rate increase from 1 April 2018" + }, + "url": "https://www.sars.gov.za/wp-content/uploads/Docs/VAT/NON-BDE-Rate-change-letter-final-.pdf" + }, + { + "title": { + "en": "Zero-rated and exempt supplies (Parliamentary Monitoring Group)" + }, + "url": "https://static.pmg.org.za/docs/Zero-rated%20and%20exempt%20supplies.pdf" + }, + { + "title": { + "en": "SARS - Tax Invoices" + }, + "url": "https://www.sars.gov.za/businesses-and-employers/government/tax-invoices/" + }, + { + "title": { + "en": "Companies Act 71 of 2008, Section 32" + }, + "url": "https://marxgore.co.za/wp-content/uploads/2020/01/Section-32-Use-of-company-name-and-registration-number.pdf" + } + ], + "time_zone": "Africa/Johannesburg", + "country": "ZA", + "currency": "ZAR", + "tax_scheme": "VAT", + "identities": [ + { + "code": "CIPC", + "name": { + "en": "CIPC Registration Number" + }, + "desc": { + "en": "South African company registration number issued by the Companies and Intellectual Property Commission (CIPC), format YYYY/NNNNNN/XX." + } + } + ], + "scenarios": [ + { + "schema": "bill/invoice", + "list": [ + { + "tags": [ + "reverse-charge" + ], + "cat": [ + "VAT" + ], + "note": { + "cat": "VAT", + "key": "reverse-charge", + "text": "Reverse charge: Customer to account for VAT to the relevant tax authority." + } + } + ] + } + ], + "corrections": [ + { + "schema": "bill/invoice", + "types": [ + "credit-note", + "debit-note" + ] + } + ], + "categories": [ + { + "code": "VAT", + "name": { + "en": "VAT" + }, + "title": { + "en": "Value Added Tax" + }, + "keys": [ + { + "key": "standard", + "name": { + "en": "Standard" + } + }, + { + "key": "zero", + "name": { + "en": "Zero" + } + }, + { + "key": "reverse-charge", + "name": { + "en": "Reverse charge" + }, + "no_percent": true + }, + { + "key": "exempt", + "name": { + "en": "Exempt" + }, + "no_percent": true + }, + { + "key": "export", + "name": { + "en": "Export" + }, + "no_percent": true + }, + { + "key": "intra-community", + "name": { + "en": "Intra-community" + }, + "no_percent": true + }, + { + "key": "outside-scope", + "name": { + "en": "Outside scope" + }, + "no_percent": true + } + ], + "rates": [ + { + "rate": "general", + "keys": [ + "standard" + ], + "name": { + "en": "General Rate" + }, + "values": [ + { + "since": "2018-04-01", + "percent": "15%" + }, + { + "since": "1993-04-07", + "percent": "14%" + }, + { + "since": "1991-09-30", + "percent": "10%" + } + ] + }, + { + "rate": "zero", + "keys": [ + "zero" + ], + "name": { + "en": "Zero Rate" + }, + "desc": { + "en": "Applies to exports, international transport, basic foodstuffs (e.g. brown bread, maize meal, rice, vegetables, milk, eggs), and fuel (petrol, diesel, illuminating paraffin). Input VAT remains recoverable, unlike exempt supplies." + }, + "values": [ + { + "percent": "0%" + } + ] + } + ], + "sources": [ + { + "title": { + "en": "SARS - Value-Added Tax" + }, + "url": "https://www.sars.gov.za/types-of-tax/value-added-tax/" + }, + { + "title": { + "en": "Zero-rated and exempt supplies (Parliamentary Monitoring Group)" + }, + "url": "https://static.pmg.org.za/docs/Zero-rated%20and%20exempt%20supplies.pdf" + } + ] + } + ] +} \ No newline at end of file diff --git a/data/rules/za.json b/data/rules/za.json new file mode 100644 index 000000000..481673c56 --- /dev/null +++ b/data/rules/za.json @@ -0,0 +1,58 @@ +{ + "id": "GOBL-ZA", + "package": "za", + "subsets": [ + { + "id": "GOBL-ZA-TAX-IDENTITY", + "object": "tax.Identity", + "subsets": [ + { + "guard": "code in [ZA]", + "subsets": [ + { + "field": "code", + "subsets": [ + { + "guard": "present", + "assert": [ + { + "id": "GOBL-ZA-TAX-IDENTITY-01", + "desc": "invalid South African VAT identity code", + "tests": "matches ^4\\d{9}$" + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "GOBL-ZA-ORG-IDENTITY", + "object": "org.Identity", + "subsets": [ + { + "guard": "context: regime in [ZA]", + "subsets": [ + { + "guard": "is CIPC", + "subsets": [ + { + "field": "code", + "assert": [ + { + "id": "GOBL-ZA-ORG-IDENTITY-01", + "desc": "invalid CIPC registration number format", + "tests": "matches ^\\d{4}/\\d{6}/\\d{2}$" + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/data/schemas/tax/regime-code.json b/data/schemas/tax/regime-code.json index 585a84591..d8374774b 100644 --- a/data/schemas/tax/regime-code.json +++ b/data/schemas/tax/regime-code.json @@ -112,6 +112,10 @@ { "const": "US", "title": "United States of America" + }, + { + "const": "ZA", + "title": "South Africa" } ], "type": "string", diff --git a/examples/za/credit-note.yaml b/examples/za/credit-note.yaml new file mode 100644 index 000000000..20a27d4a1 --- /dev/null +++ b/examples/za/credit-note.yaml @@ -0,0 +1,60 @@ +# Partial credit note against invoice-b2b.yaml: 20 of the 100 units of +# maize meal were returned. VAT Act s21 governs credit notes as adjustments +# to a previously issued tax invoice; South Africa places no additional +# constraints on which correction types are valid or requires any stamps to +# be copied across, so this exercises the plain GOBL core correction flow. +$schema: "https://gobl.org/draft-0/bill/invoice" +uuid: "019035bd-4524-73ab-bf44-6037841ce6a3" +type: "credit-note" +issue_date: "2026-06-22" +currency: "ZAR" +series: "SAMPLE" +code: "003" + +preceding: + - series: "SAMPLE" + code: "001" + issue_date: "2026-06-15" + reason: "Partial return of goods" + +supplier: + tax_id: + country: "ZA" + code: "4123456789" + identities: + - type: "CIPC" + code: "2015/123456/07" + name: "Highveld Trading (Pty) Ltd" + emails: + - addr: "billing@example.co.za" + addresses: + - num: "44" + street: "Rivonia Road" + locality: "Sandton" + region: "Gauteng" + code: "2196" + country: "ZA" + +customer: + tax_id: + country: "ZA" + code: "4987654321" + name: "Table Mountain Distributors CC" + emails: + - addr: "accounts@example.co.za" + addresses: + - num: "12" + street: "Long Street" + locality: "Cape Town" + region: "Western Cape" + code: "8001" + country: "ZA" + +lines: + - quantity: 20 + item: + name: "Maize meal (zero-rated basic foodstuff)" + price: "25.00" + taxes: + - cat: VAT + rate: zero diff --git a/examples/za/invoice-b2b.yaml b/examples/za/invoice-b2b.yaml new file mode 100644 index 000000000..fe4f1c4e8 --- /dev/null +++ b/examples/za/invoice-b2b.yaml @@ -0,0 +1,56 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +uuid: "019035bd-4524-73ab-bf44-6037841ce6a1" +issue_date: "2026-06-15" +currency: "ZAR" +series: "SAMPLE" +code: "001" + +supplier: + tax_id: + country: "ZA" + code: "4123456789" + identities: + - type: "CIPC" + code: "2015/123456/07" + name: "Highveld Trading (Pty) Ltd" + emails: + - addr: "billing@example.co.za" + addresses: + - num: "44" + street: "Rivonia Road" + locality: "Sandton" + region: "Gauteng" + code: "2196" + country: "ZA" + +customer: + tax_id: + country: "ZA" + code: "4987654321" + name: "Table Mountain Distributors CC" + emails: + - addr: "accounts@example.co.za" + addresses: + - num: "12" + street: "Long Street" + locality: "Cape Town" + region: "Western Cape" + code: "8001" + country: "ZA" + +lines: + - quantity: 15 + item: + name: "Consulting services" + price: "850.00" + unit: "h" + taxes: + - cat: VAT + rate: standard + - quantity: 100 + item: + name: "Maize meal (zero-rated basic foodstuff)" + price: "25.00" + taxes: + - cat: VAT + rate: zero diff --git a/examples/za/invoice-unregistered-supplier.yaml b/examples/za/invoice-unregistered-supplier.yaml new file mode 100644 index 000000000..b1ac22c15 --- /dev/null +++ b/examples/za/invoice-unregistered-supplier.yaml @@ -0,0 +1,39 @@ +# Demonstrates a supplier trading below the VAT registration threshold (ZAR +# 120,000 voluntary / ZAR 2.3 million compulsory, since 1 April 2026). Such a +# business has no VAT number and may not charge VAT at all, so the invoice +# carries no supplier tax_id and no tax category on its lines - a state GOBL +# core already supports without any ZA-specific rule forcing a tax ID. +$schema: "https://gobl.org/draft-0/bill/invoice" +uuid: "019035bd-4524-73ab-bf44-6037841ce6a2" +issue_date: "2026-06-20" +currency: "ZAR" +series: "SAMPLE" +code: "002" + +supplier: + name: "Kalahari Crafts" + emails: + - addr: "hello@example.co.za" + addresses: + - num: "3" + street: "Church Street" + locality: "Upington" + region: "Northern Cape" + code: "8801" + country: "ZA" + +customer: + name: "Jane's Retail Shop" + addresses: + - num: "9" + street: "Market Street" + locality: "Kimberley" + region: "Northern Cape" + code: "8301" + country: "ZA" + +lines: + - quantity: 50 + item: + name: "Handmade woven baskets" + price: "120.00" diff --git a/examples/za/out/credit-note.json b/examples/za/out/credit-note.json new file mode 100644 index 000000000..14c58f40f --- /dev/null +++ b/examples/za/out/credit-note.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "2611e4454e9dd69976fa527a7e7fe2b8bde6df4dfe6b371d9e13ca3b5fe10e15" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ZA", + "uuid": "019035bd-4524-73ab-bf44-6037841ce6a3", + "type": "credit-note", + "series": "SAMPLE", + "code": "003", + "issue_date": "2026-06-22", + "currency": "ZAR", + "preceding": [ + { + "issue_date": "2026-06-15", + "series": "SAMPLE", + "code": "001", + "reason": "Partial return of goods" + } + ], + "supplier": { + "name": "Highveld Trading (Pty) Ltd", + "tax_id": { + "country": "ZA", + "code": "4123456789" + }, + "identities": [ + { + "type": "CIPC", + "code": "2015/123456/07" + } + ], + "addresses": [ + { + "num": "44", + "street": "Rivonia Road", + "locality": "Sandton", + "region": "Gauteng", + "code": "2196", + "country": "ZA" + } + ], + "emails": [ + { + "addr": "billing@example.co.za" + } + ] + }, + "customer": { + "name": "Table Mountain Distributors CC", + "tax_id": { + "country": "ZA", + "code": "4987654321" + }, + "addresses": [ + { + "num": "12", + "street": "Long Street", + "locality": "Cape Town", + "region": "Western Cape", + "code": "8001", + "country": "ZA" + } + ], + "emails": [ + { + "addr": "accounts@example.co.za" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Maize meal (zero-rated basic foodstuff)", + "price": "25.00" + }, + "sum": "500.00", + "taxes": [ + { + "cat": "VAT", + "key": "zero", + "percent": "0%" + } + ], + "total": "500.00" + } + ], + "totals": { + "sum": "500.00", + "total": "500.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "zero", + "base": "500.00", + "percent": "0%", + "amount": "0.00" + } + ], + "amount": "0.00" + } + ], + "sum": "0.00" + }, + "tax": "0.00", + "total_with_tax": "500.00", + "payable": "500.00" + } + } +} \ No newline at end of file diff --git a/examples/za/out/invoice-b2b.json b/examples/za/out/invoice-b2b.json new file mode 100644 index 000000000..2d09f61b8 --- /dev/null +++ b/examples/za/out/invoice-b2b.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "36abc2d7ac5c5b7275f13e3017462262b2cdf061eec73ca7d20b72763b8218a7" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ZA", + "uuid": "019035bd-4524-73ab-bf44-6037841ce6a1", + "type": "standard", + "series": "SAMPLE", + "code": "001", + "issue_date": "2026-06-15", + "currency": "ZAR", + "supplier": { + "name": "Highveld Trading (Pty) Ltd", + "tax_id": { + "country": "ZA", + "code": "4123456789" + }, + "identities": [ + { + "type": "CIPC", + "code": "2015/123456/07" + } + ], + "addresses": [ + { + "num": "44", + "street": "Rivonia Road", + "locality": "Sandton", + "region": "Gauteng", + "code": "2196", + "country": "ZA" + } + ], + "emails": [ + { + "addr": "billing@example.co.za" + } + ] + }, + "customer": { + "name": "Table Mountain Distributors CC", + "tax_id": { + "country": "ZA", + "code": "4987654321" + }, + "addresses": [ + { + "num": "12", + "street": "Long Street", + "locality": "Cape Town", + "region": "Western Cape", + "code": "8001", + "country": "ZA" + } + ], + "emails": [ + { + "addr": "accounts@example.co.za" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "15", + "item": { + "name": "Consulting services", + "price": "850.00", + "unit": "h" + }, + "sum": "12750.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "15%" + } + ], + "total": "12750.00" + }, + { + "i": 2, + "quantity": "100", + "item": { + "name": "Maize meal (zero-rated basic foodstuff)", + "price": "25.00" + }, + "sum": "2500.00", + "taxes": [ + { + "cat": "VAT", + "key": "zero", + "percent": "0%" + } + ], + "total": "2500.00" + } + ], + "totals": { + "sum": "15250.00", + "total": "15250.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "12750.00", + "percent": "15%", + "amount": "1912.50" + }, + { + "key": "zero", + "base": "2500.00", + "percent": "0%", + "amount": "0.00" + } + ], + "amount": "1912.50" + } + ], + "sum": "1912.50" + }, + "tax": "1912.50", + "total_with_tax": "17162.50", + "payable": "17162.50" + } + } +} \ No newline at end of file diff --git a/examples/za/out/invoice-unregistered-supplier.json b/examples/za/out/invoice-unregistered-supplier.json new file mode 100644 index 000000000..35347ab60 --- /dev/null +++ b/examples/za/out/invoice-unregistered-supplier.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "7bcacac08104ff7fd618420d878c585060aaf4b919091f854980de6ed790dc6d" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "uuid": "019035bd-4524-73ab-bf44-6037841ce6a2", + "type": "standard", + "series": "SAMPLE", + "code": "002", + "issue_date": "2026-06-20", + "currency": "ZAR", + "supplier": { + "name": "Kalahari Crafts", + "addresses": [ + { + "num": "3", + "street": "Church Street", + "locality": "Upington", + "region": "Northern Cape", + "code": "8801", + "country": "ZA" + } + ], + "emails": [ + { + "addr": "hello@example.co.za" + } + ] + }, + "customer": { + "name": "Jane's Retail Shop", + "addresses": [ + { + "num": "9", + "street": "Market Street", + "locality": "Kimberley", + "region": "Northern Cape", + "code": "8301", + "country": "ZA" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "50", + "item": { + "name": "Handmade woven baskets", + "price": "120.00" + }, + "sum": "6000.00", + "total": "6000.00" + } + ], + "totals": { + "sum": "6000.00", + "total": "6000.00", + "tax": "0.00", + "total_with_tax": "6000.00", + "payable": "6000.00" + } + } +} \ No newline at end of file diff --git a/regimes/regimes.go b/regimes/regimes.go index a6b6d7203..633c4b095 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -32,4 +32,5 @@ import ( _ "github.com/invopop/gobl/regimes/se" _ "github.com/invopop/gobl/regimes/sg" _ "github.com/invopop/gobl/regimes/us" + _ "github.com/invopop/gobl/regimes/za" ) diff --git a/regimes/za/org_identities.go b/regimes/za/org_identities.go new file mode 100644 index 000000000..eb16fe5c8 --- /dev/null +++ b/regimes/za/org_identities.go @@ -0,0 +1,67 @@ +package za + +import ( + "regexp" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/tax" +) + +// IdentityTypeCIPC represents a company registration number issued by the +// Companies and Intellectual Property Commission (CIPC). Section 32(4) of +// the Companies Act 71 of 2008 requires registered companies to display +// this number - in addition to their VAT number, where applicable - on +// invoices and other business correspondence, on penalty of a fine or +// imprisonment. +// +// Reference: https://marxgore.co.za/wp-content/uploads/2020/01/Section-32-Use-of-company-name-and-registration-number.pdf +const IdentityTypeCIPC cbc.Code = "CIPC" + +// cipcRegexp matches CIPC registration numbers in the format YYYY/NNNNNN/XX, +// e.g. 2020/123456/07. The two-digit suffix identifies the entity type: 06 +// public company, 07 private company, 08 non-profit company, 21 +// incorporation, 30 state-owned - and 23 for Close Corporations, a legal +// form that could not be newly registered after 1 May 2011 but under the +// Close Corporations Act 69 of 1984 remains valid for entities formed +// before then, and is still bound by the same registration-number +// disclosure duty as companies. The number is sequential plus a type +// suffix; like the VAT number, CIPC does not publish a check digit +// algorithm for it. +var cipcRegexp = regexp.MustCompile(`^\d{4}/\d{6}/\d{2}$`) + +var identityTypeDefinitions = []*cbc.Definition{ + { + Code: IdentityTypeCIPC, + Name: i18n.String{ + i18n.EN: "CIPC Registration Number", + }, + Desc: i18n.String{ + i18n.EN: "South African company registration number issued by the Companies and Intellectual Property Commission (CIPC), format YYYY/NNNNNN/XX.", + }, + }, +} + +func orgIdentityRules() *rules.Set { + return rules.For(new(org.Identity), + rules.When( + is.InContext(tax.RegimeIn(CountryCode)), + rules.When( + is.Func("is CIPC", isCIPCIdentity), + rules.Field("code", + rules.Assert("01", "invalid CIPC registration number format", + is.MatchesRegexp(cipcRegexp), + ), + ), + ), + ), + ) +} + +func isCIPCIdentity(val any) bool { + id, _ := val.(*org.Identity) + return id != nil && id.Type == IdentityTypeCIPC +} diff --git a/regimes/za/org_identities_test.go b/regimes/za/org_identities_test.go new file mode 100644 index 000000000..b1092eaa3 --- /dev/null +++ b/regimes/za/org_identities_test.go @@ -0,0 +1,52 @@ +package za_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/za" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestIdentityTypeDefinitions(t *testing.T) { + assert.Equal(t, "CIPC", string(za.IdentityTypeCIPC)) +} + +func TestCIPCIdentityValidation(t *testing.T) { + tests := []struct { + name string + code cbc.Code + err string + }{ + {name: "valid private company", code: "2020/123456/07"}, + {name: "valid public company", code: "1998/654321/06"}, + {name: "valid close corporation", code: "2005/123456/23"}, + {name: "too short year", code: "202/123456/07", err: "[GOBL-ZA-ORG-IDENTITY-01]"}, + {name: "too short sequence", code: "2020/12345/07", err: "[GOBL-ZA-ORG-IDENTITY-01]"}, + {name: "missing suffix", code: "2020/123456", err: "[GOBL-ZA-ORG-IDENTITY-01]"}, + {name: "wrong separators", code: "2020-123456-07", err: "[GOBL-ZA-ORG-IDENTITY-01]"}, + {name: "non-numeric", code: "2020/12345A/07", err: "[GOBL-ZA-ORG-IDENTITY-01]"}, + } + + ctx := tax.RegimeContext(za.CountryCode) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := &org.Identity{Type: za.IdentityTypeCIPC, Code: tt.code} + err := rules.Validate(id, ctx) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.err) + } + }) + } +} + +func TestNonCIPCIdentityIgnored(t *testing.T) { + ctx := tax.RegimeContext(za.CountryCode) + id := &org.Identity{Type: "other", Code: "not-a-cipc-number"} + assert.NoError(t, rules.Validate(id, ctx)) +} diff --git a/regimes/za/tax_categories.go b/regimes/za/tax_categories.go new file mode 100644 index 000000000..8524f346f --- /dev/null +++ b/regimes/za/tax_categories.go @@ -0,0 +1,92 @@ +package za + +import ( + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" +) + +// taxCategories defines the VAT category and rates for South Africa. +// +// Exempt, reverse-charge, export, intra-community, and outside-scope +// supplies are already covered by the global VAT keys (tax.GlobalVATKeys) +// and require no local rate: they carry no percentage at all, as opposed to +// zero-rated supplies which do carry an explicit (if 0%) rate. This +// distinction is legally significant under the VAT Act — zero-rated +// vendors may still recover input VAT, exempt vendors may not — and is +// preserved here rather than collapsed. +var taxCategories = []*tax.CategoryDef{ + // + // VAT + // + { + Code: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "VAT", + }, + Title: i18n.String{ + i18n.EN: "Value Added Tax", + }, + Retained: false, + Keys: tax.GlobalVATKeys(), + Sources: []*cbc.Source{ + { + Title: i18n.NewString("SARS - Value-Added Tax"), + URL: "https://www.sars.gov.za/types-of-tax/value-added-tax/", + }, + { + Title: i18n.NewString("Zero-rated and exempt supplies (Parliamentary Monitoring Group)"), + URL: "https://static.pmg.org.za/docs/Zero-rated%20and%20exempt%20supplies.pdf", + }, + }, + Rates: []*tax.RateDef{ + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateGeneral, + Name: i18n.String{ + i18n.EN: "General Rate", + }, + Values: []*tax.RateValueDef{ + { + // Increased 1 April 2018 under the Rates and Monetary + // Amounts and Amendment of Revenue Laws Act 21 of 2018. + // A further increase to 15.5%/16% was announced for + // 2025/2026 but withdrawn before taking effect. + Since: cal.NewDate(2018, 4, 1), + Percent: num.MakePercentage(15, 2), + }, + { + // Increased 7 April 1993. + Since: cal.NewDate(1993, 4, 7), + Percent: num.MakePercentage(14, 2), + }, + { + // VAT introduced 30 September 1991 at 10%. + Since: cal.NewDate(1991, 9, 30), + Percent: num.MakePercentage(10, 2), + }, + }, + }, + { + Keys: []cbc.Key{tax.KeyZero}, + Rate: tax.RateZero, + Name: i18n.String{ + i18n.EN: "Zero Rate", + }, + Description: i18n.String{ + i18n.EN: "Applies to exports, international transport, basic foodstuffs (e.g. brown bread, maize meal, rice, vegetables, milk, eggs), and fuel (petrol, diesel, illuminating paraffin). Input VAT remains recoverable, unlike exempt supplies.", + }, + Values: []*tax.RateValueDef{ + { + // Has been 0% since VAT's introduction; no history to + // track, so no Since date is needed here (unlike the + // general rate above). + Percent: num.MakePercentage(0, 2), + }, + }, + }, + }, + }, +} diff --git a/regimes/za/tax_identity.go b/regimes/za/tax_identity.go new file mode 100644 index 000000000..63313b5e9 --- /dev/null +++ b/regimes/za/tax_identity.go @@ -0,0 +1,33 @@ +package za + +import ( + "regexp" + + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/rules/is" + "github.com/invopop/gobl/tax" +) + +// taxCodeRegexp matches South African VAT registration numbers: always 10 +// digits, always starting with the digit 4 (e.g. 4480152117). +// +// Unlike most countries covered by GOBL, SARS has never published a check +// digit algorithm for this number, so format is the only thing that can be +// validated offline here. Authoritative validation requires a live lookup +// against SARS's VAT Vendor Search service (https://secure.sarsefiling.co.za/vatvendorsearch.aspx), +// which is a runtime/network concern outside the scope of this library. +// +// Reference: https://lookuptax.com/docs/how-to-verify/vat-verification-south-africa +var taxCodeRegexp = regexp.MustCompile(`^4\d{9}$`) + +func taxIdentityRules() *rules.Set { + return rules.For(new(tax.Identity), + rules.When(tax.IdentityIn(CountryCode), + rules.Field("code", + rules.AssertIfPresent("01", "invalid South African VAT identity code", + is.MatchesRegexp(taxCodeRegexp), + ), + ), + ), + ) +} diff --git a/regimes/za/tax_identity_test.go b/regimes/za/tax_identity_test.go new file mode 100644 index 000000000..9da977e9d --- /dev/null +++ b/regimes/za/tax_identity_test.go @@ -0,0 +1,109 @@ +package za_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/norm" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestTaxIdentityRules(t *testing.T) { + tests := []struct { + name string + inputCode cbc.Code + expectedErr string + }{ + { + name: "valid, starts with 4", + inputCode: "4123456789", + }, + { + name: "valid, another example", + inputCode: "4480152117", + }, + { + name: "empty code", + inputCode: "", + }, + { + name: "too short", + inputCode: "412345678", + expectedErr: "IDENTITY-01", + }, + { + name: "too long", + inputCode: "41234567890", + expectedErr: "IDENTITY-01", + }, + { + name: "does not start with 4", + inputCode: "5123456789", + expectedErr: "IDENTITY-01", + }, + { + name: "contains letters", + inputCode: "412345678A", + expectedErr: "IDENTITY-01", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: "ZA", Code: tt.inputCode} + err := rules.Validate(tID) + if tt.expectedErr == "" { + assert.NoError(t, err) + } else { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.expectedErr) + } + } + }) + } +} + +func TestNormalizeTaxIdentity(t *testing.T) { + tests := []struct { + name string + inputCode cbc.Code + expectedCode cbc.Code + }{ + { + name: "strips ZA prefix", + inputCode: "ZA4123456789", + expectedCode: "4123456789", + }, + { + name: "strips spaces", + inputCode: "4123 456 789", + expectedCode: "4123456789", + }, + { + name: "already normalized", + inputCode: "4123456789", + expectedCode: "4123456789", + }, + { + name: "empty", + inputCode: "", + expectedCode: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: "ZA", Code: tt.inputCode} + norm.Normalize(tID) + assert.Equal(t, tt.expectedCode, tID.Code) + }) + } + + t.Run("nil identity", func(t *testing.T) { + assert.NotPanics(t, func() { + norm.Normalize((*tax.Identity)(nil)) + }) + }) +} diff --git a/regimes/za/za.go b/regimes/za/za.go new file mode 100644 index 000000000..81ac6ce71 --- /dev/null +++ b/regimes/za/za.go @@ -0,0 +1,127 @@ +// Package za provides the tax regime for South Africa. +package za + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/norm" + "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/rules" + "github.com/invopop/gobl/tax" +) + +// CountryCode is the ISO 3166-1 alpha-2 code for South Africa. +const CountryCode = "ZA" + +func init() { + tax.RegisterRegimeDef(New()) + rules.Register("za", rules.GOBL.Add(CountryCode), + taxIdentityRules(), + orgIdentityRules(), + ) + norm.Register( + norm.When(tax.IdentityIn(CountryCode), norm.For(func(id *tax.Identity) { tax.NormalizeIdentity(id) })), + ) +} + +// New instantiates a new South Africa regime. +func New() *tax.RegimeDef { + return &tax.RegimeDef{ + Country: CountryCode, + Currency: currency.ZAR, + TaxScheme: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "South Africa", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + South Africa's tax system is administered by the South African + Revenue Service (SARS) under the Value-Added Tax Act No. 89 of 1991. + + VAT is levied at a standard rate of 15%, in effect since 1 April 2018 + (previously 14% since 1993). A 2025 budget proposal to raise the rate + further to 15.5% and then 16% was withdrawn before taking effect, and + the rate has remained at 15%. + + Zero-rated supplies (0%, with input VAT still recoverable) include + basic foodstuffs, fuel, exports, international transport, and + services consumed outside South Africa. Exempt supplies (no VAT + charged, no input VAT recovery) include financial services, + residential rental, non-international passenger transport, and + education. The VAT Act treats these very differently, and this + regime preserves the distinction rather than collapsing both into a + single "0%" concept. + + Vendors must register for VAT once taxable supplies exceed the + compulsory threshold (ZAR 2.3 million over 12 months, since 1 April + 2026; ZAR 1 million previously) and may register voluntarily above a + lower threshold (ZAR 120,000, since 1 April 2026; ZAR 50,000 + previously). Businesses below these thresholds may trade without a + VAT number, so one is not always present. + + Businesses are identified for VAT purposes by a 10-digit VAT + registration number that always starts with the digit 4 (e.g. + 4480152117). Unlike most countries covered by GOBL, SARS does not + publish a check digit algorithm for this number, so only its format + can be validated here. Authoritative verification requires a live + lookup against SARS's VAT Vendor Search service, which is outside + the scope of this library. + + Registered companies must additionally display their CIPC company + registration number (format YYYY/NNNNNN/XX) on invoices and other + business correspondence, per section 32(4) of the Companies Act 71 + of 2008. + + Both credit and debit notes are supported for invoice corrections, + per section 21 of the VAT Act. + + As of mid-2026, e-invoicing is not yet mandatory in South Africa. + The Tax Administration Laws Amendment Act (published April 2026) + establishes a legal framework for a phased mandate expected to + complete by 2028, but the technical format has not yet been + finalized, so no e-invoicing addon is implemented here. + `), + }, + Sources: []*cbc.Source{ + { + Title: i18n.NewString("SARS - Value-Added Tax"), + URL: "https://www.sars.gov.za/types-of-tax/value-added-tax/", + }, + { + Title: i18n.NewString("Value-Added Tax Act No. 89 of 1991"), + URL: "https://www.gov.za/sites/default/files/gcis_document/201505/act-89-1991s.pdf", + }, + { + Title: i18n.NewString("SARS - VAT rate increase from 1 April 2018"), + URL: "https://www.sars.gov.za/wp-content/uploads/Docs/VAT/NON-BDE-Rate-change-letter-final-.pdf", + }, + { + Title: i18n.NewString("Zero-rated and exempt supplies (Parliamentary Monitoring Group)"), + URL: "https://static.pmg.org.za/docs/Zero-rated%20and%20exempt%20supplies.pdf", + }, + { + Title: i18n.NewString("SARS - Tax Invoices"), + URL: "https://www.sars.gov.za/businesses-and-employers/government/tax-invoices/", + }, + { + Title: i18n.NewString("Companies Act 71 of 2008, Section 32"), + URL: "https://marxgore.co.za/wp-content/uploads/2020/01/Section-32-Use-of-company-name-and-registration-number.pdf", + }, + }, + TimeZone: "Africa/Johannesburg", + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + bill.InvoiceTypeDebitNote, + }, + }, + }, + Categories: taxCategories, + Identities: identityTypeDefinitions, + Scenarios: []*tax.ScenarioSet{bill.InvoiceScenarios()}, + } +} diff --git a/regimes/za/za_test.go b/regimes/za/za_test.go new file mode 100644 index 000000000..25b807baa --- /dev/null +++ b/regimes/za/za_test.go @@ -0,0 +1,26 @@ +package za_test + +import ( + "testing" + + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/regimes/za" + "github.com/invopop/gobl/rules" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + regime := za.New() + require.NotNil(t, regime) + assert.Equal(t, l10n.ZA, regime.Country.Code()) + assert.Equal(t, "South Africa", regime.Name.String()) + assert.NotNil(t, regime.Categories) + assert.Len(t, regime.Categories, 1) + assert.Equal(t, "VAT", regime.Categories[0].Code.String()) +} + +func TestValidate(t *testing.T) { + regime := za.New() + assert.NoError(t, rules.Validate(regime)) +}