Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 for individual suppliers.

## [v0.402.0] - 2026-04-30

Expand Down
35 changes: 32 additions & 3 deletions addons/es/tbai/bill.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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),
),
),
),
)
}

Expand All @@ -153,3 +173,12 @@ func notesHasGeneralKey(val any) bool {
}
return false
}

func isBizkaiaIndividual(val any) bool {
inv, ok := val.(*bill.Invoice)
if !ok || inv == nil || inv.Tax == nil || inv.Supplier == nil {
return false
}
return inv.Tax.Ext.Get(ExtKeyRegion) == ExtValueRegionBI &&
es.TaxIdentityKey(inv.Supplier.TaxID) != es.TaxIdentityOrg
}
86 changes: 79 additions & 7 deletions addons/es/tbai/bill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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))
})
}

Expand Down Expand Up @@ -181,6 +181,78 @@ 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")
})

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) {
Expand Down Expand Up @@ -226,7 +298,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{
Expand Down
47 changes: 44 additions & 3 deletions addons/es/tbai/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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}$`,
},
}
29 changes: 29 additions & 0 deletions data/addons/es-tbai-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions data/rules/es-tbai.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}$'"
}
]
}
]
}
]
},
Expand Down
Loading