Skip to content
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
# Dependency directories (remove the comment below to include it)
# vendor/

# Go workspace file
# Go workspace files
go.work
go.work.sum

.claude/
104 changes: 104 additions & 0 deletions creditnote.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package ubl

import "encoding/xml"

// CreditNote represents a UBL Credit Note document, with fields declared in the
// exact sequence of the UBL-CreditNote-2.1 XSD.
//
// The Invoice and CreditNote XSDs diverge in more than one place — cbc:TaxPointDate
// precedes the type code, there is no cbc:DueDate, cac:AllowanceCharge follows the
// exchange rates rather than preceding them, the document-reference block is
// ordered differently, and cac:ProjectReference / cac:PrepaidPayment /
// cac:WithholdingTaxTotal do not exist. Rather than juggle one struct for both
// layouts, credit notes are built as an Invoice and mapped to this type at the
// marshalling boundary (see toCreditNote); fields the CreditNote XSD omits are
// simply not carried over.
type CreditNote struct {
XMLName xml.Name
DocumentHeader

TaxPointDate string `xml:"cbc:TaxPointDate,omitempty"`
CreditNoteTypeCode *IDType `xml:"cbc:CreditNoteTypeCode,omitempty"`
Note []string `xml:"cbc:Note,omitempty"`

DocumentCurrency

OrderReference *OrderReference `xml:"cac:OrderReference,omitempty"`
BillingReference []*BillingReference `xml:"cac:BillingReference,omitempty"`
DespatchDocumentReference []Reference `xml:"cac:DespatchDocumentReference,omitempty"`
ReceiptDocumentReference []Reference `xml:"cac:ReceiptDocumentReference,omitempty"`
ContractDocumentReference []Reference `xml:"cac:ContractDocumentReference,omitempty"`
AdditionalDocumentReference []Reference `xml:"cac:AdditionalDocumentReference,omitempty"`
StatementDocumentReference []Reference `xml:"cac:StatementDocumentReference,omitempty"`
OriginatorDocumentReference []Reference `xml:"cac:OriginatorDocumentReference,omitempty"`

DocumentParties

TaxExchangeRate *ExchangeRate `xml:"cac:TaxExchangeRate,omitempty"`
PricingExchangeRate *ExchangeRate `xml:"cac:PricingExchangeRate,omitempty"`
PaymentExchangeRate *ExchangeRate `xml:"cac:PaymentExchangeRate,omitempty"`
PaymentAlternativeExchangeRate *ExchangeRate `xml:"cac:PaymentAlternativeExchangeRate,omitempty"`
AllowanceCharge []AllowanceCharge `xml:"cac:AllowanceCharge,omitempty"`
TaxTotal []TaxTotal `xml:"cac:TaxTotal,omitempty"`
LegalMonetaryTotal MonetaryTotal `xml:"cac:LegalMonetaryTotal"`
CreditNoteLines []InvoiceLine `xml:"cac:CreditNoteLine,omitempty"`
}

// toCreditNote projects an Invoice built for a credit note onto the CreditNote
// layout. Only the elements the CreditNote XSD allows are carried across; the
// invoice-only fields (DueDate, ProjectReference, PrepaidPayment,
// WithholdingTaxTotal, InvoiceTypeCode) are dropped by construction. Lines are
// taken from whichever slice the builder populated.
func (ui *Invoice) toCreditNote() *CreditNote {
lines := ui.CreditNoteLines
if len(lines) == 0 {
lines = ui.InvoiceLines
}
return &CreditNote{
XMLName: ui.XMLName,
DocumentHeader: ui.DocumentHeader,
TaxPointDate: ui.TaxPointDate,
CreditNoteTypeCode: ui.CreditNoteTypeCode,
Note: ui.Note,
DocumentCurrency: ui.DocumentCurrency,

OrderReference: ui.OrderReference,
BillingReference: ui.BillingReference,
DespatchDocumentReference: ui.DespatchDocumentReference,
ReceiptDocumentReference: ui.ReceiptDocumentReference,
ContractDocumentReference: ui.ContractDocumentReference,
AdditionalDocumentReference: ui.AdditionalDocumentReference,
StatementDocumentReference: ui.StatementDocumentReference,
OriginatorDocumentReference: ui.OriginatorDocumentReference,

DocumentParties: ui.DocumentParties,

TaxExchangeRate: ui.TaxExchangeRate,
PricingExchangeRate: ui.PricingExchangeRate,
PaymentExchangeRate: ui.PaymentExchangeRate,
PaymentAlternativeExchangeRate: ui.PaymentAlternativeExchangeRate,
AllowanceCharge: ui.AllowanceCharge,
TaxTotal: ui.TaxTotal,
LegalMonetaryTotal: ui.LegalMonetaryTotal,
CreditNoteLines: lines,
}
}

