From 87955bcbd639eef6af2209ef90f98e249fda7aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20Gonz=C3=A1lez=20Munar?= Date: Tue, 24 Feb 2026 12:17:27 +0100 Subject: [PATCH 1/7] feat(regimes): Add Andorra regime --- data/regimes/ad.json | 229 +++++++++++++++++++++++++++++++ examples/ad/credit-note.yaml | 31 +++++ examples/ad/invoice.yaml | 40 ++++++ examples/ad/out/credit-note.json | 86 ++++++++++++ examples/ad/out/invoice.json | 102 ++++++++++++++ regimes/ad/README.md | 41 ++++++ regimes/ad/ad.go | 64 +++++++++ regimes/ad/ad_test.go | 137 ++++++++++++++++++ regimes/ad/invoices.go | 31 +++++ regimes/ad/tax_categories.go | 152 ++++++++++++++++++++ regimes/ad/tax_identity.go | 31 +++++ regimes/ad/tax_identity_test.go | 77 +++++++++++ regimes/regimes.go | 1 + 13 files changed, 1022 insertions(+) create mode 100644 data/regimes/ad.json create mode 100644 examples/ad/credit-note.yaml create mode 100644 examples/ad/invoice.yaml create mode 100644 examples/ad/out/credit-note.json create mode 100644 examples/ad/out/invoice.json create mode 100644 regimes/ad/README.md create mode 100644 regimes/ad/ad.go create mode 100644 regimes/ad/ad_test.go create mode 100644 regimes/ad/invoices.go create mode 100644 regimes/ad/tax_categories.go create mode 100644 regimes/ad/tax_identity.go create mode 100644 regimes/ad/tax_identity_test.go diff --git a/data/regimes/ad.json b/data/regimes/ad.json new file mode 100644 index 000000000..a1d8de072 --- /dev/null +++ b/data/regimes/ad.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/regime-def", + "name": { + "ca": "Andorra", + "en": "Andorra", + "es": "Andorra" + }, + "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": [ + "standard" + ], + "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..c58bf8e3b --- /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": "b4b00ca3b1ec562b98ab71e048c304b45013419f3f68f73178db8f555942fda9" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "AD", + "uuid": "019c8f4b-5d5e-7be1-914f-d44cbc4dc9af", + "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..100e801f4 --- /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": "ca762e9704ad6dcab7427ff369faa3ac1d8338dc59caf395540ee713b6727ded" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "AD", + "uuid": "019c8f4b-5d5e-7be3-b459-53d33e0c7de8", + "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/README.md b/regimes/ad/README.md new file mode 100644 index 000000000..d5dfe4656 --- /dev/null +++ b/regimes/ad/README.md @@ -0,0 +1,41 @@ +# 🇦🇩 Andorra Tax Regime (AD) + +Implementation of the tax regime for Andorra. The main indirect tax in Andorra is the **Impost General Indirecte (IGI)**, and it is enforced since 1st of January 2013. + +## Tax Rates + +These are the current IGI tax rates: + +| Rate | Key | Percent | Description | +| ---- | --- | ------- | ----------- | +| General | `general` | 4.5% | Standard rate for most goods and services. | +| Reduced | `reduced` | 1.0% | Food, water, books, newspapers. | +| Super-Reduced | `super-reduced` | 0.0% | Health, education, social services. | +| Special | `special` | 2.5% | Transport, libraries, museums. | +| Increased | `increased` | 9.5% | Banking and financial services. | + + +## Date requirements + +- 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. + +## Tax Identity (NRT) + +The **Número de Registre Tributari (NRT)** is the tax identification number in Andorra. + +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. + +## References + +- [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) diff --git a/regimes/ad/ad.go b/regimes/ad/ad.go new file mode 100644 index 000000000..c9c8f0c87 --- /dev/null +++ b/regimes/ad/ad.go @@ -0,0 +1,64 @@ +// 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/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", + }, + 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..5eab87806 --- /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.KeyStandard}, + 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..3a56ce38d --- /dev/null +++ b/regimes/ad/tax_identity.go @@ -0,0 +1,31 @@ +package ad + +import ( + "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(`^[A,E,F,L][0-9]{6}[A-Z]$`) +) + +func validateTaxIdentity(t *tax.Identity) error { + 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..d1cba5e0e --- /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: "1234567A", + 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: "X 123456 A", + 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" From adf405e4084d9bbe5360f9fc0875645661d31757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20Gonz=C3=A1lez=20Munar?= Date: Tue, 24 Feb 2026 12:29:11 +0100 Subject: [PATCH 2/7] fix unit test case --- regimes/ad/tax_identity_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/regimes/ad/tax_identity_test.go b/regimes/ad/tax_identity_test.go index d1cba5e0e..6b5fbeb3e 100644 --- a/regimes/ad/tax_identity_test.go +++ b/regimes/ad/tax_identity_test.go @@ -54,7 +54,7 @@ func TestValidateTaxIdentity(t *testing.T) { }, { name: "invalid first letter", - code: "X 123456 A", + code: "X123456A", err: "code: must be in a valid format", }, } From 05ed915edcf5e7206b4056abf0cf768aeaf2fcdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20Gonz=C3=A1lez=20Munar?= Date: Tue, 24 Feb 2026 12:50:14 +0100 Subject: [PATCH 3/7] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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 From 940d7a76d115f33fe29bc5189510959692d9b739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20Gonz=C3=A1lez=20Munar?= Date: Tue, 24 Feb 2026 16:43:54 +0100 Subject: [PATCH 4/7] use zero tax key --- data/regimes/ad.json | 2 +- regimes/ad/tax_categories.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/regimes/ad.json b/data/regimes/ad.json index a1d8de072..2117177d7 100644 --- a/data/regimes/ad.json +++ b/data/regimes/ad.json @@ -150,7 +150,7 @@ { "rate": "super-reduced", "keys": [ - "standard" + "zero" ], "name": { "ca": "Tipus superreduït", diff --git a/regimes/ad/tax_categories.go b/regimes/ad/tax_categories.go index 5eab87806..f61195ef9 100644 --- a/regimes/ad/tax_categories.go +++ b/regimes/ad/tax_categories.go @@ -87,7 +87,7 @@ func taxCategories() []*tax.CategoryDef { }, }, { - Keys: []cbc.Key{tax.KeyStandard}, + Keys: []cbc.Key{tax.KeyZero}, Rate: tax.RateSuperReduced, Name: i18n.String{ i18n.EN: "Super-Reduced Rate", From c63a909a643e1106a780ac5358027b5efb7885e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20Gonz=C3=A1lez=20Munar?= Date: Tue, 24 Feb 2026 17:05:53 +0100 Subject: [PATCH 5/7] fix invoice test --- bill/invoice_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From d818f1ec5dfe9adf28acb2421344f8af38e917b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20Gonz=C3=A1lez=20Munar?= Date: Thu, 26 Feb 2026 00:00:12 +0100 Subject: [PATCH 6/7] Add description and remove README --- data/regimes/ad.json | 3 +++ regimes/ad/README.md | 41 ----------------------------------------- regimes/ad/ad.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 41 deletions(-) delete mode 100644 regimes/ad/README.md diff --git a/data/regimes/ad.json b/data/regimes/ad.json index 2117177d7..efff47d39 100644 --- a/data/regimes/ad.json +++ b/data/regimes/ad.json @@ -5,6 +5,9 @@ "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", diff --git a/regimes/ad/README.md b/regimes/ad/README.md deleted file mode 100644 index d5dfe4656..000000000 --- a/regimes/ad/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# 🇦🇩 Andorra Tax Regime (AD) - -Implementation of the tax regime for Andorra. The main indirect tax in Andorra is the **Impost General Indirecte (IGI)**, and it is enforced since 1st of January 2013. - -## Tax Rates - -These are the current IGI tax rates: - -| Rate | Key | Percent | Description | -| ---- | --- | ------- | ----------- | -| General | `general` | 4.5% | Standard rate for most goods and services. | -| Reduced | `reduced` | 1.0% | Food, water, books, newspapers. | -| Super-Reduced | `super-reduced` | 0.0% | Health, education, social services. | -| Special | `special` | 2.5% | Transport, libraries, museums. | -| Increased | `increased` | 9.5% | Banking and financial services. | - - -## Date requirements - -- 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. - -## Tax Identity (NRT) - -The **Número de Registre Tributari (NRT)** is the tax identification number in Andorra. - -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. - -## References - -- [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) diff --git a/regimes/ad/ad.go b/regimes/ad/ad.go index c9c8f0c87..ceba72e8c 100644 --- a/regimes/ad/ad.go +++ b/regimes/ad/ad.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/pkg/here" "github.com/invopop/gobl/tax" ) @@ -25,6 +26,37 @@ func New() *tax.RegimeDef { 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. + It has different rates: + - General: 4.5% (general goods and services) + - Reduced: 1.0% (food products, books, newspapers, magazines, medicines, etc.) + - Super-reduced: 0.0% (health, education, social services) + - Special: 2.5% (transportation, cultural goods and services) + - Increased: 9.5% (banking and financial services) + + 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 and (Nota de Càrrec). + + Invoice presentation reqirements 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, From 0acd2b021f4eb0cf175da70b5fedf7e5fce4cf3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20Gonz=C3=A1lez=20Munar?= Date: Sun, 1 Mar 2026 19:58:59 +0100 Subject: [PATCH 7/7] PR suggestions --- examples/ad/out/credit-note.json | 4 ++-- examples/ad/out/invoice.json | 4 ++-- regimes/ad/ad.go | 11 ++--------- regimes/ad/tax_identity.go | 6 +++++- regimes/ad/tax_identity_test.go | 2 +- 5 files changed, 12 insertions(+), 15 deletions(-) diff --git a/examples/ad/out/credit-note.json b/examples/ad/out/credit-note.json index c58bf8e3b..727464b1d 100644 --- a/examples/ad/out/credit-note.json +++ b/examples/ad/out/credit-note.json @@ -4,13 +4,13 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "b4b00ca3b1ec562b98ab71e048c304b45013419f3f68f73178db8f555942fda9" + "val": "804318f01c7eef79b66a7de009d486dba1f10c1794ee128abc1e274619bfcdb8" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", "$regime": "AD", - "uuid": "019c8f4b-5d5e-7be1-914f-d44cbc4dc9af", + "uuid": "019caa49-8f5c-7a79-95f3-a73ca167fe5b", "type": "credit-note", "series": "2024", "code": "CN-0001", diff --git a/examples/ad/out/invoice.json b/examples/ad/out/invoice.json index 100e801f4..ae23e5070 100644 --- a/examples/ad/out/invoice.json +++ b/examples/ad/out/invoice.json @@ -4,13 +4,13 @@ "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", "dig": { "alg": "sha256", - "val": "ca762e9704ad6dcab7427ff369faa3ac1d8338dc59caf395540ee713b6727ded" + "val": "b155629d82bd596b301f795cb0b716fb79cfa4bcd6ef43768617d0c8a8ebf995" } }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", "$regime": "AD", - "uuid": "019c8f4b-5d5e-7be3-b459-53d33e0c7de8", + "uuid": "019caa49-8f5c-7a7b-bad8-71248cb9a1d8", "type": "standard", "series": "2024", "code": "0001", diff --git a/regimes/ad/ad.go b/regimes/ad/ad.go index ceba72e8c..c6a8ec4e0 100644 --- a/regimes/ad/ad.go +++ b/regimes/ad/ad.go @@ -29,13 +29,6 @@ func New() *tax.RegimeDef { 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. - It has different rates: - - General: 4.5% (general goods and services) - - Reduced: 1.0% (food products, books, newspapers, magazines, medicines, etc.) - - Super-reduced: 0.0% (health, education, social services) - - Special: 2.5% (transportation, cultural goods and services) - - Increased: 9.5% (banking and financial services) - 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 @@ -45,9 +38,9 @@ func New() *tax.RegimeDef { - Six digits. - A trailing control letter. - Invoices allow corrections through credit notes (Nota d'Abonament) and debit notes and (Nota de Càrrec). + Invoices allow corrections through credit notes (Nota d'Abonament) and debit notes (Nota de Càrrec). - Invoice presentation reqirements are: + 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. diff --git a/regimes/ad/tax_identity.go b/regimes/ad/tax_identity.go index 3a56ce38d..644fff8cd 100644 --- a/regimes/ad/tax_identity.go +++ b/regimes/ad/tax_identity.go @@ -1,6 +1,7 @@ package ad import ( + "errors" "regexp" "github.com/invopop/gobl/tax" @@ -18,10 +19,13 @@ import ( // Followed by six digits, and ending with a control letter. // Example: L-123456-A (often displayed with hyphens) var ( - nrtRegexp = regexp.MustCompile(`^[A,E,F,L][0-9]{6}[A-Z]$`) + 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, diff --git a/regimes/ad/tax_identity_test.go b/regimes/ad/tax_identity_test.go index 6b5fbeb3e..8f55c2cd4 100644 --- a/regimes/ad/tax_identity_test.go +++ b/regimes/ad/tax_identity_test.go @@ -39,7 +39,7 @@ func TestValidateTaxIdentity(t *testing.T) { }, { name: "invalid - missing leading letter", - code: "1234567A", + code: "123456A", err: "code: must be in a valid format", }, {