Skip to content

Add ZATCA specific conversion#71

Open
migueltorresvalls wants to merge 26 commits into
mainfrom
sa-specific
Open

Add ZATCA specific conversion#71
migueltorresvalls wants to merge 26 commits into
mainfrom
sa-specific

Conversation

@migueltorresvalls
Copy link
Copy Markdown
Contributor

@migueltorresvalls migueltorresvalls commented May 6, 2026

ZATCA support

  • New ContextZATCA with ZATCA customization/profile IDs and zatca.V1 addon.
  • Address mapping: BuildingNumber, PlotIdentification, CitySubdivisionName for ZATCA address requirements.
  • Delivery period: LatestDeliveryDate support for ZATCA supply date ranges.
  • Signature structures: new extension.go and signature.go with UBL extension and document signature types for XAdES embedding.
  • New XML namespaces: ext, sig, sac, sbc.
  • 16 golden test files (8 convert + 8 parse): simplified/standard invoices, credit/debit notes, zero-rated, and multi-currency.

Cross-cutting changes

  • tax.Extensions API migration: map-style access (ext[key]) replaced with .Get(), .Set(), tax.ExtensionsOf() across all files.
  • InvoiceTypeCode changed from string to *IDType to support the name attribute needed by ZATCA.
  • Exchange rates: multi-currency tax total support (BT-111) derived from dual TaxTotal blocks.
  • Attachments: AddAttachments made public, added UUID field, URL is now optional.
  • Removed post-addon Validate() in ensureAddons — only Calculate() is called.
  • XML encoding fix: ' replaced with literal ' in serialized output.

Test coverage

Category Files
Convert (GOBL->UBL) simplified-invoice, simplified-credit-note, simplified-debit-note, simplified-zero-rated, standard-invoice, standard-credit-note, standard-debit-note, standard-usd-invoice
Parse (UBL->GOBL) Same 8 scenarios with round-trip verification

Dependencies

Requires the sa-addon branch of gobl (pointed to via go.mod replace directive).

@migueltorresvalls
Copy link
Copy Markdown
Contributor Author

migueltorresvalls commented May 6, 2026

Test are failing because depends on invopop/gobl#777

Fixed with: go get github.com/invopop/gobl@addon-sa

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 6, 2026

Codecov Report

❌ Patch coverage is 78.32370% with 75 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.63%. Comparing base (c7582f7) to head (9db9e47).

Files with missing lines Patch % Lines
invoice_parse.go 72.41% 20 Missing and 12 partials ⚠️
party.go 60.86% 5 Missing and 4 partials ⚠️
ordering.go 25.00% 5 Missing and 1 partial ⚠️
totals.go 82.85% 3 Missing and 3 partials ⚠️
attachments.go 66.66% 3 Missing and 2 partials ⚠️
lines.go 77.27% 3 Missing and 2 partials ⚠️
ubl.go 37.50% 5 Missing ⚠️
delivery_parse.go 60.00% 2 Missing and 2 partials ⚠️
ordering_parse.go 33.33% 1 Missing and 1 partial ⚠️
payment_parse.go 85.71% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #71      +/-   ##
==========================================
+ Coverage   76.56%   77.63%   +1.07%     
==========================================
  Files          26       28       +2     
  Lines        1933     2106     +173     
==========================================
+ Hits         1480     1635     +155     
- Misses        330      335       +5     
- Partials      123      136      +13     

☔ View full report in Codecov by Sentry.
📢 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.

@migueltorresvalls
Copy link
Copy Markdown
Contributor Author

Tests are running OK locally but failing when pushing with error:

Could not pull latest version information: SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

Copilot's explanation:

The failure in job 74992376272 is caused by the step "Upload coverage reports to Codecov" using codecov/codecov-action@v4.0.1, resulting in an Error: write EPIPE. This error often indicates a broken pipe, typically due to issues communicating with the Codecov service or an incorrectly handled upload process.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 adds Saudi Arabia ZATCA (Phase 2) support to the GOBL↔UBL converter/parser, along with cross-cutting updates to extension handling, multi-currency tax totals/exchange rates, attachments, and golden fixtures.

Changes:

  • Introduce ContextZATCA and ZATCA-specific mappings (address fields, delivery period, invoice headers/type code attributes, tax total behavior).
  • Update core conversion/parsing to use the new tax.Extensions method-based API and derive exchange rates from dual TaxTotal blocks.
  • Add UBL extension/signature struct scaffolding and refresh a large set of golden XML/JSON fixtures.

Reviewed changes

