Skip to content

Commit 8b6e4de

Browse files
pmenendzclaude
andcommitted
Route es-tbai-exemption=S2 to Sujeta/NoExenta
The converter previously mapped any rate without a percent to either NoSujeta (for OT/RL) or Exenta (everything else), so reverse-charge combos (es-tbai-exemption=S2, percent nil after addon normalization) ended up under CausaExencion=S2, which is invalid per the TBAI XSD. Extend the dispatcher to read es-tbai-exemption set by the es-tbai-v1 addon: S2 routes to Sujeta/NoExenta/TipoNoExenta=S2 with a 0% DetalleIVA, and VT/IE join OT/RL in NoSujeta. The tag-based reverse-charge fallback is preserved for invoices built without running the addon. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d64a86f commit 8b6e4de

4 files changed

Lines changed: 400 additions & 8 deletions

File tree

convert/breakdown.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func newDesgloseFactura(taxInfo taxInfo, rates []*tax.RateTotal) *DesgloseFactur
145145
})
146146
} else {
147147
dne := df.Sujeta.NoExenta.appendDetalle(&DetalleNoExenta{
148-
TipoNoExenta: taxInfo.nonExemptedType(),
148+
TipoNoExenta: taxInfo.nonExemptedType(rate),
149149
DesgloseIVA: &DesgloseIVA{},
150150
})
151151

@@ -219,9 +219,13 @@ func (di *DesgloseIVA) appendDetalle(d *DetalleIVA) *DetalleIVA {
219219
}
220220

221221
func newDetalleIVA(taxInfo taxInfo, rate *tax.RateTotal) *DetalleIVA {
222+
percent := num.PercentageZero // S2 reverse-charge rates have no percent
223+
if rate.Percent != nil {
224+
percent = *rate.Percent
225+
}
222226
diva := &DetalleIVA{
223227
BaseImponible: rate.Base.Rescale(2).String(),
224-
TipoImpositivo: formatPercent(*rate.Percent),
228+
TipoImpositivo: formatPercent(percent),
225229
CuotaImpuesto: rate.Amount.Rescale(2).String(),
226230
}
227231

@@ -254,16 +258,28 @@ func newTaxInfo(gobl *bill.Invoice) taxInfo {
254258
}
255259
}
256260

257-
func (t taxInfo) nonExemptedType() string {
258-
if t.reverseCharge {
261+
// notSubjectExemptionCodes are the es-tbai-exemption values that the
262+
// es-tbai-v1 addon assigns to "outside-scope" tax combos; they map to
263+
// DetalleNoSujeta/Causa in the TicketBAI XML.
264+
var notSubjectExemptionCodes = []cbc.Code{"OT", "RL", "VT", "IE"}
265+
266+
// reverseChargeExemptionCodes are the es-tbai-exemption values that map
267+
// to DetalleNoExenta/TipoNoExenta = S2 (subject to VAT with reverse
268+
// charge) in the TicketBAI XML.
269+
var reverseChargeExemptionCodes = []cbc.Code{"S2"}
270+
271+
// nonExemptedType returns the TBAI TipoNoExenta value for a "subject and
272+
// non-exempt" tax rate. The new path consults the es-tbai-exemption
273+
// extension set by the es-tbai-v1 addon normalizer; the legacy fallback
274+
// relies on the invoice-wide reverse-charge tag for callers that have
275+
// not been normalized through the addon.
276+
func (t taxInfo) nonExemptedType(r *tax.RateTotal) string {
277+
if t.reverseCharge || r.Ext.Get(tbai.ExtKeyExempt).In(reverseChargeExemptionCodes...) {
259278
return "S2"
260279
}
261-
262280
return "S1"
263281
}
264282

265-
var notSubjectExemptionCodes = []cbc.Code{"OT", "RL"}
266-
267283
func (t taxInfo) isNoSujeta(r *tax.RateTotal) bool {
268284
if t.customerRates {
269285
return true
@@ -279,5 +295,8 @@ func (t taxInfo) causaNoSujeta(r *tax.RateTotal) string {
279295
}
280296

281297
func (taxInfo) isExenta(r *tax.RateTotal) bool {
282-
return r.Percent == nil && !r.Ext.Get(tbai.ExtKeyExempt).In(notSubjectExemptionCodes...)
298+
code := r.Ext.Get(tbai.ExtKeyExempt)
299+
return r.Percent == nil &&
300+
!code.In(notSubjectExemptionCodes...) &&
301+
!code.In(reverseChargeExemptionCodes...)
283302
}

convert/breakdown_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,92 @@ func TestDesgloseConversion(t *testing.T) {
465465
desglose := invoice.Factura.TipoDesglose.DesgloseFactura
466466
assert.Equal(t, "S2", desglose.Sujeta.NoExenta.DetalleNoExenta[0].TipoNoExenta)
467467
})
468+
469+
t.Run("should route es-tbai-exemption=S2 to Sujeta.NoExenta with TipoNoExenta=S2", func(t *testing.T) {
470+
goblInvoice := invoiceFromCountry("ES")
471+
goblInvoice.Lines = []*bill.Line{{
472+
Index: 1,
473+
Quantity: num.MakeAmount(100, 0),
474+
Item: &org.Item{Name: "A", Price: num.NewAmount(10, 0)},
475+
Taxes: tax.Set{
476+
&tax.Combo{
477+
Category: tax.CategoryVAT,
478+
Key: tax.KeyReverseCharge,
479+
},
480+
},
481+
}}
482+
_ = goblInvoice.Calculate()
483+
484+
invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI)
485+
486+
desglose := invoice.Factura.TipoDesglose.DesgloseFactura
487+
require.NotNil(t, desglose.Sujeta)
488+
require.NotNil(t, desglose.Sujeta.NoExenta)
489+
require.Len(t, desglose.Sujeta.NoExenta.DetalleNoExenta, 1)
490+
detalle := desglose.Sujeta.NoExenta.DetalleNoExenta[0]
491+
assert.Equal(t, "S2", detalle.TipoNoExenta)
492+
require.Len(t, detalle.DesgloseIVA.DetalleIVA, 1)
493+
diva := detalle.DesgloseIVA.DetalleIVA[0]
494+
assert.Equal(t, "1000.00", diva.BaseImponible)
495+
assert.Equal(t, "0.00", diva.TipoImpositivo)
496+
assert.Equal(t, "0.00", diva.CuotaImpuesto)
497+
assert.Nil(t, desglose.Sujeta.Exenta, "S2 must not appear under Sujeta.Exenta")
498+
assert.Nil(t, desglose.NoSujeta, "S2 must not appear under NoSujeta")
499+
})
500+
501+
t.Run("should route es-tbai-exemption=S2 set via extension only to Sujeta.NoExenta", func(t *testing.T) {
502+
// Caller sets the exemption code directly via the extension;
503+
// the es-tbai-v1 addon normalizer maps it back to KeyReverseCharge.
504+
goblInvoice := invoiceFromCountry("ES")
505+
goblInvoice.Lines = []*bill.Line{{
506+
Index: 1,
507+
Quantity: num.MakeAmount(100, 0),
508+
Item: &org.Item{Name: "A", Price: num.NewAmount(10, 0)},
509+
Taxes: tax.Set{
510+
&tax.Combo{
511+
Category: tax.CategoryVAT,
512+
Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, "S2"),
513+
},
514+
},
515+
}}
516+
_ = goblInvoice.Calculate()
517+
518+
invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI)
519+
520+
desglose := invoice.Factura.TipoDesglose.DesgloseFactura
521+
require.NotNil(t, desglose.Sujeta)
522+
require.NotNil(t, desglose.Sujeta.NoExenta)
523+
assert.Equal(t, "S2", desglose.Sujeta.NoExenta.DetalleNoExenta[0].TipoNoExenta)
524+
})
525+
526+
t.Run("should route es-tbai-exemption=VT and IE to NoSujeta", func(t *testing.T) {
527+
for _, code := range []cbc.Code{"VT", "IE"} {
528+
t.Run(string(code), func(t *testing.T) {
529+
goblInvoice := invoiceFromCountry("ES")
530+
goblInvoice.Lines = []*bill.Line{{
531+
Index: 1,
532+
Quantity: num.MakeAmount(100, 0),
533+
Item: &org.Item{Name: "A", Price: num.NewAmount(10, 0)},
534+
Taxes: tax.Set{
535+
&tax.Combo{
536+
Category: tax.CategoryVAT,
537+
Key: tax.KeyOutsideScope,
538+
Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, code),
539+
},
540+
},
541+
}}
542+
_ = goblInvoice.Calculate()
543+
544+
invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI)
545+
546+
desglose := invoice.Factura.TipoDesglose.DesgloseFactura
547+
require.NotNil(t, desglose.NoSujeta)
548+
require.Len(t, desglose.NoSujeta.DetalleNoSujeta, 1)
549+
assert.Equal(t, string(code), desglose.NoSujeta.DetalleNoSujeta[0].Causa)
550+
assert.Nil(t, desglose.Sujeta, "%s must not appear under Sujeta", code)
551+
})
552+
}
553+
})
468554
}
469555

470556
func invoiceFromCountry(countryCode l10n.TaxCountryCode) *bill.Invoice {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
{
2+
"$schema": "https://gobl.org/draft-0/envelope",
3+
"head": {
4+
"uuid": "0192237d-49c2-79a7-8249-4280119d8adf",
5+
"dig": {
6+
"alg": "sha256",
7+
"val": "c4759712e2abf4b3bc807bdf991930c07148cb325c41350a5752bec0ac66e0f1"
8+
}
9+
},
10+
"doc": {
11+
"$schema": "https://gobl.org/draft-0/bill/invoice",
12+
"$regime": "ES",
13+
"$addons": [
14+
"es-tbai-v1"
15+
],
16+
"uuid": "3b445a46-a0ac-11ee-a4ee-e6a7901137ee",
17+
"type": "standard",
18+
"series": "RC",
19+
"code": "0001",
20+
"issue_date": "2022-02-01",
21+
"currency": "EUR",
22+
"tax": {
23+
"ext": {
24+
"es-tbai-region": "BI"
25+
}
26+
},
27+
"supplier": {
28+
"name": "ZIURTAPEN ZERBITZU ENPRESA-EMPRESA CERTIFICACION Y SERVICIOS IZENPE SA",
29+
"tax_id": {
30+
"country": "ES",
31+
"code": "S7836107H"
32+
},
33+
"addresses": [
34+
{
35+
"num": "42",
36+
"street": "San Frantzisko",
37+
"locality": "Bilbo",
38+
"region": "Bizkaia",
39+
"code": "48003",
40+
"country": "ES"
41+
}
42+
],
43+
"emails": [
44+
{
45+
"addr": "billing@example.com"
46+
}
47+
]
48+
},
49+
"customer": {
50+
"name": "Sample Consumer",
51+
"tax_id": {
52+
"country": "NL",
53+
"code": "000099995B57"
54+
}
55+
},
56+
"lines": [
57+
{
58+
"i": 1,
59+
"quantity": "20",
60+
"item": {
61+
"name": "Development services",
62+
"price": "90.00",
63+
"unit": "h",
64+
"ext": {
65+
"es-tbai-product": "services"
66+
}
67+
},
68+
"sum": "1800.00",
69+
"taxes": [
70+
{
71+
"cat": "VAT",
72+
"key": "reverse-charge",
73+
"ext": {
74+
"es-tbai-exemption": "S2",
75+
"es-tbai-product": "services",
76+
"es-tbai-regime": "01"
77+
}
78+
}
79+
],
80+
"total": "1800.00"
81+
}
82+
],
83+
"totals": {
84+
"sum": "1800.00",
85+
"total": "1800.00",
86+
"taxes": {
87+
"categories": [
88+
{
89+
"code": "VAT",
90+
"rates": [
91+
{
92+
"key": "reverse-charge",
93+
"ext": {
94+
"es-tbai-exemption": "S2",
95+
"es-tbai-product": "services",
96+
"es-tbai-regime": "01"
97+
},
98+
"base": "1800.00",
99+
"amount": "0.00"
100+
}
101+
],
102+
"amount": "0.00"
103+
}
104+
],
105+
"sum": "0.00"
106+
},
107+
"tax": "0.00",
108+
"total_with_tax": "1800.00",
109+
"payable": "1800.00"
110+
},
111+
"notes": [
112+
{
113+
"key": "general",
114+
"text": "Reverse-charge services invoiced to an EU customer."
115+
}
116+
]
117+
}
118+
}

0 commit comments

Comments
 (0)