diff --git a/convert/breakdown.go b/convert/breakdown.go index 646f813..c09d076 100644 --- a/convert/breakdown.go +++ b/convert/breakdown.go @@ -89,7 +89,6 @@ type DetalleNoSujeta struct { type taxInfo struct { simplifiedRegime bool - reverseCharge bool customerRates bool } @@ -145,7 +144,7 @@ func newDesgloseFactura(taxInfo taxInfo, rates []*tax.RateTotal) *DesgloseFactur }) } else { dne := df.Sujeta.NoExenta.appendDetalle(&DetalleNoExenta{ - TipoNoExenta: taxInfo.nonExemptedType(), + TipoNoExenta: nonExemptedType(rate), DesgloseIVA: &DesgloseIVA{}, }) @@ -219,9 +218,13 @@ func (di *DesgloseIVA) appendDetalle(d *DetalleIVA) *DetalleIVA { } func newDetalleIVA(taxInfo taxInfo, rate *tax.RateTotal) *DetalleIVA { + percent := num.PercentageZero + if rate.Percent != nil { + percent = *rate.Percent + } diva := &DetalleIVA{ BaseImponible: rate.Base.Rescale(2).String(), - TipoImpositivo: formatPercent(*rate.Percent), + TipoImpositivo: formatPercent(percent), CuotaImpuesto: rate.Amount.Rescale(2).String(), } @@ -249,21 +252,27 @@ func formatPercent(percent num.Percentage) string { func newTaxInfo(gobl *bill.Invoice) taxInfo { return taxInfo{ simplifiedRegime: gobl.HasTags(es.TagSimplifiedScheme), - reverseCharge: gobl.HasTags(tax.TagReverseCharge), customerRates: gobl.HasTags(tax.TagCustomerRates), } } -func (t taxInfo) nonExemptedType() string { - if t.reverseCharge { +// notSubjectExemptionCodes lists the es-tbai-exemption codes that map to +// DetalleNoSujeta/Causa. +var notSubjectExemptionCodes = []cbc.Code{"OT", "RL", "VT", "IE"} + +// reverseChargeExemptionCodes lists the es-tbai-exemption codes that map to +// DetalleNoExenta/TipoNoExenta = S2. +var reverseChargeExemptionCodes = []cbc.Code{"S2"} + +// nonExemptedType returns the TBAI TipoNoExenta value for a subject, +// non-exempt tax rate. +func nonExemptedType(r *tax.RateTotal) string { + if r.Ext.Get(tbai.ExtKeyExempt).In(reverseChargeExemptionCodes...) { return "S2" } - return "S1" } -var notSubjectExemptionCodes = []cbc.Code{"OT", "RL"} - func (t taxInfo) isNoSujeta(r *tax.RateTotal) bool { if t.customerRates { return true @@ -279,5 +288,8 @@ func (t taxInfo) causaNoSujeta(r *tax.RateTotal) string { } func (taxInfo) isExenta(r *tax.RateTotal) bool { - return r.Percent == nil && !r.Ext.Get(tbai.ExtKeyExempt).In(notSubjectExemptionCodes...) + code := r.Ext.Get(tbai.ExtKeyExempt) + return r.Percent == nil && + !code.In(notSubjectExemptionCodes...) && + !code.In(reverseChargeExemptionCodes...) } diff --git a/convert/breakdown_test.go b/convert/breakdown_test.go index 0cfaa2e..ab56e21 100644 --- a/convert/breakdown_test.go +++ b/convert/breakdown_test.go @@ -449,22 +449,89 @@ func TestDesgloseConversion(t *testing.T) { assert.Equal(t, "S", desgloseIVA.DetalleIVA[0].OperacionEnRecargoDeEquivalenciaORegimenSimplificado) }) - t.Run("should mark tax details if there is reverse charge", func(t *testing.T) { + t.Run("should route es-tbai-exemption=S2 to Sujeta.NoExenta with TipoNoExenta=S2", func(t *testing.T) { goblInvoice := invoiceFromCountry("ES") - goblInvoice.SetTags(tax.TagReverseCharge) goblInvoice.Lines = []*bill.Line{{ Index: 1, Quantity: num.MakeAmount(100, 0), Item: &org.Item{Name: "A", Price: num.NewAmount(10, 0)}, - Taxes: tax.Set{&tax.Combo{Category: tax.CategoryVAT, Rate: "standard"}}, + Taxes: tax.Set{ + &tax.Combo{ + Category: tax.CategoryVAT, + Key: tax.KeyReverseCharge, + }, + }, + }} + _ = goblInvoice.Calculate() + + invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI) + + desglose := invoice.Factura.TipoDesglose.DesgloseFactura + require.NotNil(t, desglose.Sujeta) + require.NotNil(t, desglose.Sujeta.NoExenta) + require.Len(t, desglose.Sujeta.NoExenta.DetalleNoExenta, 1) + detalle := desglose.Sujeta.NoExenta.DetalleNoExenta[0] + assert.Equal(t, "S2", detalle.TipoNoExenta) + require.Len(t, detalle.DesgloseIVA.DetalleIVA, 1) + diva := detalle.DesgloseIVA.DetalleIVA[0] + assert.Equal(t, "1000.00", diva.BaseImponible) + assert.Equal(t, "0.00", diva.TipoImpositivo) + assert.Equal(t, "0.00", diva.CuotaImpuesto) + assert.Nil(t, desglose.Sujeta.Exenta, "S2 must not appear under Sujeta.Exenta") + assert.Nil(t, desglose.NoSujeta, "S2 must not appear under NoSujeta") + }) + + t.Run("should route es-tbai-exemption=S2 set via extension only to Sujeta.NoExenta", func(t *testing.T) { + goblInvoice := invoiceFromCountry("ES") + goblInvoice.Lines = []*bill.Line{{ + Index: 1, + Quantity: num.MakeAmount(100, 0), + Item: &org.Item{Name: "A", Price: num.NewAmount(10, 0)}, + Taxes: tax.Set{ + &tax.Combo{ + Category: tax.CategoryVAT, + Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, "S2"), + }, + }, }} _ = goblInvoice.Calculate() invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI) desglose := invoice.Factura.TipoDesglose.DesgloseFactura + require.NotNil(t, desglose.Sujeta) + require.NotNil(t, desglose.Sujeta.NoExenta) assert.Equal(t, "S2", desglose.Sujeta.NoExenta.DetalleNoExenta[0].TipoNoExenta) }) + + t.Run("should route es-tbai-exemption=VT and IE to NoSujeta", func(t *testing.T) { + for _, code := range []cbc.Code{"VT", "IE"} { + t.Run(string(code), func(t *testing.T) { + goblInvoice := invoiceFromCountry("ES") + goblInvoice.Lines = []*bill.Line{{ + Index: 1, + Quantity: num.MakeAmount(100, 0), + Item: &org.Item{Name: "A", Price: num.NewAmount(10, 0)}, + Taxes: tax.Set{ + &tax.Combo{ + Category: tax.CategoryVAT, + Key: tax.KeyOutsideScope, + Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, code), + }, + }, + }} + _ = goblInvoice.Calculate() + + invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI) + + desglose := invoice.Factura.TipoDesglose.DesgloseFactura + require.NotNil(t, desglose.NoSujeta) + require.Len(t, desglose.NoSujeta.DetalleNoSujeta, 1) + assert.Equal(t, string(code), desglose.NoSujeta.DetalleNoSujeta[0].Causa) + assert.Nil(t, desglose.Sujeta, "%s must not appear under Sujeta", code) + }) + } + }) } func invoiceFromCountry(countryCode l10n.TaxCountryCode) *bill.Invoice { diff --git a/document.go b/document.go index 4c8fc20..be2f49c 100644 --- a/document.go +++ b/document.go @@ -27,6 +27,9 @@ func (c *Client) Convert(env *gobl.Envelope) (*convert.TicketBAI, error) { if inv.Supplier.TaxID.Country != l10n.ES.Tax() { return nil, ErrValidation.withMessage("only spanish invoices are supported") } + if !tbai.V1.In(inv.GetAddons()...) { + return nil, ErrValidation.withMessage("invoice must declare the %s addon", tbai.V1) + } if inv.Totals == nil || inv.Totals.Taxes == nil { return nil, ErrValidation.withMessage("missing taxes") } diff --git a/test/data/invoice-es-es-reverse-charge.json b/test/data/invoice-es-es-reverse-charge.json new file mode 100644 index 0000000..6beefe7 --- /dev/null +++ b/test/data/invoice-es-es-reverse-charge.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "0192237d-49c2-79a7-8249-4280119d8adf", + "dig": { + "alg": "sha256", + "val": "e26cf550a130cf4220ae9a86da1c49fbc326851767131b4b63cc8e22d62c7f23" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "ES", + "$addons": [ + "es-tbai-v1" + ], + "uuid": "3b445a46-a0ac-11ee-a4ee-e6a7901137ee", + "type": "standard", + "series": "RC", + "code": "0001", + "issue_date": "2022-02-01", + "currency": "EUR", + "tax": { + "ext": { + "es-tbai-region": "BI" + } + }, + "supplier": { + "name": "ZIURTAPEN ZERBITZU ENPRESA-EMPRESA CERTIFICACION Y SERVICIOS IZENPE SA", + "tax_id": { + "country": "ES", + "code": "S7836107H" + }, + "addresses": [ + { + "num": "42", + "street": "San Frantzisko", + "locality": "Bilbo", + "region": "Bizkaia", + "code": "48003", + "country": "ES" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Customer", + "tax_id": { + "country": "ES", + "code": "54387763P" + } + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Construction services subject to reverse charge", + "price": "90.00", + "unit": "h", + "ext": { + "es-tbai-product": "services" + } + }, + "sum": "1800.00", + "taxes": [ + { + "cat": "VAT", + "key": "reverse-charge", + "ext": { + "es-tbai-exemption": "S2", + "es-tbai-product": "services" + } + } + ], + "total": "1800.00" + } + ], + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "reverse-charge", + "ext": { + "es-tbai-exemption": "S2", + "es-tbai-product": "services" + }, + "base": "1800.00", + "amount": "0.00" + } + ], + "amount": "0.00" + } + ], + "sum": "0.00" + }, + "tax": "0.00", + "total_with_tax": "1800.00", + "payable": "1800.00" + }, + "notes": [ + { + "key": "general", + "text": "Reverse-charge construction services (Art. 84.1.2.f LIVA)." + } + ] + } +} \ No newline at end of file diff --git a/test/data/out/invoice-es-es-reverse-charge.xml b/test/data/out/invoice-es-es-reverse-charge.xml new file mode 100644 index 0000000..ecc6a0c --- /dev/null +++ b/test/data/out/invoice-es-es-reverse-charge.xml @@ -0,0 +1,163 @@ + + + + 1.2 + + + + S7836107H + ZIURTAPEN ZERBITZU ENPRESA-EMPRESA CERTIFICACION Y SERVICIOS IZENPE SA + + + + 54387763P + Sample Customer + + + T + + + + RC + 0001 + 01-02-2022 + 05:00:00 + N + + + 01-02-2022 + Reverse-charge construction services (Art. 84.1.2.f LIVA). + + + Construction services subject to reverse charge + 20 + 90.00 + 0.00 + 1800.00 + + + 1800.00 + 0.00 + + + 01 + + + + + + + + + S2 + + + 1800.00 + 0.00 + 0.00 + + + + + + + + + + + AF + 1234567890 + 01-01-2021 + 1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + + + My License + + 12345678A + + My Software + 1.0 + + + + + + + + + + + + e+52IzVqxhaDlzMOO5TgZr5yZRf3zx9abqb1tO5hXX7F3XT/KEADKLUuaTvabwBrs09flGAUHfxzJdyo0DikYg== + + + + gh73jUF5a6UHUrk/LAyx1Nuc+ASHjOPP0rPAFq3bgXXPX0qgQZVAVoAFt4fSFJLVJWHt+08Vxop+EZpT/cThzQ== + + + + + + + ROtvBpNSVJyWeKxWKKx4KYgHs62nlUESuiMwJWvvtbwHGv8McNSc8FzKg1MtTqzrK8PSG326Y1RLaNSnT5m+0g== + + + IKxnsdndTQDy59tDxbzd8kKNVki+Z9f1pxOHOI37lzfMHzC4p/h8qQCDRzVGtzFYVnhmz/lFFCqv2jNAoaXy12E2dzzKUBtnPAAyAMQXjD+XAtvyQcVraAeX6qxnZ1vBN87t6UD/VkZuQb2Ju3FbFf7dtv/BxOjkCmXQCcQiujxpRZh4LKiYZk5vDozIRIq6WiU7nLVdPZZPVvwePe6qZDoNyMJEahnSnIW1xt2qIliwmLZMGeE2s77MXjf6KNVhuVZSacQlkojkxZq6JZcDcrtHR4Ae09B93gxGpaArQAIbjx9ih2mactc3JeQgQMjbI2eoGIkicjhCgCqHv5CERg== + + + MIIJtzCCB5+gAwIBAgICG3YwDQYJKoZIhvcNAQELBQAwgZcxCzAJBgNVBAYTAkVTMRQwEgYDVQQKDAtJWkVOUEUgUy5BLjE6MDgGA1UECwwxTlpaIFppdXJ0YWdpcmkgcHVibGlrb2EgLSBDZXJ0aWZpY2FkbyBwdWJsaWNvIFNDSTE2MDQGA1UEAwwtQ0EgZGUgQ2l1ZGFkYW5vcyB5IEVudGlkYWRlcyAoNCkgLSBERVNBUlJPTExPMB4XDTIxMDMxNTEwMDQzN1oXDTI1MDMxNTEwMDQzN1owggFtMQswCQYDVQQGEwJFUzFvMG0GA1UEDQxmUmVnOkdhc3RlaXogL0hvamE6Qi0xNjY0NzIgL1RvbW86MzczOTMgL1NlY2Npb246OCAvTGlicm86MSAvRm9saW86MjAgL0ZlY2hhOjEwLTAyLTE5NzQgL0luc2NyaXBjacOzbjoxMRgwFgYDVQRhDA9WQVRFUy1TNzgzNjEwN0gxPTA7BgNVBAsMNE9yZGV6a2FyaSB6aXVydGFnaXJpYSAtIENlcnRpZmljYWRvIGRlIHJlcHJlc2VudGFudGUxFDASBgNVBAoMC0laRU5QRSBTLkEuMRIwEAYDVQQFEwk5OTk5OTk3M0sxGDAWBgNVBAQMD0ZJQ1RJQ0lPIEFDVElWTzEWMBQGA1UEKgwNUkVQUkVTRU5UQU5URTE4MDYGA1UEAwwvOTk5OTk5NzNLIFJFUFJFU0VOVEFOVEUgRklDVElDSU8gKFI6IFM3ODM2MTA3SCkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCsI8cbiOSsEMrK+lr6Vn7xeDlI1UJIVWM4kMTyXoFcU9F7LkdbSv5jS1D1g3/c8YId1nFjPrHXjpBbjv4Am9QEvHKPa9djI9lTKS3gut5DDU1ePRAagnSCAr2Y6m4isbMF54S5tp0/Ng+myx5c2E+hMmgNw6uZ9KvdwaYY1gQW/N/7qS0KlA1eB0CSHyzZeVRgbYAXI6AMCtuYCRVNLbnzJBvSN0J4SuZeiM/KK0I0oj/8THajszp8hg3v2cfOMAGu5cM3yuBAPTBPBZCkGofwZqMn2ioMZXwRYuXJv4UEncs+d9qZbVGFpc9y0vwbQuPhZU2omSGTWedL4DlnbXDHAgMBAAGjggQyMIIELjCBxwYDVR0SBIG/MIG8hhVodHRwOi8vd3d3Lml6ZW5wZS5jb22BD2luZm9AaXplbnBlLmNvbaSBkTCBjjFHMEUGA1UECgw+SVpFTlBFIFMuQS4gLSBDSUYgQTAxMzM3MjYwLVJNZXJjLlZpdG9yaWEtR2FzdGVpeiBUMTA1NSBGNjIgUzgxQzBBBgNVBAkMOkF2ZGEgZGVsIE1lZGl0ZXJyYW5lbyBFdG9yYmlkZWEgMTQgLSAwMTAxMCBWaXRvcmlhLUdhc3RlaXowDgYDVR0PAQH/BAQDAgXgMB8GA1UdJQQYMBYGCCsGAQUFBwMCBgorBgEEAYI3CgMMMB0GA1UdDgQWBBQRR3q3tcdVPr8U7NS+zkf7EBt32jAfBgNVHSMEGDAWgBRv//0N8gxjDyZxXRrgb2VkZjhrkzCCATQGA1UdIASCASswggEnMIIBDQYJKwYBBAHzOWYMMIH/MCUGCCsGAQUFBwIBFhlodHRwOi8vd3d3Lml6ZW5wZS5jb20vY3BzMIHVBggrBgEFBQcCAjCByAyBxUtvbnRzdWx0YSB3d3cuaXplbnBlLmNvbS1lbiBiYWxkaW50emFrIGV0YSBrb25kaXppb2FrIHppdXJ0YWdpcmlhbiBmaWRhdHUgZWRvIGVyYWJpbGkgYXVycmV0aWsgLSBDb25zdWx0ZSBlbiB3d3cuaXplbnBlLmNvbSBsb3MgdMOpcm1pbm9zIHkgY29uZGljaW9uZXMgYW50ZXMgZGUgdXRpbGl6YXIgbyBjb25maWFyIGVuIGVsIGNlcnRpZmljYWRvMAkGBwQAi+xAAQIwCQYHYIVUAQMFCDCBogYIKwYBBQUHAQEEgZUwgZIwJQYIKwYBBQUHMAGGGWh0dHA6Ly9vY3NwZGVzLml6ZW5wZS5jb20waQYIKwYBBQUHMAKGXWh0dHA6Ly93d3cuaXplbnBlLmNvbS9jb250ZW5pZG9zL2luZm9ybWFjaW9uL2Nhc19pemVucGUvZXNfY2FzL2FkanVudG9zL0NDRUVSX2NlcnRfc2hhMjU2LmNydDCB2wYIKwYBBQUHAQMEgc4wgcswCAYGBACORgEBMAgGBgQAjkYBBDALBgYEAI5GAQMCAQ8wfAYGBACORgEFMHIwJBYeaHR0cHM6Ly93d3cuaXplbnBlLmNvbS9wZHMvZW4vEwJlbjAkFh5odHRwczovL3d3dy5pemVucGUuY29tL3Bkcy9ldS8TAmV1MCQWHmh0dHBzOi8vd3d3Lml6ZW5wZS5jb20vcGRzL2VzLxMCZXMwEwYGBACORgEGMAkGBwQAjkYBBgEwFQYIKwYBBQUHCwIwCQYHBACL7EkBAjA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vY3JsZGVzLml6ZW5wZS5jb20vY2dpLWJpbi9jcmwyMA0GCSqGSIb3DQEBCwUAA4ICAQCJaRL+xyX6HFu/6AX7N0j/r1ZB3OAY8t4S3KBvxQBs/PGeQmHFr8cnFXxb2cfkZ/5IzDDMElicChXq86BgaXn6xxtw4q30/qsuwz8iwF+mLKENFGIYTdxmaGCuSBwhENWrv03uUeskL4gIMQIu5fhpdZRj2aW4ccsb9QdfLCHRtjxrgZMoL7tpXRYQcpKgMKroAg3PuIhwdg3eLhVZvihtUJ0oNGtEBz+04eRCZjXx8dWVrmYxfTEqHFaYc8Nxu6AuQvPQdBAC2DoxYLUpihUBvKo8aUrU/QhUCue76Sq9hnzi6TXCofNeixMZBB+ODwvcInWTX0N/m7zC/9wRcQ1vE6gO6lbNd6JnwiLHclf+oLAsRLWwN9dEeDEER2IvtlmIapKhxDrPQ9zSAiibaVSAVxvWC5bal2CgfgijlamDQ8lPD+/Fv1O6s8hGTEfMiDNErCC7IWn3ckjg+Ipz11DQO2hRI/VmarFDFtreavVsSgwseVJIQxavgOzMJVFRx1TQCBZFvW9RnM32QDygq2vrs234fD3ak8DtdmKd0aPLNYSN3zaiaPOzfhK6Z5m36Zltqzhzjg0YkoPcbmPGUO5tq7X7SwFeb0Kx7gEPzZHavbcnNYtuPgqFJklNk2+3zztodccRgZGtLRQJay+xwSNh8YiU+NmIgx2ad24qugodzg== + + + + rCPHG4jkrBDKyvpa+lZ+8Xg5SNVCSFVjOJDE8l6BXFPRey5HW0r+Y0tQ9YN/3PGCHdZxYz6x146QW47+AJvUBLxyj2vXYyPZUykt4LreQw1NXj0QGoJ0ggK9mOpuIrGzBeeEubadPzYPpsseXNhPoTJoDcOrmfSr3cGmGNYEFvzf+6ktCpQNXgdAkh8s2XlUYG2AFyOgDArbmAkVTS258yQb0jdCeErmXojPyitCNKI//Ex2o7M6fIYN79nHzjABruXDN8rgQD0wTwWQpBqH8GajJ9oqDGV8EWLlyb+FBJ3LPnfamW1RhaXPctL8G0Lj4WVNqJkhk1nnS+A5Z21wxw== + AQAB + + + + + + + + 2022-02-01T04:00:00+00:00 + + + + + 2GrtUTk1CEDaqlG+Cq/RIrbT29BNG+spDJ8FVfBsMoyCSuzhnp/MSqbKXz1n6veO0QIXxMk2FfWtIG5R5Tclkw== + + + CN=CA de Ciudadanos y Entidades (4) - DESARROLLO,OU=NZZ Ziurtagiri publikoa - Certificado publico SCI,O=IZENPE S.A.,C=ES + 7030 + + + + + + + https://www.batuz.eus/fitxategiak/batuz/ticketbai/sinadura_elektronikoaren_zehaztapenak_especificaciones_de_la_firma_electronica_v1_0.pdf + + + + Quzn98x3PMbSHwbUzaj5f5KOpiH0u8bvmwbbbNkO9Es= + + + + + + Thirdparty + + + + + + + urn:oid:1.2.840.10003.5.109.10 + + text/xml + + + + + + + \ No newline at end of file