diff --git a/.gitignore b/.gitignore index 803be27d..b91830cf 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/creditnote.go b/creditnote.go new file mode 100644 index 00000000..6f221761 --- /dev/null +++ b/creditnote.go @@ -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 +} diff --git a/creditnote_test.go b/creditnote_test.go new file mode 100644 index 00000000..bd976dca --- /dev/null +++ b/creditnote_test.go @@ -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"}}} + + // 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, "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ) + + // Invoice-only elements must never appear in a credit note. + for _, forbidden := range []string{ + "", + "", + "", + "", + "", + "", + } { + 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 + } +} diff --git a/extension_test.go b/extension_test.go index 6a4f6469..53632f18 100644 --- a/extension_test.go +++ b/extension_test.go @@ -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}, }, } diff --git a/go.mod b/go.mod index 74695b1c..0d2c7fa7 100644 --- a/go.mod +++ b/go.mod @@ -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 google.golang.org/grpc v1.79.3 ) diff --git a/go.sum b/go.sum index caa17118..ed0f1c5d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/invoice.go b/invoice.go index 4239110e..7020aa57 100644 --- a/invoice.go +++ b/invoice.go @@ -20,17 +20,24 @@ const ( NamespaceUBLCreditNote = "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2" ) -// Schema locationa and customization constants +// XML root element local names for the supported UBL document types. +const ( + rootNameInvoice = "Invoice" + rootNameCreditNote = "CreditNote" +) + +// Schema location and customization constants const ( SchemaLocationInvoice = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd" SchemaLocationCrediteNote = "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2 https://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-CreditNote-2.1.xsd" ) -// Invoice represents the root element of a UBL Invoice **or** Credit Note; the structures -// between the two types are so similar, that it doesn't make much sense to separate. -type Invoice struct { +// DocumentHeader is the leading element run shared, in identical sequence, by +// the UBL Invoice and CreditNote XSDs (the root attributes through +// cbc:IssueTime). Both document structs embed it so the shared prefix can never +// drift between the two types. +type DocumentHeader struct { // Attributes - XMLName xml.Name CACNamespace string `xml:"xmlns:cac,attr"` CBCNamespace string `xml:"xmlns:cbc,attr"` QDTNamespace string `xml:"xmlns:qdt,attr"` @@ -51,53 +58,92 @@ type Invoice struct { UUID string `xml:"cbc:UUID,omitempty"` IssueDate string `xml:"cbc:IssueDate"` IssueTime string `xml:"cbc:IssueTime,omitempty"` - DueDate string `xml:"cbc:DueDate,omitempty"` +} + +// DocumentCurrency is the currency/accounting element run shared, in identical +// sequence, by both XSDs (cbc:DocumentCurrencyCode through cac:InvoicePeriod). +type DocumentCurrency struct { + DocumentCurrencyCode string `xml:"cbc:DocumentCurrencyCode,omitempty"` + TaxCurrencyCode string `xml:"cbc:TaxCurrencyCode,omitempty"` + PricingCurrencyCode string `xml:"cbc:PricingCurrencyCode,omitempty"` + PaymentCurrencyCode string `xml:"cbc:PaymentCurrencyCode,omitempty"` + PaymentAlternativeCurrencyCode string `xml:"cbc:PaymentAlternativeCurrencyCode,omitempty"` + AccountingCostCode string `xml:"cbc:AccountingCostCode,omitempty"` + AccountingCost string `xml:"cbc:AccountingCost,omitempty"` + LineCountNumeric int `xml:"cbc:LineCountNumeric,omitempty"` + BuyerReference string `xml:"cbc:BuyerReference,omitempty"` + InvoicePeriod []Period `xml:"cac:InvoicePeriod,omitempty"` +} + +// DocumentParties is the party/delivery/payment element run shared, in identical +// sequence, by both XSDs (cac:Signature through cac:PaymentTerms). +type DocumentParties struct { + Signature []Signature `xml:"cac:Signature,omitempty"` + AccountingSupplierParty SupplierParty `xml:"cac:AccountingSupplierParty"` + AccountingCustomerParty CustomerParty `xml:"cac:AccountingCustomerParty"` + PayeeParty *Party `xml:"cac:PayeeParty,omitempty"` + BuyerCustomerParty *CustomerParty `xml:"cac:BuyerCustomerParty,omitempty"` + SellerSupplierParty *SupplierParty `xml:"cac:SellerSupplierParty,omitempty"` + TaxRepresentativeParty *Party `xml:"cac:TaxRepresentativeParty,omitempty"` + Delivery []*Delivery `xml:"cac:Delivery,omitempty"` + DeliveryTerms *DeliveryTerms `xml:"cac:DeliveryTerms,omitempty"` + PaymentMeans []PaymentMeans `xml:"cac:PaymentMeans,omitempty"` + PaymentTerms *PaymentTerms `xml:"cac:PaymentTerms,omitempty"` +} + +// Invoice represents a UBL Invoice document, with fields declared in the exact +// sequence of the UBL-Invoice-2.1 XSD. +// +// It doubles as the parse target for **both** Invoice and CreditNote XML: +// unmarshalling is order-independent, so the extra CreditNote-only fields it +// carries (CreditNoteTypeCode, CreditNoteLines) are populated when a credit note +// is parsed and simply stay empty — and therefore omitted — when marshalling +// a real invoice. Marshalling a credit note goes through CreditNote (see +// creditnote.go), which lays the divergent elements out in CreditNote-XSD order. +// +// The shared element runs live in the embedded DocumentHeader, DocumentCurrency +// and DocumentParties types. Because their fields are promoted, a keyed struct +// literal cannot set them directly (e.g. Invoice{Signature: ...} does not +// compile); construct via the embedded type (Invoice{DocumentParties: +// DocumentParties{Signature: ...}}) or assign the promoted field after +// construction (inv.Signature = ...). Most callers should use Convert instead. +type Invoice struct { + XMLName xml.Name + DocumentHeader + + DueDate string `xml:"cbc:DueDate,omitempty"` InvoiceTypeCode *IDType `xml:"cbc:InvoiceTypeCode,omitempty"` CreditNoteTypeCode *IDType `xml:"cbc:CreditNoteTypeCode,omitempty"` - Note []string `xml:"cbc:Note,omitempty"` - TaxPointDate string `xml:"cbc:TaxPointDate,omitempty"` - DocumentCurrencyCode string `xml:"cbc:DocumentCurrencyCode,omitempty"` - TaxCurrencyCode string `xml:"cbc:TaxCurrencyCode,omitempty"` - PricingCurrencyCode string `xml:"cbc:PricingCurrencyCode,omitempty"` - PaymentCurrencyCode string `xml:"cbc:PaymentCurrencyCode,omitempty"` - PaymentAlternativeCurrencyCode string `xml:"cbc:PaymentAlternativeCurrencyCode,omitempty"` - AccountingCost string `xml:"cbc:AccountingCost,omitempty"` - LineCountNumeric int `xml:"cbc:LineCountNumeric,omitempty"` - BuyerReference string `xml:"cbc:BuyerReference,omitempty"` - InvoicePeriod []Period `xml:"cac:InvoicePeriod,omitempty"` - OrderReference *OrderReference `xml:"cac:OrderReference,omitempty"` - BillingReference []*BillingReference `xml:"cac:BillingReference,omitempty"` - DespatchDocumentReference []Reference `xml:"cac:DespatchDocumentReference,omitempty"` - ReceiptDocumentReference []Reference `xml:"cac:ReceiptDocumentReference,omitempty"` - StatementDocumentReference []Reference `xml:"cac:StatementDocumentReference,omitempty"` - OriginatorDocumentReference []Reference `xml:"cac:OriginatorDocumentReference,omitempty"` - ContractDocumentReference []Reference `xml:"cac:ContractDocumentReference,omitempty"` - AdditionalDocumentReference []Reference `xml:"cac:AdditionalDocumentReference,omitempty"` - ProjectReference []ProjectReference `xml:"cac:ProjectReference,omitempty"` - Signature []Signature `xml:"cac:Signature,omitempty"` - AccountingSupplierParty SupplierParty `xml:"cac:AccountingSupplierParty"` - AccountingCustomerParty CustomerParty `xml:"cac:AccountingCustomerParty"` - PayeeParty *Party `xml:"cac:PayeeParty,omitempty"` - BuyerCustomerParty *CustomerParty `xml:"cac:BuyerCustomerParty,omitempty"` - SellerSupplierParty *SupplierParty `xml:"cac:SellerSupplierParty,omitempty"` - TaxRepresentativeParty *Party `xml:"cac:TaxRepresentativeParty,omitempty"` - Delivery []*Delivery `xml:"cac:Delivery,omitempty"` - DeliveryTerms *DeliveryTerms `xml:"cac:DeliveryTerms,omitempty"` - PaymentMeans []PaymentMeans `xml:"cac:PaymentMeans,omitempty"` - PaymentTerms *PaymentTerms `xml:"cac:PaymentTerms,omitempty"` - PrepaidPayment []PrepaidPayment `xml:"cac:PrepaidPayment,omitempty"` - AllowanceCharge []AllowanceCharge `xml:"cac:AllowanceCharge,omitempty"` - TaxExchangeRate *ExchangeRate `xml:"cac:TaxExchangeRate,omitempty"` - PricingExchangeRate *ExchangeRate `xml:"cac:PricingExchangeRate,omitempty"` - PaymentExchangeRate *ExchangeRate `xml:"cac:PaymentExchangeRate,omitempty"` - PaymentAlternativeExchangeRate *ExchangeRate `xml:"cac:PaymentAlternativeExchangeRate,omitempty"` - TaxTotal []TaxTotal `xml:"cac:TaxTotal,omitempty"` - WithholdingTaxTotal []TaxTotal `xml:"cac:WithholdingTaxTotal,omitempty"` - LegalMonetaryTotal MonetaryTotal `xml:"cac:LegalMonetaryTotal"` - InvoiceLines []InvoiceLine `xml:"cac:InvoiceLine,omitempty"` - CreditNoteLines []InvoiceLine `xml:"cac:CreditNoteLine,omitempty"` + Note []string `xml:"cbc:Note,omitempty"` + TaxPointDate string `xml:"cbc:TaxPointDate,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"` + StatementDocumentReference []Reference `xml:"cac:StatementDocumentReference,omitempty"` + OriginatorDocumentReference []Reference `xml:"cac:OriginatorDocumentReference,omitempty"` + ContractDocumentReference []Reference `xml:"cac:ContractDocumentReference,omitempty"` + AdditionalDocumentReference []Reference `xml:"cac:AdditionalDocumentReference,omitempty"` + ProjectReference []ProjectReference `xml:"cac:ProjectReference,omitempty"` + + DocumentParties + + PrepaidPayment []PrepaidPayment `xml:"cac:PrepaidPayment,omitempty"` + AllowanceCharge []AllowanceCharge `xml:"cac:AllowanceCharge,omitempty"` + TaxExchangeRate *ExchangeRate `xml:"cac:TaxExchangeRate,omitempty"` + PricingExchangeRate *ExchangeRate `xml:"cac:PricingExchangeRate,omitempty"` + PaymentExchangeRate *ExchangeRate `xml:"cac:PaymentExchangeRate,omitempty"` + PaymentAlternativeExchangeRate *ExchangeRate `xml:"cac:PaymentAlternativeExchangeRate,omitempty"` + TaxTotal []TaxTotal `xml:"cac:TaxTotal,omitempty"` + WithholdingTaxTotal []TaxTotal `xml:"cac:WithholdingTaxTotal,omitempty"` + LegalMonetaryTotal MonetaryTotal `xml:"cac:LegalMonetaryTotal"` + InvoiceLines []InvoiceLine `xml:"cac:InvoiceLine,omitempty"` + CreditNoteLines []InvoiceLine `xml:"cac:CreditNoteLine,omitempty"` } func ublInvoice(inv *bill.Invoice, o *options) (*Invoice, error) { @@ -123,25 +169,31 @@ func ublInvoice(inv *bill.Invoice, o *options) (*Invoice, error) { // Create the UBL document out := &Invoice{ - XMLName: xml.Name{Local: "Invoice"}, - CACNamespace: NamespaceCAC, - CBCNamespace: NamespaceCBC, - QDTNamespace: NamespaceQDT, - UDTNamespace: NamespaceUDT, - UBLNamespace: NamespaceUBLInvoice, - CCTSNamespace: NamespaceCCTS, - XSINamespace: NamespaceXSI, - EXTNamespace: NamespaceEXT, - SchemaLocation: SchemaLocationInvoice, - CustomizationID: customizationID, - ProfileID: profileID, - ID: invoiceNumber(inv.Series, inv.Code), - IssueDate: formatDate(inv.IssueDate), - AccountingCost: "", // TODO: ordering cost - InvoiceTypeCode: &IDType{Value: tc}, - DocumentCurrencyCode: string(inv.Currency), - AccountingSupplierParty: SupplierParty{Party: newParty(inv.Supplier, o.context)}, - AccountingCustomerParty: CustomerParty{Party: newParty(inv.Customer, o.context)}, + XMLName: xml.Name{Local: rootNameInvoice}, + DocumentHeader: DocumentHeader{ + CACNamespace: NamespaceCAC, + CBCNamespace: NamespaceCBC, + QDTNamespace: NamespaceQDT, + UDTNamespace: NamespaceUDT, + UBLNamespace: NamespaceUBLInvoice, + CCTSNamespace: NamespaceCCTS, + XSINamespace: NamespaceXSI, + EXTNamespace: NamespaceEXT, + SchemaLocation: SchemaLocationInvoice, + CustomizationID: customizationID, + ProfileID: profileID, + ID: invoiceNumber(inv.Series, inv.Code), + IssueDate: formatDate(inv.IssueDate), + }, + InvoiceTypeCode: &IDType{Value: tc}, + DocumentCurrency: DocumentCurrency{ + AccountingCost: "", // TODO: ordering cost + DocumentCurrencyCode: string(inv.Currency), + }, + DocumentParties: DocumentParties{ + AccountingSupplierParty: SupplierParty{Party: newParty(inv.Supplier, o.context)}, + AccountingCustomerParty: CustomerParty{Party: newParty(inv.Customer, o.context)}, + }, } // PEPPOL-EN16931-R005 / BR-53: only map BT-6 when a matching exchange rate @@ -169,7 +221,7 @@ func ublInvoice(inv *bill.Invoice, o *options) (*Invoice, error) { } if docType.In(bill.InvoiceTypeCreditNote) { - out.XMLName = xml.Name{Local: "CreditNote"} + out.XMLName = xml.Name{Local: rootNameCreditNote} out.UBLNamespace = NamespaceUBLCreditNote out.SchemaLocation = SchemaLocationCrediteNote out.InvoiceTypeCode = nil @@ -272,7 +324,7 @@ func ConvertInvoice(env *gobl.Envelope, opts ...Option) (*Invoice, error) { // based on XML name instead of gobl's invoice type key func (ui *Invoice) getInvoiceTypeBasedOnXMLName() cbc.Key { switch ui.XMLName.Local { - case "CreditNote": + case rootNameCreditNote: return bill.InvoiceTypeCreditNote default: return bill.InvoiceTypeStandard diff --git a/signature_test.go b/signature_test.go index 331e171d..73072dd5 100644 --- a/signature_test.go +++ b/signature_test.go @@ -29,10 +29,9 @@ func TestAddSignatureReference(t *testing.T) { t.Run("appends to existing slice", func(t *testing.T) { existingMethod := "existing-method" - inv := &ubl.Invoice{ - Signature: []ubl.Signature{ - {ID: "existing-id", SignatureMethod: &existingMethod}, - }, + inv := &ubl.Invoice{} + inv.Signature = []ubl.Signature{ + {ID: "existing-id", SignatureMethod: &existingMethod}, } inv.AddSignatureReference(ubl.SignatureMethod, ubl.ReferenceSignatureID) diff --git a/test/data/convert/en16931/credit-note-ordering.json b/test/data/convert/en16931/credit-note-ordering.json new file mode 100644 index 00000000..1a17a117 --- /dev/null +++ b/test/data/convert/en16931/credit-note-ordering.json @@ -0,0 +1,186 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "0195ce71-dc9c-72c8-bf2c-9890a4a9f0a2", + "dig": { + "alg": "sha256", + "val": "e48a0403eee45b707f188387a4c9fee651d58aa04fbc189a223a20b0036d9910" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "DE", + "$addons": [ + "eu-en16931-v2017" + ], + "uuid": "0195ce71-dc9c-72c8-bf2c-9890a4a9f0a2", + "type": "credit-note", + "series": "CN", + "code": "002", + "issue_date": "2024-05-15", + "value_date": "2024-05-10", + "currency": "EUR", + "preceding": [ + { + "issue_date": "2024-04-20", + "series": "SAMPLE", + "code": "085", + "ext": { + "untdid-document-type": "380" + } + } + ], + "tax": { + "ext": { + "untdid-document-type": "381" + } + }, + "supplier": { + "name": "Provide One GmbH", + "tax_id": { + "country": "DE", + "code": "111111125" + }, + "addresses": [ + { + "num": "16", + "street": "Dietmar-Hopp-Allee", + "locality": "Walldorf", + "code": "69190", + "country": "DE" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "DE", + "code": "282741168" + }, + "addresses": [ + { + "num": "25", + "street": "Werner-Heisenberg-Allee", + "locality": "München", + "code": "80939", + "country": "DE" + } + ], + "emails": [ + { + "addr": "email@sample.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "percent": "19%", + "ext": { + "untdid-tax-category": "S" + } + } + ], + "total": "1800.00" + } + ], + "discounts": [ + { + "i": 1, + "reason": "Volume discount", + "percent": "5%", + "amount": "90.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "percent": "19%", + "ext": { + "untdid-tax-category": "S" + } + } + ] + } + ], + "charges": [ + { + "i": 1, + "reason": "Shipping fee", + "amount": "50.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "percent": "19%", + "ext": { + "untdid-tax-category": "S" + } + } + ] + } + ], + "payment": { + "terms": { + "notes": "on receipt within 30 days" + }, + "instructions": { + "key": "credit-transfer", + "ref": "0003434323213231", + "credit_transfer": [ + { + "iban": "NO9386011117947", + "bic": "DNBANOKK" + } + ], + "ext": { + "untdid-payment-means": "30" + } + } + }, + "totals": { + "sum": "1800.00", + "discount": "90.00", + "charge": "50.00", + "total": "1760.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "ext": { + "untdid-tax-category": "S" + }, + "base": "1760.00", + "percent": "19%", + "amount": "334.40" + } + ], + "amount": "334.40" + } + ], + "sum": "334.40" + }, + "tax": "334.40", + "total_with_tax": "2094.40", + "payable": "2094.40" + } + } +} \ No newline at end of file diff --git a/test/data/convert/en16931/out/credit-note-ordering.xml b/test/data/convert/en16931/out/credit-note-ordering.xml new file mode 100644 index 00000000..cce271d6 --- /dev/null +++ b/test/data/convert/en16931/out/credit-note-ordering.xml @@ -0,0 +1,152 @@ + + + urn:cen.eu:en16931:2017 + CN-002 + 2024-05-15 + 2024-05-10 + 381 + EUR + + NA + + + + SAMPLE-085 + 2024-04-20 + 380 + + + + + + Provide One GmbH + + + Dietmar-Hopp-Allee 16 + Walldorf + 69190 + + DE + + + + DE111111125 + + VAT + + + + Provide One GmbH + + + billing@example.com + + + + + + + Sample Consumer + + + Werner-Heisenberg-Allee 25 + München + 80939 + + DE + + + + DE282741168 + + VAT + + + + Sample Consumer + + + email@sample.com + + + + + 30 + 0003434323213231 + + NO9386011117947 + + DNBANOKK + + + + + on receipt within 30 days + + + true + Shipping fee + 50.00 + + S + 19 + + VAT + + + + + false + Volume discount + 5 + 90.00 + 1800.00 + + S + 19 + + VAT + + + + + 334.40 + + 1760.00 + 334.40 + + S + 19 + + VAT + + + + + + 1800.00 + 1760.00 + 2094.40 + 90.00 + 50.00 + 2094.40 + + + 1 + 20 + 1800.00 + + Development services + + S + 19 + + VAT + + + + + 90.00 + + + \ No newline at end of file diff --git a/ubl.go b/ubl.go index 65764cad..1d45625a 100644 --- a/ubl.go +++ b/ubl.go @@ -171,7 +171,7 @@ func extractRootNamespace(data []byte) (string, error) { // Bytes returns the raw XML of the UBL document including // the XML Header. func Bytes(in any) ([]byte, error) { - b, err := xml.MarshalIndent(in, "", " ") + b, err := xml.MarshalIndent(marshalDocument(in), "", " ") if err != nil { return nil, err } @@ -185,7 +185,7 @@ func Bytes(in any) ([]byte, error) { // BytesCompact returns the raw XML of the UBL document without // indentation, including the XML Header. func BytesCompact(in any) ([]byte, error) { - b, err := xml.Marshal(in) + b, err := xml.Marshal(marshalDocument(in)) if err != nil { return nil, err } diff --git a/xsd_validation_test.go b/xsd_validation_test.go new file mode 100644 index 00000000..fb32b901 --- /dev/null +++ b/xsd_validation_test.go @@ -0,0 +1,228 @@ +package ubl_test + +import ( + "encoding/xml" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + ubl "github.com/invopop/gobl.ubl" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// xsdValidatorSource is a tiny Java program that compiles a W3C XML Schema and +// validates one or more XML files against it, printing one "VALID:"/"INVALID:" +// line per file and exiting non-zero if any file failed. Java ships a full +// XSD 1.0 validator in the standard library, which the Go ecosystem lacks, so +// the tests below compile and exec this at run time (guarded by a skip when no +// JDK is available). +const xsdValidatorSource = `import javax.xml.XMLConstants; +import javax.xml.transform.stream.StreamSource; +import javax.xml.validation.Schema; +import javax.xml.validation.SchemaFactory; +import javax.xml.validation.Validator; +import java.io.File; + +public class Validate { + public static void main(String[] args) throws Exception { + SchemaFactory f = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); + Schema s = f.newSchema(new File(args[0])); + boolean ok = true; + for (int i = 1; i < args.length; i++) { + Validator v = s.newValidator(); + try { + v.validate(new StreamSource(new File(args[i]))); + System.out.println("VALID: " + args[i]); + } catch (Exception e) { + ok = false; + System.out.println("INVALID: " + args[i] + " :: " + e.getMessage()); + } + } + if (!ok) { + System.exit(1); + } + } +} +` + +// compileXSDValidator writes the Java validator into a temp dir and compiles +// it, returning the directory to use as the java classpath. The calling test +// is skipped when no JDK is available. +func compileXSDValidator(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("java"); err != nil { + t.Skip("java not available, skipping XSD validation") + } + if _, err := exec.LookPath("javac"); err != nil { + t.Skip("javac not available, skipping XSD validation") + } + dir := t.TempDir() + src := filepath.Join(dir, "Validate.java") + require.NoError(t, os.WriteFile(src, []byte(xsdValidatorSource), 0o600)) + out, err := exec.Command("javac", src).CombinedOutput() + require.NoError(t, err, "javac failed: %s", out) + return dir +} + +// getSchemaPath returns the path of the UBL 2.1 XSD bundle in test/data. +func getSchemaPath() string { + return filepath.Join(getDataPath(), "schema") +} + +// maindocXSD maps a UBL root element local name to its maindoc schema file. +func maindocXSD(rootLocal string) string { + switch rootLocal { + case "Invoice": + return filepath.Join(getSchemaPath(), "maindoc", "UBL-Invoice-2.1.xsd") + case "CreditNote": + return filepath.Join(getSchemaPath(), "maindoc", "UBL-CreditNote-2.1.xsd") + default: + return "" + } +} + +// rootElementLocal returns the local name of the root element of an XML file. +func rootElementLocal(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() //nolint:errcheck + dec := xml.NewDecoder(f) + for { + tok, err := dec.Token() + require.NoError(t, err, "no root element found in %s", path) + if se, ok := tok.(xml.StartElement); ok { + return se.Name.Local + } + } +} + +// validateXSD runs the compiled Java validator for the given XML files against +// one maindoc schema, returning the combined output and whether every file was +// schema-valid. +func validateXSD(t *testing.T, classDir, xsdPath string, xmlPaths ...string) (string, bool) { + t.Helper() + args := append([]string{"-cp", classDir, "Validate", xsdPath}, xmlPaths...) + out, err := exec.Command("java", args...).CombinedOutput() + if err != nil { + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr, "java validator did not run: %v: %s", err, out) + return string(out), false + } + return string(out), true +} + +// TestXSDValidateConvertGoldens validates every generated golden under +// test/data/convert/*/out against the stock UBL 2.1 maindoc XSDs, choosing +// the Invoice or CreditNote schema by the root element of each file. This +// pins down real schema validity — element order included — which the +// byte-comparison golden tests alone cannot guarantee. +func TestXSDValidateConvertGoldens(t *testing.T) { + classDir := compileXSDValidator(t) + + files, err := filepath.Glob(filepath.Join(getConvertPath(), "*", "out", "*.xml")) + require.NoError(t, err) + require.NotEmpty(t, files, "no convert goldens found") + + // Group files by root element so each schema is compiled once per group. + groups := make(map[string][]string) + for _, f := range files { + root := rootElementLocal(t, f) + require.NotEmpty(t, maindocXSD(root), "unexpected root element %q in %s", root, f) + groups[root] = append(groups[root], f) + } + + for root, group := range groups { + t.Run(root, func(t *testing.T) { + out, ok := validateXSD(t, classDir, maindocXSD(root), group...) + if !ok { + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if strings.HasPrefix(line, "INVALID: ") { + assert.Fail(t, "golden is not schema-valid", line) + } + } + } + }) + } +} + +// TestCreditNoteXSDOrderingDifferential proves that mapping credit notes onto +// the dedicated CreditNote layout is what makes the output schema-valid. The +// same *ubl.Invoice is marshalled twice: through ubl.Bytes (the shipped path, +// which remaps to the CreditNote element sequence) and through encoding/xml +// directly on the Invoice struct (reproducing the old invoice-ordered +// behavior). The former must pass UBL-CreditNote-2.1.xsd and the latter must +// fail it, because the invoice layout emits cbc:TaxPointDate after +// cbc:CreditNoteTypeCode while the CreditNote XSD sequence requires the +// reverse. +func TestCreditNoteXSDOrderingDifferential(t *testing.T) { + classDir := compileXSDValidator(t) + cnXSD := maindocXSD("CreditNote") + + eur := "EUR" + 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-XSD-1" + inv.IssueDate = "2024-02-14" + + // The ordering divergence under test: the CreditNote XSD places + // cbc:TaxPointDate immediately before cbc:CreditNoteTypeCode, while the + // Invoice layout emits the type code first. + inv.TaxPointDate = "2024-02-10" + inv.CreditNoteTypeCode = &ubl.IDType{Value: "381"} + + inv.DocumentCurrencyCode = "EUR" + inv.AllowanceCharge = []ubl.AllowanceCharge{{ + ChargeIndicator: true, + Amount: ubl.Amount{CurrencyID: &eur, Value: "10.00"}, + }} + inv.TaxTotal = []ubl.TaxTotal{{ + TaxAmount: ubl.Amount{CurrencyID: &eur, Value: "19.00"}, + }} + inv.LegalMonetaryTotal = ubl.MonetaryTotal{ + LineExtensionAmount: ubl.Amount{CurrencyID: &eur, Value: "100.00"}, + TaxExclusiveAmount: ubl.Amount{CurrencyID: &eur, Value: "110.00"}, + TaxInclusiveAmount: ubl.Amount{CurrencyID: &eur, Value: "129.00"}, + PayableAmount: &ubl.Amount{CurrencyID: &eur, Value: "129.00"}, + } + inv.CreditNoteLines = []ubl.InvoiceLine{{ + ID: "1", + CreditedQuantity: &ubl.Quantity{UnitCode: "C62", Value: "1"}, + LineExtensionAmount: ubl.Amount{CurrencyID: &eur, Value: "100.00"}, + Item: &ubl.Item{Name: "Development services"}, + }} + + // (a) The shipped path: remapped onto the CreditNote layout. + fixed, err := ubl.Bytes(inv) + require.NoError(t, err) + + // (b) The old behavior: marshalling the Invoice struct directly emits the + // invoice element sequence under a CreditNote root. + legacy, err := xml.MarshalIndent(inv, "", " ") + require.NoError(t, err) + legacy = append([]byte(xml.Header), legacy...) + + dir := t.TempDir() + fixedPath := filepath.Join(dir, "creditnote-fixed.xml") + legacyPath := filepath.Join(dir, "creditnote-legacy.xml") + require.NoError(t, os.WriteFile(fixedPath, fixed, 0o600)) + require.NoError(t, os.WriteFile(legacyPath, legacy, 0o600)) + + out, ok := validateXSD(t, classDir, cnXSD, fixedPath) + assert.True(t, ok, "credit note marshalled via ubl.Bytes must be schema-valid: %s", out) + + out, ok = validateXSD(t, classDir, cnXSD, legacyPath) + assert.False(t, ok, "invoice-ordered marshalling must violate the CreditNote XSD: %s", out) + assert.Contains(t, out, "TaxPointDate", + "the schema violation should be the misplaced cbc:TaxPointDate: %s", out) +}