diff --git a/data/regimes/at.json b/data/regimes/at.json index 507d6468..527e0fd4 100644 --- a/data/regimes/at.json +++ b/data/regimes/at.json @@ -64,6 +64,15 @@ ] } ], + "identity_keys": [ + { + "key": "at-tax-number", + "name": { + "de": "Steuernummer", + "en": "Tax Number" + } + } + ], "scenarios": [ { "schema": "bill/invoice", diff --git a/data/regimes/fr.json b/data/regimes/fr.json index 4eb9fa60..9437f651 100644 --- a/data/regimes/fr.json +++ b/data/regimes/fr.json @@ -68,6 +68,15 @@ ] } ], + "identity_types": [ + { + "value": "SPI", + "name": { + "en": "Index Steering System", + "fr": "Système de Pilotage des Indices" + } + } + ], "scenarios": [ { "schema": "bill/invoice", diff --git a/data/regimes/gb.json b/data/regimes/gb.json index 898b5736..731e686d 100644 --- a/data/regimes/gb.json +++ b/data/regimes/gb.json @@ -68,6 +68,20 @@ ] } ], + "identity_types": [ + { + "value": "UTR", + "name": { + "en": "Unique Taxpayer Reference" + } + }, + { + "value": "NINO", + "name": { + "en": "National Insurance Number" + } + } + ], "scenarios": [ { "schema": "bill/invoice", diff --git a/data/regimes/ie.json b/data/regimes/ie.json new file mode 100644 index 00000000..29751f87 --- /dev/null +++ b/data/regimes/ie.json @@ -0,0 +1,172 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/regime-def", + "name": { + "en": "Ireland" + }, + "time_zone": "Europe/Dublin", + "country": "IE", + "currency": "EUR", + "tags": [ + { + "schema": "bill/invoice", + "list": [ + { + "key": "simplified", + "name": { + "de": "Vereinfachte Rechnung", + "en": "Simplified Invoice", + "es": "Factura Simplificada", + "it": "Fattura Semplificata" + }, + "desc": { + "de": "Wird für B2C-Transaktionen verwendet, wenn die Kundendaten nicht verfügbar sind. Bitte wenden Sie sich an die örtlichen Behörden, um die Grenzwerte zu ermitteln.", + "en": "Used for B2C transactions when the client details are not available, check with local authorities for limits.", + "es": "Usado para transacciones B2C cuando los detalles del cliente no están disponibles, consulte con las autoridades locales para los límites.", + "it": "Utilizzato per le transazioni B2C quando i dettagli del cliente non sono disponibili, controllare con le autorità locali per i limiti." + } + }, + { + "key": "reverse-charge", + "name": { + "de": "Umkehr der Steuerschuld", + "en": "Reverse Charge", + "es": "Inversión del Sujeto Pasivo", + "it": "Inversione del soggetto passivo" + } + }, + { + "key": "self-billed", + "name": { + "de": "Rechnung durch den Leistungsempfänger", + "en": "Self-billed", + "es": "Facturación por el destinatario", + "it": "Autofattura" + } + }, + { + "key": "customer-rates", + "name": { + "de": "Kundensätze", + "en": "Customer rates", + "es": "Tarifas aplicables al destinatario", + "it": "Aliquote applicabili al destinatario" + } + }, + { + "key": "partial", + "name": { + "de": "Teilweise", + "en": "Partial", + "es": "Parcial", + "it": "Parziale" + } + } + ] + } + ], + "scenarios": [ + { + "schema": "bill/invoice", + "list": [ + { + "tags": [ + "reverse-charge" + ], + "note": { + "key": "legal", + "src": "reverse-charge", + "text": "Reverse charge: Customer to account for VAT to the relevant tax authority." + } + } + ] + } + ], + "corrections": [ + { + "schema": "bill/invoice", + "types": [ + "credit-note" + ] + } + ], + "categories": [ + { + "code": "VAT", + "name": { + "en": "VAT" + }, + "title": { + "en": "Value Added Tax" + }, + "rates": [ + { + "key": "zero", + "name": { + "en": "Zero Rate" + }, + "values": [ + { + "percent": "0.0%" + } + ] + }, + { + "key": "standard", + "name": { + "en": "Standard Rate" + }, + "values": [ + { + "since": "2021-02-28", + "percent": "23%" + }, + { + "since": "2020-09-01", + "percent": "21%" + }, + { + "since": "2012-01-01", + "percent": "23%" + } + ] + }, + { + "key": "reduced", + "name": { + "en": "Reduced Rate" + }, + "values": [ + { + "since": "2003-01-01", + "percent": "13.5%" + } + ] + }, + { + "key": "super-reduced", + "name": { + "en": "Reduced Rate" + }, + "values": [ + { + "since": "2011-07-01", + "percent": "9%" + } + ] + }, + { + "key": "special", + "name": { + "en": "Reduced Rate" + }, + "values": [ + { + "since": "2005-01-01", + "percent": "4.8%" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/data/regimes/pl.json b/data/regimes/pl.json index 4b9ff349..8bdd8b53 100644 --- a/data/regimes/pl.json +++ b/data/regimes/pl.json @@ -165,6 +165,15 @@ ] } ], + "identity_types": [ + { + "value": "PESEL", + "name": { + "en": "Tax Number", + "pl": "Numer podatkowy" + } + } + ], "payment_means_keys": [ { "key": "cash", diff --git a/data/schemas/bill/invoice.json b/data/schemas/bill/invoice.json index 50122c1e..76d8b62a 100644 --- a/data/schemas/bill/invoice.json +++ b/data/schemas/bill/invoice.json @@ -217,6 +217,10 @@ "const": "GB", "title": "United Kingdom" }, + { + "const": "IE", + "title": "Ireland" + }, { "const": "IT", "title": "Italy" diff --git a/data/schemas/tax/regime-def.json b/data/schemas/tax/regime-def.json index 6c05cf62..cec3a3e9 100644 --- a/data/schemas/tax/regime-def.json +++ b/data/schemas/tax/regime-def.json @@ -278,21 +278,20 @@ "title": "Extensions", "description": "Extensions defines the keys that can be used for extended or extra data inside the regime that\nis specific to the regime and cannot be easily determined from other GOBL structures.\nTypically these are used to define local codes for suppliers, customers, products, or tax rates." }, - "tax_identity_type_keys": { + "identity_keys": { "items": { "$ref": "https://gobl.org/draft-0/cbc/key-definition" }, "type": "array", - "title": "Tax Identity Type Keys", - "description": "Tax Identity types specific for the regime and may be validated\nagainst." + "title": "Identity Keys", + "description": "Identity keys used in addition to regular tax identities and specific for the\nregime that may be validated against." }, - "identity_keys": { + "identity_types": { "items": { - "$ref": "https://gobl.org/draft-0/cbc/key-definition" + "$ref": "https://gobl.org/draft-0/cbc/value-definition" }, "type": "array", - "title": "Identity Keys", - "description": "Identity keys used in addition to regular tax identities and specific for the\nregime that may be validated against." + "description": "Identity Types are used as an alternative to Identity Keys when there is a clear local\ndefinition of a specific code." }, "charge_keys": { "items": { 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..205d78ac --- /dev/null +++ b/regimes/at/identitites.go @@ -0,0 +1,82 @@ +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" +) + +// https://www.oecd.org/content/dam/oecd/en/topics/policy-issue-focus/aeoi/austria-tin.pdf + +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..cfdeed6d 100644 --- a/regimes/fr/fr.go +++ b/regimes/fr/fr.go @@ -6,21 +6,12 @@ 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" ) -// Identification keys used for additional codes not -// covered by the standard fields. -const ( - IdentityTypeSIREN cbc.Code = "SIREN" // SIREN is the main local tax code used in france, we use the normalized VAT version for the tax ID. - IdentityTypeSIRET cbc.Code = "SIRET" // SIRET number combines the SIREN with a branch number. - IdentityTypeRCS cbc.Code = "RCS" // Trade and Companies Register. - IdentityTypeRM cbc.Code = "RM" // Directory of Traders. - IdentityTypeNAF cbc.Code = "NAF" // Identifies the main branch of activity of the company or self-employed person. -) - func init() { tax.RegisterRegimeDef(New()) } @@ -56,9 +47,10 @@ func New() *tax.RegimeDef { }, }, }, - Validator: Validate, - Normalizer: Normalize, - Categories: taxCategories, + Validator: Validate, + Normalizer: Normalize, + Categories: taxCategories, + IdentityTypes: identityTypeDefinitions, // identities.go } } @@ -69,6 +61,8 @@ func Validate(doc interface{}) error { return validateInvoice(obj) case *tax.Identity: return validateTaxIdentity(obj) + case *org.Identity: + return validateIdentity(obj) } return nil } @@ -78,5 +72,7 @@ func Normalize(doc any) { switch obj := doc.(type) { case *tax.Identity: normalizeTaxIdentity(obj) + case *org.Identity: + normalizeIdentity(obj) } } diff --git a/regimes/fr/identities.go b/regimes/fr/identities.go new file mode 100644 index 00000000..03ba1fc4 --- /dev/null +++ b/regimes/fr/identities.go @@ -0,0 +1,73 @@ +package fr + +import ( + "regexp" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/org" + "github.com/invopop/validation" +) + +// Identification keys used for additional codes not +// covered by the standard fields. +const ( + IdentityTypeSIREN cbc.Code = "SIREN" // SIREN is the main local tax code used in france, we use the normalized VAT version for the tax ID. + IdentityTypeSIRET cbc.Code = "SIRET" // SIRET number combines the SIREN with a branch number. + IdentityTypeRCS cbc.Code = "RCS" // Trade and Companies Register. + IdentityTypeRM cbc.Code = "RM" // Directory of Traders. + IdentityTypeNAF cbc.Code = "NAF" // Identifies the main branch of activity of the company or self-employed person. + IdentityTypeSPI cbc.Code = "SPI" // Système de Pilotage des Indices + IdentityTypeNIF cbc.Code = "NIF" // Numéro d'identification fiscale (people) +) + +var ( + identityTypeSPIPattern = regexp.MustCompile(`^[0-3]\d{12}$`) +) + +var badCharsRegexPattern = regexp.MustCompile(`[^\d]`) + +var identityTypeDefinitions = []*cbc.ValueDefinition{ + { + // https://www.oecd.org/content/dam/oecd/en/topics/policy-issue-focus/aeoi/france-tin.pdf + Value: IdentityTypeSPI.String(), + Name: i18n.String{ + i18n.EN: "Index Steering System", + i18n.FR: "Système de Pilotage des Indices", + }, + }, +} + +func normalizeIdentity(id *org.Identity) { + if id == nil { + return + } + switch id.Type { + case IdentityTypeSPI: + code := id.Code.String() + code = badCharsRegexPattern.ReplaceAllString(code, "") + id.Code = cbc.Code(code) + } +} + +// validateIdentity performs basic validation checks on identities provided. +func validateIdentity(id *org.Identity) error { + return validation.ValidateStruct(id, + validation.Field(&id.Code, + validation.By(identityValidator(id.Type)), + validation.Skip, + ), + ) +} + +func identityValidator(typ cbc.Code) validation.RuleFunc { + return func(value interface{}) error { + switch typ { + case IdentityTypeSPI: + return validation.Validate(value, validation.Match(identityTypeSPIPattern)) + //TODO: Add the other types + default: + return nil + } + } +} diff --git a/regimes/fr/identities_test.go b/regimes/fr/identities_test.go new file mode 100644 index 00000000..60022b74 --- /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{ + Type: fr.IdentityTypeSPI, + 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..ea1d3bc7 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, + IdentityTypes: identityTypeDefinitions, 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 validateIdentity(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: + normalizeIdentity(obj) } } diff --git a/regimes/gb/identities.go b/regimes/gb/identities.go new file mode 100644 index 00000000..24b678ae --- /dev/null +++ b/regimes/gb/identities.go @@ -0,0 +1,157 @@ +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 ( + // IdentityTypeUTR represents the UK Unique Taxpayer Reference (UTR). + IdentityTypeUTR cbc.Code = "UTR" + // IdentityTypeNINO represents the UK National Insurance Number (NINO). + IdentityTypeNINO cbc.Code = "NINO" +) + +var badCharsRegexPattern = regexp.MustCompile(`[^\d]`) +var ninoPattern = `^[A-CEGHJ-PR-TW-Z]{2}\d{6}[A-D]$` +var utrPattern = `^[1-9]\d{9}$` + +// https://design.tax.service.gov.uk/hmrc-design-patterns/unique-taxpayer-reference/ +// https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim39110 + +var identityTypeDefinitions = []*cbc.ValueDefinition{ + { + Value: IdentityTypeUTR.String(), + Name: i18n.String{ + i18n.EN: "Unique Taxpayer Reference", + }, + }, + { + Value: IdentityTypeNINO.String(), + Name: i18n.String{ + i18n.EN: "National Insurance Number", + }, + }, +} + +func normalizeIdentity(id *org.Identity) { + if id == nil || (id.Type != IdentityTypeUTR && id.Type != IdentityTypeNINO) { + return + } + + if id.Type == IdentityTypeUTR { + code := id.Code.String() + code = badCharsRegexPattern.ReplaceAllString(code, "") + id.Code = cbc.Code(code) + } else if id.Type == IdentityTypeNINO { + code := id.Code.String() + code = strings.ToUpper(code) + code = tax.IdentityCodeBadCharsRegexp.ReplaceAllString(code, "") + id.Code = cbc.Code(code) + } +} + +func validateIdentity(id *org.Identity) error { + if id == nil { + return nil + } + + if id.Type == IdentityTypeNINO { + return validation.ValidateStruct(id, + validation.Field(&id.Code, validation.By(validateNino)), + ) + } else if id.Type == IdentityTypeUTR { + return validation.ValidateStruct(id, + validation.Field(&id.Code, validation.By(validateUtr)), + ) + } + + return nil +} + +// validateUtr validates the normalized Unique Taxpayer Reference (UTR). +func validateUtr(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 +} + +// validateNino validates the normalized National Insurance Number (NINO). +func validateNino(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..4634f9a1 --- /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 + idCode cbc.Code + initialCode string + expectedCode string + expectedError string + }{ + { + name: "Normalize UTR - spaces removed", + idCode: gb.IdentityTypeUTR, + initialCode: " 1234567890 ", + expectedCode: "1234567890", + expectedError: "", + }, + { + name: "Validate valid UTR", + idCode: gb.IdentityTypeUTR, + initialCode: "1234567890", + expectedCode: "1234567890", + expectedError: "", + }, + { + name: "Validate invalid UTR - starts with 0", + idCode: gb.IdentityTypeUTR, + initialCode: "0234567890", + expectedCode: "0234567890", + expectedError: "code: invalid UTR format.", + }, + { + name: "Normalize NINO - to uppercase", + idCode: gb.IdentityTypeNINO, + initialCode: "ab123456c", + expectedCode: "AB123456C", + expectedError: "", + }, + { + name: "Validate valid NINO", + idCode: gb.IdentityTypeNINO, + initialCode: "AB123456C", + expectedCode: "AB123456C", + expectedError: "", + }, + { + name: "Validate invalid NINO - disallowed prefix", + idCode: gb.IdentityTypeNINO, + initialCode: "QQ123456Z", + expectedCode: "QQ123456Z", + expectedError: "code: invalid NINO format.", + }, + { + name: "Validate invalid NINO - incorrect format", + idCode: gb.IdentityTypeNINO, + initialCode: "A123456C", + expectedCode: "A123456C", + expectedError: "code: invalid NINO format.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := &org.Identity{ + Type: tt.idCode, + 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/ie.go b/regimes/ie/ie.go new file mode 100644 index 00000000..db63f50b --- /dev/null +++ b/regimes/ie/ie.go @@ -0,0 +1,61 @@ +// Package ie 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..5838bef3 --- /dev/null +++ b/regimes/ie/tax_identity.go @@ -0,0 +1,39 @@ +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})$` +) + +// https://euipo.europa.eu/tunnel-web/secure/webdav/guest/document_library/Documents/COSME/VAT%20numbers%20EU.pdf + +// 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..f66b08c8 --- /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 ( + // IdentityTypePESEL represents the Polish tax number (PESEL). It is not + // required for invoices, but can be included for identification purposes. + IdentityTypePESEL cbc.Code = "PESEL" +) + +// Reference: https://en.wikipedia.org/wiki/PESEL + +var identityTypeDefinitions = []*cbc.ValueDefinition{ + { + Value: IdentityTypePESEL.String(), + Name: i18n.String{ + i18n.EN: "Tax Number", + i18n.PL: "Numer podatkowy", + }, + }, +} + +func validateTaxNumber(id *org.Identity) error { + if id == nil || id.Type != IdentityTypePESEL { + 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..33afe9da --- /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{ + Type: pl.IdentityTypePESEL, + Code: cbc.Code("44051401359"), // Replace with an actual valid PESEL number + }, + wantErr: false, + }, + { + name: "Invalid length PESEL", + identity: &org.Identity{ + Type: pl.IdentityTypePESEL, + Code: cbc.Code("1234567890"), // Invalid PESEL with less than 11 digits + }, + wantErr: true, + }, + { + name: "Invalid checksum PESEL", + identity: &org.Identity{ + Type: pl.IdentityTypePESEL, + Code: cbc.Code("44051401358"), // Incorrect checksum + }, + wantErr: true, + }, + { + name: "Empty PESEL code", + identity: &org.Identity{ + Type: pl.IdentityTypePESEL, + Code: cbc.Code(""), + }, + wantErr: false, + }, + { + name: "Wrong Code Identity", + identity: &org.Identity{ + Type: cbc.Code("NINO"), + 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..4504f68b 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 + IdentityTypes: identityTypeDefinitions, // 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" diff --git a/tax/regime_def.go b/tax/regime_def.go index 90273dbe..1e95df29 100644 --- a/tax/regime_def.go +++ b/tax/regime_def.go @@ -61,14 +61,14 @@ type RegimeDef struct { // Typically these are used to define local codes for suppliers, customers, products, or tax rates. Extensions []*cbc.KeyDefinition `json:"extensions,omitempty" jsonschema:"title=Extensions"` - // Tax Identity types specific for the regime and may be validated - // against. - TaxIdentityTypeKeys []*cbc.KeyDefinition `json:"tax_identity_type_keys,omitempty" jsonschema:"title=Tax Identity Type Keys"` - // Identity keys used in addition to regular tax identities and specific for the // regime that may be validated against. IdentityKeys []*cbc.KeyDefinition `json:"identity_keys,omitempty" jsonschema:"title=Identity Keys"` + // Identity Types are used as an alternative to Identity Keys when there is a clear local + // definition of a specific code. + IdentityTypes []*cbc.ValueDefinition `json:"identity_types,omitempty" jsonschema:"title=Identity Types"` + // Charge keys specific for the regime and may be validated or used in the UI as suggestions ChargeKeys []*cbc.KeyDefinition `json:"charge_keys,omitempty" jsonschema:"title=Charge Keys"` @@ -282,8 +282,8 @@ func (r *RegimeDef) ValidateWithContext(ctx context.Context) error { validation.Field(&r.Zone), validation.Field(&r.Currency), validation.Field(&r.Tags), - validation.Field(&r.TaxIdentityTypeKeys), validation.Field(&r.IdentityKeys), + validation.Field(&r.IdentityTypes), validation.Field(&r.Extensions), validation.Field(&r.ChargeKeys), validation.Field(&r.PaymentMeansKeys),