diff --git a/CHANGELOG.md b/CHANGELOG.md index a70a390d3..9dc493459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `gr-mydata-v1`: Corrected exemption codes 3 and 4 mapping to `outside-scope` +### Added + +- `ad`: Andorra regime + ## [v0.308.0] - 2026-02-17 ### Removed diff --git a/bill/invoice_test.go b/bill/invoice_test.go index 4f2f68cd9..bc351d09a 100644 --- a/bill/invoice_test.go +++ b/bill/invoice_test.go @@ -1098,8 +1098,8 @@ func TestInvoiceForUnknownRegime(t *testing.T) { inv := baseInvoice(t, lines...) // Set an undefined regime - inv.Supplier.TaxID.Country = l10n.AD.Tax() - assert.Nil(t, tax.RegimeDefFor(l10n.AD), "if Andorra is defined, change this to another country") + inv.Supplier.TaxID.Country = l10n.AO.Tax() + assert.Nil(t, tax.RegimeDefFor(l10n.AO), "if Angola is defined, change this to another country") assert.ErrorContains(t, inv.Calculate(), "currency: missing") inv.Currency = currency.USD diff --git a/data/regimes/ad.json b/data/regimes/ad.json new file mode 100644 index 000000000..efff47d39 --- /dev/null +++ b/data/regimes/ad.json @@ -0,0 +1,232 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/regime-def", + "name": { + "ca": "Andorra", + "en": "Andorra", + "es": "Andorra" + }, + "description": { + "en": "The main indirect tax in Andorra is the 'Impost General Indirecte (IGI)', and it is enforced since 1st of January 2013.\nIt has different rates:\n- General: 4.5% (general goods and services)\n- Reduced: 1.0% (food products, books, newspapers, magazines, medicines, etc.)\n- Super-reduced: 0.0% (health, education, social services)\n- Special: 2.5% (transportation, cultural goods and services)\n- Increased: 9.5% (banking and financial services)\n\nThe NRT (Número de Registre Tributari) is the tax identification number for companies in Andorra. It has the following format: 'X-999999-X'\n- A leading letter (identifying the type of person/entity):\n - F: Individual Residents\n - E: Non-resident Individuals\n - L: Limited Liability Companies (S.L.)\n - A: Joint-stock Corporations (S.A.)\n- Six digits.\n- A trailing control letter.\n\nInvoices allow corrections through credit notes (Nota d'Abonament) and debit notes and (Nota de Càrrec).\n\nInvoice presentation reqirements are:\n- Quarterly: Companies with a turnover of more than €250,000 (April, July, October, January).\n- Semestral: Companies with a turnover of less than €250,000 (July, January).\n- Start of activity: Generally declared semestrally (July and January), unless the special regime applies.\n\nSources:\n- [Departament de Tributs i de Fronteres - Andorra](https://www.impostos.ad)\n- [Andorra NRT number guide](https://lookuptax.com/docs/tax-identification-number/andorra-tax-id-guide)" + }, + "time_zone": "Europe/Andorra", + "country": "AD", + "currency": "EUR", + "tax_scheme": "VAT", + "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", + "debit-note" + ] + } + ], + "categories": [ + { + "code": "VAT", + "name": { + "ca": "IGI", + "en": "VAT", + "es": "IGI" + }, + "title": { + "ca": "Impost General Indirecte", + "en": "General Indirect Tax", + "es": "Impuesto General Indirecto" + }, + "desc": { + "ca": "L'Impost General Indirecte (IGI) és el principal impost indirecte que grava el consum a Andorra.", + "en": "The General Indirect Tax (IGI) is the main indirect tax levied on consumption in Andorra.", + "es": "El Impuesto General Indirecto (IGI) es el principal impuesto indirecto que grava el consumo en Andorra." + }, + "keys": [ + { + "key": "standard", + "name": { + "en": "Standard" + } + }, + { + "key": "zero", + "name": { + "en": "Zero" + } + }, + { + "key": "reverse-charge", + "name": { + "en": "Reverse charge" + }, + "no_percent": true + }, + { + "key": "exempt", + "name": { + "en": "Exempt" + }, + "no_percent": true + }, + { + "key": "export", + "name": { + "en": "Export" + }, + "no_percent": true + }, + { + "key": "intra-community", + "name": { + "en": "Intra-community" + }, + "no_percent": true + }, + { + "key": "outside-scope", + "name": { + "en": "Outside scope" + }, + "no_percent": true + } + ], + "rates": [ + { + "rate": "general", + "keys": [ + "standard" + ], + "name": { + "ca": "Tipus general", + "en": "General Rate", + "es": "Tipo general" + }, + "desc": { + "ca": "Tipus general de l'IGI aplicat a la majoria de béns i serveis (4,5%).", + "en": "General IGI rate applied to most goods and services (4.5%).", + "es": "Tipo general del IGI aplicado a la mayoría de bienes y servicios (4,5%)." + }, + "values": [ + { + "since": "2013-01-01", + "percent": "4.5%" + } + ] + }, + { + "rate": "reduced", + "keys": [ + "standard" + ], + "name": { + "ca": "Tipus reduït", + "en": "Reduced Rate", + "es": "Tipo reducido" + }, + "desc": { + "ca": "Tipus reduït de l'IGI (1%).", + "en": "Reduced IGI rate (1%).", + "es": "Tipo reducido del IGI (1%)." + }, + "values": [ + { + "since": "2013-01-01", + "percent": "1.0%" + } + ] + }, + { + "rate": "super-reduced", + "keys": [ + "zero" + ], + "name": { + "ca": "Tipus superreduït", + "en": "Super-Reduced Rate", + "es": "Tipo superreducido" + }, + "desc": { + "ca": "Tipus superreduït de l'IGI (0%).", + "en": "Super-reduced IGI rate (0%).", + "es": "Tipo superreducido del IGI (0%)." + }, + "values": [ + { + "since": "2013-01-01", + "percent": "0.0%" + } + ] + }, + { + "rate": "special", + "keys": [ + "standard" + ], + "name": { + "ca": "Tipus especial", + "en": "Special Rate", + "es": "Tipo especial" + }, + "desc": { + "ca": "Tipus especial de l'IGI (2,5%).", + "en": "Special IGI rate (2.5%).", + "es": "Tipo especial del IGI (2,5%)." + }, + "values": [ + { + "since": "2013-01-01", + "percent": "2.5%" + } + ] + }, + { + "rate": "increased", + "keys": [ + "standard" + ], + "name": { + "ca": "Tipus incrementat", + "en": "Increased Rate", + "es": "Tipo incrementado" + }, + "desc": { + "ca": "Tipus incrementat de l'IGI (9,5%).", + "en": "Increased IGI rate (9.5%).", + "es": "Tipo incrementado del IGI (9,5%)." + }, + "values": [ + { + "since": "2013-01-01", + "percent": "9.5%" + } + ] + } + ], + "sources": [ + { + "title": { + "ca": "Departament de Tributs i de Fronteres - Andorra", + "en": "Departament de Tributs i de Fronteres - Andorra", + "es": "Departamento de Tributos y Fronteras - Andorra" + }, + "url": "https://www.e-tramits.ad/tramits/ca/impostos/igi" + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/ad/credit-note.yaml b/examples/ad/credit-note.yaml new file mode 100644 index 000000000..2d4c39a4b --- /dev/null +++ b/examples/ad/credit-note.yaml @@ -0,0 +1,31 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +$regime: AD +type: credit-note +series: "2024" +code: "CN-0001" +issue_date: "2024-01-05" +currency: EUR +preceding: + - regime: AD + type: standard + series: "2024" + code: "0001" + issue_date: "2024-01-01" +supplier: + name: "Empresa Andorrana" + tax_id: + country: AD + code: L123456A +customer: + name: "Client Particular" + tax_id: + country: AD + code: F121212B +lines: + - quantity: 1 + item: + name: "Producte Estàndard (Devolució)" + price: 100.00 + taxes: + - cat: VAT + rate: general diff --git a/examples/ad/invoice.yaml b/examples/ad/invoice.yaml new file mode 100644 index 000000000..d048aa232 --- /dev/null +++ b/examples/ad/invoice.yaml @@ -0,0 +1,40 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +$regime: AD +type: standard +series: "2024" +code: "0001" +issue_date: "2024-01-01" +currency: EUR +supplier: + name: "Empresa Andorrana" + tax_id: + country: AD + code: L123456A + address: + street: "Av. Meritxell, 1" + locality: "Andorra la Vella" + country: AD +customer: + name: "Client Particular" + tax_id: + country: AD + code: F121212B + address: + street: "Carrer Major, 10" + locality: "Escaldes-Engordany" + country: AD +lines: + - quantity: 1 + item: + name: "Producte Estàndard" + price: 100.00 + taxes: + - cat: VAT + rate: general + - quantity: 2 + item: + name: "Llibre (Tipus Reduït)" + price: 20.00 + taxes: + - cat: VAT + rate: reduced diff --git a/examples/ad/out/credit-note.json b/examples/ad/out/credit-note.json new file mode 100644 index 000000000..727464b1d --- /dev/null +++ b/examples/ad/out/credit-note.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "804318f01c7eef79b66a7de009d486dba1f10c1794ee128abc1e274619bfcdb8" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "AD", + "uuid": "019caa49-8f5c-7a79-95f3-a73ca167fe5b", + "type": "credit-note", + "series": "2024", + "code": "CN-0001", + "issue_date": "2024-01-05", + "currency": "EUR", + "preceding": [ + { + "type": "standard", + "issue_date": "2024-01-01", + "series": "2024", + "code": "0001" + } + ], + "supplier": { + "name": "Empresa Andorrana", + "tax_id": { + "country": "AD", + "code": "L123456A" + } + }, + "customer": { + "name": "Client Particular", + "tax_id": { + "country": "AD", + "code": "F121212B" + } + }, + "lines": [ + { + "i": 1, + "quantity": "1", + "item": { + "name": "Producte Estàndard (Devolució)", + "price": "100.00" + }, + "sum": "100.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "4.5%" + } + ], + "total": "100.00" + } + ], + "totals": { + "sum": "100.00", + "total": "100.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "100.00", + "percent": "4.5%", + "amount": "4.50" + } + ], + "amount": "4.50" + } + ], + "sum": "4.50" + }, + "tax": "4.50", + "total_with_tax": "104.50", + "payable": "104.50" + } + } +} \ No newline at end of file diff --git a/examples/ad/out/invoice.json b/examples/ad/out/invoice.json new file mode 100644 index 000000000..ae23e5070 --- /dev/null +++ b/examples/ad/out/invoice.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "b155629d82bd596b301f795cb0b716fb79cfa4bcd6ef43768617d0c8a8ebf995" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "AD", + "uuid": "019caa49-8f5c-7a7b-bad8-71248cb9a1d8", + "type": "standard", + "series": "2024", + "code": "0001", + "issue_date": "2024-01-01", + "currency": "EUR", + "supplier": { + "name": "Empresa Andorrana", + "tax_id": { + "country": "AD", + "code": "L123456A" + } + }, + "customer": { + "name": "Client Particular", + "tax_id": { + "country": "AD", + "code": "F121212B" + } + }, + "lines": [ + { + "i": 1, + "quantity": "1", + "item": { + "name": "Producte Estàndard", + "price": "100.00" + }, + "sum": "100.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "4.5%" + } + ], + "total": "100.00" + }, + { + "i": 2, + "quantity": "2", + "item": { + "name": "Llibre (Tipus Reduït)", + "price": "20.00" + }, + "sum": "40.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "reduced", + "percent": "1.0%" + } + ], + "total": "40.00" + } + ], + "totals": { + "sum": "140.00", + "total": "140.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "100.00", + "percent": "4.5%", + "amount": "4.50" + }, + { + "key": "standard", + "base": "40.00", + "percent": "1.0%", + "amount": "0.40" + } + ], + "amount": "4.90" + } + ], + "sum": "4.90" + }, + "tax": "4.90", + "total_with_tax": "144.90", + "payable": "144.90" + } + } +} \ No newline at end of file diff --git a/regimes/ad/ad.go b/regimes/ad/ad.go new file mode 100644 index 000000000..c6a8ec4e0 --- /dev/null +++ b/regimes/ad/ad.go @@ -0,0 +1,89 @@ +// Package ad provides the tax regime data for Andorra. +package ad + +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 provides the tax region definition +func New() *tax.RegimeDef { + return &tax.RegimeDef{ + Country: l10n.AD.Tax(), + Currency: currency.EUR, + TaxScheme: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "Andorra", + i18n.CA: "Andorra", + i18n.ES: "Andorra", + }, + Description: i18n.String{ + i18n.EN: here.Doc(` + The main indirect tax in Andorra is the 'Impost General Indirecte (IGI)', and it is enforced since 1st of January 2013. + The NRT (Número de Registre Tributari) is the tax identification number for companies in Andorra. It has the following format: 'X-999999-X' + - A leading letter (identifying the type of person/entity): + - F: Individual Residents + - E: Non-resident Individuals + - L: Limited Liability Companies (S.L.) + - A: Joint-stock Corporations (S.A.) + - Six digits. + - A trailing control letter. + + Invoices allow corrections through credit notes (Nota d'Abonament) and debit notes (Nota de Càrrec). + + Invoice presentation requirements are: + - Quarterly: Companies with a turnover of more than €250,000 (April, July, October, January). + - Semestral: Companies with a turnover of less than €250,000 (July, January). + - Start of activity: Generally declared semestrally (July and January), unless the special regime applies. + + Sources: + - [Departament de Tributs i de Fronteres - Andorra](https://www.impostos.ad) + - [Andorra NRT number guide](https://lookuptax.com/docs/tax-identification-number/andorra-tax-id-guide) + `), + }, + TimeZone: "Europe/Andorra", + Validator: Validate, + Normalizer: Normalize, + Scenarios: []*tax.ScenarioSet{ + bill.InvoiceScenarios(), + }, + Corrections: []*tax.CorrectionDefinition{ + { + Schema: bill.ShortSchemaInvoice, + Types: []cbc.Key{ + bill.InvoiceTypeCreditNote, // CAT: Nota d'Abonament + bill.InvoiceTypeDebitNote, // CAT: Nota de Càrrec + }, + }, + }, + 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 validateInvoice(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/ad/ad_test.go b/regimes/ad/ad_test.go new file mode 100644 index 000000000..33695bf1f --- /dev/null +++ b/regimes/ad/ad_test.go @@ -0,0 +1,137 @@ +package ad_test + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/ad" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + r := ad.New() + assert.Equal(t, l10n.AD.Tax(), r.Country) + assert.Equal(t, tax.CategoryVAT, r.TaxScheme) +} + +func TestInvoiceValidation(t *testing.T) { + _ = ad.New() // Ensure the package is initialized + inv := &bill.Invoice{ + Regime: tax.WithRegime(l10n.AD.Tax()), + Code: "123", + Supplier: &org.Party{ + Name: "Test Supplier", + TaxID: &tax.Identity{ + Country: l10n.AD.Tax(), + Code: "L123456A", + }, + }, + Customer: &org.Party{ + Name: "Test Customer", + TaxID: &tax.Identity{ + Country: l10n.AD.Tax(), + Code: "F121212B", + }, + }, + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Name: "Test Item", + Price: num.NewAmount(10000, 2), + }, + Taxes: tax.Set{ + { + Category: tax.CategoryVAT, + Rate: tax.RateGeneral, + }, + }, + }, + }, + } + + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) +} + +func TestInvoiceValidationNilSupplier(t *testing.T) { + _ = ad.New() + inv := &bill.Invoice{ + Regime: tax.WithRegime(l10n.AD.Tax()), + } + require.NoError(t, ad.Validate(inv)) +} + +func TestTaxCategories(t *testing.T) { + r := ad.New() + cat := r.Categories[0] + assert.Equal(t, tax.CategoryVAT, cat.Code) + + // Test Standard Rate + rate := cat.RateDef(tax.KeyStandard, tax.RateGeneral) + require.NotNil(t, rate) + val := rate.Value(cal.Today(), nil) + require.NotNil(t, val) + assert.Equal(t, "4.5%", val.Percent.String()) + + // Test Increased Rate + rate = cat.RateDef(tax.KeyStandard, ad.RateIncreased) + require.NotNil(t, rate) + val = rate.Value(cal.Today(), nil) + require.NotNil(t, val) + assert.Equal(t, "9.5%", val.Percent.String()) +} + +func TestValidate(t *testing.T) { + _ = ad.New() + tests := []struct { + name string + doc any + }{ + { + name: "identity", + doc: &tax.Identity{ + Country: l10n.AD.Tax(), + Code: "F123456A", + }, + }, + { + name: "other", + doc: &org.Party{Name: "Test"}, + }, + { + name: "nil", + doc: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ad.Validate(tt.doc) + assert.NoError(t, err) + }) + } +} + +func TestNormalize(t *testing.T) { + _ = ad.New() + t.Run("identity", func(t *testing.T) { + tID := &tax.Identity{ + Country: l10n.AD.Tax(), + Code: " f123456a ", + } + ad.Normalize(tID) + assert.Equal(t, cbc.Code("F123456A"), tID.Code) + }) + t.Run("other", func(t *testing.T) { + p := &org.Party{Name: "Test"} + ad.Normalize(p) + assert.Equal(t, "Test", p.Name) + }) +} diff --git a/regimes/ad/invoices.go b/regimes/ad/invoices.go new file mode 100644 index 000000000..77fa8becd --- /dev/null +++ b/regimes/ad/invoices.go @@ -0,0 +1,31 @@ +package ad + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +func validateInvoice(inv *bill.Invoice) error { + return validation.ValidateStruct(inv, + validation.Field(&inv.Supplier, + validation.By(validateInvoiceSupplier), + validation.Skip, + ), + ) +} + +func validateInvoiceSupplier(value any) error { + p, ok := value.(*org.Party) + if !ok || p == nil { + return nil + } + return validation.ValidateStruct(p, + validation.Field(&p.TaxID, + validation.Required, + tax.RequireIdentityCode, + validation.Skip, + ), + ) +} diff --git a/regimes/ad/tax_categories.go b/regimes/ad/tax_categories.go new file mode 100644 index 000000000..f61195ef9 --- /dev/null +++ b/regimes/ad/tax_categories.go @@ -0,0 +1,152 @@ +package ad + +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" +) + +// RateIncreased is the key for the increased rate in Andorra. +const RateIncreased cbc.Key = "increased" + +func taxCategories() []*tax.CategoryDef { + // + // IGI (VAT) + // + return []*tax.CategoryDef{ + { + Code: tax.CategoryVAT, + Name: i18n.String{ + i18n.EN: "VAT", + i18n.CA: "IGI", + i18n.ES: "IGI", + }, + Title: i18n.String{ + i18n.EN: "General Indirect Tax", + i18n.CA: "Impost General Indirecte", + i18n.ES: "Impuesto General Indirecto", + }, + Description: &i18n.String{ + i18n.EN: "The General Indirect Tax (IGI) is the main indirect tax levied on consumption in Andorra.", + i18n.CA: "L'Impost General Indirecte (IGI) és el principal impost indirecte que grava el consum a Andorra.", + i18n.ES: "El Impuesto General Indirecto (IGI) es el principal impuesto indirecto que grava el consumo en Andorra.", + }, + Sources: []*cbc.Source{ + { + Title: i18n.String{ + i18n.EN: "Departament de Tributs i de Fronteres - Andorra", + i18n.CA: "Departament de Tributs i de Fronteres - Andorra", + i18n.ES: "Departamento de Tributos y Fronteras - Andorra", + }, + URL: "https://www.e-tramits.ad/tramits/ca/impostos/igi", + }, + }, + Retained: false, + Keys: tax.GlobalVATKeys(), + Rates: []*tax.RateDef{ + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateGeneral, + Name: i18n.String{ + i18n.EN: "General Rate", + i18n.CA: "Tipus general", + i18n.ES: "Tipo general", + }, + Description: i18n.String{ + i18n.EN: "General IGI rate applied to most goods and services (4.5%).", + i18n.CA: "Tipus general de l'IGI aplicat a la majoria de béns i serveis (4,5%).", + i18n.ES: "Tipo general del IGI aplicado a la mayoría de bienes y servicios (4,5%).", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2013, 1, 1), + Percent: num.MakePercentage(45, 3), + }, + }, + }, + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateReduced, + Name: i18n.String{ + i18n.EN: "Reduced Rate", + i18n.CA: "Tipus reduït", + i18n.ES: "Tipo reducido", + }, + Description: i18n.String{ + i18n.EN: "Reduced IGI rate (1%).", + i18n.CA: "Tipus reduït de l'IGI (1%).", + i18n.ES: "Tipo reducido del IGI (1%).", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2013, 1, 1), + Percent: num.MakePercentage(10, 3), + }, + }, + }, + { + Keys: []cbc.Key{tax.KeyZero}, + Rate: tax.RateSuperReduced, + Name: i18n.String{ + i18n.EN: "Super-Reduced Rate", + i18n.CA: "Tipus superreduït", + i18n.ES: "Tipo superreducido", + }, + Description: i18n.String{ + i18n.EN: "Super-reduced IGI rate (0%).", + i18n.CA: "Tipus superreduït de l'IGI (0%).", + i18n.ES: "Tipo superreducido del IGI (0%).", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2013, 1, 1), + Percent: num.MakePercentage(0, 3), + }, + }, + }, + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: tax.RateSpecial, + Name: i18n.String{ + i18n.EN: "Special Rate", + i18n.CA: "Tipus especial", + i18n.ES: "Tipo especial", + }, + Description: i18n.String{ + i18n.EN: "Special IGI rate (2.5%).", + i18n.CA: "Tipus especial de l'IGI (2,5%).", + i18n.ES: "Tipo especial del IGI (2,5%).", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2013, 1, 1), + Percent: num.MakePercentage(25, 3), + }, + }, + }, + { + Keys: []cbc.Key{tax.KeyStandard}, + Rate: RateIncreased, + Name: i18n.String{ + i18n.EN: "Increased Rate", + i18n.CA: "Tipus incrementat", + i18n.ES: "Tipo incrementado", + }, + Description: i18n.String{ + i18n.EN: "Increased IGI rate (9.5%).", + i18n.CA: "Tipus incrementat de l'IGI (9,5%).", + i18n.ES: "Tipo incrementado del IGI (9,5%).", + }, + Values: []*tax.RateValueDef{ + { + Since: cal.NewDate(2013, 1, 1), + Percent: num.MakePercentage(95, 3), + }, + }, + }, + }, + }, + } +} diff --git a/regimes/ad/tax_identity.go b/regimes/ad/tax_identity.go new file mode 100644 index 000000000..644fff8cd --- /dev/null +++ b/regimes/ad/tax_identity.go @@ -0,0 +1,35 @@ +package ad + +import ( + "errors" + "regexp" + + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +// NRT (Número de Registre Tributari) format: +// A leading letter that can be: +// +// F: Individual Residents +// E: Non-resident Individuals +// L: Limited Liability Companies (S.L.) +// A: Joint-stock Corporations (S.A.) +// +// Followed by six digits, and ending with a control letter. +// Example: L-123456-A (often displayed with hyphens) +var ( + nrtRegexp = regexp.MustCompile(`^[AEFL][0-9]{6}[A-Z]$`) +) + +func validateTaxIdentity(t *tax.Identity) error { + if t == nil { + return errors.New("tax identity cannot be nil") + } + return validation.ValidateStruct(t, + validation.Field(&t.Code, + validation.Required, + validation.Match(nrtRegexp), + ), + ) +} diff --git a/regimes/ad/tax_identity_test.go b/regimes/ad/tax_identity_test.go new file mode 100644 index 000000000..8f55c2cd4 --- /dev/null +++ b/regimes/ad/tax_identity_test.go @@ -0,0 +1,77 @@ +package ad + +import ( + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestValidateTaxIdentity(t *testing.T) { + tests := []struct { + name string + code string + err string + }{ + { + name: "valid NRT resident individual", + code: "F123456A", + }, + { + name: "valid NRT SL", + code: "L123456B", + }, + { + name: "valid NRT SA", + code: "A123456C", + }, + { + name: "invalid - too short", + code: "L12345A", + err: "code: must be in a valid format", + }, + { + name: "invalid - too long", + code: "L1234567A", + err: "code: must be in a valid format", + }, + { + name: "invalid - missing leading letter", + code: "123456A", + err: "code: must be in a valid format", + }, + { + name: "invalid - missing trailing letter", + code: "L1234567", + err: "code: must be in a valid format", + }, + { + name: "invalid - spaces", + code: "L 123456 A", + err: "code: must be in a valid format", + }, + { + name: "invalid first letter", + code: "X123456A", + err: "code: must be in a valid format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tID := &tax.Identity{ + Country: l10n.AD.Tax(), + Code: cbc.Code(tt.code), + } + err := validateTaxIdentity(tID) + if tt.err == "" { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.err) + } + }) + } +} diff --git a/regimes/regimes.go b/regimes/regimes.go index ff3cb73a8..062e699f5 100644 --- a/regimes/regimes.go +++ b/regimes/regimes.go @@ -5,6 +5,7 @@ package regimes import ( // Import all the regime definitions which will automatically // add themselves to the tax regime register. + _ "github.com/invopop/gobl/regimes/ad" _ "github.com/invopop/gobl/regimes/ae" _ "github.com/invopop/gobl/regimes/ar" _ "github.com/invopop/gobl/regimes/at"