// marshalDocument returns the value to hand to encoding/xml: a credit-note
// Invoice is remapped onto its CreditNote layout so the emitted element sequence
// matches the UBL-CreditNote XSD without any post-marshal byte surgery.
func marshalDocument(in any) any {
var inv *Invoice
switch v := in.(type) {
case *Invoice:
inv = v
case Invoice:
inv = &v
default:
return in
}
if inv.XMLName.Local == rootNameCreditNote {
return inv.toCreditNote()
}
return in
}
118 changes: 118 additions & 0 deletions creditnote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package ubl_test

import (
"encoding/xml"
"io"
"strings"
"testing"

ubl "github.com/invopop/gobl.ubl"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestCreditNoteMarshalOrdering verifies that a credit note is marshalled in the
// element sequence mandated by the UBL-CreditNote-2.1 XSD, which diverges from
// the Invoice XSD in several places, and that invoice-only elements never leak
// into a credit note. Credit notes are built as an Invoice and mapped onto the
// CreditNote layout at the marshalling boundary, so this exercises that mapping
// end to end — no post-marshal byte surgery involved.
func TestCreditNoteMarshalOrdering(t *testing.T) {
inv := &ubl.Invoice{XMLName: xml.Name{Local: "CreditNote"}}
inv.CACNamespace = ubl.NamespaceCAC
inv.CBCNamespace = ubl.NamespaceCBC
inv.QDTNamespace = ubl.NamespaceQDT
inv.UDTNamespace = ubl.NamespaceUDT
inv.CCTSNamespace = ubl.NamespaceCCTS
inv.UBLNamespace = ubl.NamespaceUBLCreditNote
inv.XSINamespace = ubl.NamespaceXSI
inv.EXTNamespace = ubl.NamespaceEXT
inv.ID = "CN-1"
inv.IssueDate = "2024-02-14"

// Head divergence: TaxPointDate precedes the type code in a credit note.
inv.TaxPointDate = "2024-02-10"
inv.CreditNoteTypeCode = &ubl.IDType{Value: "381"}

// Invoice-only head fields that must be dropped.
inv.DueDate = "2024-03-15"
inv.InvoiceTypeCode = &ubl.IDType{Value: "380"}

// Reference block: the credit note orders Contract/Additional ahead of
// Statement/Originator (the invoice does the reverse).
inv.ContractDocumentReference = []ubl.Reference{{ID: ubl.IDType{Value: "CONTRACT-1"}}}
inv.StatementDocumentReference = []ubl.Reference{{ID: ubl.IDType{Value: "STATEMENT-1"}}}
inv.OriginatorDocumentReference = []ubl.Reference{{ID: ubl.IDType{Value: "ORIGINATOR-1"}}}
Comment on lines +41 to +45

// Invoice-only reference that must be dropped.
inv.ProjectReference = []ubl.ProjectReference{{ID: "PROJECT-1"}}

// Tail divergence: AllowanceCharge follows the exchange rates in a credit
// note (it precedes them in an invoice).
inv.TaxExchangeRate = &ubl.ExchangeRate{}
inv.AllowanceCharge = []ubl.AllowanceCharge{{ChargeIndicator: true, Amount: ubl.Amount{Value: "10.00"}}}
inv.TaxTotal = []ubl.TaxTotal{{TaxAmount: ubl.Amount{Value: "5.00"}}}
inv.LegalMonetaryTotal = ubl.MonetaryTotal{LineExtensionAmount: ubl.Amount{Value: "100.00"}}
inv.CreditNoteLines = []ubl.InvoiceLine{{ID: "1", LineExtensionAmount: ubl.Amount{Value: "100.00"}}}

// Invoice-only tail fields that must be dropped.
inv.PrepaidPayment = []ubl.PrepaidPayment{{ID: "PREPAID-1"}}
inv.WithholdingTaxTotal = []ubl.TaxTotal{{TaxAmount: ubl.Amount{Value: "1.00"}}}

data, err := ubl.Bytes(inv)
require.NoError(t, err)
out := string(data)

// The output must be well-formed XML with all prefixes bound.
dec := xml.NewDecoder(strings.NewReader(out))
for {
_, err := dec.Token()
if err == io.EOF {
break
}
require.NoError(t, err, "marshalled credit note must be well-formed XML")
}

// Root element is a CreditNote.
assert.Contains(t, out, "<CreditNote")

// Element order must follow the CreditNote XSD sequence.
assertOrder(t, out,
"<cbc:TaxPointDate>",
"<cbc:CreditNoteTypeCode>",
"<cac:ContractDocumentReference>",
"<cac:StatementDocumentReference>",
"<cac:OriginatorDocumentReference>",
Comment on lines +83 to +85
"<cac:TaxExchangeRate>",
"<cac:AllowanceCharge>",
"<cac:TaxTotal>",
"<cac:LegalMonetaryTotal>",
"<cac:CreditNoteLine>",
)

// Invoice-only elements must never appear in a credit note.
for _, forbidden := range []string{
"<cbc:DueDate>",
"<cbc:InvoiceTypeCode>",
"<cac:ProjectReference>",
"<cac:PrepaidPayment>",
"<cac:WithholdingTaxTotal>",
"<cac:InvoiceLine>",
} {
assert.NotContainsf(t, out, forbidden, "credit note must not contain %s", forbidden)
}
}

// assertOrder asserts that each needle appears in s, in the given order.
func assertOrder(t *testing.T, s string, needles ...string) {
t.Helper()
prev := -1
prevNeedle := ""
for _, n := range needles {
idx := strings.Index(s, n)
require.GreaterOrEqualf(t, idx, 0, "expected output to contain %s", n)
assert.Greaterf(t, idx, prev, "%s must appear after %s", n, prevNeedle)
prev = idx
prevNeedle = n
}
}
9 changes: 4 additions & 5 deletions extension_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,10 @@ func TestAddExtension(t *testing.T) {

t.Run("appends to existing Extensions", func(t *testing.T) {
uri := "urn:existing"
inv := &ubl.Invoice{
Extensions: &ubl.Extensions{
Extension: []ubl.Extension{
{ExtensionURI: &uri},
},
inv := &ubl.Invoice{}
inv.Extensions = &ubl.Extensions{
Extension: []ubl.Extension{
{ExtensionURI: &uri},
},
}

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ require (
github.com/invopop/gobl.sa.zatca v0.0.2
github.com/invopop/phive v0.6.0
github.com/invopop/validation v0.8.0
github.com/invopop/xmlctx v0.13.0
github.com/invopop/xmlctx v0.13.1-0.20260702181431-906a424b89a0
github.com/invopop/xmldsig v0.14.0
Comment on lines 16 to 20
google.golang.org/grpc v1.79.3
)
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ github.com/invopop/phive v0.6.0 h1:wtk5+ieD/muViF6SJXGGVA/vYPzed9NoaCl63TjmWU4=
github.com/invopop/phive v0.6.0/go.mod h1:2Njf8Ci6tjfZkvq7VfdX5Esjx4Q3lzETciSFZ2afKFA=
github.com/invopop/validation v0.8.0 h1:e5hXHGnONHImgJdonIpNbctg1hlWy1ncaHoVIQ0JWuw=
github.com/invopop/validation v0.8.0/go.mod h1:nLLeXYPGwUNfdCdJo7/q3yaHO62LSx/3ri7JvgKR9vg=
github.com/invopop/xmlctx v0.13.0 h1:ZNRMC0O/A5h8InoLVSA7tIjjrhJn/NDBYfByBUpSb+g=
github.com/invopop/xmlctx v0.13.0/go.mod h1:xZ3Bdf0jq2GcjN6QNroUa+l37kyXDXmDEEKiBj9NAl0=
github.com/invopop/xmlctx v0.13.1-0.20260702181431-906a424b89a0 h1:tQmmx542fAPQj40YNrwbHSlDMITG6hbEkabBRg6jryQ=
github.com/invopop/xmlctx v0.13.1-0.20260702181431-906a424b89a0/go.mod h1:xZ3Bdf0jq2GcjN6QNroUa+l37kyXDXmDEEKiBj9NAl0=
github.com/invopop/xmldsig v0.14.0 h1:ROwf32DZtX2EekrrOSjLLiY0s2kPEcqHZculITlZfAw=
github.com/invopop/xmldsig v0.14.0/go.mod h1:oWDotOqdKbbwmfA1B057TVsNZW/V1XgoXsRgrW5by18=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
Expand Down
Loading
Loading