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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions convert/breakdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ type DetalleNoSujeta struct {

type taxInfo struct {
simplifiedRegime bool
reverseCharge bool
customerRates bool
}

Expand Down Expand Up @@ -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{},
})

Expand Down Expand Up @@ -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(),
}

Expand Down Expand Up @@ -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
Expand All @@ -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...)
}
73 changes: 70 additions & 3 deletions convert/breakdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions document.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
116 changes: 116 additions & 0 deletions test/data/invoice-es-es-reverse-charge.json
Original file line number Diff line number Diff line change
@@ -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)."
}
]
}
}
Loading
Loading