Copilot reviewed 46 out of 103 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
utils.go Switch note parsing to tax.ExtensionsOf(...).
ubl.go Register ext namespace; remove post-addon Validate(); replace &#39; in serialized XML.
totals.go Add rounding to TaxTotal; ZATCA/BT-111 extra TaxTotal; derive exchange rates from dual TaxTotal; migrate extensions API usage.
signature.go Add UBL document signature struct scaffolding (XAdES embedding).
extension.go Add UBL extension wrapper structs and helpers.
payment.go Make InstructionNote omitempty; ZATCA-specific use of InstructionNote; migrate extensions API checks.
payment_test.go Update test to use new extensions constructor.
payment_parse.go Update parsing to pass options/context and use tax.ExtensionsOf(...).
payment_parse_test.go Update extension access to .Get().
party.go Add ZATCA address fields + mappings; context-aware party mapping; migrate extensions API usage.
party_parse.go Context-aware party parsing; map ZATCA address fields; migrate extensions API usage.
party_parse_test.go Update extension access to .Get().
ordering.go Context-aware ordering mapping (skip PEPPOL-specific requirements for ZATCA); add UUID to references.
ordering_parse.go Migrate to .Set() for extensions; capture DocumentTypeCode into GOBL ext.
lines.go Add line-level TaxTotal (ZATCA KSA-11); migrate extensions API usage.
lines_parse.go Migrate extensions API usage in line parsing.
lines_parse_test.go Update extension access to .Get().
invoice.go Add ZATCA header behavior (UUID, IssueTime, invoice type name attr); make InvoiceTypeCode an *IDType; ensure ext namespace on root; context-aware conversions.
invoice_test.go Update expectations for InvoiceTypeCode pointer + extensions constructor.
invoice_parse.go ZATCA parsing support (invoice type name attr, IssueTime); derive exchange rates from dual TaxTotal; context-aware payment/party parsing.
attachments.go Export AddAttachments, add UUID support, make URL optional.
delivery.go Add LatestDeliveryDate and map delivery period to date range (start/end).
delivery_parse.go Parse ZATCA delivery period (Actual+Latest) into GOBL Period.
context.go Add ContextZATCA and include it in reverse-lookup list.
common.go Add EXT/SIG/SAC/SBC namespace constants; migrate extensions API check in getTypeCode; remove old extensions structs.
charges_parse.go Migrate charges/discounts parsing to .Set() / tax.ExtensionsOf(...).
charges_parse_test.go Update extension access to .Get().
examples_test.go Update Phive port; include ZATCA context in convert/parse example tests.
go.mod Update GOBL version, add xmldsig/cloud deps, bump testify, refresh indirect deps.
test/data/parse/zatca/standard-usd-invoice.xml New ZATCA parse fixture (USD doc currency + SAR tax currency).
test/data/parse/zatca/simplified-zero-rated.xml New ZATCA parse fixture (simplified zero-rated scenario).
test/data/parse/zatca/out/standard-usd-invoice.json Expected parsed GOBL output for ZATCA USD invoice.
test/data/parse/zatca/out/standard-invoice.json Expected parsed GOBL output for ZATCA standard invoice.
test/data/parse/zatca/out/standard-debit-note.json Expected parsed GOBL output for ZATCA debit note.
test/data/parse/zatca/out/standard-credit-note.json Expected parsed GOBL output for ZATCA credit note.
test/data/parse/zatca/out/simplified-zero-rated.json Expected parsed GOBL output for ZATCA simplified zero-rated.
test/data/parse/zatca/out/simplified-invoice.json Expected parsed GOBL output for ZATCA simplified invoice.
test/data/parse/zatca/out/simplified-debit-note.json Expected parsed GOBL output for ZATCA simplified debit note.
test/data/parse/zatca/out/simplified-credit-note.json Expected parsed GOBL output for ZATCA simplified credit note.
test/data/parse/peppol/out/Allowance-example.json Golden update for parsed exchange rates + identity ext.
test/data/parse/france-cius/out/b2b-reg.json Golden update for identity ext structure.
test/data/parse/en16931/out/ubl-example5.json Golden update for parsed exchange rates.
test/data/parse/en16931/out/custom-namespace-prefixes.json Golden update for address num mapping.
test/data/parse/en16931/out/credit-note1.json Golden update for address num mapping.
test/data/convert/zatca/standard-usd-invoice.json New ZATCA convert input fixture (USD doc currency + SAR tax currency).
test/data/convert/zatca/standard-invoice.json New ZATCA convert input fixture (standard invoice).
test/data/convert/zatca/standard-debit-note.json New ZATCA convert input fixture (debit note).
test/data/convert/zatca/standard-credit-note.json New ZATCA convert input fixture (credit note).
test/data/convert/zatca/simplified-zero-rated.json New ZATCA convert input fixture (simplified zero-rated).
test/data/convert/zatca/simplified-invoice.json New ZATCA convert input fixture (simplified invoice).
test/data/convert/zatca/simplified-debit-note.json New ZATCA convert input fixture (simplified debit note).
test/data/convert/zatca/simplified-credit-note.json New ZATCA convert input fixture (simplified credit note).
test/data/convert/zatca/out/standard-usd-invoice.xml New expected UBL output for ZATCA USD invoice.
test/data/convert/zatca/out/standard-invoice.xml New expected UBL output for ZATCA standard invoice.
test/data/convert/zatca/out/standard-credit-note.xml New expected UBL output for ZATCA credit note (invoice-rooted per ZATCA).
test/data/convert/zatca/out/simplified-invoice.xml New expected UBL output for ZATCA simplified invoice.
test/data/convert/zatca/out/simplified-debit-note.xml New expected UBL output for ZATCA simplified debit note.
test/data/convert/zatca/out/simplified-credit-note.xml New expected UBL output for ZATCA simplified credit note.
test/data/convert/xrechnung/out/invoice-xr-minimal.xml Golden update to include ext namespace.
test/data/convert/xrechnung/out/invoice-due-date-with-notes.xml Golden update to include tax currency + dual tax totals.
test/data/convert/xrechnung/out/credit-note-xr.xml Golden update to include ext namespace.
test/data/convert/peppol/out/peppol-reverse-charge.xml Golden update to include ext namespace.
test/data/convert/peppol/out/peppol-1.xml Golden update to include ext namespace.
test/data/convert/peppol/out/peppol-1-advance.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-with-prepayment.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-with-item-codes.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-with-document-discount.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-with-delivery.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-with-contract-ref.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-with-charges.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-prices-include-vat.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-partially-paid.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-multi-line.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-minimal.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-intra-comunity.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-cross-border-b2b.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-corrective.xml Golden update to include ext namespace.
test/data/convert/peppol/out/invoice-complete.xml Golden update to include ext namespace.
test/data/convert/peppol/out/credit-note.xml Golden update to include ext namespace.
test/data/convert/peppol/out/credit-note-peppol.xml Golden update to include ext namespace.
test/data/convert/peppol/invoice-prices-include-vat.json Golden update (line sums/totals + totals block).
test/data/convert/peppol-self-billed/out/self-billed-invoice.xml Golden update to include ext namespace.
test/data/convert/peppol-self-billed/out/credit-note-self-billed.xml Golden update to include ext namespace.
test/data/convert/france-extended/out/invoice-fr-extended.xml Golden update to include ext namespace.
test/data/convert/france-extended/out/invoice-fr-extended-detailed.xml Golden update to include ext namespace.
test/data/convert/france-cius/out/invoice-fr-cius.xml Golden update (apostrophe entity fix + ext namespace).
test/data/convert/france-cius/out/credit-note-fr.xml Golden update (apostrophe entity fix + ext namespace).
test/data/convert/en16931/out/invoice-proforma.xml Golden update to include ext namespace.
test/data/convert/en16931/out/invoice-multi-vat-rates.xml Golden update to include ext namespace.
test/data/convert/en16931/out/invoice-minimal.xml Golden update to include ext namespace.
test/data/convert/en16931/out/invoice-export-zero-rated.xml Golden update to include ext namespace.
test/data/convert/en16931/out/invoice-complete.xml Golden update to include ext namespace.
test/data/convert/en16931/out/invoice-attachments.xml Golden update to include ext namespace.
test/data/convert/en16931/out/credit-note-simple.xml Golden update to include ext namespace.

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

