From ef28972e0d30403daf23dd102d947962633ed0e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luismi=20Cavall=C3=A9?= Date: Wed, 6 May 2026 12:43:48 +0200 Subject: [PATCH 1/3] Add Bizkaia activity extension to es-tbai-v1 --- CHANGELOG.md | 1 + addons/es/tbai/bill.go | 40 ++++++++++++++++-- addons/es/tbai/bill_test.go | 78 ++++++++++++++++++++++++++++++++---- addons/es/tbai/extensions.go | 47 ++++++++++++++++++++-- data/addons/es-tbai-v1.json | 29 ++++++++++++++ data/rules/es-tbai.json | 35 ++++++++++++++++ 6 files changed, 217 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07da11acb..ce22b9aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `cbc.CodeMap`: added `Lookup` method that returns the code matching a given key, falling back hierarchically to less specific keys. - `pay`: added `MeansKeyCredit` and `MeansKeyDebit` qualifiers, enabling the `card+credit` and `card+debit` payment means. Adapted all addons mapping payment means to extensions to use the two new qualified means. +- `es-tbai-v1`: added `es-tbai-bi-activity` extension for the Bizkaia activity code (epígrafe) required to individual suppliers. ## [v0.402.0] - 2026-04-30 diff --git a/addons/es/tbai/bill.go b/addons/es/tbai/bill.go index fa605959a..fab156be2 100644 --- a/addons/es/tbai/bill.go +++ b/addons/es/tbai/bill.go @@ -49,11 +49,11 @@ func normalizeInvoiceTax(inv *bill.Invoice) { // to use them to set the region code automatically. switch strings.ToLower(addr.Region) { case "alava", "álava", "araba", "vi": - tx.Ext = tx.Ext.Set(ExtKeyRegion, "VI") + tx.Ext = tx.Ext.Set(ExtKeyRegion, ExtValueRegionVI) case "bizkaia", "vizcaya", "bi": - tx.Ext = tx.Ext.Set(ExtKeyRegion, "BI") + tx.Ext = tx.Ext.Set(ExtKeyRegion, ExtValueRegionBI) case "gipuzkoa", "guipuzcoa", "guipúzcoa", "ss": - tx.Ext = tx.Ext.Set(ExtKeyRegion, "SS") + tx.Ext = tx.Ext.Set(ExtKeyRegion, ExtValueRegionSS) default: return } @@ -138,6 +138,26 @@ func billInvoiceRules() *rules.Set { is.Func("has general note", notesHasGeneralKey), ), ), + // Supplier + // Code 10: activity ext required for Bizkaia individuals (Modelo 140 LROE) + // Code 11: activity ext, when present, must be a valid epígrafe code + rules.When( + is.Func("Bizkaia individual", isBizkaiaIndividual), + rules.Field("supplier", + rules.Field("ext", + rules.Assert("10", fmt.Sprintf("extension '%s' is required for Bizkaia individuals", ExtKeyBIActivity), + tax.ExtensionsRequire(ExtKeyBIActivity), + ), + ), + ), + ), + rules.Field("supplier", + rules.Field("ext", + rules.Assert("11", fmt.Sprintf("extension '%s' must be a valid Bizkaia activity code (epígrafe)", ExtKeyBIActivity), + tax.ExtensionHasValidCode(ExtKeyBIActivity), + ), + ), + ), ) } @@ -153,3 +173,17 @@ func notesHasGeneralKey(val any) bool { } return false } + +func isBizkaiaIndividual(val any) bool { + inv, ok := val.(*bill.Invoice) + if !ok || inv == nil { + return false + } + if inv.Tax == nil || inv.Tax.Ext.Get(ExtKeyRegion) != ExtValueRegionBI { + return false + } + if inv.Supplier == nil { + return false + } + return es.TaxIdentityKey(inv.Supplier.TaxID) != es.TaxIdentityOrg +} diff --git a/addons/es/tbai/bill_test.go b/addons/es/tbai/bill_test.go index 24046995c..5b442c836 100644 --- a/addons/es/tbai/bill_test.go +++ b/addons/es/tbai/bill_test.go @@ -38,7 +38,7 @@ func TestInvoiceNormalization(t *testing.T) { Region: "Vizcaya", }) require.NoError(t, inv.Calculate()) - assert.Equal(t, "BI", inv.Tax.Ext.Get(tbai.ExtKeyRegion).String()) + assert.Equal(t, tbai.ExtValueRegionBI, inv.Tax.Ext.Get(tbai.ExtKeyRegion)) }) t.Run("standard invoice in Gipuzkoa", func(t *testing.T) { @@ -48,7 +48,7 @@ func TestInvoiceNormalization(t *testing.T) { Region: "Gipuzkoa", }) require.NoError(t, inv.Calculate()) - assert.Equal(t, "SS", inv.Tax.Ext.Get(tbai.ExtKeyRegion).String()) + assert.Equal(t, tbai.ExtValueRegionSS, inv.Tax.Ext.Get(tbai.ExtKeyRegion)) }) t.Run("standard invoice in Álava (accent)", func(t *testing.T) { @@ -58,7 +58,7 @@ func TestInvoiceNormalization(t *testing.T) { Region: "Álava", }) require.NoError(t, inv.Calculate()) - assert.Equal(t, "VI", inv.Tax.Ext.Get(tbai.ExtKeyRegion).String()) + assert.Equal(t, tbai.ExtValueRegionVI, inv.Tax.Ext.Get(tbai.ExtKeyRegion)) }) t.Run("standard invoice in Araba", func(t *testing.T) { @@ -68,7 +68,7 @@ func TestInvoiceNormalization(t *testing.T) { Region: "Araba", }) require.NoError(t, inv.Calculate()) - assert.Equal(t, "VI", inv.Tax.Ext.Get(tbai.ExtKeyRegion).String()) + assert.Equal(t, tbai.ExtValueRegionVI, inv.Tax.Ext.Get(tbai.ExtKeyRegion)) }) t.Run("standard invoice in Araba", func(t *testing.T) { @@ -88,11 +88,11 @@ func TestInvoiceNormalization(t *testing.T) { }) inv.Tax = &bill.Tax{ Ext: tax.ExtensionsOf(cbc.CodeMap{ - tbai.ExtKeyRegion: "BI", // not Alaba + tbai.ExtKeyRegion: tbai.ExtValueRegionBI, // not Alaba }), } require.NoError(t, inv.Calculate()) - assert.Equal(t, "BI", inv.Tax.Ext.Get(tbai.ExtKeyRegion).String()) + assert.Equal(t, tbai.ExtValueRegionBI, inv.Tax.Ext.Get(tbai.ExtKeyRegion)) }) } @@ -181,6 +181,70 @@ func TestInvoiceValidation(t *testing.T) { assert.Len(t, inv.Preceding, 1) assert.NoError(t, rules.Validate(inv)) }) + + t.Run("BI individual missing activity", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Supplier.TaxID = &tax.Identity{Country: "ES", Code: "12345678Z"} + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "GOBL-ES-TBAI-BILL-INVOICE-10") + assert.ErrorContains(t, err, "es-tbai-bi-activity") + }) + + t.Run("BI individual with valid activity", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Supplier.TaxID = &tax.Identity{Country: "ES", Code: "12345678Z"} + inv.Supplier.Ext = tax.ExtensionsOf(cbc.CodeMap{ + tbai.ExtKeyBIActivity: "722300", + }) + require.NoError(t, inv.Calculate()) + assert.NoError(t, rules.Validate(inv)) + }) + + t.Run("BI persona jurídica without activity", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Supplier.TaxID = &tax.Identity{Country: "ES", Code: "B64847106"} + require.NoError(t, inv.Calculate()) + assert.NoError(t, rules.Validate(inv)) + }) + + t.Run("VI individual without activity", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(tbai.ExtKeyRegion, tbai.ExtValueRegionVI) + inv.Supplier.TaxID = &tax.Identity{Country: "ES", Code: "12345678Z"} + require.NoError(t, inv.Calculate()) + assert.NoError(t, rules.Validate(inv)) + }) + + t.Run("SS individual without activity", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Tax.Ext = inv.Tax.Ext.Set(tbai.ExtKeyRegion, tbai.ExtValueRegionSS) + inv.Supplier.TaxID = &tax.Identity{Country: "ES", Code: "12345678Z"} + require.NoError(t, inv.Calculate()) + assert.NoError(t, rules.Validate(inv)) + }) + + t.Run("BI individual with non-numeric activity", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Supplier.TaxID = &tax.Identity{Country: "ES", Code: "12345678Z"} + inv.Supplier.Ext = tax.ExtensionsOf(cbc.CodeMap{ + tbai.ExtKeyBIActivity: "abc", + }) + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "es-tbai-bi-activity") + }) + + t.Run("BI individual with too-long activity", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Supplier.TaxID = &tax.Identity{Country: "ES", Code: "12345678Z"} + inv.Supplier.Ext = tax.ExtensionsOf(cbc.CodeMap{ + tbai.ExtKeyBIActivity: "12345678", + }) + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "es-tbai-bi-activity") + }) } func TestBillLineNormalization(t *testing.T) { @@ -226,7 +290,7 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { Code: "123", Tax: &bill.Tax{ Ext: tax.ExtensionsOf(cbc.CodeMap{ - tbai.ExtKeyRegion: "BI", + tbai.ExtKeyRegion: tbai.ExtValueRegionBI, }), }, Supplier: &org.Party{ diff --git a/addons/es/tbai/extensions.go b/addons/es/tbai/extensions.go index 811f887b0..2075dc54d 100644 --- a/addons/es/tbai/extensions.go +++ b/addons/es/tbai/extensions.go @@ -12,6 +12,7 @@ const ( ExtKeyExempt cbc.Key = "es-tbai-exemption" ExtKeyProduct cbc.Key = "es-tbai-product" ExtKeyCorrection cbc.Key = "es-tbai-correction" + ExtKeyBIActivity cbc.Key = "es-tbai-bi-activity" ) // Extension values for product key. @@ -21,6 +22,13 @@ const ( ExtValueProductResale cbc.Code = "resale" ) +// Extension values for region key. +const ( + ExtValueRegionVI cbc.Code = "VI" // Araba + ExtValueRegionBI cbc.Code = "BI" // Bizkaia + ExtValueRegionSS cbc.Code = "SS" // Gipuzkoa +) + var extensions = []*cbc.Definition{ { Key: ExtKeyRegion, @@ -37,21 +45,21 @@ var extensions = []*cbc.Definition{ }, Values: []*cbc.Definition{ { - Code: "VI", + Code: ExtValueRegionVI, Name: i18n.String{ i18n.EN: "Araba", i18n.ES: "Álava", }, }, { - Code: "BI", + Code: ExtValueRegionBI, Name: i18n.String{ i18n.EN: "Bizkaia", i18n.ES: "Vizcaya", }, }, { - Code: "SS", + Code: ExtValueRegionSS, Name: i18n.String{ i18n.EN: "Gipuzkoa", i18n.ES: "Guipúzcoa", @@ -274,4 +282,37 @@ var extensions = []*cbc.Definition{ }, }, }, + { + Key: ExtKeyBIActivity, + Name: i18n.String{ + i18n.EN: "Activity Code (Bizkaia)", + i18n.ES: "Código de Actividad (Bizkaia)", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Economic activity code (epígrafe) for individual issuers submitting through + Bizkaia's LROE Modelo 140 register. Not required for organisations, who + file through Modelo 240. + `), + }, + Sources: []*cbc.Source{ + { + Title: i18n.String{ + i18n.EN: "Batuz LROE list of activity codes", + i18n.ES: "Lista de epígrafes LROE Batuz", + }, + URL: "https://www.batuz.eus/fitxategiak/batuz/lroe/batuz_lroe_lista_epigrafes_v1_0_4.xlsx", + ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + { + Title: i18n.String{ + i18n.EN: "LROE Modelo 140 specification", + i18n.ES: "Especificación LROE Modelo 140", + }, + URL: "https://www.batuz.eus/fitxategiak/batuz/lroe/lroe_140_v_1_0.pdf", + ContentType: "application/pdf", + }, + }, + Pattern: `^\d{1,7}$`, + }, } diff --git a/data/addons/es-tbai-v1.json b/data/addons/es-tbai-v1.json index 937d659d8..739f48b6b 100644 --- a/data/addons/es-tbai-v1.json +++ b/data/addons/es-tbai-v1.json @@ -211,6 +211,35 @@ } } ] + }, + { + "key": "es-tbai-bi-activity", + "name": { + "en": "Activity Code (Bizkaia)", + "es": "Código de Actividad (Bizkaia)" + }, + "desc": { + "en": "Economic activity code (epígrafe) for individual issuers submitting through\nBizkaia's LROE Modelo 140 register. Not required for organisations, who\nfile through Modelo 240." + }, + "sources": [ + { + "title": { + "en": "Batuz LROE list of activity codes", + "es": "Lista de epígrafes LROE Batuz" + }, + "url": "https://www.batuz.eus/fitxategiak/batuz/lroe/batuz_lroe_lista_epigrafes_v1_0_4.xlsx", + "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }, + { + "title": { + "en": "LROE Modelo 140 specification", + "es": "Especificación LROE Modelo 140" + }, + "url": "https://www.batuz.eus/fitxategiak/batuz/lroe/lroe_140_v_1_0.pdf", + "content_type": "application/pdf" + } + ], + "pattern": "^\\d{1,7}$" } ], "scenarios": null, diff --git a/data/rules/es-tbai.json b/data/rules/es-tbai.json index 4057a7bd0..f20419a2d 100644 --- a/data/rules/es-tbai.json +++ b/data/rules/es-tbai.json @@ -117,6 +117,41 @@ "tests": "has general note" } ] + }, + { + "guard": "Bizkaia individual", + "subsets": [ + { + "field": "supplier", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-ES-TBAI-BILL-INVOICE-10", + "desc": "extension 'es-tbai-bi-activity' is required for Bizkaia individuals", + "tests": "ext require [es-tbai-bi-activity]" + } + ] + } + ] + } + ] + }, + { + "field": "supplier", + "subsets": [ + { + "field": "ext", + "assert": [ + { + "id": "GOBL-ES-TBAI-BILL-INVOICE-11", + "desc": "extension 'es-tbai-bi-activity' must be a valid Bizkaia activity code (epígrafe)", + "tests": "ext 'es-tbai-bi-activity' matches pattern '^\\d{1,7}$'" + } + ] + } + ] } ] }, From ca974b8879108929cbce7c487fdfd34b743ce5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luismi=20Cavall=C3=A9?= Date: Wed, 6 May 2026 14:16:46 +0200 Subject: [PATCH 2/3] Extend code coverage --- addons/es/tbai/bill.go | 11 +++-------- addons/es/tbai/bill_test.go | 8 ++++++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/addons/es/tbai/bill.go b/addons/es/tbai/bill.go index fab156be2..3ce68d3c9 100644 --- a/addons/es/tbai/bill.go +++ b/addons/es/tbai/bill.go @@ -176,14 +176,9 @@ func notesHasGeneralKey(val any) bool { func isBizkaiaIndividual(val any) bool { inv, ok := val.(*bill.Invoice) - if !ok || inv == nil { + if !ok || inv == nil || inv.Tax == nil || inv.Supplier == nil { return false } - if inv.Tax == nil || inv.Tax.Ext.Get(ExtKeyRegion) != ExtValueRegionBI { - return false - } - if inv.Supplier == nil { - return false - } - return es.TaxIdentityKey(inv.Supplier.TaxID) != es.TaxIdentityOrg + return inv.Tax.Ext.Get(ExtKeyRegion) == ExtValueRegionBI && + es.TaxIdentityKey(inv.Supplier.TaxID) != es.TaxIdentityOrg } diff --git a/addons/es/tbai/bill_test.go b/addons/es/tbai/bill_test.go index 5b442c836..6fe113c1d 100644 --- a/addons/es/tbai/bill_test.go +++ b/addons/es/tbai/bill_test.go @@ -245,6 +245,14 @@ func TestInvoiceValidation(t *testing.T) { err := rules.Validate(inv) assert.ErrorContains(t, err, "es-tbai-bi-activity") }) + + t.Run("No tax", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Tax = nil + require.NoError(t, inv.Calculate()) + err := rules.Validate(inv) + assert.ErrorContains(t, err, "tax is required") + }) } func TestBillLineNormalization(t *testing.T) { From 1158dde3d8e5daf55b07166e37aceb1e856fbcc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luismi=20Cavall=C3=A9?= Date: Fri, 8 May 2026 14:11:58 +0200 Subject: [PATCH 3/3] Fix grammar in CHANGELOG entry Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce22b9aae..d102dc757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `cbc.CodeMap`: added `Lookup` method that returns the code matching a given key, falling back hierarchically to less specific keys. - `pay`: added `MeansKeyCredit` and `MeansKeyDebit` qualifiers, enabling the `card+credit` and `card+debit` payment means. Adapted all addons mapping payment means to extensions to use the two new qualified means. -- `es-tbai-v1`: added `es-tbai-bi-activity` extension for the Bizkaia activity code (epígrafe) required to individual suppliers. +- `es-tbai-v1`: added `es-tbai-bi-activity` extension for the Bizkaia activity code (epígrafe) required for individual suppliers. ## [v0.402.0] - 2026-04-30