From 6aa13a0578c8c19afd8baada1cbf1cc1b95dc160 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Wed, 4 Dec 2024 17:25:20 +0000 Subject: [PATCH] Refactoring handling of Verifactu extensions and scenarios, plus other simplifications --- addons/es/verifactu/bill.go | 163 ++++++++++++++++++ .../{invoice_test.go => bill_test.go} | 40 +++-- addons/es/verifactu/extensions.go | 64 ++++++- addons/es/verifactu/invoice.go | 128 -------------- addons/es/verifactu/scenarios.go | 48 ------ addons/es/verifactu/scenarios_test.go | 29 ++-- addons/es/verifactu/tax.go | 55 ++++++ addons/es/verifactu/tax_test.go | 49 ++++++ addons/es/verifactu/verifactu.go | 20 ++- bill/invoice.go | 1 + bill/invoice_correct.go | 7 +- bill/invoice_correct_test.go | 2 +- bill/invoice_scenarios.go | 22 +-- bill/tax.go | 13 ++ bill/tax_test.go | 40 +++++ cbc/key.go | 14 ++ cbc/key_test.go | 7 + examples/es/invoice-es-es-verifactu.yaml | 53 ++++++ examples/es/out/invoice-es-es-verifactu.json | 149 ++++++++++++++++ tax/constants.go | 4 +- tax/corrections.go | 3 +- tax/extensions.go | 42 +++++ tax/extensions_test.go | 66 +++++++ 23 files changed, 769 insertions(+), 250 deletions(-) create mode 100644 addons/es/verifactu/bill.go rename addons/es/verifactu/{invoice_test.go => bill_test.go} (86%) delete mode 100644 addons/es/verifactu/invoice.go create mode 100644 addons/es/verifactu/tax.go create mode 100644 addons/es/verifactu/tax_test.go create mode 100644 examples/es/invoice-es-es-verifactu.yaml create mode 100644 examples/es/out/invoice-es-es-verifactu.json diff --git a/addons/es/verifactu/bill.go b/addons/es/verifactu/bill.go new file mode 100644 index 00000000..c05608d3 --- /dev/null +++ b/addons/es/verifactu/bill.go @@ -0,0 +1,163 @@ +package verifactu + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/regimes/es" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +var invoiceCorrectionDefinitions = tax.CorrectionSet{ + { + Schema: bill.ShortSchemaInvoice, + Extensions: []cbc.Key{ + ExtKeyDocType, + }, + }, +} + +func normalizeInvoice(inv *bill.Invoice) { + // Try to move any preceding choices to the document level + for _, row := range inv.Preceding { + if len(row.Ext) == 0 { + continue + } + found := false + if row.Ext.Has(ExtKeyDocType) { + if inv.Tax == nil || !found { + inv.Tax = inv.Tax.MergeExtensions(tax.Extensions{ + ExtKeyDocType: row.Ext[ExtKeyDocType], + }) + found = true // only assign first one + } + delete(row.Ext, ExtKeyDocType) + } + } + + // Try to normalize the correction type, which is especially complex for + // Verifactu implying that scenarios cannot be used. + switch inv.Type { + case bill.InvoiceTypeCreditNote, bill.InvoiceTypeDebitNote: + inv.Tax = inv.Tax.MergeExtensions(tax.Extensions{ + ExtKeyCorrectionType: "I", + }) + case bill.InvoiceTypeCorrective: + if inv.Tax == nil || inv.Tax.Ext.Get(ExtKeyDocType) != "F3" { + inv.Tax = inv.Tax.MergeExtensions(tax.Extensions{ + ExtKeyCorrectionType: "S", + }) + } else { + // Substitutions of simplified invoices cannot have a correction type + delete(inv.Tax.Ext, ExtKeyCorrectionType) + } + } +} + +func validateInvoice(inv *bill.Invoice) error { + return validation.ValidateStruct(inv, + validation.Field(&inv.Customer, + validation.By(validateInvoiceCustomer), + validation.Skip, + ), + validation.Field(&inv.Preceding, + validation.When( + inv.Type.In(es.InvoiceCorrectionTypes...), + validation.Required, + ), + validation.Each( + validation.By(validateInvoicePreceding), + ), + validation.Skip, + ), + validation.Field(&inv.Tax, + validation.Required, + validation.By(validateInvoiceTax(inv.Type)), + validation.Skip, + ), + validation.Field(&inv.Notes, + cbc.ValidateNotesHasKey(cbc.NoteKeyGeneral), + validation.Skip, + ), + ) +} + +var docTypesStandard = []tax.ExtValue{ // Standard invoices + "F1", "F2", +} +var docTypesCreditDebit = []tax.ExtValue{ // Credit or Debit notes + "R1", "R2", "R3", "R4", "R5", +} +var docTypesCorrective = []tax.ExtValue{ // Substitutions + "F3", "R1", "R2", "R3", "R4", "R5", +} + +func validateInvoiceTax(it cbc.Key) validation.RuleFunc { + return func(val any) error { + obj := val.(*bill.Tax) + return validation.ValidateStruct(obj, + validation.Field(&obj.Ext, + tax.ExtensionsRequires(ExtKeyDocType), + validation.When( + it.In(bill.InvoiceTypeStandard), + tax.ExtensionsHasValues( + ExtKeyDocType, + docTypesStandard..., + ), + ), + validation.When( + it.In(bill.InvoiceTypeCreditNote, bill.InvoiceTypeDebitNote), + tax.ExtensionsHasValues( + ExtKeyDocType, + docTypesCreditDebit..., + ), + ), + validation.When( + it.In(bill.InvoiceTypeCorrective), + tax.ExtensionsHasValues( + ExtKeyDocType, + docTypesCorrective..., + ), + ), + validation.When( + obj.Ext.Get(ExtKeyDocType).In(docTypesCreditDebit...), + tax.ExtensionsRequires(ExtKeyCorrectionType), + ), + validation.Skip, + ), + ) + } +} + +func validateInvoiceCustomer(val any) error { + obj, ok := val.(*org.Party) + if !ok || obj == nil { + return nil + } + // Customers must have a tax ID to at least set the country, + // and Spanish ones should also have an ID. There are more complex + // rules for exports. + return validation.ValidateStruct(obj, + validation.Field(&obj.TaxID, + validation.Required, + validation.When( + obj.TaxID != nil && obj.TaxID.Country.In("ES"), + tax.RequireIdentityCode, + ), + validation.Skip, + ), + ) +} + +func validateInvoicePreceding(val any) error { + p, ok := val.(*org.DocumentRef) + if !ok || p == nil { + return nil + } + return validation.ValidateStruct(p, + validation.Field(&p.IssueDate, + validation.Required, + ), + ) +} diff --git a/addons/es/verifactu/invoice_test.go b/addons/es/verifactu/bill_test.go similarity index 86% rename from addons/es/verifactu/invoice_test.go rename to addons/es/verifactu/bill_test.go index fffc2365..a6c3ec57 100644 --- a/addons/es/verifactu/invoice_test.go +++ b/addons/es/verifactu/bill_test.go @@ -1,6 +1,7 @@ package verifactu_test import ( + "encoding/json" "testing" "github.com/invopop/gobl/addons/es/verifactu" @@ -29,8 +30,10 @@ func TestInvoiceValidation(t *testing.T) { t.Run("without exemption reason", func(t *testing.T) { inv := testInvoiceStandard(t) + inv.Lines[0].Taxes[0].Rate = "" + inv.Lines[0].Taxes[0].Percent = num.NewPercentage(21, 2) inv.Lines[0].Taxes[0].Ext = nil - assertValidationError(t, inv, "es-verifactu-tax-classification: required") + assertValidationError(t, inv, "es-verifactu-op-class: required") }) t.Run("without notes", func(t *testing.T) { @@ -56,6 +59,17 @@ func TestInvoiceValidation(t *testing.T) { assert.Equal(t, inv.Tax.Ext[verifactu.ExtKeyDocType].String(), "F2") }) + t.Run("simplified substitution", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.SetTags(tax.TagSimplified) + require.NoError(t, inv.Calculate()) + + inv.Correct(bill.Corrective, bill.WithExtension(verifactu.ExtKeyDocType, "F3")) + require.NoError(t, inv.Validate()) + assert.Equal(t, inv.Tax.Ext[verifactu.ExtKeyDocType].String(), "F3") + assert.Empty(t, inv.Tax.Ext[verifactu.ExtKeyCorrectionType]) + }) + t.Run("correction invoice requires preceding", func(t *testing.T) { inv := testInvoiceStandard(t) inv.Type = bill.InvoiceTypeCreditNote @@ -82,28 +96,19 @@ func TestInvoiceValidation(t *testing.T) { Series: "ABC", Code: "122", IssueDate: &d, + Ext: tax.Extensions{ + verifactu.ExtKeyDocType: "R1", + }, }, } require.NoError(t, inv.Calculate()) + data, _ := json.MarshalIndent(inv, "", " ") + t.Log(string(data)) require.NoError(t, inv.Validate()) assert.Equal(t, inv.Tax.Ext[verifactu.ExtKeyDocType].String(), "R1") + assert.Empty(t, inv.Preceding[0].Ext) }) - t.Run("substitution invoice", func(t *testing.T) { - inv := testInvoiceStandard(t) - inv.Tags = tax.Tags{List: []cbc.Key{verifactu.TagSubstitution}} - d := cal.MakeDate(2024, 1, 1) - inv.Preceding = []*org.DocumentRef{ - { - Series: "ABC", - Code: "122", - IssueDate: &d, - }, - } - require.NoError(t, inv.Calculate()) - require.NoError(t, inv.Validate()) - assert.Equal(t, inv.Tax.Ext[verifactu.ExtKeyDocType].String(), "F3") - }) } func assertValidationError(t *testing.T, inv *bill.Invoice, expected string) { @@ -143,9 +148,6 @@ func testInvoiceStandard(t *testing.T) *bill.Invoice { { Category: "VAT", Rate: "standard", - Ext: tax.Extensions{ - "es-verifactu-tax-classification": "S1", - }, }, }, }, diff --git a/addons/es/verifactu/extensions.go b/addons/es/verifactu/extensions.go index 6190289b..d57ac7c8 100644 --- a/addons/es/verifactu/extensions.go +++ b/addons/es/verifactu/extensions.go @@ -8,8 +8,10 @@ import ( // Extension keys for Verifactu const ( - ExtKeyDocType cbc.Key = "es-verifactu-doc-type" - ExtKeyTaxClassification cbc.Key = "es-verifactu-tax-classification" + ExtKeyDocType cbc.Key = "es-verifactu-doc-type" + ExtKeyOpClass cbc.Key = "es-verifactu-op-class" + ExtKeyCorrectionType cbc.Key = "es-verifactu-correction-type" + ExtKeyExempt cbc.Key = "es-verifactu-exempt" ) var extensions = []*cbc.KeyDefinition{ @@ -44,7 +46,7 @@ var extensions = []*cbc.KeyDefinition{ { Value: "F3", Name: i18n.String{ - i18n.EN: "Invoice in substitution of simplified invoices.", + i18n.EN: "Invoice issued as a replacement for simplified invoices that have been billed and declared.", i18n.ES: "Factura emitida en sustitución de facturas simplificadas facturadas y declaradas.", }, }, @@ -86,17 +88,49 @@ var extensions = []*cbc.KeyDefinition{ }, }, { - Key: ExtKeyTaxClassification, + Key: ExtKeyCorrectionType, Name: i18n.String{ - i18n.EN: "Verifactu Tax Classification/Exemption Code - L9/10", - i18n.ES: "Código de Clasificación/Exención de Impuesto de Verifactu - L9/10", + i18n.EN: "Verifactu Correction Type Code - L3", + i18n.ES: "Código de Tipo de Corrección de Verifactu - L3", }, Desc: i18n.String{ i18n.EN: here.Doc(` - Tax classification code used to identify the type of tax being applied to the line. It includes both exemption reasons and tax scenarios. - These lists are separate in Verifactu but combined here for convenience. + Correction type code used to identify the type of correction being made. + This value will be determined automatically according to the invoice type. + Corrective invoices will be marked as "S", while credit and debit notes as "I". + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "S", + Name: i18n.String{ + i18n.EN: "Substitution", + i18n.ES: "Por Sustitución", + }, + }, + { + Value: "I", + Name: i18n.String{ + i18n.EN: "Differences", + i18n.ES: "Por Diferencias", + }, + }, + }, + }, + { + Key: ExtKeyOpClass, + Name: i18n.String{ + i18n.EN: "Verifactu Operation Classification/Exemption Code - L9", + i18n.ES: "Código de Clasificación/Exención de Impuesto de Verifactu - L9", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Operation classification code used to identify if taxes should be applied to the line. Source: VeriFactu Ministerial Order: * https://www.boe.es/diario_boe/txt.php?id=BOE-A-2024-22138 + For details on how best to use and apply these and other codes, see the + AEAT FAQ: + * https://sede.agenciatributaria.gob.es/Sede/impuestos-tasas/iva/iva-libros-registro-iva-traves-aeat/preguntas-frecuentes/3-libro-registro-facturas-expedidas.html?faqId=b5556c3d02bc9510VgnVCM100000dc381e0aRCRD `), }, Values: []*cbc.ValueDefinition{ @@ -128,6 +162,20 @@ var extensions = []*cbc.KeyDefinition{ i18n.ES: "Operación No Sujeta por Reglas de localización", }, }, + }, + }, + { + Key: ExtKeyExempt, + Name: i18n.String{ + i18n.EN: "Verifactu Exemption Code - L10", + i18n.ES: "Código de Exención de Impuesto de Verifactu - L10", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Exemption code used to explain why the operation is exempt from taxes. + `), + }, + Values: []*cbc.ValueDefinition{ { Value: "E1", Name: i18n.String{ diff --git a/addons/es/verifactu/invoice.go b/addons/es/verifactu/invoice.go deleted file mode 100644 index ca6f0820..00000000 --- a/addons/es/verifactu/invoice.go +++ /dev/null @@ -1,128 +0,0 @@ -package verifactu - -import ( - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/org" - "github.com/invopop/gobl/regimes/es" - "github.com/invopop/gobl/tax" - "github.com/invopop/validation" -) - -var invoiceCorrectionDefinitions = tax.CorrectionSet{ - { - Schema: bill.ShortSchemaInvoice, - Extensions: []cbc.Key{ - ExtKeyDocType, - }, - }, -} - -func validateInvoice(inv *bill.Invoice) error { - return validation.ValidateStruct(inv, - validation.Field(&inv.Customer, - validation.By(validateInvoiceCustomer), - validation.Skip, - ), - validation.Field(&inv.Preceding, - validation.When( - inv.Type.In(es.InvoiceCorrectionTypes...), - validation.Required, - ), - validation.Each( - validation.By(validateInvoicePreceding), - ), - validation.Skip, - ), - validation.Field(&inv.Tax, - validation.Required, - validation.By(validateInvoiceTax), - validation.Skip, - ), - validation.Field(&inv.Lines, - validation.Each( - validation.By(validateInvoiceLine), - validation.Skip, - ), - validation.Skip, - ), - validation.Field(&inv.Notes, - cbc.ValidateNotesHasKey(cbc.NoteKeyGeneral), - validation.Skip, - ), - ) -} - -func validateInvoiceTax(val any) error { - obj, ok := val.(*bill.Tax) - if obj == nil || !ok { - return nil - } - return validation.ValidateStruct(obj, - validation.Field(&obj.Ext, - tax.ExtensionsRequires(ExtKeyDocType), - validation.Skip, - ), - ) -} - -func validateInvoiceCustomer(val any) error { - obj, ok := val.(*org.Party) - if !ok || obj == nil { - return nil - } - // Customers must have a tax ID to at least set the country, - // and Spanish ones should also have an ID. There are more complex - // rules for exports. - return validation.ValidateStruct(obj, - validation.Field(&obj.TaxID, - validation.Required, - validation.When( - obj.TaxID != nil && obj.TaxID.Country.In("ES"), - tax.RequireIdentityCode, - ), - validation.Skip, - ), - ) -} - -func validateInvoicePreceding(val any) error { - p, ok := val.(*org.DocumentRef) - if !ok || p == nil { - return nil - } - return validation.ValidateStruct(p, - validation.Field(&p.IssueDate, - validation.Required, - ), - ) -} - -func validateInvoiceLine(value any) error { - obj, ok := value.(*bill.Line) - if !ok || obj == nil { - return nil - } - return validation.ValidateStruct(obj, - validation.Field(&obj.Taxes, - validation.Each( - validation.By(validateInvoiceLineTax), - validation.Skip, - ), - validation.Skip, - ), - ) -} - -func validateInvoiceLineTax(value any) error { - obj, ok := value.(*tax.Combo) - if obj == nil || !ok { - return nil - } - return validation.ValidateStruct(obj, - validation.Field(&obj.Ext, - tax.ExtensionsRequires(ExtKeyTaxClassification), - validation.Skip, - ), - ) -} diff --git a/addons/es/verifactu/scenarios.go b/addons/es/verifactu/scenarios.go index 2951c9c7..509364ff 100644 --- a/addons/es/verifactu/scenarios.go +++ b/addons/es/verifactu/scenarios.go @@ -3,31 +3,9 @@ package verifactu import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" - "github.com/invopop/gobl/i18n" - "github.com/invopop/gobl/regimes/es" "github.com/invopop/gobl/tax" ) -const ( - // TagSubstitution is used to identify the case for: - // Factura emitida en sustitución de facturas simplificadas facturadas y declaradas. - // To be used when a simplified invoice has been issued and declared. - TagSubstitution cbc.Key = "substitution" -) - -var invoiceTags = &tax.TagSet{ - Schema: bill.ShortSchemaInvoice, - List: []*cbc.KeyDefinition{ - { - Key: TagSubstitution, - Name: i18n.String{ - i18n.EN: "Invoice issued in substitution of simplified invoices issued and declared", - i18n.ES: "Factura emitida en sustitución de facturas simplificadas facturadas y declaradas", - }, - }, - }, -} - var scenarios = []*tax.ScenarioSet{ { Schema: bill.ShortSchemaInvoice, @@ -52,32 +30,6 @@ var scenarios = []*tax.ScenarioSet{ ExtKeyDocType: "F2", }, }, - { - Types: []cbc.Key{ - bill.InvoiceTypeStandard, - }, - Tags: []cbc.Key{ - TagSubstitution, - }, - Ext: tax.Extensions{ - ExtKeyDocType: "F3", - }, - }, - { - Types: es.InvoiceCorrectionTypes, - Ext: tax.Extensions{ - ExtKeyDocType: "R1", - }, - }, - { - Types: es.InvoiceCorrectionTypes, - Tags: []cbc.Key{ - tax.TagSimplified, - }, - Ext: tax.Extensions{ - ExtKeyDocType: "R5", - }, - }, }, }, } diff --git a/addons/es/verifactu/scenarios_test.go b/addons/es/verifactu/scenarios_test.go index 9352132a..46b85766 100644 --- a/addons/es/verifactu/scenarios_test.go +++ b/addons/es/verifactu/scenarios_test.go @@ -11,7 +11,6 @@ import ( ) func TestInvoiceDocumentScenarios(t *testing.T) { - t.Run("with addon", func(t *testing.T) { i := testInvoiceStandard(t) require.NoError(t, i.Calculate()) @@ -26,36 +25,32 @@ func TestInvoiceDocumentScenarios(t *testing.T) { assert.Equal(t, i.Tax.Ext[verifactu.ExtKeyDocType].String(), "F2") }) - t.Run("substitution invoice", func(t *testing.T) { - i := testInvoiceStandard(t) - i.SetTags(verifactu.TagSubstitution) - require.NoError(t, i.Calculate()) - assert.Len(t, i.Notes, 1) - assert.Equal(t, i.Tax.Ext[verifactu.ExtKeyDocType].String(), "F3") - }) - t.Run("credit note", func(t *testing.T) { i := testInvoiceStandard(t) - i.Type = bill.InvoiceTypeCreditNote - require.NoError(t, i.Calculate()) + require.NoError(t, i.Correct(bill.Credit, bill.WithExtension(verifactu.ExtKeyDocType, "R1"))) + // require.NoError(t, i.Calculate()) assert.Len(t, i.Notes, 1) assert.Equal(t, i.Tax.Ext[verifactu.ExtKeyDocType].String(), "R1") + assert.Equal(t, i.Tax.Ext.Get(verifactu.ExtKeyCorrectionType).String(), "I") }) t.Run("corrective", func(t *testing.T) { i := testInvoiceStandard(t) - i.Type = bill.InvoiceTypeCorrective - require.NoError(t, i.Calculate()) + require.NoError(t, i.Correct(bill.Corrective, bill.WithExtension(verifactu.ExtKeyDocType, "R2"))) assert.Len(t, i.Notes, 1) - assert.Equal(t, i.Tax.Ext[verifactu.ExtKeyDocType].String(), "R1") + assert.Equal(t, i.Tax.Ext.Get(verifactu.ExtKeyDocType).String(), "R2") + assert.Equal(t, i.Tax.Ext.Get(verifactu.ExtKeyCorrectionType).String(), "S") }) - t.Run("simplified credit note", func(t *testing.T) { + t.Run("simplified corrective", func(t *testing.T) { i := testInvoiceStandard(t) - i.Type = bill.InvoiceTypeCreditNote i.SetTags(tax.TagSimplified) require.NoError(t, i.Calculate()) + + require.NoError(t, i.Correct(bill.Corrective, bill.WithExtension(verifactu.ExtKeyDocType, "F3"))) + assert.Len(t, i.Notes, 1) - assert.Equal(t, i.Tax.Ext[verifactu.ExtKeyDocType].String(), "R5") + assert.Equal(t, "F3", i.Tax.Ext.Get(verifactu.ExtKeyDocType).String()) + assert.Equal(t, "", i.Tax.Ext.Get(verifactu.ExtKeyCorrectionType).String()) }) } diff --git a/addons/es/verifactu/tax.go b/addons/es/verifactu/tax.go new file mode 100644 index 00000000..f45476db --- /dev/null +++ b/addons/es/verifactu/tax.go @@ -0,0 +1,55 @@ +package verifactu + +import ( + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/regimes/es" + "github.com/invopop/gobl/tax" + "github.com/invopop/validation" +) + +// Simple map of the tax rates to operation classes. These only apply to tax combos +// in Spain and only for the most basic of situations. +var taxCategoryOpClassMap = tax.Extensions{ + tax.RateStandard: "S1", + tax.RateReduced: "S1", + tax.RateSuperReduced: "S1", + tax.RateZero: "S1", +} + +func normalizeTaxCombo(tc *tax.Combo) { + if tc.Country != "" && tc.Country != l10n.ES.Tax() { + return + } + switch tc.Category { + case tax.CategoryVAT, es.TaxCategoryIGIC: + if tc.Rate.IsEmpty() { + return + } + v := taxCategoryOpClassMap.Get(tc.Rate) + if v == "" { + return + } + tc.Ext = tc.Ext.Merge( + tax.Extensions{ExtKeyOpClass: v}, + ) + } +} + +func validateTaxCombo(tc *tax.Combo) error { + if !tc.Category.In(tax.CategoryVAT, es.TaxCategoryIGIC) { + return nil + } + return validation.ValidateStruct(tc, + validation.Field(&tc.Ext, + validation.When( + tc.Percent != nil, // Taxed + tax.ExtensionsRequires(ExtKeyOpClass), + ), + validation.When( + tc.Percent == nil && !tc.Ext.Has(ExtKeyOpClass), + tax.ExtensionsRequires(ExtKeyExempt), + ), + validation.Skip, + ), + ) +} diff --git a/addons/es/verifactu/tax_test.go b/addons/es/verifactu/tax_test.go new file mode 100644 index 00000000..51a27be9 --- /dev/null +++ b/addons/es/verifactu/tax_test.go @@ -0,0 +1,49 @@ +package verifactu + +import ( + "testing" + + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestNormalizeTaxCombo(t *testing.T) { + t.Run("valid", func(t *testing.T) { + tc := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + } + normalizeTaxCombo(tc) + assert.Equal(t, "S1", tc.Ext.Get(ExtKeyOpClass).String()) + }) + + t.Run("valid with country", func(t *testing.T) { + tc := &tax.Combo{ + Country: l10n.ES.Tax(), + Category: tax.CategoryVAT, + Rate: tax.RateSuperReduced, + } + normalizeTaxCombo(tc) + assert.Equal(t, "S1", tc.Ext.Get(ExtKeyOpClass).String()) + }) + + t.Run("undefined rate", func(t *testing.T) { + tc := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateExempt, + } + normalizeTaxCombo(tc) + assert.Empty(t, tc.Ext.Get(ExtKeyOpClass).String()) + }) + + t.Run("foreign country", func(t *testing.T) { + tc := &tax.Combo{ + Country: "FR", + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + } + normalizeTaxCombo(tc) + assert.Empty(t, tc.Ext.Get(ExtKeyOpClass).String()) + }) +} diff --git a/addons/es/verifactu/verifactu.go b/addons/es/verifactu/verifactu.go index 7c6a971d..4ead855c 100644 --- a/addons/es/verifactu/verifactu.go +++ b/addons/es/verifactu/verifactu.go @@ -27,27 +27,31 @@ func newAddon() *tax.AddonDef { return &tax.AddonDef{ Key: V1, Name: i18n.String{ - i18n.EN: "Spain Verifactu", - }, - Extensions: extensions, - Validator: validate, - Tags: []*tax.TagSet{ - invoiceTags, + i18n.EN: "Spain Verifactu V1", }, + Extensions: extensions, + Validator: validate, Scenarios: scenarios, Normalizer: normalize, Corrections: invoiceCorrectionDefinitions, } } -func normalize(_ any) { - // nothing to normalize yet +func normalize(doc any) { + switch obj := doc.(type) { + case *bill.Invoice: + normalizeInvoice(obj) + case *tax.Combo: + normalizeTaxCombo(obj) + } } func validate(doc any) error { switch obj := doc.(type) { case *bill.Invoice: return validateInvoice(obj) + case *tax.Combo: + return validateTaxCombo(obj) } return nil } diff --git a/bill/invoice.go b/bill/invoice.go index b52c1dc5..77731c83 100644 --- a/bill/invoice.go +++ b/bill/invoice.go @@ -267,6 +267,7 @@ func (inv *Invoice) Calculate() error { if err := inv.calculate(); err != nil { return err } + if err := inv.prepareScenarios(); err != nil { return err } diff --git a/bill/invoice_correct.go b/bill/invoice_correct.go index 82cece22..2cdc2f0a 100644 --- a/bill/invoice_correct.go +++ b/bill/invoice_correct.go @@ -33,7 +33,8 @@ type CorrectionOptions struct { Stamps []*head.Stamp `json:"stamps,omitempty" jsonschema:"title=Stamps"` // Human readable reason for the corrective operation. Reason string `json:"reason,omitempty" jsonschema:"title=Reason"` - // Extensions for region specific requirements. + // Extensions for region specific requirements that may be added in the preceding + // or at the document level, according to the local rules. Ext tax.Extensions `json:"ext,omitempty" jsonschema:"title=Extensions"` // In case we want to use a raw json object as a source of the options. @@ -303,7 +304,7 @@ func (inv *Invoice) Correct(opts ...schema.Option) error { } // correctionDef tries to determine a final correction definition -// by merge potentially multiple sources. The results include +// by merging potentially multiple sources. The results include // a key that can be used to identify the definition. func (inv *Invoice) correctionDef() *tax.CorrectionDefinition { cd := &tax.CorrectionDefinition{ @@ -363,7 +364,7 @@ func (inv *Invoice) validatePrecedingData(o *CorrectionOptions, cd *tax.Correcti pre.Stamps = append(pre.Stamps, s) } - if !o.Type.In(cd.Types...) { + if len(cd.Types) > 0 && !o.Type.In(cd.Types...) { return fmt.Errorf("invalid correction type: %v", o.Type.String()) } diff --git a/bill/invoice_correct_test.go b/bill/invoice_correct_test.go index 8e8f6da4..53800e1c 100644 --- a/bill/invoice_correct_test.go +++ b/bill/invoice_correct_test.go @@ -20,7 +20,6 @@ import ( ) func TestInvoiceCorrect(t *testing.T) { - i := testInvoicePTForCorrection(t) err := i.Correct(bill.Corrective) require.Error(t, err) @@ -263,6 +262,7 @@ func testInvoiceESForCorrection(t *testing.T) *bill.Invoice { func testInvoicePTForCorrection(t *testing.T) *bill.Invoice { t.Helper() i := &bill.Invoice{ + Regime: tax.WithRegime("PT"), Series: "TEST", Code: "123", Tax: &bill.Tax{ diff --git a/bill/invoice_scenarios.go b/bill/invoice_scenarios.go index 9458d5d7..3e59e99f 100644 --- a/bill/invoice_scenarios.go +++ b/bill/invoice_scenarios.go @@ -49,16 +49,11 @@ func (inv *Invoice) scenarioSummary() *tax.ScenarioSummary { ss.Merge(a.Scenarios) } - inv.removePreviousScenarios(ss) + inv.removePreviousScenarioNotes(ss) return ss.SummaryFor(inv) } -func (inv *Invoice) removePreviousScenarios(ss *tax.ScenarioSet) { - if inv.Tax != nil && len(inv.Tax.Ext) > 0 { - for _, ek := range ss.ExtensionKeys() { - delete(inv.Tax.Ext, ek) - } - } +func (inv *Invoice) removePreviousScenarioNotes(ss *tax.ScenarioSet) { for _, n := range ss.Notes() { for i, n2 := range inv.Notes { if n.SameAs(n2) { @@ -75,6 +70,7 @@ func (inv *Invoice) prepareScenarios() error { if ss == nil { return nil } + for _, n := range ss.Notes { // make sure we don't already have the same note in the invoice for _, n2 := range inv.Notes { @@ -87,16 +83,10 @@ func (inv *Invoice) prepareScenarios() error { inv.Notes = append(inv.Notes, n) } } + // Apply extensions at the document level - for k, v := range ss.Ext { - if inv.Tax == nil { - inv.Tax = new(Tax) - } - if inv.Tax.Ext == nil { - inv.Tax.Ext = make(tax.Extensions) - } - // Always override - inv.Tax.Ext[k] = v + if len(ss.Ext) > 0 { + inv.Tax = inv.Tax.MergeExtensions(ss.Ext) } return nil diff --git a/bill/tax.go b/bill/tax.go index 1b8c3cd6..781a0aff 100644 --- a/bill/tax.go +++ b/bill/tax.go @@ -27,6 +27,19 @@ type Tax struct { tags []cbc.Key } +// MergeExtensions makes it easier to add extensions to the tax object +// by automatically handling nil data, and replying a new updated instance. +func (t *Tax) MergeExtensions(ext tax.Extensions) *Tax { + if len(ext) == 0 { + return t + } + if t == nil { + t = new(Tax) + } + t.Ext = t.Ext.Merge(ext) + return t +} + // Normalize performs normalization on the tax and embedded objects using the // provided list of normalizers. func (t *Tax) Normalize(normalizers tax.Normalizers) { diff --git a/bill/tax_test.go b/bill/tax_test.go index 91a4f0a3..e1ae7cfc 100644 --- a/bill/tax_test.go +++ b/bill/tax_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -118,3 +119,42 @@ func TestInvoiceTaxTagsMigration(t *testing.T) { assert.Equal(t, "simplified", inv.GetTags()[0].String()) } + +func TestTaxMergeExtensions(t *testing.T) { + t.Run("nil tax", func(t *testing.T) { + var tx *bill.Tax + ext := tax.Extensions{ + "vat-cat": "standard", + } + tx = tx.MergeExtensions(ext) + assert.Equal(t, "standard", tx.Ext["vat-cat"].String()) + }) + t.Run("nil extensions", func(t *testing.T) { + tx := &bill.Tax{} + tx = tx.MergeExtensions(nil) + assert.Nil(t, tx.Ext) + }) + t.Run("with extensions", func(t *testing.T) { + tx := &bill.Tax{ + Ext: tax.Extensions{ + "vat-cat": "standard", + }, + } + tx = tx.MergeExtensions(tax.Extensions{ + "vat-cat": "reduced", + }) + assert.Equal(t, "reduced", tx.Ext["vat-cat"].String()) + }) + t.Run("new extensions", func(t *testing.T) { + tx := &bill.Tax{ + Ext: tax.Extensions{ + "vat-test": "bar", + }, + } + tx = tx.MergeExtensions(tax.Extensions{ + "vat-cat": "reduced", + }) + assert.Equal(t, "reduced", tx.Ext["vat-cat"].String()) + assert.Equal(t, "bar", tx.Ext["vat-test"].String()) + }) +} diff --git a/cbc/key.go b/cbc/key.go index ef571bd4..6193929d 100644 --- a/cbc/key.go +++ b/cbc/key.go @@ -75,6 +75,20 @@ func (k Key) Has(ke Key) bool { return false } +// Pop removes the last key from a list and returns the remaining base, +// or an empty key if there is nothing left. +// +// Example: +// +// Key("a+b+c").Pop() => Key("a+b") +func (k Key) Pop() Key { + ks := strings.Split(k.String(), KeySeparator) + if len(ks) == 0 { + return KeyEmpty + } + return Key(strings.Join(ks[:len(ks)-1], KeySeparator)) +} + // HasPrefix checks to see if the key starts with the provided key. // As per `Has`, only the complete key between `+` symbols are // matched. diff --git a/cbc/key_test.go b/cbc/key_test.go index 928a2a09..a3dd3b4d 100644 --- a/cbc/key_test.go +++ b/cbc/key_test.go @@ -66,6 +66,13 @@ func TestKeyIn(t *testing.T) { assert.False(t, c.In("pro", "reduced")) } +func TestKeyPop(t *testing.T) { + k := cbc.Key("a+b+c") + assert.Equal(t, cbc.Key("a+b"), k.Pop()) + assert.Equal(t, cbc.Key("a"), k.Pop().Pop()) + assert.Equal(t, cbc.KeyEmpty, k.Pop().Pop().Pop()) +} + func TestAppendUniqueKeys(t *testing.T) { keys := []cbc.Key{"a", "b", "c"} keys = cbc.AppendUniqueKeys(keys, "b", "d") diff --git a/examples/es/invoice-es-es-verifactu.yaml b/examples/es/invoice-es-es-verifactu.yaml new file mode 100644 index 00000000..6a383445 --- /dev/null +++ b/examples/es/invoice-es-es-verifactu.yaml @@ -0,0 +1,53 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +$addons: ["es-verifactu-v1"] +uuid: "3aea7b56-59d8-4beb-90bd-f8f280d852a0" +currency: "EUR" +issue_date: "2024-12-04" +series: "SAMPLE" +code: "004" + +supplier: + tax_id: + country: "ES" + code: "B98602642" # random + name: "Provide One S.L." + emails: + - addr: "billing@example.com" + addresses: + - num: "42" + street: "Calle Pradillo" + locality: "Madrid" + region: "Madrid" + code: "28002" + country: "ES" + +customer: + tax_id: + country: "ES" + code: "54387763P" + name: "Sample Consumer" + +lines: + - quantity: 20 + item: + name: "Development services" + price: "90.00" + unit: "h" + discounts: + - percent: "10%" + reason: "Special discount" + - amount: "0.00" + taxes: + - cat: VAT + rate: standard + - quantity: 1 + item: + name: "Financial service" + price: "10.00" + taxes: + - cat: VAT + rate: zero + +notes: + - key: "general" + text: "Random invoice" diff --git a/examples/es/out/invoice-es-es-verifactu.json b/examples/es/out/invoice-es-es-verifactu.json new file mode 100644 index 00000000..266974d1 --- /dev/null +++ b/examples/es/out/invoice-es-es-verifactu.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "3c92526c4aadfca341185a20891dd65f24a1d8e69df6aa3bb2f54f7dfbcfe758" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$addons": [ + "es-verifactu-v1" + ], + "uuid": "3aea7b56-59d8-4beb-90bd-f8f280d852a0", + "type": "standard", + "series": "SAMPLE", + "code": "004", + "issue_date": "2024-12-04", + "currency": "EUR", + "tax": { + "ext": { + "es-verifactu-doc-type": "F1" + } + }, + "supplier": { + "name": "Provide One S.L.", + "tax_id": { + "country": "ES", + "code": "B98602642" + }, + "addresses": [ + { + "num": "42", + "street": "Calle Pradillo", + "locality": "Madrid", + "region": "Madrid", + "code": "28002", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "ES", + "code": "54387763P" + } + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "discounts": [ + { + "reason": "Special discount", + "percent": "10%", + "amount": "180.00" + } + ], + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "21.0%", + "ext": { + "es-verifactu-op-class": "S1" + } + } + ], + "total": "1620.00" + }, + { + "i": 2, + "quantity": "1", + "item": { + "name": "Financial service", + "price": "10.00" + }, + "sum": "10.00", + "taxes": [ + { + "cat": "VAT", + "rate": "zero", + "percent": "0.0%", + "ext": { + "es-verifactu-op-class": "S1" + } + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "1630.00", + "total": "1630.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "ext": { + "es-verifactu-op-class": "S1" + }, + "base": "1620.00", + "percent": "21.0%", + "amount": "340.20" + }, + { + "key": "zero", + "ext": { + "es-verifactu-op-class": "S1" + }, + "base": "10.00", + "percent": "0.0%", + "amount": "0.00" + } + ], + "amount": "340.20" + } + ], + "sum": "340.20" + }, + "tax": "340.20", + "total_with_tax": "1970.20", + "payable": "1970.20" + }, + "notes": [ + { + "key": "general", + "text": "Random invoice" + } + ] + } +} \ No newline at end of file diff --git a/tax/constants.go b/tax/constants.go index 88c8984d..6ffccff8 100644 --- a/tax/constants.go +++ b/tax/constants.go @@ -31,5 +31,7 @@ const ( TagPartial cbc.Key = "partial" TagB2G cbc.Key = "b2g" TagExport cbc.Key = "export" - TagEEA cbc.Key = "eea" // European Economic Area, used with exports + + // European Economic Area, used with exports + TagEEA cbc.Key = "eea" ) diff --git a/tax/corrections.go b/tax/corrections.go index a1f08b0e..87fe2da0 100644 --- a/tax/corrections.go +++ b/tax/corrections.go @@ -50,13 +50,14 @@ func (cd *CorrectionDefinition) Merge(other *CorrectionDefinition) *CorrectionDe if cd.Schema != other.Schema { return cd } - return &CorrectionDefinition{ + cd = &CorrectionDefinition{ Schema: cd.Schema, Types: append(cd.Types, other.Types...), Extensions: append(cd.Extensions, other.Extensions...), ReasonRequired: cd.ReasonRequired || other.ReasonRequired, Stamps: append(cd.Stamps, other.Stamps...), } + return cd } // HasType returns true if the correction definition has a type that matches the one provided. diff --git a/tax/extensions.go b/tax/extensions.go index 3a2b4350..aba56bee 100644 --- a/tax/extensions.go +++ b/tax/extensions.go @@ -110,6 +110,24 @@ func (em Extensions) Validate() error { return nil } +// Get returns the value for the provided key or an empty string if not found +// or the extensions map is nil. If the key is composed of sub-keys and +// no precise match is found, the key will be split until one of the sub +// components is found. +func (em Extensions) Get(k cbc.Key) ExtValue { + if len(em) == 0 { + return "" + } + // while k is not empty, pop the last key and check if it exists + for k != cbc.KeyEmpty { + if v, ok := em[k]; ok { + return v + } + k = k.Pop() + } + return "" +} + // Has returns true if the code map has values for all the provided keys. func (em Extensions) Has(keys ...cbc.Key) bool { for _, k := range keys { @@ -216,9 +234,17 @@ func ExtensionsRequires(keys ...cbc.Key) validation.Rule { } } +func ExtensionsExclude(keys ...cbc.Key) validation.Rule { + return validateExtCodeMap{ + exclude: true, + keys: keys, + } +} + type validateExtCodeMap struct { keys []cbc.Key required bool + exclude bool } func (v validateExtCodeMap) Validate(value interface{}) error { @@ -234,6 +260,12 @@ func (v validateExtCodeMap) Validate(value interface{}) error { err[k.String()] = errors.New("required") } } + } else if v.exclude { + for _, k := range v.keys { + if _, ok := em[k]; ok { + err[k.String()] = errors.New("must be blank") + } + } } else { for k := range em { if !k.In(v.keys...) { @@ -304,6 +336,16 @@ func (ev ExtValue) String() string { return string(ev) } +// In returns true if the value is in the provided list. +func (ev ExtValue) In(values ...ExtValue) bool { + for _, v := range values { + if ev == v { + return true + } + } + return false +} + // Key returns the key value or empty if the value is a Code. func (ev ExtValue) Key() cbc.Key { k := cbc.Key(ev) diff --git a/tax/extensions_test.go b/tax/extensions_test.go index a7a718c9..f0bd1ab6 100644 --- a/tax/extensions_test.go +++ b/tax/extensions_test.go @@ -229,6 +229,41 @@ func TestExtensionsRequiresValidation(t *testing.T) { }) } +func TestExtensionsExcludeValidation(t *testing.T) { + t.Run("nil", func(t *testing.T) { + err := validation.Validate(nil, + tax.ExtensionsExclude(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("empty", func(t *testing.T) { + em := tax.Extensions{} + err := validation.Validate(em, + tax.ExtensionsExclude(untdid.ExtKeyDocumentType), + ) + assert.NoError(t, err) + }) + t.Run("correct", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + } + err := validation.Validate(em, + tax.ExtensionsExclude(untdid.ExtKeyDocumentType), + ) + assert.ErrorContains(t, err, "untdid-document-type: must be blank") + }) + t.Run("correct with extras", func(t *testing.T) { + em := tax.Extensions{ + untdid.ExtKeyDocumentType: "326", + iso.ExtKeySchemeID: "1234", + } + err := validation.Validate(em, + tax.ExtensionsExclude(untdid.ExtKeyCharge), + ) + assert.NoError(t, err) + }) +} + func TestExtensionsHasValues(t *testing.T) { t.Run("nil", func(t *testing.T) { err := validation.Validate(nil, @@ -448,3 +483,34 @@ func TestExtensionLookup(t *testing.T) { assert.Equal(t, cbc.Key("key2"), em.Lookup("bar")) assert.Equal(t, cbc.KeyEmpty, em.Lookup("missing")) } + +func TestExtensionGet(t *testing.T) { + t.Run("empty", func(t *testing.T) { + var em tax.Extensions + assert.Equal(t, "", em.Get("key").String()) + }) + t.Run("with value", func(t *testing.T) { + em := tax.Extensions{ + "key": "value", + } + assert.Equal(t, "value", em.Get("key").String()) + }) + t.Run("missing", func(t *testing.T) { + em := tax.Extensions{ + "key": "value", + } + assert.Equal(t, "", em.Get("missing").String()) + }) + t.Run("with sub-keys", func(t *testing.T) { + em := tax.Extensions{ + "key": "value", + } + assert.Equal(t, "value", em.Get("key+foo").String()) + }) +} + +func TestExtValueIn(t *testing.T) { + ev := tax.ExtValue("IT") + assert.True(t, ev.In("IT", "ES")) + assert.False(t, ev.In("ES", "FR")) +}