diff --git a/CHANGELOG.md b/CHANGELOG.md index e3319455c..27a7351d5 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 + +- `au`: Added the Australian GST regime. + ## [v0.309.0] - 2026-04-01 ### Added diff --git a/data/regimes/au.json b/data/regimes/au.json new file mode 100644 index 000000000..1ba25325a --- /dev/null +++ b/data/regimes/au.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/regime-def", + "name": { + "en": "Australia" + }, + "description": { + "en": "Australia's indirect tax system is administered by the Australian\nTaxation Office (ATO). Goods and Services Tax (GST) applies at a\nstandard rate of 10%. Supplies may also be GST-free (for example, many\nexports) or input-taxed (for example, certain financial supplies and\nresidential rent). In GOBL's generic GST model, GST-free supplies map to\nthe zero key and input-taxed supplies map to the exempt key.\n\nBusinesses are identified by an Australian Business Number (ABN), an\n11-digit identifier used for GST registration and invoicing. Australian\ntax invoices must show the supplier's details and ABN, and invoices of\nAUD 1,000 or more, or self-billed invoices, must also identify the\ncustomer. Electronic invoicing is aligned with the Peppol PINT A-NZ\nspecification." + }, + "sources": [ + { + "title": { + "en": "ATO - GST" + }, + "url": "https://www.ato.gov.au/businesses-and-organisations/gst-excise-and-indirect-taxes/gst" + }, + { + "title": { + "en": "ATO - Tax invoices" + }, + "url": "https://www.ato.gov.au/businesses-and-organisations/gst-excise-and-indirect-taxes/gst/tax-invoices" + }, + { + "title": { + "en": "Peppol PINT A-NZ BIS" + }, + "url": "https://docs.peppol.eu/poac/aunz/pint-aunz/bis/" + }, + { + "title": { + "en": "ATO - eInvoicing" + }, + "url": "https://www.ato.gov.au/businesses-and-organisations/invoicing-and-using-accounting-software/einvoicing" + } + ], + "time_zone": "Australia/Sydney", + "country": "AU", + "currency": "AUD", + "tax_scheme": "GST", + "corrections": [ + { + "schema": "bill/invoice", + "types": [ + "credit-note", + "debit-note" + ] + } + ], + "categories": [ + { + "code": "GST", + "name": { + "en": "GST" + }, + "title": { + "en": "Goods and Services Tax" + }, + "keys": [ + { + "key": "standard", + "name": { + "en": "Standard" + } + }, + { + "key": "zero", + "name": { + "en": "Zero" + } + }, + { + "key": "exempt", + "name": { + "en": "Exempt" + }, + "no_percent": true + }, + { + "key": "outside-scope", + "name": { + "en": "Outside scope" + }, + "no_percent": true + } + ], + "rates": [ + { + "rate": "general", + "keys": [ + "standard" + ], + "name": { + "en": "General rate" + }, + "values": [ + { + "since": "2000-07-01", + "percent": "10%" + } + ] + } + ], + "sources": [ + { + "title": { + "en": "ATO - GST" + }, + "url": "https://www.ato.gov.au/businesses-and-organisations/gst-excise-and-indirect-taxes/gst" + } + ] + } + ] +} \ No newline at end of file diff --git a/data/schemas/tax/regime-code.json b/data/schemas/tax/regime-code.json index c4b6e938a..d0b7abb81 100644 --- a/data/schemas/tax/regime-code.json +++ b/data/schemas/tax/regime-code.json @@ -17,6 +17,10 @@ "const": "AT", "title": "Austria" }, + { + "const": "AU", + "title": "Australia" + }, { "const": "BE", "title": "Belgium" diff --git a/examples/au/invoice-au-au.yaml b/examples/au/invoice-au-au.yaml new file mode 100644 index 000000000..b25f9c3d7 --- /dev/null +++ b/examples/au/invoice-au-au.yaml @@ -0,0 +1,41 @@ +$schema: https://gobl.org/draft-0/bill/invoice +$regime: AU +uuid: 2d301f3b-370a-4e15-a554-a8e05f61e93b +currency: AUD +series: "2026" +code: "AU0001" +issue_date: "2026-04-03" + +supplier: + name: Example Supplier Pty Ltd + tax_id: + country: AU + code: "51824753556" + addresses: + - street: George Street + num: "100" + locality: Sydney + region: NSW + code: "2000" + country: AU + +customer: + name: Example Customer Pty Ltd + addresses: + - street: Collins Street + num: "200" + locality: Melbourne + region: VIC + code: "3000" + country: AU + +lines: + - quantity: "1" + item: + name: Software engineering services + price: "900.00" + unit: h + taxes: + - cat: GST + rate: general + key: standard diff --git a/examples/au/out/invoice-au-au.json b/examples/au/out/invoice-au-au.json new file mode 100644 index 000000000..90fb5f843 --- /dev/null +++ b/examples/au/out/invoice-au-au.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "9b83035fda7d9f82243097be6bfdfc321d431f25d4d355b2a00700ed5c9b6687" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "AU", + "uuid": "2d301f3b-370a-4e15-a554-a8e05f61e93b", + "type": "standard", + "series": "2026", + "code": "AU0001", + "issue_date": "2026-04-03", + "currency": "AUD", + "supplier": { + "name": "Example Supplier Pty Ltd", + "tax_id": { + "country": "AU", + "code": "51824753556" + }, + "addresses": [ + { + "num": "100", + "street": "George Street", + "locality": "Sydney", + "region": "NSW", + "code": "2000", + "country": "AU" + } + ] + }, + "customer": { + "name": "Example Customer Pty Ltd", + "addresses": [ + { + "num": "200", + "street": "Collins Street", + "locality": "Melbourne", + "region": "VIC", + "code": "3000", + "country": "AU" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "1", + "item": { + "name": "Software engineering services", + "price": "900.00", + "unit": "h" + }, + "sum": "900.00", + "taxes": [ + { + "cat": "GST", + "key": "standard", + "rate": "general", + "percent": "10%" + } + ], + "total": "900.00" + } + ], + "totals": { + "sum": "900.00", + "total": "900.00", + "taxes": { + "categories": [ + { + "code": "GST", + "rates": [ + { + "key": "standard", + "base": "900.00", + "percent": "10%", + "amount": "90.00" + } + ], + "amount": "90.00" + } + ], + "sum": "90.00" + }, + "tax": "90.00", + "total_with_tax": "990.00", + "payable": "990.00" + } + } +} diff --git a/regimes/au/README.md b/regimes/au/README.md new file mode 100644 index 000000000..1b411ca32 --- /dev/null +++ b/regimes/au/README.md @@ -0,0 +1,47 @@ +# Australia (`AU`) + +Australia uses a Goods and Services Tax (GST) system administered by the Australian Taxation Office (ATO). GOBL models the Australian regime with a 10% standard GST rate, support for GST-free and input-taxed supplies through the generic GST key model, ABN validation, and invoice validation rules for supplier and customer identification. + +## Public Documentation + +- [ATO - GST](https://www.ato.gov.au/businesses-and-organisations/gst-excise-and-indirect-taxes/gst) +- [ATO - Tax invoices](https://www.ato.gov.au/businesses-and-organisations/gst-excise-and-indirect-taxes/gst/tax-invoices) +- [ABR - ABN format](https://abr.business.gov.au/Help/AbnFormat) +- [Peppol PINT A-NZ BIS](https://docs.peppol.eu/poac/aunz/pint-aunz/bis/) + +## Tax Identity (ABN) + +Australian businesses are commonly identified by an Australian Business Number (ABN). The ABN is 11 digits long, usually written with spaces for display, but normalized in GOBL without separators. + +Validation follows the ABR checksum algorithm: + +| Position | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Weight | 10 | 1 | 3 | 5 | 7 | 9 | 11 | 13 | 15 | 17 | 19 | + +1. Subtract 1 from the first digit. +2. Multiply each digit by its weight. +3. Sum the products. +4. The total must be divisible by 89. + +## GST + +Australian GST distinguishes between taxable supplies, GST-free supplies, and input-taxed supplies. GOBL keeps Australia on the shared GST model and maps those concepts as follows: + +| Australian concept | GOBL key / handling | GST treatment | +| --- | --- | --- | +| Taxable supply | `standard` / `general` | 10% GST | +| GST-free supply | `zero` | 0% GST through the shared GST zero key | +| Input-taxed supply | `exempt` | No GST charged; used as the generic mapping for input-taxed treatment | +| Outside scope / non-taxable | `outside-scope` | Not part of the GST calculation | + +| Rate Name | GOBL Rate Key | Percent | Since | +| --- | --- | --- | --- | +| General rate | `standard` / `general` | 10% | 2000-07-01 | + +## Tax Invoices + +- Suppliers must include their details and ABN. +- Invoices of AUD 1,000 or more must identify the customer. +- Self-billed invoices must identify the customer regardless of amount. +- In this implementation pass, customer identification is satisfied by the customer's name; an AU ABN may also be included but is not required when the name is present. diff --git a/regimes/au/au.go b/regimes/au/au.go new file mode 100644 index 000000000..b6b53bb77 --- /dev/null +++ b/regimes/au/au.go @@ -0,0 +1,95 @@ +// Package au provides models for dealing with Australia. +package au + +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/l10n" + "github.com/invopop/gobl/pkg/here" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterRegimeDef(New()) +} + +// New instantiates a new Australian regime. +func New() *tax.RegimeDef { + return &tax.RegimeDef{ + Country: l10n.AU.Tax(), + Currency: currency.AUD, + TaxScheme: tax.CategoryGST, + Name: i18n.String{ + i18n.EN: "Australia", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + Australia's indirect tax system is administered by the Australian + Taxation Office (ATO). Goods and Services Tax (GST) applies at a + standard rate of 10%. Supplies may also be GST-free (for example, many + exports) or input-taxed (for example, certain financial supplies and + residential rent). In GOBL's generic GST model, GST-free supplies map to + the zero key and input-taxed supplies map to the exempt key. + + Businesses are identified by an Australian Business Number (ABN), an + 11-digit identifier used for GST registration and invoicing. Australian + tax invoices must show the supplier's details and ABN, and invoices of + AUD 1,000 or more, or self-billed invoices, must also identify the + customer. Electronic invoicing is aligned with the Peppol PINT A-NZ + specification. + `), + }, + Sources: []*cbc.Source{ + { + Title: i18n.NewString("ATO - GST"), + URL: "https://www.ato.gov.au/businesses-and-organisations/gst-excise-and-indirect-taxes/gst", + }, + { + Title: i18n.NewString("ATO - Tax invoices"), + URL: "https://www.ato.gov.au/businesses-and-organisations/gst-excise-and-indirect-taxes/gst/tax-invoices", + }, + { + Title: i18n.NewString("Peppol PINT A-NZ BIS"), + URL: "https://docs.peppol.eu/poac/aunz/pint-aunz/bis/", + }, + { + Title: i18n.NewString("ATO - eInvoicing"), + URL: "https://www.ato.gov.au/businesses-and-organisations/invoicing-and-using-accounting-software/einvoicing", + }, + }, + TimeZone: "Australia/Sydney", + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + bill.InvoiceTypeDebitNote, + }, + }, + }, + Validator: Validate, + Normalizer: Normalize, + Categories: taxCategories(), + } +} + +// Validate checks the document type and determines if it can be validated. +func Validate(doc any) error { + switch obj := doc.(type) { + case *bill.Invoice: + return validateBillInvoice(obj) + case *tax.Identity: + return validateTaxIdentity(obj) + } + return nil +} + +// Normalize will attempt to clean the object passed to it. +func Normalize(doc any) { + switch obj := doc.(type) { + case *tax.Identity: + tax.NormalizeIdentity(obj) + } +} diff --git a/regimes/au/au_test.go b/regimes/au/au_test.go new file mode 100644 index 000000000..f12f3755c --- /dev/null +++ b/regimes/au/au_test.go @@ -0,0 +1,65 @@ +package au_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/regimes/au" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + t.Parallel() + + regime := au.New() + + assert.Equal(t, l10n.AU.Tax(), regime.Country) + assert.Equal(t, currency.AUD, regime.Currency) + assert.Equal(t, tax.CategoryGST, regime.TaxScheme) + assert.Equal(t, "Australia", regime.Name.String()) + assert.NotEmpty(t, regime.Categories) + assert.NotNil(t, regime.Validator) + assert.NotNil(t, regime.Normalizer) + assert.Len(t, regime.Corrections, 1) + assert.Equal(t, []string{ + bill.InvoiceTypeCreditNote.String(), + bill.InvoiceTypeDebitNote.String(), + }, []string{ + regime.Corrections[0].Types[0].String(), + regime.Corrections[0].Types[1].String(), + }) + assert.Contains(t, regime.Description.String(), "GST-free") + assert.Contains(t, regime.Description.String(), "input-taxed") + assert.True(t, strings.Contains(regime.Description.String(), "exempt key")) + assert.NotNil(t, regime.CategoryDef(tax.CategoryGST)) + assert.NotNil(t, regime.CategoryDef(tax.CategoryGST).KeyDef(tax.KeyExempt)) +} + +func TestRegimeValidation(t *testing.T) { + t.Parallel() + + regime := au.New() + require.NoError(t, regime.Validate()) +} + +func TestCorrectionOptionsSchema(t *testing.T) { + t.Parallel() + + inv := validInvoice() + require.NoError(t, inv.Calculate()) + + out, err := inv.CorrectionOptionsSchema() + require.NoError(t, err) + + data, err := json.Marshal(out) + require.NoError(t, err) + + assert.Contains(t, string(data), `"const":"credit-note"`) + assert.Contains(t, string(data), `"const":"debit-note"`) +} diff --git a/regimes/au/bill_invoice.go b/regimes/au/bill_invoice.go new file mode 100644 index 000000000..87ef40adc --- /dev/null +++ b/regimes/au/bill_invoice.go @@ -0,0 +1,86 @@ +package au + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +var customerIdentificationThreshold = num.MakeAmount(100000, 2) + +func validateBillInvoice(inv *bill.Invoice) error { + if inv == nil { + return nil + } + return validation.ValidateStruct(inv, + validation.Field(&inv.Supplier, + validation.By(validateBillInvoiceSupplier), + validation.Skip, + ), + validation.Field(&inv.Customer, + validation.When( + requiresCustomerIdentification(inv), + validation.Required, + ).Else( + validation.Skip, + ), + validation.By(validateBillInvoiceCustomer(inv)), + validation.Skip, + ), + ) +} + +func validateBillInvoiceSupplier(value any) error { + party, ok := value.(*org.Party) + if !ok || party == nil { + return nil + } + return validation.ValidateStruct(party, + validation.Field(&party.Name, + validation.Required, + validation.Skip, + ), + validation.Field(&party.TaxID, + validation.Required, + tax.RequireIdentityCode, + validation.By(validateBillInvoiceSupplierTaxID), + validation.Skip, + ), + ) +} + +func validateBillInvoiceSupplierTaxID(value any) error { + tID := value.(*tax.Identity) + return validation.ValidateStruct(tID, + validation.Field(&tID.Country, + validation.In(l10n.AU.Tax()), + validation.Skip, + ), + ) +} + +func validateBillInvoiceCustomer(inv *bill.Invoice) validation.RuleFunc { + return func(value any) error { + party, ok := value.(*org.Party) + if !ok || party == nil || !requiresCustomerIdentification(inv) { + return nil + } + return validation.ValidateStruct(party, + validation.Field(&party.Name, + validation.Required, + validation.Skip, + ), + ) + } +} + +func requiresCustomerIdentification(inv *bill.Invoice) bool { + if inv.HasTags(tax.TagSelfBilled) { + return true + } + return inv.Totals != nil && + inv.Totals.TotalWithTax.Compare(customerIdentificationThreshold) >= 0 +} diff --git a/regimes/au/bill_invoice_test.go b/regimes/au/bill_invoice_test.go new file mode 100644 index 000000000..91d2c13da --- /dev/null +++ b/regimes/au/bill_invoice_test.go @@ -0,0 +1,177 @@ +package au_test + +import ( + "testing" + "time" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/au" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func validInvoice() *bill.Invoice { + return &bill.Invoice{ + Regime: tax.WithRegime("AU"), + Series: "2026", + Code: "AU0001", + IssueDate: cal.MakeDate(2026, time.April, 3), + Currency: currency.AUD, + Supplier: &org.Party{ + Name: "Example Supplier Pty Ltd", + TaxID: &tax.Identity{ + Country: l10n.AU.Tax(), + Code: "51824753556", + }, + Addresses: []*org.Address{ + { + Street: "George Street", + Number: "100", + Locality: "Sydney", + State: "NSW", + Code: "2000", + Country: l10n.AU.ISO(), + }, + }, + }, + Customer: &org.Party{ + Name: "Example Customer Pty Ltd", + Addresses: []*org.Address{ + { + Street: "Collins Street", + Number: "200", + Locality: "Melbourne", + State: "VIC", + Code: "3000", + Country: l10n.AU.ISO(), + }, + }, + }, + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Name: "Software engineering services", + Price: num.NewAmount(90000, 2), + Unit: org.UnitHour, + }, + Taxes: tax.Set{ + { + Category: tax.CategoryGST, + Rate: tax.RateGeneral, + }, + }, + }, + }, + } +} + +func TestInvoiceValidation(t *testing.T) { + t.Parallel() + + t.Run("nil invoice", func(t *testing.T) { + t.Parallel() + var inv *bill.Invoice + require.NoError(t, au.Validate(inv)) + }) + + t.Run("valid invoice under threshold", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + }) + + t.Run("valid invoice at threshold with customer name only", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.Lines[0].Item.Price = num.NewAmount(100000, 2) + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + }) + + t.Run("invoice at threshold without customer", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.Lines[0].Item.Price = num.NewAmount(100000, 2) + inv.Customer = nil + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "customer") + }) + + t.Run("self billed invoice under threshold with customer name", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.SetTags(tax.TagSelfBilled) + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + }) + + t.Run("self billed invoice under threshold without customer", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.SetTags(tax.TagSelfBilled) + inv.Customer = nil + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "customer") + }) + + t.Run("regime validator with nil supplier party", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.Supplier = nil + require.NoError(t, au.Validate(inv)) + }) + + t.Run("nil supplier", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.Supplier = nil + require.NoError(t, inv.Calculate()) + require.Error(t, inv.Validate()) + }) + + t.Run("missing supplier tax ID", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.Supplier.TaxID = nil + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "tax_id") + }) + + t.Run("missing supplier name", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.Supplier.Name = "" + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "name") + }) + + t.Run("supplier tax ID must be australian", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.Supplier.TaxID.Country = "US" + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "country") + }) + + t.Run("supplier tax ID must be valid ABN", func(t *testing.T) { + t.Parallel() + inv := validInvoice() + inv.Supplier.TaxID.Code = "11111111111" + require.NoError(t, inv.Calculate()) + err := inv.Validate() + assert.ErrorContains(t, err, "invalid checksum") + }) +} diff --git a/regimes/au/tax_categories.go b/regimes/au/tax_categories.go new file mode 100644 index 000000000..5d14b2cbf --- /dev/null +++ b/regimes/au/tax_categories.go @@ -0,0 +1,46 @@ +package au + +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" +) + +func taxCategories() []*tax.CategoryDef { + return []*tax.CategoryDef{ + { + Code: tax.CategoryGST, + Name: i18n.String{ + i18n.EN: "GST", + }, + Title: i18n.String{ + i18n.EN: "Goods and Services Tax", + }, + Sources: []*cbc.Source{ + { + Title: i18n.NewString("ATO - GST"), + URL: "https://www.ato.gov.au/businesses-and-organisations/gst-excise-and-indirect-taxes/gst", + }, + }, + Retained: false, + Keys: tax.GlobalGSTKeys(), + Rates: []*tax.RateDef{ + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateGeneral, + Name: i18n.String{ + i18n.EN: "General rate", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2000, 7, 1), + Percent: num.MakePercentage(10, 2), + }, + }, + }, + }, + }, + } +} diff --git a/regimes/au/tax_identity.go b/regimes/au/tax_identity.go new file mode 100644 index 000000000..ab1aa94db --- /dev/null +++ b/regimes/au/tax_identity.go @@ -0,0 +1,57 @@ +package au + +import ( + "errors" + "strconv" + "strings" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +const abnLength = 11 + +var abnWeights = []int{10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19} + +// validateTaxIdentity performs validation specific to Australian tax IDs. +func validateTaxIdentity(tID *tax.Identity) error { + if tID == nil { + return nil + } + return validation.ValidateStruct(tID, + validation.Field(&tID.Code, + validation.By(validateABN), + validation.Skip, + ), + ) +} + +// validateABN checks Australian Business Numbers (ABNs). +// Reference: https://abr.business.gov.au/Help/AbnFormat +func validateABN(value any) error { + code, _ := value.(cbc.Code) + normalized := strings.ReplaceAll(strings.ToUpper(code.String()), " ", "") + if normalized == "" { + return nil + } + if len(normalized) != abnLength { + return errors.New("invalid length") + } + if _, err := strconv.Atoi(normalized); err != nil { + return errors.New("invalid characters, expected numeric") + } + + sum := 0 + for i, r := range normalized { + digit := int(r - '0') + if i == 0 { + digit-- + } + sum += digit * abnWeights[i] + } + if sum%89 != 0 { + return errors.New("invalid checksum") + } + return nil +} diff --git a/regimes/au/tax_identity_test.go b/regimes/au/tax_identity_test.go new file mode 100644 index 000000000..0cf942da1 --- /dev/null +++ b/regimes/au/tax_identity_test.go @@ -0,0 +1,82 @@ +package au_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/regimes/au" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestValidateTaxIdentity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + code cbc.Code + expectedErr string + }{ + {name: "valid ABN from ATO", code: "51824753556"}, + {name: "valid ABN with spaces", code: "51 824 753 556"}, + {name: "second valid ABN", code: "53004085616"}, + {name: "empty ABN", code: ""}, + {name: "too short", code: "1234567890", expectedErr: "invalid length"}, + {name: "too long", code: "123456789012", expectedErr: "invalid length"}, + {name: "invalid checksum", code: "11111111111", expectedErr: "invalid checksum"}, + {name: "contains non-numeric characters", code: "5182475355A", expectedErr: "invalid characters, expected numeric"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tID := &tax.Identity{Country: "AU", Code: tt.code} + + err := au.Validate(tID) + + if tt.expectedErr == "" { + assert.NoError(t, err) + } else if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.expectedErr) + } + }) + } + + t.Run("nil identity", func(t *testing.T) { + var tID *tax.Identity + assert.NoError(t, au.Validate(tID)) + }) +} + +func TestNormalizeTaxIdentity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input cbc.Code + expected cbc.Code + }{ + {name: "spaces stripped", input: "51 824 753 556", expected: "51824753556"}, + {name: "country prefix stripped", input: "AU51824753556", expected: "51824753556"}, + {name: "already normalized", input: "51824753556", expected: "51824753556"}, + {name: "empty code", input: "", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tID := &tax.Identity{Country: "AU", Code: tt.input} + + au.Normalize(tID) + + assert.Equal(t, tt.expected, tID.Code) + }) + } + + t.Run("nil identity", func(t *testing.T) { + assert.NotPanics(t, func() { + var tID *tax.Identity + au.Normalize(tID) + }) + }) +} diff --git a/regimes/regimes.go b/regimes/regimes.go index ff3cb73a8..a9a774813 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -8,6 +8,7 @@ import ( _ "github.com/invopop/gobl/regimes/ae" _ "github.com/invopop/gobl/regimes/ar" _ "github.com/invopop/gobl/regimes/at" + _ "github.com/invopop/gobl/regimes/au" _ "github.com/invopop/gobl/regimes/be" _ "github.com/invopop/gobl/regimes/br" _ "github.com/invopop/gobl/regimes/ca"