From 56db1256cc8430ac8bf7d6342c1073a0adfa28a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Wed, 4 Mar 2026 20:15:04 +0000 Subject: [PATCH 1/2] Fix empty fields being generated in payment output Prevent blank XML elements from being emitted when source data has no meaningful values for credit transfer, card, mandate, and payment terms fields. Co-Authored-By: Claude Sonnet 4.6 --- payment.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/payment.go b/payment.go index 0e12c080..f8153012 100644 --- a/payment.go +++ b/payment.go @@ -131,28 +131,32 @@ func (ui *Invoice) addPaymentInstructions(instr *pay.Instructions) error { ui.PaymentMeans[0].PayeeFinancialAccount = newCreditTransferAccount(instr.CreditTransfer[0]) } if instr.DirectDebit != nil { - ui.PaymentMeans[0].PaymentMandate = &PaymentMandate{ - ID: IDType{Value: instr.DirectDebit.Ref}, + mandate := &PaymentMandate{} + if instr.DirectDebit.Ref != "" { + mandate.ID = IDType{Value: instr.DirectDebit.Ref} } if instr.DirectDebit.Account != "" { - ui.PaymentMeans[0].PayerFinancialAccount = &FinancialAccount{ + mandate.PayerFinancialAccount = &FinancialAccount{ ID: &instr.DirectDebit.Account, } } + ui.PaymentMeans[0].PaymentMandate = mandate } if instr.Card != nil { - ui.PaymentMeans[0].CardAccount = &CardAccount{ - PrimaryAccountNumberID: &instr.Card.Last4, + card := &CardAccount{} + if instr.Card.Last4 != "" { + card.PrimaryAccountNumberID = &instr.Card.Last4 } if instr.Card.Holder != "" { - ui.PaymentMeans[0].CardAccount.HolderName = &instr.Card.Holder + card.HolderName = &instr.Card.Holder } + ui.PaymentMeans[0].CardAccount = card } return nil } func newCreditTransferAccount(ct *pay.CreditTransfer) *FinancialAccount { - pfa := new(FinancialAccount) + pfa := &FinancialAccount{} if ct.IBAN != "" { pfa.ID = &ct.IBAN } else if ct.Number != "" { @@ -164,6 +168,9 @@ func newCreditTransferAccount(ct *pay.CreditTransfer) *FinancialAccount { if ct.BIC != "" { pfa.FinancialInstitutionBranch = &Branch{ID: &ct.BIC} } + if pfa.ID == nil && pfa.Name == nil && pfa.FinancialInstitutionBranch == nil { + return nil + } return pfa } @@ -193,7 +200,7 @@ func (ui *Invoice) addPaymentTerms(inv *bill.Invoice, pymt *bill.PaymentDetails) } } else if len(pymt.Terms.DueDates) == 1 && ui.CreditNoteTypeCode == "" { ui.DueDate = formatDate(*pymt.Terms.DueDates[0].Date) - } else { + } else if pymt.Terms.Notes != "" { ui.PaymentTerms = append(ui.PaymentTerms, PaymentTerms{ Note: []string{pymt.Terms.Notes}, }) From 79609de2604d160eb8f843685bf3d18dc5f0e1b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Olivi=C3=A9?= Date: Thu, 5 Mar 2026 16:04:55 +0000 Subject: [PATCH 2/2] Add tests for payment and totals changes --- payment_test.go | 94 +++++++++++++++++++++++++++++++++++++++++++++++++ totals_test.go | 19 ++++++++++ 2 files changed, 113 insertions(+) diff --git a/payment_test.go b/payment_test.go index f498b351..e2408bbf 100644 --- a/payment_test.go +++ b/payment_test.go @@ -5,6 +5,7 @@ import ( ubl "github.com/invopop/gobl.ubl" "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/pay" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -36,6 +37,99 @@ func TestNewPayment(t *testing.T) { assert.Equal(t, "DNBANOKK", *doc.PaymentMeans[0].PayeeFinancialAccount.FinancialInstitutionBranch.ID) }) + t.Run("credit transfer with no account fields omits financial account", func(t *testing.T) { + env, err := loadTestEnvelope("invoice-minimal.json") + require.NoError(t, err) + + inv, ok := env.Extract().(*bill.Invoice) + require.True(t, ok) + + inv.Payment.Instructions.CreditTransfer = []*pay.CreditTransfer{{}} + + doc, err := ubl.ConvertInvoice(env) + require.NoError(t, err) + assert.Nil(t, doc.PaymentMeans[0].PayeeFinancialAccount) + }) + + t.Run("card with empty last4 omits PAN", func(t *testing.T) { + env, err := loadTestEnvelope("invoice-minimal.json") + require.NoError(t, err) + + inv, ok := env.Extract().(*bill.Invoice) + require.True(t, ok) + + inv.Payment.Instructions.Card = &pay.Card{Holder: "John Doe"} + + doc, err := ubl.ConvertInvoice(env) + require.NoError(t, err) + require.NotNil(t, doc.PaymentMeans[0].CardAccount) + assert.Nil(t, doc.PaymentMeans[0].CardAccount.PrimaryAccountNumberID) + assert.Equal(t, "John Doe", *doc.PaymentMeans[0].CardAccount.HolderName) + }) + + t.Run("direct debit with empty ref omits mandate ID", func(t *testing.T) { + env, err := loadTestEnvelope("invoice-minimal.json") + require.NoError(t, err) + + inv, ok := env.Extract().(*bill.Invoice) + require.True(t, ok) + + inv.Payment.Instructions.DirectDebit = &pay.DirectDebit{Account: "DE89370400440532013000"} + + doc, err := ubl.ConvertInvoice(env) + require.NoError(t, err) + require.NotNil(t, doc.PaymentMeans[0].PaymentMandate) + assert.Empty(t, doc.PaymentMeans[0].PaymentMandate.ID.Value) + assert.Equal(t, "DE89370400440532013000", *doc.PaymentMeans[0].PaymentMandate.PayerFinancialAccount.ID) + }) + + t.Run("instruction detail mapped to payment means name", func(t *testing.T) { + env, err := loadTestEnvelope("invoice-minimal.json") + require.NoError(t, err) + + inv, ok := env.Extract().(*bill.Invoice) + require.True(t, ok) + + inv.Payment.Instructions.Detail = "Bank transfer" + + doc, err := ubl.ConvertInvoice(env) + require.NoError(t, err) + require.NotNil(t, doc.PaymentMeans[0].PaymentMeansCode.Name) + assert.Equal(t, "Bank transfer", *doc.PaymentMeans[0].PaymentMeansCode.Name) + }) + + t.Run("payment terms with empty notes omits payment terms", func(t *testing.T) { + env, err := loadTestEnvelope("invoice-minimal.json") + require.NoError(t, err) + + inv, ok := env.Extract().(*bill.Invoice) + require.True(t, ok) + + inv.Payment.Terms.Notes = "" + + doc, err := ubl.ConvertInvoice(env) + require.NoError(t, err) + assert.Empty(t, doc.PaymentTerms) + }) + + t.Run("BT-90 creditor ID on supplier when no payee", func(t *testing.T) { + env, err := loadTestEnvelope("invoice-minimal.json") + require.NoError(t, err) + + inv, ok := env.Extract().(*bill.Invoice) + require.True(t, ok) + + inv.Payment.Instructions.DirectDebit = &pay.DirectDebit{Creditor: "DE98ZZZ09999999999"} + + doc, err := ubl.ConvertInvoice(env) + require.NoError(t, err) + require.Nil(t, doc.PayeeParty) + ids := doc.AccountingSupplierParty.Party.PartyIdentification + require.NotEmpty(t, ids) + assert.Equal(t, "DE98ZZZ09999999999", ids[len(ids)-1].ID.Value) + assert.Equal(t, "SEPA", *ids[len(ids)-1].ID.SchemeID) + }) + t.Run("document type extension", func(t *testing.T) { env, err := loadTestEnvelope("invoice-minimal.json") require.NoError(t, err) diff --git a/totals_test.go b/totals_test.go index 66b76635..b81cbcea 100644 --- a/totals_test.go +++ b/totals_test.go @@ -24,4 +24,23 @@ func TestNewTotals(t *testing.T) { assert.Equal(t, "VAT", doc.TaxTotal[0].TaxSubtotal[0].TaxCategory.TaxScheme.ID) assert.Equal(t, "21.0", *doc.TaxTotal[0].TaxSubtotal[0].TaxCategory.Percent) }) + + t.Run("exemption reason from legal note with reverse-charge tag", func(t *testing.T) { + doc, err := testInvoiceFrom("peppol/peppol-reverse-charge.json") + require.NoError(t, err) + + subtotal := doc.TaxTotal[0].TaxSubtotal[0] + require.NotNil(t, subtotal.TaxCategory.TaxExemptionReason) + assert.Equal(t, "Reverse Charge / Umkehr der Steuerschuld.", *subtotal.TaxCategory.TaxExemptionReason) + require.NotNil(t, subtotal.TaxCategory.TaxExemptionReasonCode) + assert.Equal(t, "VATEX-EU-AE", *subtotal.TaxCategory.TaxExemptionReasonCode) + }) + + t.Run("no exemption reason without reverse-charge tag", func(t *testing.T) { + doc, err := testInvoiceFrom("peppol/peppol-1.json") + require.NoError(t, err) + + subtotal := doc.TaxTotal[0].TaxSubtotal[0] + assert.Nil(t, subtotal.TaxCategory.TaxExemptionReason) + }) }