Skip to content

fix: emit credit notes in CreditNote-XSD element order via a typed model - #102

Open
alvarolivie wants to merge 5 commits into
mainfrom
fix/creditnote-xsd-typed-ordering
Open

fix: emit credit notes in CreditNote-XSD element order via a typed model#102
alvarolivie wants to merge 5 commits into
mainfrom
fix/creditnote-xsd-typed-ordering

Conversation

@alvarolivie

Copy link
Copy Markdown
Collaborator

Problem

UBL's Invoice and CreditNote XSDs sequence their elements differently, yet this package marshalled both from one shared struct in invoice order. Confirmed against the official UBL-2.1 schemas, the credit note diverges in several places:

  • cbc:TaxPointDate precedes the type code (invoice: type code first)
  • no cbc:DueDate
  • document-reference block reordered (Contract/Additional before Statement/Originator)
  • cac:AllowanceCharge follows the exchange rates (invoice: precedes them)
  • cac:ProjectReference, cac:PrepaidPayment, cac:WithholdingTaxTotal do not exist

Since AllowanceCharge, the document references, and ProjectReference/PrepaidPayment are all populated by the builder, a shared struct emits structurally invalid credit-note XML the moment those elements are present. This supersedes #101, which addressed only the TaxPointDate case by rewriting the marshalled bytes.

Approach — model the two documents as the distinct types they are

encoding/xml emits fields in declaration order, so correct output falls out of a correct type. No bytes are touched after marshalling.

  • Extract the element runs that are identical in both XSDs into embedded structs — documentHeader, documentCurrency, documentParties — so the ~40 shared fields are declared once and can't drift.
  • Invoice keeps all builder/parser logic and, because unmarshalling is order-independent, doubles as the parse target for both document kinds.
  • New CreditNote type declares its divergent fields in exact UBL-CreditNote-2.1 order.
  • toCreditNote() maps InvoiceCreditNote at the Bytes/BytesCompact boundary; invoice-only fields are dropped by construction.

Tests

  • New TestCreditNoteMarshalOrdering asserts every divergence end-to-end: TaxPointDate before the type code, the reordered reference block, AllowanceCharge after the exchange rates, and that DueDate/InvoiceTypeCode/ProjectReference/PrepaidPayment/WithholdingTaxTotal/InvoiceLine never appear in a credit note.
  • Full suite green (including parse round-trips); existing golden convert files unchanged (no regressions); golangci-lint 0 issues.

Dependency ⚠️

Parsing into embedded structs needs anonymous-embed decoding support in xmlctx: invopop/xmlctx#3. go.mod is currently pinned to that branch's pseudo-version so CI is green.

Before merge: merge xmlctx#3, tag it (e.g. v0.14.0), then `go get github.com/invopop/xmlctx@v0.14.0` and update go.mod/go.sum.

Not included (deliberate follow-up)

The if CreditNoteTypeCode != nil behaviour branches in build/parse remain. Retiring them means a real behaviour split (separate builders/parsers) and is better as its own PR; this change is purely the structural/ordering fix.

🤖 Generated with Claude Code

UBL's Invoice and CreditNote schemas sequence their elements differently:
cbc:TaxPointDate precedes the type code, there is no cbc:DueDate, the
document-reference block is reordered, cac:AllowanceCharge follows the
exchange rates rather than preceding them, and cac:ProjectReference /
cac:PrepaidPayment / cac:WithholdingTaxTotal do not exist. A single shared
struct emitted invoice-ordered XML for both, producing structurally invalid
credit notes whenever those elements were present.

Model the two documents as the distinct types their XSDs describe:

- Extract the element runs that are identical in both schemas into embedded
  structs (documentHeader, documentCurrency, documentParties) so the shared
  ~40 fields live once and cannot drift.
- Keep Invoice as the builder/parser (and order-independent parse target for
  both document kinds); add a CreditNote type whose fields follow the
  UBL-CreditNote-2.1 sequence.
