Skip to content

Commit 8959701

Browse files
pmenendzclaude
andcommitted
Read regime, exemption, and identity-type from es-tbai-* extensions
Wires the converter to read three new gobl tbai-addon extensions and folds the existing legacy paths into fallbacks: - es-tbai-regime: per-combo `ClaveRegimenIvaOpTrascendencia` value. The addon now normalizes this from per-combo signals (export, surcharge, simplified-scheme) or to 01 by default; explicit values are preserved. Legacy inference in `collectRegimeCodes` is kept as fallback for documents not run through the addon. - es-tbai-exemption: with the addon's S2 mapping for reverse-charge, the breakdown dispatcher now routes S2 to Sujeta/NoExenta/TipoNoExenta instead of CausaExencion. VT/IE join OT/RL in NoSujeta. The tag-based reverse-charge fallback is preserved. - es-tbai-identity-type: read on customer identities to select the L7 IDType in IDOtro; falls back to the existing key->code map when the extension is absent. Bumps gobl to the pseudo-version that ships both extensions in the tbai addon. Regenerates test/data and test/data/out fixtures so the extension values are present where normalization sets them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1c02772 commit 8959701

31 files changed

Lines changed: 715 additions & 121 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ The following extension can be applied to each line tax:
224224

225225
_(\*) As noted elsewhere, `RL` will be set automatically set in invoices using the `customer-rates` tax tag. It can also be set explicitly using the `es-tbai-exemption` extension in invoices not using that tag._
226226

227+
- `es-tbai-regime` - sets the `ClaveRegimenIvaOpTrascendencia` field per VAT/IGIC tax combo. Codes follow the TicketBAI XSD list (`01``17`, `51``53`). If not provided, GOBL fills it in during normalization from per-combo signals — `tax.KeyExport``02`, equivalence-surcharge rate → `51`, the invoice-level `simplified-scheme` tag → `52`, otherwise `01`. Set it explicitly when none of those defaults applies (e.g. travel agencies → `05`, cash accounting → `07`, OSS/IOSS → `17`); explicit values are always preserved.
228+
229+
- `es-tbai-identity-type` - sets the `IDType` value under `IDOtro` for the customer's identity (L7 list, codes `02``06`). Normalization maps `org.IdentityKeyPassport``03`, `IdentityKeyForeign``04`, `IdentityKeyResident``05`, `IdentityKeyOther``06`. Set it explicitly on an identity with no key (or to override). Spanish NIFs use the `NIF` field directly, and EU/non-EU tax IDs map to `IDOtro/IDType` `02`/`04` automatically.
230+
227231
### Use-Cases
228232