Comment thread party.go
Comment thread party.go
Comment thread delivery.go
Comment thread payment.go
Comment thread invoice.go
Comment thread invoice.go
Comment thread totals.go
Comment thread signature.go
Comment thread extension.go
Copy link
Copy Markdown
Collaborator

@alvarolivie alvarolivie left a comment

Choose a reason for hiding this comment

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

Looks great, I've mentioned a few things. To make this cleaner you might want to create a PR to update GOBL so you remove all those changes that come from updating GOBL and keep this clean.

Comment thread invoice.go
Comment on lines +266 to +268
// Some countries don't differentiate between Invoice and notes
// treating all the same. This helper returns the invoice type
// based on XML name instead of gobl's invoice type key
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Quick doubt on this. Are we not already doing this somehow on parse?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, on parse we do infer the invoice type, but from the UNTDID document type code. Without this method, the snippet below would treat invoices as a credit notes triggering an error in ZATCA's systems.

gobl.ubl/lines.go

Lines 63 to 67 in c7582f7

if inv.Type.In(bill.InvoiceTypeCreditNote) {
invLine.CreditedQuantity = iq
} else {
invLine.InvoicedQuantity = iq
}

Comment thread invoice_parse.go
return env, nil
}

func (ui *Invoice) goblInvoice(o *options) (*bill.Invoice, error) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

