diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8f8ba4..03174fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- `es-verifactu-v1`: added initial Spain VeriFactu addon. +- `tax`: Extensions `Get` convenience method that helps when using extensions using sub-keys. +- `tax`: `ExtensionsExclude` validator for checking that extensions do **not** include certain keys. +- `tax`: `ExtValue.In` for comparing extension values. +- `bill`: `Tax.MergeExtensions` convenience method for adding extensions to tax objects and avoid nil panics. +- `cbc`: `Key.Pop` method for splitting keys with sub-keys, e.g. `cbc.Key("a+b").Pop() == cbc.Key("a")`. + +### Fixed + +- `bill`: corrected issues around correction definitions and merging types. + ## [v0.206.1] - 2024-11-28 ### Fixed diff --git a/addons/addons.go b/addons/addons.go index c632e9b3..1e09e457 100644 --- a/addons/addons.go +++ b/addons/addons.go @@ -14,6 +14,7 @@ import ( _ "github.com/invopop/gobl/addons/de/xrechnung" _ "github.com/invopop/gobl/addons/es/facturae" _ "github.com/invopop/gobl/addons/es/tbai" + _ "github.com/invopop/gobl/addons/es/verifactu" _ "github.com/invopop/gobl/addons/eu/en16931" _ "github.com/invopop/gobl/addons/gr/mydata" _ "github.com/invopop/gobl/addons/it/sdi" 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/bill_test.go b/addons/es/verifactu/bill_test.go new file mode 100644 index 00000000..c5035d01 --- /dev/null +++ b/addons/es/verifactu/bill_test.go @@ -0,0 +1,162 @@ +package verifactu_test + +import ( + "encoding/json" + "testing" + + "github.com/invopop/gobl/addons/es/verifactu" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvoiceValidation(t *testing.T) { + t.Run("standard invoice", func(t *testing.T) { + inv := testInvoiceStandard(t) + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + assert.Equal(t, inv.Tax.Ext[verifactu.ExtKeyDocType].String(), "F1") + }) + t.Run("missing customer tax ID", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Customer.TaxID = nil + assertValidationError(t, inv, "customer: (tax_id: cannot be blank.)") + }) + + 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-op-class: required") + }) + + t.Run("without notes", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Notes = nil + assertValidationError(t, inv, "notes: with key 'general' missing") + }) + + t.Run("missing doc type", func(t *testing.T) { + inv := testInvoiceStandard(t) + require.NoError(t, inv.Calculate()) + inv.Tax.Ext = nil + err := inv.Validate() + require.ErrorContains(t, err, "es-verifactu-doc-type: required") + }) + + t.Run("simplified invoice", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.SetTags(tax.TagSimplified) + inv.Customer = nil + require.NoError(t, inv.Calculate()) + require.NoError(t, inv.Validate()) + 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()) + + require.NoError(t, 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 + assertValidationError(t, inv, "preceding: cannot be blank") + }) + + t.Run("correction invoice preceding requires issue date", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Type = bill.InvoiceTypeCreditNote + inv.Preceding = []*org.DocumentRef{ + { + Code: "123", + }, + } + assertValidationError(t, inv, "preceding: (0: (issue_date: cannot be blank.).)") + }) + + t.Run("correction invoice with preceding", func(t *testing.T) { + inv := testInvoiceStandard(t) + inv.Type = bill.InvoiceTypeCreditNote + d := cal.MakeDate(2024, 1, 1) + inv.Preceding = []*org.DocumentRef{ + { + 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) + }) + +} + +func assertValidationError(t *testing.T, inv *bill.Invoice, expected string) { + require.NoError(t, inv.Calculate()) + err := inv.Validate() + require.ErrorContains(t, err, expected) +} + +func testInvoiceStandard(t *testing.T) *bill.Invoice { + t.Helper() + return &bill.Invoice{ + Addons: tax.WithAddons(verifactu.V1), + Code: "123", + Supplier: &org.Party{ + Name: "Test Supplier", + TaxID: &tax.Identity{ + Country: "ES", + Code: "B98602642", + }, + }, + Customer: &org.Party{ + Name: "Test Customer", + TaxID: &tax.Identity{ + Country: "NL", + Code: "000099995B57", + }, + }, + Lines: []*bill.Line{ + { + Quantity: num.MakeAmount(1, 0), + Item: &org.Item{ + Name: "bogus", + Price: num.MakeAmount(10000, 2), + Unit: org.UnitPackage, + }, + Taxes: tax.Set{ + { + Category: "VAT", + Rate: "standard", + }, + }, + }, + }, + Notes: []*cbc.Note{ + { + Key: cbc.NoteKeyGeneral, + Text: "This is a test invoice", + }, + }, + } +} diff --git a/addons/es/verifactu/extensions.go b/addons/es/verifactu/extensions.go new file mode 100644 index 00000000..5b0c114a --- /dev/null +++ b/addons/es/verifactu/extensions.go @@ -0,0 +1,357 @@ +package verifactu + +import ( + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/pkg/here" +) + +// Extension keys for Verifactu +const ( + 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" + ExtKeyRegime cbc.Key = "es-verifactu-regime" +) + +var extensions = []*cbc.KeyDefinition{ + { + Key: ExtKeyDocType, + Name: i18n.String{ + i18n.EN: "Verifactu Invoice Type Code - L2", + i18n.ES: "Código de Tipo de Factura de Verifactu - L2", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Invoice type code used to identify the type of invoice being sent. + Source: VeriFactu Ministerial Order: + * https://www.boe.es/diario_boe/txt.php?id=BOE-A-2024-22138 + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "F1", + Name: i18n.String{ + i18n.EN: "Invoice (Article 6, 7.2 and 7.3 of RD 1619/2012)", + i18n.ES: "Factura (Art. 6, 7.2 y 7.3 del RD 1619/2012)", + }, + }, + { + Value: "F2", + Name: i18n.String{ + i18n.EN: "Simplified invoice (Article 6.1.d) of RD 1619/2012)", + i18n.ES: "Factura Simplificada (Art. 6.1.d) del RD 1619/2012)", + }, + }, + { + Value: "F3", + Name: i18n.String{ + 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.", + }, + }, + { + Value: "R1", + Name: i18n.String{ + i18n.EN: "Rectified invoice: error based on law and Article 80 One, Two and Six LIVA", + i18n.ES: "Factura rectificativa: error fundado en derecho y Art. 80 Uno, Dos y Seis LIVA", + }, + }, + { + Value: "R2", + Name: i18n.String{ + i18n.ES: "Factura rectificativa: artículo 80.3", + i18n.EN: "Rectified invoice: error based on law and Article 80.3", + }, + }, + { + Value: "R3", + Name: i18n.String{ + i18n.ES: "Factura rectificativa: artículo 80.4", + i18n.EN: "Rectified invoice: error based on law and Article 80.4", + }, + }, + { + Value: "R4", + Name: i18n.String{ + i18n.ES: "Factura rectificativa: Resto", + i18n.EN: "Rectified invoice: Other", + }, + }, + { + Value: "R5", + Name: i18n.String{ + i18n.ES: "Factura rectificativa: facturas simplificadas", + i18n.EN: "Rectified invoice: simplified invoices", + }, + }, + }, + }, + { + Key: ExtKeyCorrectionType, + Name: i18n.String{ + 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(` + 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{ + { + Value: "S1", + Name: i18n.String{ + i18n.EN: "Subject and Not Exempt - Without reverse charge", + i18n.ES: "Operación Sujeta y No exenta - Sin inversión del sujeto pasivo", + }, + }, + { + Value: "S2", + Name: i18n.String{ + i18n.EN: "Subject and Not Exempt - With reverse charge", + i18n.ES: "Operación Sujeta y No exenta - Con Inversión del sujeto pasivo", + }, + }, + { + Value: "N1", + Name: i18n.String{ + i18n.EN: "Not Subject - Articles 7, 14, others", + i18n.ES: "Operación No Sujeta artículo 7, 14, otros", + }, + }, + { + Value: "N2", + Name: i18n.String{ + i18n.EN: "Not Subject - Due to location rules", + 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{ + i18n.EN: "Exempt: pursuant to Article 20. Exemptions in internal operations.", + i18n.ES: "Exenta: por el artículo 20. Exenciones en operaciones interiores.", + }, + }, + { + Value: "E2", + Name: i18n.String{ + i18n.EN: "Exempt: pursuant to Article 21. Exemptions in exports of goods.", + i18n.ES: "Exenta: por el artículo 21. Exenciones en las exportaciones de bienes.", + }, + }, + { + Value: "E3", + Name: i18n.String{ + i18n.EN: "Exempt: pursuant to Article 22. Exemptions in operations asimilated to exports.", + i18n.ES: "Exenta: por el artículo 22. Exenciones en las operaciones asimiladas a las exportaciones.", + }, + }, + { + Value: "E4", + Name: i18n.String{ + i18n.EN: "Exempt: pursuant to Articles 23 and 24. Exemptions related to temporary deposit, customs and fiscal regimes, and other situations.", + i18n.ES: "Exenta: por el artículos 23 y 24. Exenciones relativas a las situaciones de depósito temporal, regímenes aduaneros y fiscales, y otras situaciones.", + }, + }, + { + Value: "E5", + Name: i18n.String{ + i18n.EN: "Exempt: pursuant to Article 25. Exemptions in the delivery of goods destined to another Member State.", + i18n.ES: "Exenta: por el artículo 25. Exenciones en las entregas de bienes destinados a otro Estado miembro.", + }, + }, + { + Value: "E6", + Name: i18n.String{ + i18n.EN: "Exempt: pursuant to other reasons", + i18n.ES: "Exenta: por otra causa", + }, + }, + }, + }, + { + Key: ExtKeyRegime, + Name: i18n.String{ + i18n.EN: "VAT/IGIC Regime Code - L8A/B", + i18n.ES: "Código de Régimen de IVA/IGIC - L8A/B", + }, + Desc: i18n.String{ + i18n.EN: here.Doc(` + Identify the type of VAT or IGIC regime applied to the operation. This list combines lists L8A which include values for VAT, and L8B for IGIC. + `), + }, + Values: []*cbc.ValueDefinition{ + { + Value: "01", + Name: i18n.String{ + i18n.EN: "General regime operation", + i18n.ES: "Operación de régimen general", + }, + }, + { + Value: "02", + Name: i18n.String{ + i18n.EN: "Export", + i18n.ES: "Exportación", + }, + }, + { + Value: "03", + Name: i18n.String{ + i18n.EN: "Special regime for used goods, art objects, antiques and collectibles", + i18n.ES: "Operaciones a las que se aplique el régimen especial de bienes usados, objetos de arte, antigüedades y objetos de colección", + }, + }, + { + Value: "04", + Name: i18n.String{ + i18n.EN: "Special regime for investment gold", + i18n.ES: "Régimen especial del oro de inversión", + }, + }, + { + Value: "05", + Name: i18n.String{ + i18n.EN: "Special regime for travel agencies", + i18n.ES: "Régimen especial de las agencias de viajes", + }, + }, + { + Value: "06", + Name: i18n.String{ + i18n.EN: "Special regime for VAT/IGIC groups (Advanced Level)", + i18n.ES: "Régimen especial grupo de entidades en IVA/IGIC (Nivel Avanzado)", + }, + }, + { + Value: "07", + Name: i18n.String{ + i18n.EN: "Special cash accounting regime", + i18n.ES: "Régimen especial del criterio de caja", + }, + }, + { + Value: "08", + Name: i18n.String{ + i18n.EN: "Operations subject to a different regime", + i18n.ES: "Operaciones sujetas a un régimen diferente", + }, + }, + { + Value: "09", + Name: i18n.String{ + i18n.EN: "Billing of travel agency services acting as mediators in name and on behalf of others", + i18n.ES: "Facturación de las prestaciones de servicios de agencias de viaje que actúan como mediadoras en nombre y por cuenta ajena", + }, + }, + { + Value: "10", + Name: i18n.String{ + i18n.EN: "Collection of professional fees or rights on behalf of third parties", + i18n.ES: "Cobros por cuenta de terceros de honorarios profesionales o de derechos derivados de la propiedad industrial", + }, + }, + { + Value: "11", + Name: i18n.String{ + i18n.EN: "Business premises rental operations", + i18n.ES: "Operaciones de arrendamiento de local de negocio", + }, + }, + { + Value: "14", + Name: i18n.String{ + i18n.EN: "Invoice with pending VAT/IGIC accrual in work certifications for Public Administration", + i18n.ES: "Factura con IVA/IGIC pendiente de devengo en certificaciones de obra cuyo destinatario sea una Administración Pública", + }, + }, + { + Value: "15", + Name: i18n.String{ + i18n.EN: "Invoice with pending VAT/IGIC accrual in successive tract operations", + i18n.ES: "Factura con IVA/IGIC pendiente de devengo en operaciones de tracto sucesivo", + }, + }, + { + Value: "17", + Name: i18n.String{ + i18n.EN: "Operation under OSS and IOSS regimes (VAT) / Special regime for retail traders. (IGIC)", + i18n.ES: "Operación acogida a alguno de los regímenes previstos en el capítulo XI del título IX (OSS e IOSS, IVA) / Régimen especial de comerciante minorista. (IGIC)", + }, + }, + { + Value: "18", + Name: i18n.String{ + i18n.EN: "Equivalence surcharge (VAT) / Special regime for small traders or retailers (IGIC)", + i18n.ES: "Recargo de equivalencia (IVA) / Régimen especial del pequeño comerciante o minorista (IGIC)", + }, + }, + { + Value: "19", + Name: i18n.String{ + i18n.EN: "Operations included in the Special Regime for Agriculture, Livestock and Fisheries", + i18n.ES: "Operaciones de actividades incluidas en el Régimen Especial de Agricultura, Ganadería y Pesca (REAGYP)", + }, + }, + { + Value: "20", + Name: i18n.String{ + i18n.EN: "Simplified regime (VAT only)", + i18n.ES: "Régimen simplificado (IVA only)", + }, + }, + }, + }, +} diff --git a/addons/es/verifactu/scenarios.go b/addons/es/verifactu/scenarios.go new file mode 100644 index 00000000..509364ff --- /dev/null +++ b/addons/es/verifactu/scenarios.go @@ -0,0 +1,35 @@ +package verifactu + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/tax" +) + +var scenarios = []*tax.ScenarioSet{ + { + Schema: bill.ShortSchemaInvoice, + List: []*tax.Scenario{ + // ** Invoice Document Types ** + { + Types: []cbc.Key{ + bill.InvoiceTypeStandard, + }, + Ext: tax.Extensions{ + ExtKeyDocType: "F1", + }, + }, + { + Types: []cbc.Key{ + bill.InvoiceTypeStandard, + }, + Tags: []cbc.Key{ + tax.TagSimplified, + }, + Ext: tax.Extensions{ + ExtKeyDocType: "F2", + }, + }, + }, + }, +} diff --git a/addons/es/verifactu/scenarios_test.go b/addons/es/verifactu/scenarios_test.go new file mode 100644 index 00000000..46b85766 --- /dev/null +++ b/addons/es/verifactu/scenarios_test.go @@ -0,0 +1,56 @@ +package verifactu_test + +import ( + "testing" + + "github.com/invopop/gobl/addons/es/verifactu" + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvoiceDocumentScenarios(t *testing.T) { + t.Run("with addon", func(t *testing.T) { + i := testInvoiceStandard(t) + require.NoError(t, i.Calculate()) + assert.Equal(t, i.Tax.Ext[verifactu.ExtKeyDocType].String(), "F1") + }) + + t.Run("simplified invoice", func(t *testing.T) { + i := testInvoiceStandard(t) + i.SetTags(tax.TagSimplified) + require.NoError(t, i.Calculate()) + assert.Len(t, i.Notes, 1) + assert.Equal(t, i.Tax.Ext[verifactu.ExtKeyDocType].String(), "F2") + }) + + t.Run("credit note", func(t *testing.T) { + i := testInvoiceStandard(t) + 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) + require.NoError(t, i.Correct(bill.Corrective, bill.WithExtension(verifactu.ExtKeyDocType, "R2"))) + assert.Len(t, i.Notes, 1) + 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 corrective", func(t *testing.T) { + i := testInvoiceStandard(t) + 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, "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..549b0f56 --- /dev/null +++ b/addons/es/verifactu/tax.go @@ -0,0 +1,62 @@ +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: + ext := make(tax.Extensions) + + // Set default tax regime to "01" (General regime operation) if not already specified + if !tc.Ext.Has(ExtKeyRegime) { + ext[ExtKeyRegime] = "01" + } + + if !tc.Rate.IsEmpty() { + if v := taxCategoryOpClassMap.Get(tc.Rate); v != "" { + ext[ExtKeyOpClass] = v + } + } + + if len(ext) > 0 { + tc.Ext = tc.Ext.Merge(ext) + } + } +} + +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), + ), + tax.ExtensionsRequires(ExtKeyRegime), + validation.Skip, + ), + ) +} diff --git a/addons/es/verifactu/tax_test.go b/addons/es/verifactu/tax_test.go new file mode 100644 index 00000000..703cfe10 --- /dev/null +++ b/addons/es/verifactu/tax_test.go @@ -0,0 +1,88 @@ +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()) + assert.Equal(t, "01", tc.Ext.Get(ExtKeyRegime).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()) + assert.Equal(t, "01", tc.Ext.Get(ExtKeyRegime).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()) + }) + + t.Run("with tax regime", func(t *testing.T) { + tc := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + Ext: tax.Extensions{ + ExtKeyRegime: "03", + }, + } + normalizeTaxCombo(tc) + assert.Equal(t, "03", tc.Ext.Get(ExtKeyRegime).String()) + }) +} + +func TestValidateTaxCombo(t *testing.T) { + t.Run("valid", func(t *testing.T) { + tc := &tax.Combo{ + Category: tax.CategoryVAT, + Rate: tax.RateStandard, + Ext: tax.Extensions{ + ExtKeyOpClass: "S1", + ExtKeyRegime: "01", + }, + } + err := validateTaxCombo(tc) + assert.NoError(t, err) + }) + + t.Run("not in category", func(t *testing.T) { + tc := &tax.Combo{ + Category: tax.CategoryGST, + Rate: tax.RateStandard, + } + err := validateTaxCombo(tc) + assert.NoError(t, err) + }) + +} diff --git a/addons/es/verifactu/verifactu.go b/addons/es/verifactu/verifactu.go new file mode 100644 index 00000000..4ead855c --- /dev/null +++ b/addons/es/verifactu/verifactu.go @@ -0,0 +1,57 @@ +// Package verifactu provides the Verifactu addon +package verifactu + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/i18n" + "github.com/invopop/gobl/tax" +) + +const ( + // V1 for Verifactu versions 1.x + V1 cbc.Key = "es-verifactu-v1" +) + +// Official stamps or codes validated by government agencies +const ( + // StampQR contains the URL included in the QR code. + StampQR cbc.Key = "verifactu-qr" +) + +func init() { + tax.RegisterAddonDef(newAddon()) +} + +func newAddon() *tax.AddonDef { + return &tax.AddonDef{ + Key: V1, + Name: i18n.String{ + i18n.EN: "Spain Verifactu V1", + }, + Extensions: extensions, + Validator: validate, + Scenarios: scenarios, + Normalizer: normalize, + Corrections: invoiceCorrectionDefinitions, + } +} + +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..ceeb7953 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) @@ -182,7 +181,7 @@ func TestCorrectionOptionsSchema(t *testing.T) { } // Sorry, this is copied and pasted from the test output! - exp := `{"properties":{"type":{"$ref":"https://gobl.org/draft-0/cbc/key","oneOf":[{"const":"credit-note","title":"Credit Note","description":"Reflects a refund either partial or complete of the preceding document. A \ncredit note effectively *extends* the previous document."},{"const":"corrective","title":"Corrective","description":"Corrected invoice that completely *replaces* the preceding document."},{"const":"debit-note","title":"Debit Note","description":"An additional set of charges to be added to the preceding document."}],"title":"Type","description":"The type of corrective invoice to produce.","default":"credit-note"},"issue_date":{"$ref":"https://gobl.org/draft-0/cal/date","title":"Issue Date","description":"When the new corrective invoice's issue date should be set to."},"series":{"$ref":"https://gobl.org/draft-0/cbc/code","title":"Series","description":"Series to assign to the new corrective invoice.","default":"TEST"},"stamps":{"items":{"$ref":"https://gobl.org/draft-0/head/stamp"},"type":"array","title":"Stamps","description":"Stamps of the previous document to include in the preceding data."},"reason":{"type":"string","title":"Reason","description":"Human readable reason for the corrective operation."},"ext":{"properties":{"es-facturae-correction":{"oneOf":[{"const":"01","title":"Invoice code"},{"const":"02","title":"Invoice series"},{"const":"03","title":"Issue date"},{"const":"04","title":"Name and surnames/Corporate name - Issuer (Sender)"},{"const":"05","title":"Name and surnames/Corporate name - Receiver"},{"const":"06","title":"Issuer's Tax Identification Number"},{"const":"07","title":"Receiver's Tax Identification Number"},{"const":"08","title":"Supplier's address"},{"const":"09","title":"Customer's address"},{"const":"10","title":"Item line"},{"const":"11","title":"Applicable Tax Rate"},{"const":"12","title":"Applicable Tax Amount"},{"const":"13","title":"Applicable Date/Period"},{"const":"14","title":"Invoice Class"},{"const":"15","title":"Legal literals"},{"const":"16","title":"Taxable Base"},{"const":"80","title":"Calculation of tax outputs"},{"const":"81","title":"Calculation of tax inputs"},{"const":"82","title":"Taxable Base modified due to return of packages and packaging materials"},{"const":"83","title":"Taxable Base modified due to discounts and rebates"},{"const":"84","title":"Taxable Base modified due to firm court ruling or administrative decision"},{"const":"85","title":"Taxable Base modified due to unpaid outputs where there is a judgement opening insolvency proceedings"}],"type":"string","title":"FacturaE Change","description":"FacturaE requires a specific and single code that explains why the previous invoice is being corrected."}},"type":"object","title":"Extensions","description":"Extensions for region specific requirements.","recommended":["es-facturae-correction"]}},"type":"object","required":["type"],"description":"CorrectionOptions defines a structure used to pass configuration options to correct a previous invoice.","recommended":["series","ext"]}` + exp := `{"properties":{"type":{"$ref":"https://gobl.org/draft-0/cbc/key","oneOf":[{"const":"credit-note","title":"Credit Note","description":"Reflects a refund either partial or complete of the preceding document. A \ncredit note effectively *extends* the previous document."},{"const":"corrective","title":"Corrective","description":"Corrected invoice that completely *replaces* the preceding document."},{"const":"debit-note","title":"Debit Note","description":"An additional set of charges to be added to the preceding document."}],"title":"Type","description":"The type of corrective invoice to produce.","default":"credit-note"},"issue_date":{"$ref":"https://gobl.org/draft-0/cal/date","title":"Issue Date","description":"When the new corrective invoice's issue date should be set to."},"series":{"$ref":"https://gobl.org/draft-0/cbc/code","title":"Series","description":"Series to assign to the new corrective invoice.","default":"TEST"},"stamps":{"items":{"$ref":"https://gobl.org/draft-0/head/stamp"},"type":"array","title":"Stamps","description":"Stamps of the previous document to include in the preceding data."},"reason":{"type":"string","title":"Reason","description":"Human readable reason for the corrective operation."},"ext":{"properties":{"es-facturae-correction":{"oneOf":[{"const":"01","title":"Invoice code"},{"const":"02","title":"Invoice series"},{"const":"03","title":"Issue date"},{"const":"04","title":"Name and surnames/Corporate name - Issuer (Sender)"},{"const":"05","title":"Name and surnames/Corporate name - Receiver"},{"const":"06","title":"Issuer's Tax Identification Number"},{"const":"07","title":"Receiver's Tax Identification Number"},{"const":"08","title":"Supplier's address"},{"const":"09","title":"Customer's address"},{"const":"10","title":"Item line"},{"const":"11","title":"Applicable Tax Rate"},{"const":"12","title":"Applicable Tax Amount"},{"const":"13","title":"Applicable Date/Period"},{"const":"14","title":"Invoice Class"},{"const":"15","title":"Legal literals"},{"const":"16","title":"Taxable Base"},{"const":"80","title":"Calculation of tax outputs"},{"const":"81","title":"Calculation of tax inputs"},{"const":"82","title":"Taxable Base modified due to return of packages and packaging materials"},{"const":"83","title":"Taxable Base modified due to discounts and rebates"},{"const":"84","title":"Taxable Base modified due to firm court ruling or administrative decision"},{"const":"85","title":"Taxable Base modified due to unpaid outputs where there is a judgement opening insolvency proceedings"}],"type":"string","title":"FacturaE Change","description":"FacturaE requires a specific and single code that explains why the previous invoice is being corrected."}},"type":"object","title":"Extensions","description":"Extensions for region specific requirements that may be added in the preceding\nor at the document level, according to the local rules.","recommended":["es-facturae-correction"]}},"type":"object","required":["type"],"description":"CorrectionOptions defines a structure used to pass configuration options to correct a previous invoice.","recommended":["series","ext"]}` data, err := json.Marshal(cos) require.NoError(t, err) if !assert.JSONEq(t, exp, string(data)) { @@ -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..a20f1543 100644 --- a/cbc/key.go +++ b/cbc/key.go @@ -75,14 +75,22 @@ 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) + 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. func (k Key) HasPrefix(ke Key) bool { ks := strings.SplitN(k.String(), KeySeparator, 2) - if len(ks) == 0 { - return false - } return ks[0] == ke.String() } diff --git a/cbc/key_test.go b/cbc/key_test.go index 928a2a09..0d054e61 100644 --- a/cbc/key_test.go +++ b/cbc/key_test.go @@ -1,10 +1,13 @@ package cbc_test import ( + "encoding/json" "testing" "github.com/invopop/gobl/cbc" + "github.com/invopop/validation" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestKey(t *testing.T) { @@ -50,6 +53,11 @@ func TestKeyHas(t *testing.T) { assert.True(t, k.Has("pro")) } +func TestKeyStrings(t *testing.T) { + keys := []cbc.Key{"a", "b", "c"} + assert.Equal(t, []string{"a", "b", "c"}, cbc.KeyStrings(keys)) +} + func TestKeyHasPrefix(t *testing.T) { k := cbc.Key("standard") assert.True(t, k.HasPrefix("standard")) @@ -57,6 +65,13 @@ func TestKeyHasPrefix(t *testing.T) { k = k.With("pro") assert.True(t, k.HasPrefix("standard")) assert.False(t, k.HasPrefix("pro")) + k = cbc.KeyEmpty + assert.False(t, k.HasPrefix("foo")) +} + +func TestKeyIsEmpty(t *testing.T) { + assert.True(t, cbc.KeyEmpty.IsEmpty()) + assert.False(t, cbc.Key("foo").IsEmpty()) } func TestKeyIn(t *testing.T) { @@ -66,8 +81,37 @@ 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") assert.Equal(t, []cbc.Key{"a", "b", "c", "d"}, keys) } + +func TestHasValidKeyIn(t *testing.T) { + k := cbc.Key("standard") + err := validation.Validate(k, cbc.HasValidKeyIn("pro", "reduced+eqs")) + assert.ErrorContains(t, err, "must be or start with a valid ke") + + err = validation.Validate(k, cbc.HasValidKeyIn("pro", "reduced+eqs", "standard")) + assert.NoError(t, err) + + k = cbc.KeyEmpty + err = validation.Validate(k, cbc.HasValidKeyIn("pro", "reduced+eqs", "standard")) + assert.NoError(t, err) +} + +func TestKeyJSONSchema(t *testing.T) { + data := []byte(`{"description":"Text identifier to be used instead of a code for a more verbose but readable identifier.", "maxLength":64, "minLength":1, "pattern":"^(?:[a-z]|[a-z0-9][a-z0-9-+]*[a-z0-9])$", "title":"Key", "type":"string"}`) + k := cbc.Key("standard") + schema := k.JSONSchema() + out, err := json.Marshal(schema) + require.NoError(t, err) + assert.JSONEq(t, string(data), string(out)) +} diff --git a/data/addons/es-verifactu-v1.json b/data/addons/es-verifactu-v1.json new file mode 100644 index 00000000..ff004412 --- /dev/null +++ b/data/addons/es-verifactu-v1.json @@ -0,0 +1,362 @@ +{ + "$schema": "https://gobl.org/draft-0/tax/addon-def", + "key": "es-verifactu-v1", + "name": { + "en": "Spain Verifactu V1" + }, + "extensions": [ + { + "key": "es-verifactu-doc-type", + "name": { + "en": "Verifactu Invoice Type Code - L2", + "es": "Código de Tipo de Factura de Verifactu - L2" + }, + "desc": { + "en": "Invoice type code used to identify the type of invoice being sent.\nSource: VeriFactu Ministerial Order:\n * https://www.boe.es/diario_boe/txt.php?id=BOE-A-2024-22138" + }, + "values": [ + { + "value": "F1", + "name": { + "en": "Invoice (Article 6, 7.2 and 7.3 of RD 1619/2012)", + "es": "Factura (Art. 6, 7.2 y 7.3 del RD 1619/2012)" + } + }, + { + "value": "F2", + "name": { + "en": "Simplified invoice (Article 6.1.d) of RD 1619/2012)", + "es": "Factura Simplificada (Art. 6.1.d) del RD 1619/2012)" + } + }, + { + "value": "F3", + "name": { + "en": "Invoice issued as a replacement for simplified invoices that have been billed and declared.", + "es": "Factura emitida en sustitución de facturas simplificadas facturadas y declaradas." + } + }, + { + "value": "R1", + "name": { + "en": "Rectified invoice: error based on law and Article 80 One, Two and Six LIVA", + "es": "Factura rectificativa: error fundado en derecho y Art. 80 Uno, Dos y Seis LIVA" + } + }, + { + "value": "R2", + "name": { + "en": "Rectified invoice: error based on law and Article 80.3", + "es": "Factura rectificativa: artículo 80.3" + } + }, + { + "value": "R3", + "name": { + "en": "Rectified invoice: error based on law and Article 80.4", + "es": "Factura rectificativa: artículo 80.4" + } + }, + { + "value": "R4", + "name": { + "en": "Rectified invoice: Other", + "es": "Factura rectificativa: Resto" + } + }, + { + "value": "R5", + "name": { + "en": "Rectified invoice: simplified invoices", + "es": "Factura rectificativa: facturas simplificadas" + } + } + ] + }, + { + "key": "es-verifactu-correction-type", + "name": { + "en": "Verifactu Correction Type Code - L3", + "es": "Código de Tipo de Corrección de Verifactu - L3" + }, + "desc": { + "en": "Correction type code used to identify the type of correction being made.\nThis value will be determined automatically according to the invoice type.\nCorrective invoices will be marked as \"S\", while credit and debit notes as \"I\"." + }, + "values": [ + { + "value": "S", + "name": { + "en": "Substitution", + "es": "Por Sustitución" + } + }, + { + "value": "I", + "name": { + "en": "Differences", + "es": "Por Diferencias" + } + } + ] + }, + { + "key": "es-verifactu-op-class", + "name": { + "en": "Verifactu Operation Classification/Exemption Code - L9", + "es": "Código de Clasificación/Exención de Impuesto de Verifactu - L9" + }, + "desc": { + "en": "Operation classification code used to identify if taxes should be applied to the line.\nSource: VeriFactu Ministerial Order:\n * https://www.boe.es/diario_boe/txt.php?id=BOE-A-2024-22138\nFor details on how best to use and apply these and other codes, see the\nAEAT FAQ:\n * 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": [ + { + "value": "S1", + "name": { + "en": "Subject and Not Exempt - Without reverse charge", + "es": "Operación Sujeta y No exenta - Sin inversión del sujeto pasivo" + } + }, + { + "value": "S2", + "name": { + "en": "Subject and Not Exempt - With reverse charge", + "es": "Operación Sujeta y No exenta - Con Inversión del sujeto pasivo" + } + }, + { + "value": "N1", + "name": { + "en": "Not Subject - Articles 7, 14, others", + "es": "Operación No Sujeta artículo 7, 14, otros" + } + }, + { + "value": "N2", + "name": { + "en": "Not Subject - Due to location rules", + "es": "Operación No Sujeta por Reglas de localización" + } + } + ] + }, + { + "key": "es-verifactu-exempt", + "name": { + "en": "Verifactu Exemption Code - L10", + "es": "Código de Exención de Impuesto de Verifactu - L10" + }, + "desc": { + "en": "Exemption code used to explain why the operation is exempt from taxes." + }, + "values": [ + { + "value": "E1", + "name": { + "en": "Exempt: pursuant to Article 20. Exemptions in internal operations.", + "es": "Exenta: por el artículo 20. Exenciones en operaciones interiores." + } + }, + { + "value": "E2", + "name": { + "en": "Exempt: pursuant to Article 21. Exemptions in exports of goods.", + "es": "Exenta: por el artículo 21. Exenciones en las exportaciones de bienes." + } + }, + { + "value": "E3", + "name": { + "en": "Exempt: pursuant to Article 22. Exemptions in operations asimilated to exports.", + "es": "Exenta: por el artículo 22. Exenciones en las operaciones asimiladas a las exportaciones." + } + }, + { + "value": "E4", + "name": { + "en": "Exempt: pursuant to Articles 23 and 24. Exemptions related to temporary deposit, customs and fiscal regimes, and other situations.", + "es": "Exenta: por el artículos 23 y 24. Exenciones relativas a las situaciones de depósito temporal, regímenes aduaneros y fiscales, y otras situaciones." + } + }, + { + "value": "E5", + "name": { + "en": "Exempt: pursuant to Article 25. Exemptions in the delivery of goods destined to another Member State.", + "es": "Exenta: por el artículo 25. Exenciones en las entregas de bienes destinados a otro Estado miembro." + } + }, + { + "value": "E6", + "name": { + "en": "Exempt: pursuant to other reasons", + "es": "Exenta: por otra causa" + } + } + ] + }, + { + "key": "es-verifactu-regime", + "name": { + "en": "VAT/IGIC Regime Code - L8A/B", + "es": "Código de Régimen de IVA/IGIC - L8A/B" + }, + "desc": { + "en": "Identify the type of VAT or IGIC regime applied to the operation. This list combines lists L8A which include values for VAT, and L8B for IGIC." + }, + "values": [ + { + "value": "01", + "name": { + "en": "General regime operation", + "es": "Operación de régimen general" + } + }, + { + "value": "02", + "name": { + "en": "Export", + "es": "Exportación" + } + }, + { + "value": "03", + "name": { + "en": "Special regime for used goods, art objects, antiques and collectibles", + "es": "Operaciones a las que se aplique el régimen especial de bienes usados, objetos de arte, antigüedades y objetos de colección" + } + }, + { + "value": "04", + "name": { + "en": "Special regime for investment gold", + "es": "Régimen especial del oro de inversión" + } + }, + { + "value": "05", + "name": { + "en": "Special regime for travel agencies", + "es": "Régimen especial de las agencias de viajes" + } + }, + { + "value": "06", + "name": { + "en": "Special regime for VAT/IGIC groups (Advanced Level)", + "es": "Régimen especial grupo de entidades en IVA/IGIC (Nivel Avanzado)" + } + }, + { + "value": "07", + "name": { + "en": "Special cash accounting regime", + "es": "Régimen especial del criterio de caja" + } + }, + { + "value": "08", + "name": { + "en": "Operations subject to a different regime", + "es": "Operaciones sujetas a un régimen diferente" + } + }, + { + "value": "09", + "name": { + "en": "Billing of travel agency services acting as mediators in name and on behalf of others", + "es": "Facturación de las prestaciones de servicios de agencias de viaje que actúan como mediadoras en nombre y por cuenta ajena" + } + }, + { + "value": "10", + "name": { + "en": "Collection of professional fees or rights on behalf of third parties", + "es": "Cobros por cuenta de terceros de honorarios profesionales o de derechos derivados de la propiedad industrial" + } + }, + { + "value": "11", + "name": { + "en": "Business premises rental operations", + "es": "Operaciones de arrendamiento de local de negocio" + } + }, + { + "value": "14", + "name": { + "en": "Invoice with pending VAT/IGIC accrual in work certifications for Public Administration", + "es": "Factura con IVA/IGIC pendiente de devengo en certificaciones de obra cuyo destinatario sea una Administración Pública" + } + }, + { + "value": "15", + "name": { + "en": "Invoice with pending VAT/IGIC accrual in successive tract operations", + "es": "Factura con IVA/IGIC pendiente de devengo en operaciones de tracto sucesivo" + } + }, + { + "value": "17", + "name": { + "en": "Operation under OSS and IOSS regimes (VAT) / Special regime for retail traders. (IGIC)", + "es": "Operación acogida a alguno de los regímenes previstos en el capítulo XI del título IX (OSS e IOSS, IVA) / Régimen especial de comerciante minorista. (IGIC)" + } + }, + { + "value": "18", + "name": { + "en": "Equivalence surcharge (VAT) / Special regime for small traders or retailers (IGIC)", + "es": "Recargo de equivalencia (IVA) / Régimen especial del pequeño comerciante o minorista (IGIC)" + } + }, + { + "value": "19", + "name": { + "en": "Operations included in the Special Regime for Agriculture, Livestock and Fisheries", + "es": "Operaciones de actividades incluidas en el Régimen Especial de Agricultura, Ganadería y Pesca (REAGYP)" + } + }, + { + "value": "20", + "name": { + "en": "Simplified regime (VAT only)", + "es": "Régimen simplificado (IVA only)" + } + } + ] + } + ], + "scenarios": [ + { + "schema": "bill/invoice", + "list": [ + { + "type": [ + "standard" + ], + "ext": { + "es-verifactu-doc-type": "F1" + } + }, + { + "type": [ + "standard" + ], + "tags": [ + "simplified" + ], + "ext": { + "es-verifactu-doc-type": "F2" + } + } + ] + } + ], + "corrections": [ + { + "schema": "bill/invoice", + "extensions": [ + "es-verifactu-doc-type" + ] + } + ] +} \ No newline at end of file diff --git a/data/schemas/bill/correction-options.json b/data/schemas/bill/correction-options.json index d77c02a0..a19120de 100644 --- a/data/schemas/bill/correction-options.json +++ b/data/schemas/bill/correction-options.json @@ -36,7 +36,7 @@ "ext": { "$ref": "https://gobl.org/draft-0/tax/extensions", "title": "Extensions", - "description": "Extensions for region specific requirements." + "description": "Extensions for region specific requirements that may be added in the preceding\nor at the document level, according to the local rules." } }, "type": "object", diff --git a/data/schemas/bill/invoice.json b/data/schemas/bill/invoice.json index ef5f02ec..236fb15f 100644 --- a/data/schemas/bill/invoice.json +++ b/data/schemas/bill/invoice.json @@ -389,6 +389,10 @@ "const": "es-tbai-v1", "title": "Spain TicketBAI" }, + { + "const": "es-verifactu-v1", + "title": "Spain Verifactu V1" + }, { "const": "eu-en16931-v2017", "title": "EN 16931-1:2017" 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..f2aa930c --- /dev/null +++ b/examples/es/out/invoice-es-es-verifactu.json @@ -0,0 +1,153 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "76bad772eedf169425dc7434038175fca40fe38b6028e2683490f7623b103827" + } + }, + "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", + "es-verifactu-regime": "01" + } + } + ], + "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", + "es-verifactu-regime": "01" + } + } + ], + "total": "10.00" + } + ], + "totals": { + "sum": "1630.00", + "total": "1630.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "ext": { + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" + }, + "base": "1620.00", + "percent": "21.0%", + "amount": "340.20" + }, + { + "key": "zero", + "ext": { + "es-verifactu-op-class": "S1", + "es-verifactu-regime": "01" + }, + "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..cd498009 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,19 @@ func ExtensionsRequires(keys ...cbc.Key) validation.Rule { } } +// ExtensionsExclude returns a validation rule that ensures that +// an extensions map does **not** include the provided keys. +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 +262,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 +338,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")) +}