229233
Under what situations should the TicketBAI system be expected to function:

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: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,9 @@ func TestDesgloseConversion(t *testing.T) {
195195
&tax.Combo{Category: es.TaxCategoryIRPF, Rate: "pro"},
196196
&tax.Combo{
197197
Category: tax.CategoryVAT,
198-
Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, "OT"),
198+
Ext: tax.ExtensionsOf(cbc.CodeMap{
199+
tbai.ExtKeyExempt: "OT",
200+
}),
199201
},
200202
},
201203
}}
@@ -217,7 +219,7 @@ func TestDesgloseConversion(t *testing.T) {
217219
Taxes: tax.Set{
218220
&tax.Combo{
219221
Category: tax.CategoryVAT,
220-
Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, "RL"),
222+
Ext: tax.ExtensionsOf(cbc.CodeMap{tbai.ExtKeyExempt: "RL"}),
221223
},
222224
},
223225
}}
@@ -239,7 +241,7 @@ func TestDesgloseConversion(t *testing.T) {
239241
Taxes: tax.Set{
240242
&tax.Combo{
241243
Category: tax.CategoryVAT,
242-
Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, "RL"),
244+
Ext: tax.ExtensionsOf(cbc.CodeMap{tbai.ExtKeyExempt: "RL"}),
243245
},
244246
},
245247
}}
@@ -347,7 +349,7 @@ func TestDesgloseConversion(t *testing.T) {
347349
&tax.Combo{
348350
Category: tax.CategoryVAT,
349351
Rate: "general",
350-
Ext: tax.MakeExtensions().Set(tbai.ExtKeyProduct, "resale"),
352+
Ext: tax.ExtensionsOf(cbc.CodeMap{tbai.ExtKeyProduct: "resale"}),
351353
},
352354
},
353355
},
@@ -374,7 +376,7 @@ func TestDesgloseConversion(t *testing.T) {
374376
&tax.Combo{
375377
Category: tax.CategoryVAT,
376378
Key: tax.KeyExempt,
377-
Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, "E1"),
379+
Ext: tax.ExtensionsOf(cbc.CodeMap{tbai.ExtKeyExempt: "E1"}),
378380
},
379381
},
380382
}}
@@ -465,6 +467,92 @@ func TestDesgloseConversion(t *testing.T) {
465467
desglose := invoice.Factura.TipoDesglose.DesgloseFactura
466468
assert.Equal(t, "S2", desglose.Sujeta.NoExenta.DetalleNoExenta[0].TipoNoExenta)
467469
})
470+
471+
t.Run("should route es-tbai-exemption=S2 to Sujeta.NoExenta with TipoNoExenta=S2", func(t *testing.T) {
472+
goblInvoice := invoiceFromCountry("ES")
473+
goblInvoice.Lines = []*bill.Line{{
474+
Index: 1,
475+
Quantity: num.MakeAmount(100, 0),
476+
Item: &org.Item{Name: "A", Price: num.NewAmount(10, 0)},
477+
Taxes: tax.Set{
478+
&tax.Combo{
479+
Category: tax.CategoryVAT,
480+
Key: tax.KeyReverseCharge,
481+
},
482+
},
483+
}}
484+
_ = goblInvoice.Calculate()
485+
486+
invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI)
487+
488+
desglose := invoice.Factura.TipoDesglose.DesgloseFactura
489+
require.NotNil(t, desglose.Sujeta)
490+
require.NotNil(t, desglose.Sujeta.NoExenta)
491+
require.Len(t, desglose.Sujeta.NoExenta.DetalleNoExenta, 1)
492+
detalle := desglose.Sujeta.NoExenta.DetalleNoExenta[0]
493+
assert.Equal(t, "S2", detalle.TipoNoExenta)
494+
require.Len(t, detalle.DesgloseIVA.DetalleIVA, 1)
495+
diva := detalle.DesgloseIVA.DetalleIVA[0]
496+
assert.Equal(t, "1000.00", diva.BaseImponible)
497+
assert.Equal(t, "0.00", diva.TipoImpositivo)
498+
assert.Equal(t, "0.00", diva.CuotaImpuesto)
499+
assert.Nil(t, desglose.Sujeta.Exenta, "S2 must not appear under Sujeta.Exenta")
500+
assert.Nil(t, desglose.NoSujeta, "S2 must not appear under NoSujeta")
501+
})
502+
503+
t.Run("should route es-tbai-exemption=S2 set via extension only to Sujeta.NoExenta", func(t *testing.T) {
504+
// Caller sets the exemption code directly via the extension;
505+
// the es-tbai-v1 addon normalizer maps it back to KeyReverseCharge.
506+
goblInvoice := invoiceFromCountry("ES")
507+
goblInvoice.Lines = []*bill.Line{{
508+
Index: 1,
509+
Quantity: num.MakeAmount(100, 0),
510+
Item: &org.Item{Name: "A", Price: num.NewAmount(10, 0)},
511+
Taxes: tax.Set{
512+
&tax.Combo{
513+
Category: tax.CategoryVAT,
514+
Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, "S2"),
515+
},
516+
},
517+
}}
518+
_ = goblInvoice.Calculate()
519+
520+
invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI)
521+
522+
desglose := invoice.Factura.TipoDesglose.DesgloseFactura
523+
require.NotNil(t, desglose.Sujeta)
524+
require.NotNil(t, desglose.Sujeta.NoExenta)
525+
assert.Equal(t, "S2", desglose.Sujeta.NoExenta.DetalleNoExenta[0].TipoNoExenta)
526+
})
527+
528+
t.Run("should route es-tbai-exemption=VT and IE to NoSujeta", func(t *testing.T) {
529+
for _, code := range []cbc.Code{"VT", "IE"} {
530+
t.Run(string(code), func(t *testing.T) {
531+
goblInvoice := invoiceFromCountry("ES")
532+
goblInvoice.Lines = []*bill.Line{{
533+
Index: 1,
534+
Quantity: num.MakeAmount(100, 0),
535+
Item: &org.Item{Name: "A", Price: num.NewAmount(10, 0)},
536+
Taxes: tax.Set{
537+
&tax.Combo{
538+
Category: tax.CategoryVAT,
539+
Key: tax.KeyOutsideScope,
540+
Ext: tax.MakeExtensions().Set(tbai.ExtKeyExempt, code),
541+
},
542+
},
543+
}}
544+
_ = goblInvoice.Calculate()
545+
546+
invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI)
547+
548+
desglose := invoice.Factura.TipoDesglose.DesgloseFactura
549+
require.NotNil(t, desglose.NoSujeta)
550+
require.Len(t, desglose.NoSujeta.DetalleNoSujeta, 1)
551+
assert.Equal(t, string(code), desglose.NoSujeta.DetalleNoSujeta[0].Causa)
552+
assert.Nil(t, desglose.Sujeta, "%s must not appear under Sujeta", code)
553+
})
554+
}
555+
})
468556
}
469557

470558
func invoiceFromCountry(countryCode l10n.TaxCountryCode) *bill.Invoice {

convert/doc_test.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,32 @@ func TestInvoiceConversion(t *testing.T) {
7373
assert.Contains(t, invoice.Sujetos.Destinatarios.IDDestinatario[0].Direccion, "PO-745")
7474
})
7575

76-
t.Run("should contain the right id for abroad customers", func(t *testing.T) {
76+
t.Run("EU customer tax ID is emitted as NIF-VAT (IDType 02)", func(t *testing.T) {
7777
goblInvoice := test.LoadInvoice("sample-invoice.json")
78-
goblInvoice.Customer.TaxID = &tax.Identity{Country: "GB", Code: "PP-123456-S"}
78+
goblInvoice.Customer.TaxID = &tax.Identity{Country: "DE", Code: "DE123456789"}
79+
goblInvoice.Customer.Name = "EU Co GmbH"
80+
81+
invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI)
82+
83+
assert.Equal(t, "DE", invoice.Sujetos.Destinatarios.IDDestinatario[0].IDOtro.CodigoPais)
84+
assert.Equal(t, "DE123456789", invoice.Sujetos.Destinatarios.IDDestinatario[0].IDOtro.ID)
85+
assert.Equal(t, "02", invoice.Sujetos.Destinatarios.IDDestinatario[0].IDOtro.IDType)
86+
assert.Equal(t, "EU Co GmbH", invoice.Sujetos.Destinatarios.IDDestinatario[0].ApellidosNombreRazonSocial)
87+
})
88+
89+
t.Run("non-EU customer tax ID is emitted as Foreign Identity (IDType 04)", func(t *testing.T) {
90+
// The TicketBAI gateway rejects non-EU tax IDs sent as NIF-VAT
91+
// with B4_2000013; they must be reported as foreign identity
92+
// documents (IDType 04) instead. GB qualifies since 2020-01-31.
93+
goblInvoice := test.LoadInvoice("sample-invoice.json")
94+
goblInvoice.Customer.TaxID = &tax.Identity{Country: "GB", Code: "GB123456789"}
7995
goblInvoice.Customer.Name = "Abroad Co LLC"
8096

8197
invoice, _ := convert.NewTicketBAI(goblInvoice, ts, role, convert.ZoneBI)
8298

8399
assert.Equal(t, "GB", invoice.Sujetos.Destinatarios.IDDestinatario[0].IDOtro.CodigoPais)
84-
assert.Equal(t, "PP-123456-S", invoice.Sujetos.Destinatarios.IDDestinatario[0].IDOtro.ID)
85-
assert.Equal(t, "02", invoice.Sujetos.Destinatarios.IDDestinatario[0].IDOtro.IDType)
100+
assert.Equal(t, "GB123456789", invoice.Sujetos.Destinatarios.IDDestinatario[0].IDOtro.ID)
101+
assert.Equal(t, "04", invoice.Sujetos.Destinatarios.IDDestinatario[0].IDOtro.IDType)
86102
assert.Equal(t, "Abroad Co LLC", invoice.Sujetos.Destinatarios.IDDestinatario[0].ApellidosNombreRazonSocial)
87103
})
88104

convert/invoice.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,51 @@ func newRetencionSoportada(inv *bill.Invoice) string {
147147
}
148148

149149
func newClaves(inv *bill.Invoice) []IDClave {
150+
// Preferred: collect unique codes from the es-tbai-regime extension
151+
// set per VAT tax combo by the es-tbai-v1 addon normalizer. This is
152+
// the path that respects an explicit ClaveRegimenIvaOpTrascendencia
153+
// the caller may have set.
154+
if codes := collectRegimeCodes(inv); len(codes) > 0 {
155+
claves := make([]IDClave, 0, len(codes))
156+
for _, c := range codes {
157+
claves = append(claves, IDClave{ClaveRegimenIvaOpTrascendencia: c})
158+
}
159+
return claves
160+
}
161+
162+
return legacyClaves(inv)
163+
}
164+
165+
// collectRegimeCodes returns the distinct ClaveRegimenIvaOpTrascendencia
166+
// codes carried by the invoice's VAT rate totals via the es-tbai-regime
167+
// extension, preserving the order of first appearance.
168+
func collectRegimeCodes(inv *bill.Invoice) []string {
169+
if inv.Totals == nil || inv.Totals.Taxes == nil {
170+
return nil
171+
}
172+
vat := inv.Totals.Taxes.Category(tax.CategoryVAT)
173+
if vat == nil {
174+
return nil
175+
}
176+
seen := make(map[string]bool, len(vat.Rates))
177+
codes := make([]string, 0, len(vat.Rates))
178+
for _, rate := range vat.Rates {
179+
c := rate.Ext.Get(tbai.ExtKeyRegime).String()
180+
if c == "" || seen[c] {
181+
continue
182+
}
183+
seen[c] = true
184+
codes = append(codes, c)
185+
}
186+
return codes
187+
}
188+
189+
// legacyClaves is the original invoice-level inference of
190+
// ClaveRegimenIvaOpTrascendencia, kept as a fallback for callers that
191+
// build TicketBAI documents from GOBL invoices that have not been
192+
// normalized by the es-tbai-v1 addon. New code should rely on the
193+
// es-tbai-regime extension instead.
194+
func legacyClaves(inv *bill.Invoice) []IDClave {
150195
claves := []IDClave{}
151196

152197
if inv.Customer != nil && partyCountry(inv.Customer) != "ES" {
@@ -207,6 +252,9 @@ func newFacturasRectificadasSustituidas(inv *bill.Invoice) *FacturasRectificadas
207252
}
208253
}
209254

255+
// hasSurchargedLines is part of the legacy ClaveRegimenIvaOpTrascendencia
256+
// inference. Detection now happens at addon-normalization time and is
257+
// reflected in the es-tbai-regime extension; see legacyClaves.
210258
func hasSurchargedLines(inv *bill.Invoice) bool {
211259
if inv.Totals == nil || inv.Totals.Taxes == nil {
212260
return false
@@ -225,6 +273,8 @@ func hasSurchargedLines(inv *bill.Invoice) bool {
225273
return false
226274
}
227275

276+
// underSimplifiedRegime is part of the legacy regime inference; see
277+
// legacyClaves.
228278
func underSimplifiedRegime(inv *bill.Invoice) bool {
229279
return inv.HasTags(es.TagSimplifiedScheme)
230280
}

0 commit comments

Comments
 (0)