split into subfunctions, gocyclo shows when we start making a function too complicated

Comment thread invoice_parse.go
if typeCode == nil {
return bill.InvoiceTypeOther
}
if ctx.Is(ContextZATCA) && typeCode.Name != nil {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Cant this be handled by the scenario with the new changes in GOBL?

Comment thread invoice_parse.go
if val, ok := InvoiceTagMap[typeCode]; ok {
return val
func tagCodeParse(typeCode *IDType, ctx Context) []cbc.Key {
var tags []cbc.Key
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same as before, can't we get this information from the scenarios.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not sure how. This function populates the gobl scenarios by setting the types and tags that the ZATCA addon expects

Comment thread lines.go
}

// Zatca specific KSA-11
if context.Is(ContextZATCA) && l.Total != nil && len(l.Taxes) > 0 && l.Taxes[0].Percent != nil {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would add a comment on why this is necessary/what is it doing

Copy link
Copy Markdown
Contributor Author

@migueltorresvalls migueltorresvalls May 14, 2026

Choose a reason for hiding this comment

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

Added a more specific comment. Basically, as per BT-KSA-11: ZATCA requires that standard invoices have a TaxTotal line amount.

Comment thread ordering.go Outdated
if ui.OrderReference == nil {
ui.OrderReference = &OrderReference{}
// BT-13: Ensure at least one of BuyerReference or OrderReference is set: PEPPOL-EN16931-R003
// Optional to ZATCA
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

instead of doing !zatca, I would set this for the Peppol contexts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

How do you plan to handle this during parse?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Its handled automatically by ordering_parse:

if ui.OrderReference != nil && ui.OrderReference.ID != "" {
ordering.Purchases = []*org.DocumentRef{
{
Code: cbc.Code(ui.OrderReference.ID),
},
}

Copy link
Copy Markdown
Contributor Author

@migueltorresvalls migueltorresvalls May 14, 2026

Choose a reason for hiding this comment

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

Just checked this with ZATCA's report and clearance endpoints and it doesn't flag it as an error so we can get rid off the if statement completely.

I initially added it because its marked as optional in ZATCA's specs

Comment thread party.go
if id.Ext != nil {
if s := id.Ext[iso.ExtKeySchemeID].String(); s != "" {
idType.SchemeID = &s
if s := id.Ext.Get(iso.ExtKeySchemeID).String(); s != "" {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do they not have and ISO scheme? I think NO has something like this as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No, these are their only supported identites:

const (
	IdentityTypeTIN      cbc.Code = "TIN"
	IdentityTypeCRN      cbc.Code = "CRN"
	IdentityTypeMom      cbc.Code = "MOM"
	IdentityTypeMLS      cbc.Code = "MLS"
	IdentityType700      cbc.Code = "700"
	IdentityTypeSAG      cbc.Code = "SAG"
	IdentityTypeNational cbc.Code = "NAT"
	IdentityTypeGcc      cbc.Code = "GCC"
	IdentityTypeIqa      cbc.Code = "IQA"
	IdentityTypePassport cbc.Code = "PAS"
	IdentityTypeOTH      cbc.Code = "OTH"
)

Comment thread payment.go
formattedDate := formatDate(*inv.Payment.Terms.DueDates[0].Date)
ui.PaymentMeans[0].PaymentDueDate = &formattedDate
}
if inv.Preceding != nil && ctx.Is(ContextZATCA) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not clear why we need this. I would try to explain when calling ctx.is why this guard is needed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Should have a comment. This is the business rules that forces this.

  • BR-KSA-17 Debit and credit note (invoice type code (BT-3) is equal to 383 or 381) must contain the reason (KSA-10) for this invoice type issuing.

Comment thread ubl.go

// Go's xml.Marshal encodes single quotes as &#39,
// this is a quick fix
b = bytes.ReplaceAll(b, []byte("&#39;"), []byte("'"))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

did you double check if this still works with the rest of the code

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.

4 participants