From 6dac3b5f6a8958212270aba8f08699ffdd928ee4 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Tue, 22 Oct 2024 15:37:14 +0000 Subject: [PATCH] Adding TIN algorithms --- regimes/at/at.go | 9 +- regimes/at/identities_test.go | 85 +++++++++++++ regimes/at/identitites.go | 80 ++++++++++++ regimes/fr/fr.go | 12 +- regimes/fr/identities.go | 77 ++++++++++++ regimes/fr/identities_test.go | 90 +++++++++++++ regimes/gb/gb.go | 8 +- regimes/gb/identities.go | 154 +++++++++++++++++++++++ regimes/gb/identities_test.go | 96 ++++++++++++++ regimes/ie/examples/invoice-b2b.yaml | 47 +++++++ regimes/ie/examples/out/invoice-b2b.json | 113 +++++++++++++++++ regimes/ie/ie.go | 61 +++++++++ regimes/ie/tax_categories.go | 94 ++++++++++++++ regimes/ie/tax_identity.go | 37 ++++++ regimes/ie/tax_identity_test.go | 56 +++++++++ regimes/pl/identities.go | 69 ++++++++++ regimes/pl/identities_test.go | 75 +++++++++++ regimes/pl/pl.go | 12 +- regimes/regimes.go | 1 + 19 files changed, 1167 insertions(+), 9 deletions(-) create mode 100644 regimes/at/identities_test.go create mode 100644 regimes/at/identitites.go create mode 100644 regimes/fr/identities.go create mode 100644 regimes/fr/identities_test.go create mode 100644 regimes/gb/identities.go create mode 100644 regimes/gb/identities_test.go create mode 100644 regimes/ie/examples/invoice-b2b.yaml create mode 100644 regimes/ie/examples/out/invoice-b2b.json create mode 100644 regimes/ie/ie.go create mode 100644 regimes/ie/tax_categories.go create mode 100644 regimes/ie/tax_identity.go create mode 100644 regimes/ie/tax_identity_test.go create mode 100644 regimes/pl/identities.go create mode 100644 regimes/pl/identities_test.go diff --git a/regimes/at/at.go b/regimes/at/at.go index d80fe4ac..02b2349b 100644 --- a/regimes/at/at.go +++ b/regimes/at/at.go @@ -6,6 +6,7 @@ import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/currency" "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/common" "github.com/invopop/gobl/tax" ) @@ -31,7 +32,8 @@ func New() *tax.RegimeDef { Tags: []*tax.TagSet{ common.InvoiceTags(), }, - Categories: taxCategories, + Categories: taxCategories, + IdentityKeys: identityKeyDefinitions, // identities.go Corrections: []*tax.CorrectionDefinition{ { Schema: bill.ShortSchemaInvoice, @@ -50,7 +52,10 @@ func Validate(doc any) error { return validateInvoice(obj) case *tax.Identity: return validateTaxIdentity(obj) + case *org.Identity: + return validateTaxNumber(obj) } + return nil } @@ -59,5 +64,7 @@ func Normalize(doc any) { switch obj := doc.(type) { case *tax.Identity: tax.NormalizeIdentity(obj) + case *org.Identity: + normalizeTaxNumber(obj) } } diff --git a/regimes/at/identities_test.go b/regimes/at/identities_test.go new file mode 100644 index 00000000..d11f798a --- /dev/null +++ b/regimes/at/identities_test.go @@ -0,0 +1,85 @@ +package at_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/at" + "github.com/stretchr/testify/assert" +) + +func TestNormalizeAndValidateTaxNumber(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "Valid PESEL with hyphen", + input: "12-3456789", + wantErr: false, + }, + { + name: "Valid PESEL with spaces", + input: "12 345 6789", + wantErr: false, + }, + { + name: "Valid PESEL with mixed symbols", + input: "12.345.6789", + wantErr: false, + }, + { + name: "Invalid length", + input: "12-34567", // Less than 9 digits + wantErr: true, + }, + { + name: "Invalid length with extra digits", + input: "12-34567890", // More than 9 digits + wantErr: true, + }, + { + name: "Invalid tax office code", + input: "00-3456789", // Tax office code should be between 1 and 99 + wantErr: true, + }, + { + name: "Invalid taxpayer number", + input: "12-0000000", // Taxpayer number should be positive + wantErr: true, + }, + { + name: "Empty input", + input: "", + wantErr: false, + }, + { + name: "Nil identity", + input: "12-3456789", // This should not error out because we will check for nil + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + identity := &org.Identity{ + Key: at.IdentityKeyTaxNumber, + Code: cbc.Code(tt.input), + } + + // Normalize the tax number first + at.Normalize(identity) + + // Validate the tax number + err := at.Validate(identity) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/regimes/at/identitites.go b/regimes/at/identitites.go new file mode 100644 index 00000000..f3440966 --- /dev/null +++ b/regimes/at/identitites.go @@ -0,0 +1,80 @@ +package at + +import ( + "errors" + "regexp" + "strconv" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/validation" +) + +const ( + // IdentityKeyTaxNumber represents the Austrian tax number (Steuernummer) issued to + // people that can be included on invoices inside Austria. For international + // sales, the registered VAT number (Umsatzsteueridentifikationsnummer) should + // be used instead. + IdentityKeyTaxNumber cbc.Key = "at-tax-number" +) + +var badCharsRegexPattern = regexp.MustCompile(`[^\d]`) + +var identityKeyDefinitions = []*cbc.KeyDefinition{ + { + Key: IdentityKeyTaxNumber, + Name: i18n.String{ + i18n.EN: "Tax Number", + i18n.DE: "Steuernummer", + }, + }, +} + +func normalizeTaxNumber(id *org.Identity) { + if id == nil || id.Key != IdentityKeyTaxNumber { + return + } + code := id.Code.String() + code = badCharsRegexPattern.ReplaceAllString(code, "") + id.Code = cbc.Code(code) +} + +func validateTaxNumber(id *org.Identity) error { + if id == nil || id.Key != IdentityKeyTaxNumber { + return nil + } + + return validation.ValidateStruct(id, + validation.Field(&id.Code, validation.By(validateTaxIdCode)), + ) +} + +// validateAustrianTaxIdCode validates the normalized tax ID code. +func validateTaxIdCode(value interface{}) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + // Austrian Steuernummer format: must have 9 digits (2 for tax office + 7 for taxpayer ID) + if len(val) != 9 { + return errors.New("length must be 9 digits") + } + + // Split into tax office code and taxpayer number + taxOffice, _ := strconv.Atoi(val[:2]) + taxpayerNumber, _ := strconv.Atoi(val[2:]) + + // Perform basic checks + if taxOffice < 1 || taxOffice > 99 { + return errors.New("invalid tax office code") + } + + if taxpayerNumber <= 0 { + return errors.New("invalid taxpayer number") + } + + return nil +} diff --git a/regimes/fr/fr.go b/regimes/fr/fr.go index f17f12dc..0dddb03a 100644 --- a/regimes/fr/fr.go +++ b/regimes/fr/fr.go @@ -6,6 +6,7 @@ import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/currency" "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/pkg/here" "github.com/invopop/gobl/regimes/common" "github.com/invopop/gobl/tax" @@ -56,9 +57,10 @@ func New() *tax.RegimeDef { }, }, }, - Validator: Validate, - Normalizer: Normalize, - Categories: taxCategories, + Validator: Validate, + Normalizer: Normalize, + Categories: taxCategories, + IdentityKeys: identityKeyDefinitions, // identities.go } } @@ -69,6 +71,8 @@ func Validate(doc interface{}) error { return validateInvoice(obj) case *tax.Identity: return validateTaxIdentity(obj) + case *org.Identity: + return validateTaxNumber(obj) } return nil } @@ -78,5 +82,7 @@ func Normalize(doc any) { switch obj := doc.(type) { case *tax.Identity: normalizeTaxIdentity(obj) + case *org.Identity: + normalizeTaxNumber(obj) } } diff --git a/regimes/fr/identities.go b/regimes/fr/identities.go new file mode 100644 index 00000000..0ec43ebe --- /dev/null +++ b/regimes/fr/identities.go @@ -0,0 +1,77 @@ +package fr + +import ( + "errors" + "regexp" + "strconv" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/validation" +) + +const ( + // IdentityKeyTaxNumber represents the French tax reference number (numéro fiscal de référence). + IdentityKeyTaxNumber cbc.Key = "fr-tax-number" +) + +var badCharsRegexPattern = regexp.MustCompile(`[^\d]`) + +var identityKeyDefinitions = []*cbc.KeyDefinition{ + { + Key: IdentityKeyTaxNumber, + Name: i18n.String{ + i18n.EN: "Tax Number", + i18n.FR: "Numéro fiscal de référence", + }, + }, +} + +// validateTaxNumber validates the French tax reference number. +func validateTaxNumber(id *org.Identity) error { + if id == nil || id.Key != IdentityKeyTaxNumber { + return nil + } + + return validation.ValidateStruct(id, + validation.Field(&id.Code, validation.By(validateTaxIdCode)), + ) +} + +// validateTaxIdCode validates the normalized tax ID code. +func validateTaxIdCode(value interface{}) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + // Check length + if len(val) != 13 { + return errors.New("length must be 13 digits") + } + + // Check that all characters are digits + if _, err := strconv.Atoi(val); err != nil { + return errors.New("must contain only digits") + } + + // Check that the first digit is 0, 1, 2, or 3 + firstDigit := val[0] + if firstDigit < '0' || firstDigit > '3' { + return errors.New("first digit must be 0, 1, 2, or 3") + } + + return nil +} + +// normalizeTaxNumber removes any non-digit characters from the tax number. +func normalizeTaxNumber(id *org.Identity) { + if id == nil || id.Key != IdentityKeyTaxNumber { + return + } + code := id.Code.String() + code = badCharsRegexPattern.ReplaceAllString(code, "") + id.Code = cbc.Code(code) +} diff --git a/regimes/fr/identities_test.go b/regimes/fr/identities_test.go new file mode 100644 index 00000000..6903a03e --- /dev/null +++ b/regimes/fr/identities_test.go @@ -0,0 +1,90 @@ +package fr_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/fr" + "github.com/stretchr/testify/assert" +) + +func TestNormalizeAndValidateTaxNumber(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "Valid tax number with leading 0", + input: "0123456789012", + wantErr: false, + }, + { + name: "Valid tax number with leading 1", + input: "1234567890123", + wantErr: false, + }, + { + name: "Valid tax number with leading 2", + input: "2234567890123", + wantErr: false, + }, + { + name: "Valid tax number with leading 3", + input: "3234567890123", + wantErr: false, + }, + { + name: "Invalid tax number with leading 4", + input: "4234567890123", // First digit not allowed + wantErr: true, + }, + { + name: "Invalid length", + input: "123456789", // Less than 13 digits + wantErr: true, + }, + { + name: "Invalid length with extra digits", + input: "12345678901234", // More than 13 digits + wantErr: true, + }, + { + name: "Invalid characters", + input: "1234A6789012", // Contains a letter + wantErr: true, + }, + { + name: "Empty input", + input: "", + wantErr: false, + }, + { + name: "Nil identity", + input: "0123456789012", // This should not error out because we will check for nil + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + identity := &org.Identity{ + Key: fr.IdentityKeyTaxNumber, + Code: cbc.Code(tt.input), + } + + // Normalize the tax number first + fr.Normalize(identity) + + // Validate the tax number + err := fr.Validate(identity) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/regimes/gb/gb.go b/regimes/gb/gb.go index 8c6ed129..8ce75de7 100644 --- a/regimes/gb/gb.go +++ b/regimes/gb/gb.go @@ -7,6 +7,7 @@ import ( "github.com/invopop/gobl/currency" "github.com/invopop/gobl/i18n" "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/common" "github.com/invopop/gobl/tax" ) @@ -45,7 +46,8 @@ func New() *tax.RegimeDef { Tags: []*tax.TagSet{ common.InvoiceTags(), }, - Categories: taxCategories, + Categories: taxCategories, + IdentityKeys: identityKeyDefinitions, Corrections: []*tax.CorrectionDefinition{ { Schema: bill.ShortSchemaInvoice, @@ -63,6 +65,8 @@ func Validate(doc interface{}) error { switch obj := doc.(type) { case *tax.Identity: return validateTaxIdentity(obj) + case *org.Identity: + return validateTaxNumber(obj) } return nil } @@ -72,5 +76,7 @@ func Normalize(doc interface{}) { switch obj := doc.(type) { case *tax.Identity: tax.NormalizeIdentity(obj, altCountryCodes...) + case *org.Identity: + normalizeTaxNumber(obj) } } diff --git a/regimes/gb/identities.go b/regimes/gb/identities.go new file mode 100644 index 00000000..ec13ce3f --- /dev/null +++ b/regimes/gb/identities.go @@ -0,0 +1,154 @@ +package gb + +import ( + "errors" + "regexp" + "strings" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +const ( + // IdentityUTR represents the UK Unique Taxpayer Reference (UTR). + IdentityUTR cbc.Key = "gb-utr" + // IdentityNINO represents the UK National Insurance Number (NINO). + IdentityNINO cbc.Key = "gb-nino" +) + +var badCharsRegexPattern = regexp.MustCompile(`[^\d]`) +var ninoPattern = `^[A-CEGHJ-PR-TW-Z]{2}\d{6}[A-D]$` +var utrPattern = `^[1-9]\d{9}$` + +var identityKeyDefinitions = []*cbc.KeyDefinition{ + { + Key: IdentityUTR, + Name: i18n.String{ + i18n.EN: "Unique Taxpayer Reference", + }, + }, + { + Key: IdentityNINO, + Name: i18n.String{ + i18n.EN: "National Insurance Number", + }, + }, +} + +func normalizeTaxNumber(id *org.Identity) { + if id == nil || (id.Key != IdentityUTR && id.Key != IdentityNINO) { + return + } + + if id.Key == IdentityUTR { + code := id.Code.String() + code = badCharsRegexPattern.ReplaceAllString(code, "") + id.Code = cbc.Code(code) + } else if id.Key == IdentityNINO { + code := id.Code.String() + code = strings.ToUpper(code) + code = tax.IdentityCodeBadCharsRegexp.ReplaceAllString(code, "") + id.Code = cbc.Code(code) + } +} + +func validateTaxNumber(id *org.Identity) error { + if id == nil { + return nil + } + + if id.Key == IdentityNINO { + return validation.ValidateStruct(id, + validation.Field(&id.Code, validation.By(validateNinoCode)), + ) + } else if id.Key == IdentityUTR { + return validation.ValidateStruct(id, + validation.Field(&id.Code, validation.By(validateUtrCode)), + ) + } + + return nil +} + +// validateUtrCode validates the normalized Unique Taxpayer Reference (UTR). +func validateUtrCode(value interface{}) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + // UK UTR pattern: 10 digits, first digit cannot be 0 + + matched, err := regexp.MatchString(utrPattern, val) + if err != nil { + return err + } + + if !matched { + return errors.New("invalid UTR format") + } + + return nil +} + +// validateNinoCode validates the normalized National Insurance Number (NINO). +func validateNinoCode(value interface{}) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + // UK NINO pattern: Two prefix letters (valid), six digits, one suffix letter (A-D) + + matched, err := regexp.MatchString(ninoPattern, val) + if err != nil { + return err + } + + if !matched { + return errors.New("invalid NINO format") + } + + // Check prefix letters + if !isValidPrefix(val[:2]) { + return errors.New("invalid prefix letters") + } + + return nil +} + +// isValidPrefix checks if the prefix letters are valid according to the specified rules. +func isValidPrefix(prefix string) bool { + // Disallowed prefixes + disallowedPrefixes := []string{"BG", "GB", "NK", "KN", "TN", "NT", "ZZ"} + if contains(disallowedPrefixes, prefix) { + return false + } + + // First letter should not be D, F, I, Q, U, or V + if strings.ContainsAny(string(prefix[0]), "DFIQUV") { + return false + } + + // Second letter should not be D, F, I, Q, U, V or O + if strings.ContainsAny(string(prefix[1]), "DFIQUV") || prefix[1] == 'O' { + return false + } + + return true +} + +// contains checks if a slice contains a specific string. +func contains(slice []string, item string) bool { + for _, v := range slice { + if v == item { + return true + } + } + return false +} diff --git a/regimes/gb/identities_test.go b/regimes/gb/identities_test.go new file mode 100644 index 00000000..6349f170 --- /dev/null +++ b/regimes/gb/identities_test.go @@ -0,0 +1,96 @@ +package gb_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/gb" + "github.com/stretchr/testify/assert" +) + +func TestUKIdentifiers(t *testing.T) { + tests := []struct { + name string + idKey cbc.Key + initialCode string + expectedCode string + expectedError string + }{ + { + name: "Normalize UTR - spaces removed", + idKey: gb.IdentityUTR, + initialCode: " 1234567890 ", + expectedCode: "1234567890", + expectedError: "", + }, + { + name: "Validate valid UTR", + idKey: gb.IdentityUTR, + initialCode: "1234567890", + expectedCode: "1234567890", + expectedError: "", + }, + { + name: "Validate invalid UTR - starts with 0", + idKey: gb.IdentityUTR, + initialCode: "0234567890", + expectedCode: "0234567890", + expectedError: "code: invalid UTR format.", + }, + { + name: "Normalize NINO - to uppercase", + idKey: gb.IdentityNINO, + initialCode: "ab123456c", + expectedCode: "AB123456C", + expectedError: "", + }, + { + name: "Validate valid NINO", + idKey: gb.IdentityNINO, + initialCode: "AB123456C", + expectedCode: "AB123456C", + expectedError: "", + }, + { + name: "Validate invalid NINO - disallowed prefix", + idKey: gb.IdentityNINO, + initialCode: "QQ123456Z", + expectedCode: "QQ123456Z", + expectedError: "code: invalid NINO format.", + }, + { + name: "Validate invalid NINO - incorrect format", + idKey: gb.IdentityNINO, + initialCode: "A123456C", + expectedCode: "A123456C", + expectedError: "code: invalid NINO format.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := &org.Identity{ + Key: tt.idKey, + Code: cbc.Code(tt.initialCode), + } + + // Normalize the identifier + gb.Normalize(id) + + // Check if the normalized code is as expected + assert.Equal(t, tt.expectedCode, id.Code.String()) + + // Validate the identifier + err := gb.Validate(id) + + // Check if the error matches expected + if tt.expectedError != "" { + assert.Error(t, err) + assert.Equal(t, tt.expectedError, err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/regimes/ie/examples/invoice-b2b.yaml b/regimes/ie/examples/invoice-b2b.yaml new file mode 100644 index 00000000..99e71fd9 --- /dev/null +++ b/regimes/ie/examples/invoice-b2b.yaml @@ -0,0 +1,47 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +uuid: "12345678-abcd-12ef-ab12-1234567890ab" +issue_date: "2024-07-31" +series: "SAMPLE" +code: "001" + +supplier: + tax_id: + country: "IE" + code: "1X23456L" + name: "Test Company Ltd." + emails: + - addr: "company@example.com" + addresses: + - num: "12" + street: "Main Street" + locality: "Dublin" + code: "D02 X285" + country: "IE" + +customer: + tax_id: + country: "IE" + code: "8Y87654B" + name: "Random Company Ltd." + emails: + - addr: "random@example.com" + addresses: + - num: "45" + street: "Another Street" + locality: "Cork" + code: "T12 RHK3" + country: "IE" + +lines: + - quantity: 20 + item: + name: "Development services" + price: "90.00" + unit: "h" + discounts: + - percent: "10%" + reason: "Special discount" + taxes: + - cat: VAT + rate: "standard" + percent: "23.0%" diff --git a/regimes/ie/examples/out/invoice-b2b.json b/regimes/ie/examples/out/invoice-b2b.json new file mode 100644 index 00000000..0cb95fee --- /dev/null +++ b/regimes/ie/examples/out/invoice-b2b.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "9b72fe31-3b38-11ee-be56-0242ac120003", + "dig": { + "alg": "sha256", + "val": "b2dd44d6fa187b19c233f03305f3c1a0531a844a4312312842cfc548b9b49572" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "IE", + "uuid": "12345678-abcd-12ef-ab12-1234567890ab", + "type": "standard", + "series": "SAMPLE", + "code": "001", + "issue_date": "2024-07-31", + "currency": "EUR", + "supplier": { + "name": "Test Company Ltd.", + "tax_id": { + "country": "IE", + "code": "1X23456L" + }, + "addresses": [ + { + "num": "12", + "street": "Main Street", + "locality": "Dublin", + "code": "D02 X285", + "country": "IE" + } + ], + "emails": [ + { + "addr": "company@example.com" + } + ] + }, + "customer": { + "name": "Random Company Ltd.", + "tax_id": { + "country": "IE", + "code": "8Y87654B" + }, + "addresses": [ + { + "num": "45", + "street": "Another Street", + "locality": "Cork", + "code": "T12 RHK3", + "country": "IE" + } + ], + "emails": [ + { + "addr": "random@example.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "percent": "10%", + "amount": "180.00", + "reason": "Special discount" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "23.0%" + } + ], + "total": "1620.00" + } + ], + "totals": { + "sum": "1620.00", + "total": "1620.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1620.00", + "percent": "23.0%", + "amount": "372.60" + } + ], + "amount": "372.60" + } + ], + "sum": "372.60" + }, + "tax": "372.60", + "total_with_tax": "1992.60", + "payable": "1992.60" + } + } +} \ No newline at end of file diff --git a/regimes/ie/ie.go b/regimes/ie/ie.go new file mode 100644 index 00000000..cdc42e84 --- /dev/null +++ b/regimes/ie/ie.go @@ -0,0 +1,61 @@ +// Package gb provides the United Kingdom tax regime. +package ie + +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/regimes/common" + "github.com/invopop/gobl/tax" +) + +func init() { + tax.RegisterRegimeDef(New()) +} + +// New provides the tax region definition +func New() *tax.RegimeDef { + return &tax.RegimeDef{ + Country: "IE", + Currency: currency.EUR, + Name: i18n.String{ + i18n.EN: "Ireland", + }, + TimeZone: "Europe/Dublin", + Validator: Validate, + Normalizer: Normalize, + Scenarios: []*tax.ScenarioSet{ + common.InvoiceScenarios(), + }, + Tags: []*tax.TagSet{ + common.InvoiceTags(), + }, + Categories: taxCategories, + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, + }, + }, + }, + } +} + +// Validate checks the document type and determines if it can be validated. +func Validate(doc interface{}) error { + switch obj := doc.(type) { + case *tax.Identity: + return validateTaxIdentity(obj) + } + return nil +} + +// Normalize will attempt to clean the object passed to it. +func Normalize(doc interface{}) { + switch obj := doc.(type) { + case *tax.Identity: + tax.NormalizeIdentity(obj) + } +} diff --git a/regimes/ie/tax_categories.go b/regimes/ie/tax_categories.go new file mode 100644 index 00000000..1a838d13 --- /dev/null +++ b/regimes/ie/tax_categories.go @@ -0,0 +1,94 @@ +package ie + +import ( + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" +) + +var taxCategories = []*tax.CategoryDef{ + // + // VAT + // + { + Code: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "VAT", + }, + Title: i18n.String{ + i18n.EN: "Value Added Tax", + }, + Retained: false, + Rates: []*tax.RateDef{ + { + Key: tax.RateZero, + Name: i18n.String{ + i18n.EN: "Zero Rate", + }, + Values: []*tax.RateValueDef{ + { + Percent: num.MakePercentage(0, 3), + }, + }, + }, + { + Key: tax.RateStandard, + Name: i18n.String{ + i18n.EN: "Standard Rate", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2021, 2, 28), + Percent: num.MakePercentage(23, 2), + }, + { + // Due to Covid + Since: cal.NewDate(2020, 9, 1), + Percent: num.MakePercentage(21, 2), + }, + { + Since: cal.NewDate(2012, 1, 1), + Percent: num.MakePercentage(23, 2), + }, + }, + }, + { + Key: tax.RateReduced, + Name: i18n.String{ + i18n.EN: "Reduced Rate", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2003, 1, 1), + Percent: num.MakePercentage(135, 3), + }, + }, + }, + { + Key: tax.RateSuperReduced, + Name: i18n.String{ + i18n.EN: "Reduced Rate", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2011, 7, 1), + Percent: num.MakePercentage(9, 2), + }, + }, + }, + { + Key: tax.RateSpecial, + Name: i18n.String{ + i18n.EN: "Reduced Rate", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2005, 1, 1), + Percent: num.MakePercentage(48, 3), + }, + }, + }, + }, + }, +} diff --git a/regimes/ie/tax_identity.go b/regimes/ie/tax_identity.go new file mode 100644 index 00000000..ab3c0ded --- /dev/null +++ b/regimes/ie/tax_identity.go @@ -0,0 +1,37 @@ +package ie + +import ( + "errors" + "regexp" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +// Source: https://github.com/ltns35/go-vat + +var ( + taxCodeRegexp = `^(\d{7}[A-Z]{1,2}|\d{1}[A-Z]{1}\d{5}[A-Z]{1})$` +) + +// validateTaxIdentity checks to ensure the NIT code looks okay. +func validateTaxIdentity(tID *tax.Identity) error { + return validation.ValidateStruct(tID, + validation.Field(&tID.Code, validation.By(validateTaxCode)), + ) +} + +func validateTaxCode(value interface{}) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + if !regexp.MustCompile(taxCodeRegexp).MatchString(val) { + return errors.New("invalid format") + } + + return nil +} diff --git a/regimes/ie/tax_identity_test.go b/regimes/ie/tax_identity_test.go new file mode 100644 index 00000000..c1630d65 --- /dev/null +++ b/regimes/ie/tax_identity_test.go @@ -0,0 +1,56 @@ +package ie_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/regimes/ie" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestValidateTaxIdentity(t *testing.T) { + tests := []struct { + name string + code cbc.Code + err string + }{ + {name: "valid ind old-style", code: "1234567T"}, + {name: "valid ind new-style", code: "1234567TW"}, + {name: "valid company", code: "1A23456T"}, + { + name: "too many digits", + code: "123456789", + err: "invalid format", + }, + { + name: "too few digits", + code: "12345T", + err: "invalid format", + }, + { + name: "no digits", + code: "ABCDEFGH", + err: "invalid format", + }, + { + name: "lower case", + code: "1234567t", + err: "invalid format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{Country: "IE", Code: tt.code} + err := ie.Validate(tID) + if tt.err == "" { + assert.NoError(t, err) + } else { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.err) + } + } + }) + } +} diff --git a/regimes/pl/identities.go b/regimes/pl/identities.go new file mode 100644 index 00000000..fbbbce92 --- /dev/null +++ b/regimes/pl/identities.go @@ -0,0 +1,69 @@ +package pl + +import ( + "errors" + "strconv" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/validation" +) + +const ( + // IdentityKeyTaxNumber represents the Polish tax number (PESEL). It is not + // required for invoices, but can be included for identification purposes. + IdentityKeyTaxNumber cbc.Key = "pl-tax-number" +) + +// Reference: https://en.wikipedia.org/wiki/PESEL + +var identityKeyDefinitions = []*cbc.KeyDefinition{ + { + Key: IdentityKeyTaxNumber, + Name: i18n.String{ + i18n.EN: "Tax Number", + i18n.PL: "Numer podatkowy", + }, + }, +} + +func validateTaxNumber(id *org.Identity) error { + if id == nil || id.Key != IdentityKeyTaxNumber { + return nil + } + + return validation.ValidateStruct(id, + validation.Field(&id.Code, validation.By(validateTaxIdCode)), + ) +} + +func validateTaxIdCode(value interface{}) error { + code, ok := value.(cbc.Code) + if !ok || code == "" { + return nil + } + val := code.String() + + if len(val) != 11 { + return errors.New("length must be 11") + } + + multipliers := []int{1, 3, 7, 9} + sum := 0 + + // Loop through the first 10 digits + for i := 0; i < 10; i++ { + digit, _ := strconv.Atoi(string(val[i])) + sum += digit * multipliers[i%4] + } + + modulo := sum % 10 + lastDigit, _ := strconv.Atoi(string(val[10])) + + if (modulo == 0 && lastDigit == 0) || lastDigit == 10-modulo { + return nil + } + + return errors.New("invalid checksum") +} diff --git a/regimes/pl/identities_test.go b/regimes/pl/identities_test.go new file mode 100644 index 00000000..dc29de6b --- /dev/null +++ b/regimes/pl/identities_test.go @@ -0,0 +1,75 @@ +package pl_test + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/pl" + "github.com/stretchr/testify/assert" +) + +func TestValidateTaxNumber(t *testing.T) { + tests := []struct { + name string + identity *org.Identity + wantErr bool + }{ + { + name: "Valid PESEL", + identity: &org.Identity{ + Key: pl.IdentityKeyTaxNumber, + Code: cbc.Code("44051401359"), // Replace with an actual valid PESEL number + }, + wantErr: false, + }, + { + name: "Invalid length PESEL", + identity: &org.Identity{ + Key: pl.IdentityKeyTaxNumber, + Code: cbc.Code("1234567890"), // Invalid PESEL with less than 11 digits + }, + wantErr: true, + }, + { + name: "Invalid checksum PESEL", + identity: &org.Identity{ + Key: pl.IdentityKeyTaxNumber, + Code: cbc.Code("44051401358"), // Incorrect checksum + }, + wantErr: true, + }, + { + name: "Empty PESEL code", + identity: &org.Identity{ + Key: pl.IdentityKeyTaxNumber, + Code: cbc.Code(""), + }, + wantErr: false, + }, + { + name: "Wrong Key Identity", + identity: &org.Identity{ + Key: cbc.Key("wrong-key"), + Code: cbc.Code("44051401359"), + }, + wantErr: false, + }, + { + name: "Nil Identity", + identity: nil, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := pl.Validate(tt.identity) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/regimes/pl/pl.go b/regimes/pl/pl.go index de90b279..a231b994 100644 --- a/regimes/pl/pl.go +++ b/regimes/pl/pl.go @@ -6,6 +6,7 @@ import ( "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/currency" "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" "github.com/invopop/gobl/regimes/common" "github.com/invopop/gobl/tax" ) @@ -43,10 +44,11 @@ func New() *tax.RegimeDef { Tags: []*tax.TagSet{ common.InvoiceTags().Merge(invoiceTags), }, - Scenarios: scenarios, // scenarios.go - Validator: Validate, - Normalizer: Normalize, - Categories: taxCategories, // tax_categories.go + Scenarios: scenarios, // scenarios.go + IdentityKeys: identityKeyDefinitions, // identities.go + Validator: Validate, + Normalizer: Normalize, + Categories: taxCategories, // tax_categories.go Corrections: []*tax.CorrectionDefinition{ { Schema: bill.ShortSchemaInvoice, @@ -72,6 +74,8 @@ func Validate(doc interface{}) error { return validateTaxIdentity(obj) case *bill.Invoice: return validateInvoice(obj) + case *org.Identity: + return validateTaxNumber(obj) // case *pay.Instructions: // return validatePayInstructions(obj) // case *pay.Advance: diff --git a/regimes/regimes.go b/regimes/regimes.go index 8a3b49c4..11a40de9 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -16,6 +16,7 @@ import ( _ "github.com/invopop/gobl/regimes/fr" _ "github.com/invopop/gobl/regimes/gb" _ "github.com/invopop/gobl/regimes/gr" + _ "github.com/invopop/gobl/regimes/ie" _ "github.com/invopop/gobl/regimes/it" _ "github.com/invopop/gobl/regimes/mx" _ "github.com/invopop/gobl/regimes/nl"