- Map Invoice -> CreditNote at the marshalling boundary; invoice-only fields
  are dropped by construction. Nothing rewrites the XML after marshalling.

Requires xmlctx embedded-struct decoding support (invopop/xmlctx#3);
go.mod is pinned to that branch pending its release tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 2, 2026 15:07
@codecov-commenter

codecov-commenter commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.85507% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.13%. Comparing base (7669190) to head (ad4aa93).

Files with missing lines Patch % Lines
creditnote.go 85.00% 5 Missing and 1 partial ⚠️
ubl.go 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #102      +/-   ##
==========================================
+ Coverage   80.91%   81.13%   +0.22%     
==========================================
  Files          28       29       +1     
  Lines        2117     2163      +46     
==========================================
+ Hits         1713     1755      +42     
- Misses        267      271       +4     
  Partials      137      137              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes structurally invalid UBL CreditNote XML caused by reusing the Invoice struct field order for both document types. It introduces a dedicated CreditNote typed model (in correct XSD element sequence) and remaps InvoiceCreditNote at the marshalling boundary so encoding/xml naturally emits the right element ordering without post-processing bytes.

Changes:

  • Route Bytes / BytesCompact through a marshalDocument adapter that remaps credit-note Invoice instances to a CreditNote struct before marshalling.
  • Refactor Invoice into embedded “shared runs” (documentHeader, documentCurrency, documentParties) and add a new CreditNote type with CreditNote-XSD field ordering.
  • Add an end-to-end ordering/leakage test for credit note marshalling; update existing tests impacted by the new embedded layout; bump xmlctx to a pseudo-version.

Reviewed changes

Copilot reviewed 7 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
ubl.go Uses marshalDocument to ensure credit notes marshal via the new CreditNote layout.
invoice.go Splits shared field runs into embedded structs and keeps Invoice as the main build/parse type.
creditnote.go Adds CreditNote model, toCreditNote() projection, and marshalDocument() adapter.
creditnote_test.go Adds test asserting CreditNote element ordering and absence of invoice-only fields.
extension_test.go Updates initialization patterns impacted by embedded struct promotion.
signature_test.go Updates initialization patterns impacted by embedded struct promotion.
go.mod / go.sum Pins xmlctx to a pseudo-version needed for embedded unmarshalling behavior.
.gitignore Adds go.work.sum to ignored workspace artifacts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread invoice.go
Comment on lines +102 to +106
type Invoice struct {
XMLName xml.Name
documentHeader

DueDate string `xml:"cbc:DueDate,omitempty"`
Comment thread invoice.go Outdated
Comment thread creditnote_test.go
Comment on lines +20 to +22
inv := &ubl.Invoice{XMLName: xml.Name{Local: "CreditNote"}}
inv.ID = "CN-1"
inv.IssueDate = "2024-02-14"
Comment thread go.mod
Comment on lines 16 to 20
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.20260702150626-9900fb876d77
github.com/invopop/xmldsig v0.14.0
Comment thread invoice.go Outdated
// 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 unmarshalled — when marshalling
alvarolivie and others added 2 commits July 2, 2026 15:18
Point at the updated invopop/xmlctx#3 commit (lint fixes + tighter
comments); still a branch pseudo-version pending the release tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Export the shared embed types (DocumentHeader/DocumentCurrency/
  DocumentParties) so external callers can build documents with keyed
  composite literals again.
- Fix a pre-existing comment typo and correct the Invoice doc wording
  (fields are omitted, not "unmarshalled", when marshalling an invoice).
- Set namespaces in the credit-note ordering test and assert the output
  is well-formed XML with all prefixes bound.
- Re-pin xmlctx to the branch head carrying the embed-decoding support.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 2, 2026 15:33
@alvarolivie

Copy link
Copy Markdown
Collaborator Author

Addressed the Copilot review in 1ed21a2:

  • invoice.go — fixed the "Schema locationa" → "Schema location" typo.
  • invoice.go — corrected the Invoice doc wording: the extra credit-note fields are omitted (not "unmarshalled") when marshalling an invoice.
  • creditnote_test.go — the test now sets the UBL namespace attributes and asserts the marshalled output is well-formed XML (all prefixes bound), so it no longer hides namespace regressions.
  • Unexported embeds — exported the shared groups as DocumentHeader / DocumentCurrency / DocumentParties, so external callers can build documents with keyed composite literals again (ubl.Invoice{DocumentHeader: ubl.DocumentHeader{...}}).
  • go.mod pseudo-version — intentional for now; it's pinned to the xmlctx branch so CI is green. It will be repinned to the tagged xmlctx release before merge (see the checklist in the PR description).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 9 changed files in this pull request and generated 3 comments.

Comment thread invoice.go
Comment thread invoice.go
Comment on lines +99 to +101
// 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.
Comment thread go.mod Outdated
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.20260702153213-8edb2c07e8d5
alvarolivie and others added 2 commits July 2, 2026 18:16
Add xsd_validation_test.go, which validates every convert golden against
the maindoc UBL-Invoice / UBL-CreditNote schemas (guarded to skip when a
Java validator isn't available), plus a differential test proving the
credit note is only schema-valid in CreditNote-XSD order: the previous
invoice-ordered layout is rejected because cbc:TaxPointDate lands after
cbc:CreditNoteTypeCode, while the shipped path validates.

Add a credit-note fixture exercising the divergent elements (TaxPointDate,
document-level allowance + charge, a billing reference) and its golden.

Re-pin xmlctx to the branch head carrying the embedded-decode support, the
encoding/xml promotion-rule parity, and the special-field tag-matching fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add the optional cbc:AccountingCostCode element to DocumentCurrency in
  its XSD position (between PaymentAlternativeCurrencyCode and
  AccountingCost) so the shared run matches the UBL 2.1 sequence exactly.
- Document how to construct an Invoice now that the shared fields are
  promoted from embedded types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alvarolivie

Copy link
Copy Markdown
Collaborator Author

Addressed the second review pass:

  • invoice.go DocumentCurrency — added the optional cbc:AccountingCostCode element in its correct XSD position (between PaymentAlternativeCurrencyCode and AccountingCost), so the shared run matches the UBL 2.1 sequence exactly.
  • invoice.go construction — the embedded groups are now exported (DocumentHeader/DocumentCurrency/DocumentParties), and the Invoice doc comment now explains the construction pattern for promoted fields (Invoice{DocumentParties: DocumentParties{Signature: ...}} or post-construction assignment; most callers should use Convert).
  • go.mod pseudo-version — intentional pin to the xmlctx branch so CI is green; will be repinned to the tagged release before merge (tracked in the PR checklist).

For added confidence this refactor is correct, this branch now also validates every generated golden against the official UBL 2.1 XSDs (xsd_validation_test.go) and includes a differential test proving the credit note is only schema-valid in CreditNote-XSD order.

Copilot AI review requested due to automatic review settings July 3, 2026 13:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 12 changed files in this pull request and generated 4 comments.

Comment thread xsd_validation_test.go
Comment on lines +56 to +58
if _, err := exec.LookPath("java"); err != nil {
t.Skip("java not available, skipping XSD validation")
}
Comment thread xsd_validation_test.go
Comment on lines +59 to +61
if _, err := exec.LookPath("javac"); err != nil {
t.Skip("javac not available, skipping XSD validation")
}
Comment thread creditnote_test.go
Comment on lines +41 to +45
// 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 thread creditnote_test.go
Comment on lines +83 to +85
"<cac:ContractDocumentReference>",
"<cac:StatementDocumentReference>",
"<cac:OriginatorDocumentReference>",
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants