diff --git a/go.mod b/go.mod
index cbaa6b0..62a9200 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/invopop/gobl.fatturapa
go 1.25.0
require (
- github.com/invopop/gobl v0.503.0
+ github.com/invopop/gobl v0.504.0
github.com/invopop/xmlctx v0.13.0
github.com/invopop/xmldsig v0.10.0
github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3
diff --git a/go.sum b/go.sum
index e5d82db..4165ab0 100644
--- a/go.sum
+++ b/go.sum
@@ -28,6 +28,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/gobl v0.503.0 h1:i15dqw6OlHdEWZzCgvM7MquMKKzBq7K8VMF8MQUJhGo=
github.com/invopop/gobl v0.503.0/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0=
+github.com/invopop/gobl v0.504.0 h1:RLmZmeAk9RL4rkMUc7xQfC70/RGiBeekRwVgMsMthQA=
+github.com/invopop/gobl v0.504.0/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/invopop/xmlctx v0.13.0 h1:ZNRMC0O/A5h8InoLVSA7tIjjrhJn/NDBYfByBUpSb+g=
diff --git a/items.go b/items.go
index c9ca6df..00aa87c 100644
--- a/items.go
+++ b/items.go
@@ -130,9 +130,11 @@ func generateLineDetails(inv *bill.Invoice) []*LineDetail {
}
// attributesToOtherData maps an item's attributes to AltriDatiGestionali blocks.
-// The attribute's type (or key as a fallback) becomes the TipoDato, and its value
-// goes to the reference field that matches: text and code to RiferimentoTesto,
-// amounts to RiferimentoNumero, and dates to RiferimentoData.
+// The attribute's type (or key as a fallback) becomes the TipoDato, and each
+// value fills the reference field that matches: text and code to
+// RiferimentoTesto, amounts to RiferimentoNumero, and dates to RiferimentoData.
+// A block may carry several references at once, so an attribute holding more
+// than one value still maps to a single block.
func attributesToOtherData(attrs []*org.Attribute) []*OtherData {
var out []*OtherData
for _, a := range attrs {
@@ -147,16 +149,20 @@ func attributesToOtherData(attrs []*org.Attribute) []*OtherData {
continue
}
od := &OtherData{DataType: name}
+ // Text and code share RiferimentoTesto, so only one of them fits.
switch {
case a.Text != "":
od.TextReference = a.Text
case a.Code != "":
od.TextReference = a.Code.String()
- case a.Amount != nil:
+ }
+ if a.Amount != nil {
od.NumReference = formatAmount8(a.Amount)
- case a.Date != nil:
+ }
+ if a.Date != nil {
od.DateReference = a.Date.String()
- default:
+ }
+ if od.TextReference == "" && od.NumReference == "" && od.DateReference == "" {
// No value to map, so skip instead of emitting an empty block.
continue
}
diff --git a/items_parse.go b/items_parse.go
index fd8610d..81d4a88 100644
--- a/items_parse.go
+++ b/items_parse.go
@@ -154,9 +154,9 @@ func goblBillInvoiceAddLineDetails(inv *bill.Invoice, lineDetails []*LineDetail,
}
// otherDataToAttribute converts an AltriDatiGestionali block into a GOBL item
-// attribute. TipoDato becomes the attribute type, and one of the reference
-// fields becomes the value. A block may carry more than one reference, but an
-// attribute holds a single value, so date wins over number, which wins over text.
+// attribute. TipoDato becomes the attribute type, and each reference field
+// becomes the value that matches it. A block may carry several references at
+// once, and they all end up in the same attribute.
func otherDataToAttribute(od *OtherData) *org.Attribute {
if od == nil {
return nil
@@ -167,34 +167,30 @@ func otherDataToAttribute(od *OtherData) *org.Attribute {
if dataType == "" {
return nil
}
- text := strings.TrimSpace(od.TextReference)
number := strings.TrimSpace(od.NumReference)
dateRef := strings.TrimSpace(od.DateReference)
- a := &org.Attribute{Type: cbc.Code(dataType)}
- if dateRef != "" {
- if date, err := parseDate(dateRef); err == nil {
- a.Date = &date
- return a
- }
+ a := &org.Attribute{
+ Type: cbc.Code(dataType),
+ Text: strings.TrimSpace(od.TextReference),
}
+ // A reference that doesn't parse is kept as text rather than dropped, but
+ // only while the text is still free.
if number != "" {
if amount, err := parseAmount(number); err == nil {
a.Amount = &amount
- return a
+ } else if a.Text == "" {
+ a.Text = number
}
}
- if text != "" {
- a.Text = text
- return a
+ if dateRef != "" {
+ if date, err := parseDate(dateRef); err == nil {
+ a.Date = &date
+ } else if a.Text == "" {
+ a.Text = dateRef
+ }
}
- // Nothing parsed cleanly, so keep the raw value instead of dropping it.
- switch {
- case dateRef != "":
- a.Text = dateRef
- case number != "":
- a.Text = number
- default:
+ if a.Text == "" && a.Amount == nil && a.Date == nil {
return nil
}
return a
diff --git a/items_parse_internal_test.go b/items_parse_internal_test.go
index 6bb4649..38740ec 100644
--- a/items_parse_internal_test.go
+++ b/items_parse_internal_test.go
@@ -31,7 +31,7 @@ func TestOtherDataToAttribute(t *testing.T) {
assert.Equal(t, "not-a-number", a.Text)
})
- t.Run("prefers the date over the number and the text", func(t *testing.T) {
+ t.Run("keeps every reference of a block in one attribute", func(t *testing.T) {
a := otherDataToAttribute(&OtherData{
DataType: "SCADENZA",
TextReference: "ABC-123",
@@ -39,25 +39,26 @@ func TestOtherDataToAttribute(t *testing.T) {
DateReference: "2024-03-15",
})
require.NotNil(t, a)
+ assert.Equal(t, "ABC-123", a.Text)
+ require.NotNil(t, a.Amount)
+ assert.Equal(t, "12.50", a.Amount.String())
require.NotNil(t, a.Date)
assert.Equal(t, "2024-03-15", a.Date.String())
- assert.Nil(t, a.Amount)
- assert.Empty(t, a.Text)
})
- t.Run("prefers the number over the text", func(t *testing.T) {
+ t.Run("keeps both the text and the number", func(t *testing.T) {
a := otherDataToAttribute(&OtherData{
- DataType: "PESO",
- TextReference: "ABC-123",
- NumReference: "12.50",
+ DataType: "CASSA-PREV",
+ TextReference: "ENASARCO TC07",
+ NumReference: "80.00",
})
require.NotNil(t, a)
+ assert.Equal(t, "ENASARCO TC07", a.Text)
require.NotNil(t, a.Amount)
- assert.Equal(t, "12.50", a.Amount.String())
- assert.Empty(t, a.Text)
+ assert.Equal(t, "80.00", a.Amount.String())
})
- t.Run("falls back to the next reference when the preferred one is invalid", func(t *testing.T) {
+ t.Run("keeps the other references when the date is invalid", func(t *testing.T) {
a := otherDataToAttribute(&OtherData{
DataType: "SCADENZA",
TextReference: "ABC-123",
@@ -68,10 +69,10 @@ func TestOtherDataToAttribute(t *testing.T) {
assert.Nil(t, a.Date)
require.NotNil(t, a.Amount)
assert.Equal(t, "12.50", a.Amount.String())
- assert.Empty(t, a.Text)
+ assert.Equal(t, "ABC-123", a.Text)
})
- t.Run("falls back to the text when the number is also invalid", func(t *testing.T) {
+ t.Run("drops an unparseable reference when the text is already taken", func(t *testing.T) {
a := otherDataToAttribute(&OtherData{
DataType: "SCADENZA",
TextReference: "ABC-123",
diff --git a/items_test.go b/items_test.go
index 8af928b..900e666 100644
--- a/items_test.go
+++ b/items_test.go
@@ -134,6 +134,52 @@ func TestAltriDatiGestionaliAttributes(t *testing.T) {
assert.Equal(t, "2024-03-15", od[3].DateReference)
})
+ t.Run("should map every value of an attribute into a single block", func(t *testing.T) {
+ env := test.LoadTestFile("invoice-simple.json", test.PathGOBLFatturaPA)
+ date := cal.MakeDate(2025, 3, 10)
+ amount := num.MakeAmount(8000, 2)
+ test.ModifyInvoice(env, func(inv *bill.Invoice) {
+ inv.Lines[0].Item.Attributes = []*org.Attribute{
+ {Type: "INTENTO", Text: "08060120341234567-000001", Date: &date},
+ {Type: "CASSA-PREV", Text: "ENASARCO TC07", Amount: &amount},
+ }
+ })
+
+ doc, err := test.ConvertFromGOBL(env)
+ require.NoError(t, err)
+
+ od := doc.Body[0].GoodsServices.LineDetails[0].OtherData
+ require.Len(t, od, 2)
+
+ // Declaration of intent: protocol number and the AdE receipt date.
+ assert.Equal(t, "INTENTO", od[0].DataType)
+ assert.Equal(t, "08060120341234567-000001", od[0].TextReference)
+ assert.Equal(t, "2025-03-10", od[0].DateReference)
+ assert.Empty(t, od[0].NumReference)
+
+ // Pension fund: which fund and how much was contributed.
+ assert.Equal(t, "CASSA-PREV", od[1].DataType)
+ assert.Equal(t, "ENASARCO TC07", od[1].TextReference)
+ assert.Equal(t, "80.00", od[1].NumReference)
+ assert.Empty(t, od[1].DateReference)
+ })
+
+ t.Run("should prefer the text over the code, as both share RiferimentoTesto", func(t *testing.T) {
+ env := test.LoadTestFile("invoice-simple.json", test.PathGOBLFatturaPA)
+ test.ModifyInvoice(env, func(inv *bill.Invoice) {
+ inv.Lines[0].Item.Attributes = []*org.Attribute{
+ {Type: "COLORE", Text: "rosso", Code: "RAL5010"},
+ }
+ })
+
+ doc, err := test.ConvertFromGOBL(env)
+ require.NoError(t, err)
+
+ od := doc.Body[0].GoodsServices.LineDetails[0].OtherData
+ require.Len(t, od, 1)
+ assert.Equal(t, "rosso", od[0].TextReference)
+ })
+
t.Run("should fall back to the attribute key when no type is set", func(t *testing.T) {
env := test.LoadTestFile("invoice-simple.json", test.PathGOBLFatturaPA)
test.ModifyInvoice(env, func(inv *bill.Invoice) {
diff --git a/payments.go b/payments.go
index a8ea542..513d8ec 100644
--- a/payments.go
+++ b/payments.go
@@ -78,8 +78,8 @@ func prepareAdvancePaymentDetails(inv *bill.Invoice) []*PaymentDetailRow {
row.Date = advance.Date.String()
}
if advance.CreditTransfer != nil {
- row.IBAN = advance.CreditTransfer.IBAN
- row.BIC = advance.CreditTransfer.BIC
+ row.IBAN = advance.CreditTransfer.IBAN.String()
+ row.BIC = advance.CreditTransfer.BIC.String()
}
dp = append(dp, row)
}
@@ -96,8 +96,8 @@ func preparePaymentDetails(inv *bill.Invoice) []*PaymentDetailRow {
}
if len(payment.Instructions.CreditTransfer) > 0 {
ct1 := payment.Instructions.CreditTransfer[0]
- br.IBAN = ct1.IBAN
- br.BIC = ct1.BIC
+ br.IBAN = ct1.IBAN.String()
+ br.BIC = ct1.BIC.String()
br.FinancialInstitution = ct1.Name
}
diff --git a/payments_parse.go b/payments_parse.go
index 5214976..a61f9c2 100644
--- a/payments_parse.go
+++ b/payments_parse.go
@@ -181,8 +181,8 @@ func goblBillPaymentAddPaymentInstructions(payment *bill.PaymentDetails, payment
// Add credit transfer if IBAN or BIC is available
if paymentDetail.IBAN != "" || paymentDetail.BIC != "" {
creditTransfer := pay.CreditTransfer{
- IBAN: paymentDetail.IBAN,
- BIC: paymentDetail.BIC,
+ IBAN: cbc.Code(paymentDetail.IBAN),
+ BIC: cbc.Code(paymentDetail.BIC),
}
if paymentDetail.FinancialInstitution != "" {
creditTransfer.Name = paymentDetail.FinancialInstitution
diff --git a/payments_parse_test.go b/payments_parse_test.go
index d1362fc..f40e55a 100644
--- a/payments_parse_test.go
+++ b/payments_parse_test.go
@@ -7,6 +7,7 @@ import (
"github.com/invopop/gobl.fatturapa/test"
"github.com/invopop/gobl/bill"
+ "github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/num"
"github.com/invopop/gobl/pay"
"github.com/stretchr/testify/assert"
@@ -70,8 +71,8 @@ func TestPaymentsInConversion(t *testing.T) {
// Check credit transfer
require.NotEmpty(t, invoice.Payment.Instructions.CreditTransfer)
creditTransfer := invoice.Payment.Instructions.CreditTransfer[0]
- assert.Equal(t, "IT60X0542811101000000123456", creditTransfer.IBAN)
- assert.Equal(t, "BCITITMM", creditTransfer.BIC)
+ assert.Equal(t, cbc.Code("IT60X0542811101000000123456"), creditTransfer.IBAN)
+ assert.Equal(t, cbc.Code("BCITITMM"), creditTransfer.BIC)
})
t.Run("should convert multiple due dates correctly", func(t *testing.T) {
diff --git a/test/data/gobl.fatturapa/invoice-despatch.json b/test/data/gobl.fatturapa/invoice-despatch.json
index 0056b26..18fb3fa 100644
--- a/test/data/gobl.fatturapa/invoice-despatch.json
+++ b/test/data/gobl.fatturapa/invoice-despatch.json
@@ -4,7 +4,7 @@
"uuid": "679a2f25-7483-11ec-9722-7ea2cb436ff6",
"dig": {
"alg": "sha256",
- "val": "2837c4f5ee910dd4e583548ba5ab67173ff09bcf4716037badb7e3e602c2463f"
+ "val": "69753172e2d48a904682d139f4536fa8128ad532268cec822d28d435ca00279f"
}
},
"doc": {
@@ -113,7 +113,8 @@
"attributes": [
{
"type": "LOTTO",
- "text": "CEM-2025-07"
+ "text": "CEM-2025-07",
+ "date": "2027-07-01"
},
{
"type": "PESO",
diff --git a/test/data/gobl.fatturapa/out/invoice-despatch.xml b/test/data/gobl.fatturapa/out/invoice-despatch.xml
index 9bc0c6f..69bda90 100644
--- a/test/data/gobl.fatturapa/out/invoice-despatch.xml
+++ b/test/data/gobl.fatturapa/out/invoice-despatch.xml
@@ -102,6 +102,7 @@
LOTTO
CEM-2025-07
+ 2027-07-01
PESO
@@ -124,7 +125,7 @@
- GW4pWcB8xBGrMF/XRtEJNqF8RcEVrn0ASbvWmDZUNajnCn9otIqzDVlS6eD7xzfSPCZCqMdpePYm02Igtybh6w==
+ /NREZsygXpAhAclOfhjNpF1lsdT6029lLYC5fhThcgbFdrxULgxBf4GNqZtclOv3djrAqLrLyeLhyyTnoyo71g==
@@ -135,7 +136,7 @@
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
- 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
+ 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