Add peppol-bis-v3 addon (Peppol BIS Billing 3.0)#811
Add peppol-bis-v3 addon (Peppol BIS Billing 3.0)#811alvarolivie wants to merge 15 commits intomainfrom
Conversation
Introduce a new addon at addons/peppol/bis/ that enforces the full Peppol BIS Billing 3.0 rule set on top of EN 16931: 55 base rules (PEPPOL-COMMON-R040..R053, PEPPOL-EN16931-*) plus 99 national CIUS rules across DK, DE, GR, IS, IT, NL, NO and SE. National rules are guarded on supplier country so they fire automatically for the right invoices. Identifier format/checksum validation is keyed on the ISO 6523 scheme id (iso-scheme-id extension) so it catches both typed and scheme-only identities. The addon is namespaced as addons/peppol/<profile>/ so a future peppol/pint addon (Peppol International) can sit alongside. ProfileID and CustomizationID are intentionally not modelled here — those are emitted by gobl.ubl Context selection at serialization time, not carried on the GOBL document. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #811 +/- ##
==========================================
+ Coverage 93.36% 93.56% +0.19%
==========================================
Files 369 384 +15
Lines 19882 21087 +1205
==========================================
+ Hits 18562 19729 +1167
- Misses 888 908 +20
- Partials 432 450 +18 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Drop five rules from the addon that are either genuinely serialization
concerns or that GOBL already carries structurally and shouldn't ask
callers to duplicate as free-text:
- DE-R-022 (attachment filename uniqueness) — UBL-level concern.
- DE-R-018 (#SKONTO# note format) — synthesize from Payment.Terms.DueDates.
- IS-R-008/R-009/R-010 (EINDAGI date note) — synthesize from DueDates.
- SE-R-005 ("Godkänd för F-skatt" boilerplate) — synthesize at UBL emit.
- GR-R-001-1..-7 (invoice ID segment parsing) — build ID from structured
TaxID/IssueDate/sequence in gobl.ubl instead of parsing a hand-encoded
underscore-delimited string.
The retained GR rules (MARK identity, supplier/customer name & VAT
format, supplier/customer Peppol endpoint scheme) stay in the addon.
New deferred.go file documents what moved and why, so the gobl.ubl
work-plan has a single reference point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce a structured marker for Swedish F-tax (F-skatt) approval as a new key-based identity on the SE regime. When set on a supplier, gobl.ubl will be able to emit the second cac:PartyTaxScheme block required by Peppol SE-R-005 — cbc:CompanyID = "Godkänd för F-skatt" under a non-VAT TaxScheme — without callers having to hand-write the Swedish boilerplate. The regime normalizer fills the boilerplate code automatically when the key is set without one. The corresponding pointer in the peppol-bis-v3 deferred-rules note now references the new identity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The F-skatt assertion is a Peppol BIS UBL artifact (it's emitted as a non-VAT cac:PartyTaxScheme block to satisfy SE-R-005), not a general property of a Swedish supplier. Keeping the identity key in the addon matches what it actually represents and avoids cluttering the SE regime with a Peppol-specific concept. The addon now declares its own Normalizer that fills the "Godkänd för F-skatt" boilerplate code when the key is set bare. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Join Series + "_" + Code and assert the result splits into exactly 6 tokens on `_`. No semantic interpretation of segments — that part (GR-R-001-2..-7) stays deferred to gobl.ubl, where the contents can be built from structured fields rather than parsed back out of a hand-encoded string. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bring coverage of addons/peppol/bis from 8.1% to 97.5% by adding table-driven tests for each rule predicate and identifier-format validator. Also harden partyHasContactName against a nil Person.Name pointer. Tests cover: - ID format and checksum validators (GLN, NO org, DK CVR, BE enterprise, IT IPA/CF/PartitaIVA, SE org, AU ABN, DK P/SE) - Base bill/line/pay/tax/org predicates including invoice notes cardinality, buyer reference, document type subsets, tax category vs VATEX coherence, line period bounds, direct debit mandate, party country resolution - Country-specific predicates for DK, DE, GR, IS, IT, NL, NO, SE - F-skatt identity normaliser dispatch through the addon Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- goconst: replace repeated "legal" scope literals with org.IdentityScopeLegal. - ineffassign: drop the dead `ok` binding in deInvoiceDocumentTypeValid; the Get-accessor interface check is the only one that matters. - staticcheck ST1005: rewrite identifier-format error strings to start with a lowercase "invalid ..." prefix so they aren't flagged for capitalization. Switch to errors.New since the messages are static. - unparam: drop the always-2026 year parameter from the bill_line_test helper and hardcode the test year. Coverage remains 97.5%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Correctness fixes: - R010/R020 (orgPartyRules): scope the inbox requirement to Invoice.Supplier and Invoice.Customer explicitly. Previously the rule fired on every nested *org.Party the traversal visited (Ordering.Buyer/Seller/Issuer, Delivery.Receiver), rejecting invoices that just happened to carry those parties. - DE-R-014: only require rt.Percent on standard-rated (`S`) categories. Exempt/zero/reverse-charge lines legitimately have no numeric percent. - DK-R-006: accept IBAN or Number on CreditTransfer entries (GOBL's canonical SEPA field is IBAN; Number is the non-IBAN fallback). - R002: split `partyAddressCountry` out of `partyCountry` and use it for the DK exception. Schematron targets cac:PostalAddress/ cbc:Country, not the tax-id country. - P0104-P0111: fail closed on unknown `VATEX-EU-*` codes by requiring category `E` instead of silently passing. - DE-R-016: require supplier VAT (TaxID) or legal-scope identity (tax registration) — do not accept arbitrary DUNS/GLN identities. - IS-R-006/R-007: accept IS IBAN (IS + 24 chars) in addition to the 12-digit domestic form. - IS firstAddressStreetAndCode: return false for nil first address, matching the DE pattern. - SE-R-006: compare VAT percents numerically (num.Percentage.Equals) instead of by string, so any equivalent representation is accepted. - DE partyHasContactName: add nil-guard on the Person.Name pointer (already landed in the test commit). Scope changes: - DK-R-003 (UNSPSC version) moved to deferred.go — GOBL has no structured classification-version slot, so the only in-GOBL option was pattern-matching free-text Description, which isn't a real convention. All 73 packages pass. addons/peppol/bis coverage 97.1%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DE-R-014 (VAT category rate percent must be stated) is structurally satisfied by the GOBL → UBL path: GOBL only carries a numeric percent on standard/reduced-rated tax combos, and gobl.ubl emits cbc:Percent for every UBL tax subtotal (0 for exempt/zero/reverse-charge/outside scope). The schematron assertion is therefore satisfied by construction at emit time, so the check is removed from the addon. Inline where each deferred rule would otherwise sit, the reason it isn't enforced here is documented alongside the country rule set (DE-R-014/R-018/R-022, DK-R-003, GR-R-001-2..-7, IS-R-008/R-009/R-010, SE-R-005). deferred.go is removed; the reasoning lives next to the code it affects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gobl.ubl's party.go emits any org.Identity with Scope=tax as a
cac:PartyTaxScheme block, using id.Type.String() as the TaxScheme/ID
and id.Code as the CompanyID. By populating all three on the F-skatt
identity (Scope, Type, Code), the existing converter path produces
exactly the non-VAT cac:PartyTaxScheme block SE-R-005 requires —
no converter-side changes needed for the rule.
Normalizer now sets:
Scope = org.IdentityScopeTax
Type = FSkattTaxSchemeID ("TAX", any non-VAT value satisfies the
schematron)
Code = FSkattText ("Godkänd för F-skatt")
All three only apply when the caller left them empty, so explicit
values are preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier DE-R-018 / IS-R-008..R-010 notes implied gobl.ubl would synthesize the #SKONTO# and EINDAGI notes from Payment.Terms.DueDates. That's not the plan — callers who want those notes add them by hand into the payment-terms text, and it's their responsibility to get the format right. Updated inline comments accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per project policy, warning-level Peppol rules aren't enforced at
the GOBL layer. Trimmed the addon to fatal-level rules only, and
added back the two fatal format checks the reviewer flagged.
Added (both fatal):
- DE-R-018: each `#SKONTO#…` line in pay.Terms.Notes / DueDate.Notes
must match the fixed format. Caller writes the note by hand (we
don't synthesize from DueDates), so this catches format mistakes
before they reach an access point.
- IS-R-008/R-009/R-010: EINDAGI marker is an org.Note with
Src=NoteSrcEINDAGI ("eindagi"); validates YYYY-MM-DD format,
requires Payment.Terms.DueDates, and asserts EINDAGI date ≥ first
due date.
Removed (all warning-level):
- DE-R-017, DE-R-019, DE-R-020, DE-R-026, DE-R-027, DE-R-028
- DK-R-017
- IS-R-001
- PEPPOL-COMMON-R044/R045/R046/R047 (IT IPA, CF, PartitaIVA)
- PEPPOL-COMMON-R052/R053 (DK P, SE numbers)
- SE-R-003, SE-R-012
Associated helpers, validators, test cases, and regex constants for
the removed rules are dropped. Net diff: -303 LOC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a new Peppol BIS Billing 3.0 addon (peppol-bis-v3) that layers Peppol fatal rule enforcement on top of the existing eu-en16931-v2017 addon, and wires it into the global addon registry along with examples and generated data artifacts.
Changes:
- Added
addons/peppol/bisGo implementation for Peppol BIS Billing 3.0 base rules + national CIUS (DK/DE/GR/IS/IT/NL/NO/SE), including identity normalization (SE F-skatt) and extensive unit tests. - Registered the addon in the global addon list/schema and included generated addon + rules JSON data.
- Updated/added examples (DK + DE) and documented the addition in
CHANGELOG.md.
Reviewed changes
Copilot reviewed 37 out of 39 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| examples/dk/out/invoice-peppol.json | Updates DK Peppol output example to include addon + ordering reference. |
| examples/dk/invoice-peppol.json | Updates DK input example to use peppol-bis-v3 and include ordering code. |
| examples/de/out/invoice-peppol.json | Adds DE Peppol output envelope example. |
| examples/de/invoice-peppol.yaml | Adds DE Peppol input example (YAML). |
| data/schemas/tax/addon-list.json | Adds peppol-bis-v3 to addon enum list. |
| data/rules/peppol-bis-v3.json | Adds generated rules export for the addon. |
| data/addons/peppol-bis-v3.json | Adds generated addon definition export. |
| addons/peppol/bis/tax.go | Implements VATEX ↔ tax category coherence rule. |
| addons/peppol/bis/tax_test.go | Unit tests for VATEX/category coherence helper. |
| addons/peppol/bis/se.go | Swedish CIUS rules/helpers (VAT rate, VAT/org number checks). |
| addons/peppol/bis/se_test.go | Unit tests for Swedish CIUS helpers. |
| addons/peppol/bis/pay.go | Implements direct-debit mandate reference requirement. |
| addons/peppol/bis/pay_test.go | Unit tests for direct-debit mandate requirement. |
| addons/peppol/bis/org.go | Implements PEPPOL-COMMON identifier/inbox scheme validation helpers. |
| addons/peppol/bis/org_test.go | Unit tests for identifier checksum/format validators. |
| addons/peppol/bis/no.go | Norwegian CIUS VAT-format rule helper. |
| addons/peppol/bis/no_test.go | Unit tests for Norwegian VAT-format helper. |
| addons/peppol/bis/nl.go | Dutch CIUS rules (credit note refs, legal scheme, payment means, etc.). |
| addons/peppol/bis/nl_test.go | Unit tests for Dutch CIUS helpers. |
| addons/peppol/bis/it.go | Italian CIUS rules (tax id length, address requirements). |
| addons/peppol/bis/it_test.go | Unit tests for Italian CIUS helpers. |
| addons/peppol/bis/is.go | Icelandic CIUS rules (EINDAGI notes + payment account constraints). |
| addons/peppol/bis/is_test.go | Unit tests for Icelandic CIUS helpers. |
| addons/peppol/bis/identities.go | Defines Peppol-specific identities (GR MARK, SE F-skatt) + normalizer. |
| addons/peppol/bis/gr.go | Greek CIUS rules (invoice ID segments, MARK identity, inbox/VAT rules). |
| addons/peppol/bis/gr_test.go | Unit tests for Greek CIUS helpers. |
| addons/peppol/bis/dk.go | Danish CIUS rules (CVR identity, payment means subsets, etc.). |
| addons/peppol/bis/dk_test.go | Unit tests for Danish CIUS helpers. |
| addons/peppol/bis/de.go | German CIUS rules (contact requirements, #SKONTO# format, payment exclusivity). |
| addons/peppol/bis/de_test.go | Unit tests for German CIUS helpers. |
| addons/peppol/bis/bill.go | Base Peppol invoice-level rules (notes, buyer ref/PO, UNTDID type codes). |
| addons/peppol/bis/bill_test.go | Unit tests for base invoice-level rules. |
| addons/peppol/bis/bill_line.go | Base line period within invoice period checks. |
| addons/peppol/bis/bill_line_test.go | Unit tests for line period boundary checks. |
| addons/peppol/bis/bis.go | Addon registration + ruleset wiring + country guard helpers. |
| addons/peppol/bis/bis_test.go | Public-facing addon registration/normalizer smoke tests. |
| addons/peppol/bis/bis_internal_test.go | Internal helper tests (party country guard, normalizer dispatch). |
| addons/addons.go | Globally registers the new addon via blank import. |
| CHANGELOG.md | Documents the new addon and F-skatt identity key. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if len(p.People) > 0 || len(p.Telephones) > 0 || len(p.Emails) > 0 { | ||
| return true | ||
| } |
There was a problem hiding this comment.
partyHasContactGroup returns true when People/Telephones/Emails slices are non-empty even if they only contain nil entries. That can let DE-R-002 pass even though there is effectively no contact info (nil elements won’t be validated by org.Person/org.Telephone/org.Email rules). Consider scanning the slices for at least one non-nil entry (and/or one with required fields present) instead of relying on len(...).
| if len(p.People) > 0 || len(p.Telephones) > 0 || len(p.Emails) > 0 { | |
| return true | |
| } | |
| for _, person := range p.People { | |
| if person != nil { | |
| return true | |
| } | |
| } | |
| for _, telephone := range p.Telephones { | |
| if telephone != nil { | |
| return true | |
| } | |
| } | |
| for _, email := range p.Emails { | |
| if email != nil { | |
| return true | |
| } | |
| } |
| func partyHasContactTelephone(val any) bool { | ||
| p, ok := val.(*org.Party) | ||
| if !ok || p == nil { | ||
| return true | ||
| } | ||
| if len(p.Telephones) > 0 { | ||
| return true | ||
| } | ||
| if len(p.People) > 0 && p.People[0] != nil && len(p.People[0].Telephones) > 0 { | ||
| return true |
There was a problem hiding this comment.
partyHasContactTelephone treats any non-empty Telephones slice as satisfying DE-R-006, even if entries are nil (or otherwise empty). Since nil elements won’t be validated by org.Telephone rules, this can incorrectly pass. Consider iterating to confirm there is at least one non-nil telephone (and ideally a non-empty Number) either on the party or on a person.
| func partyHasContactTelephone(val any) bool { | |
| p, ok := val.(*org.Party) | |
| if !ok || p == nil { | |
| return true | |
| } | |
| if len(p.Telephones) > 0 { | |
| return true | |
| } | |
| if len(p.People) > 0 && p.People[0] != nil && len(p.People[0].Telephones) > 0 { | |
| return true | |
| func hasUsableTelephone(telephones []*org.Telephone) bool { | |
| for _, tel := range telephones { | |
| if tel != nil && strings.TrimSpace(tel.Number) != "" { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| func partyHasContactTelephone(val any) bool { | |
| p, ok := val.(*org.Party) | |
| if !ok || p == nil { | |
| return true | |
| } | |
| if hasUsableTelephone(p.Telephones) { | |
| return true | |
| } | |
| for _, person := range p.People { | |
| if person != nil && hasUsableTelephone(person.Telephones) { | |
| return true | |
| } |
| func partyHasContactEmail(val any) bool { | ||
| p, ok := val.(*org.Party) | ||
| if !ok || p == nil { | ||
| return true | ||
| } | ||
| if len(p.Emails) > 0 { | ||
| return true | ||
| } | ||
| if len(p.People) > 0 && p.People[0] != nil && len(p.People[0].Emails) > 0 { | ||
| return true |
There was a problem hiding this comment.
partyHasContactEmail treats any non-empty Emails slice as satisfying DE-R-007, even if entries are nil (or otherwise empty). Because nil elements won’t be validated by org.Email rules, this can incorrectly pass. Consider iterating to confirm there is at least one non-nil email (and ideally a non-empty Address) either on the party or on a person.
| func partyHasContactEmail(val any) bool { | |
| p, ok := val.(*org.Party) | |
| if !ok || p == nil { | |
| return true | |
| } | |
| if len(p.Emails) > 0 { | |
| return true | |
| } | |
| if len(p.People) > 0 && p.People[0] != nil && len(p.People[0].Emails) > 0 { | |
| return true | |
| func hasAnyContactEmail(emails []*org.Email) bool { | |
| for _, email := range emails { | |
| if email != nil && strings.TrimSpace(email.Address) != "" { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| func partyHasContactEmail(val any) bool { | |
| p, ok := val.(*org.Party) | |
| if !ok || p == nil { | |
| return true | |
| } | |
| if hasAnyContactEmail(p.Emails) { | |
| return true | |
| } | |
| for _, person := range p.People { | |
| if person != nil && hasAnyContactEmail(person.Emails) { | |
| return true | |
| } |
| - iban: "DE89370400440532013000" | ||
| name: "Random Bank Co." | ||
| terms: | ||
| detail: "Please pay within 30 days" |
There was a problem hiding this comment.
This example uses payment.terms.detail, which is a deprecated/backwards-compat field (Terms is schema’d as notes). Consider switching to payment.terms.notes to keep the example JSON-schema-valid and avoid relying on deprecated input.
Rule IDs:
- Base rule sets now use short sequential IDs "01", "02", ... instead
of repeating the Peppol code. The Peppol reference stays in the
description so grepping still works.
- Country-specific rule sets use a two-letter country prefix
("DK-01", "DE-01", etc.) so assertions across countries don't
collide under the shared bill.Invoice namespace. Final fault
codes look like GOBL-PEPPOL-BIS-V3-BILL-INVOICE-DE-02 instead of
GOBL-PEPPOL-BIS-V3-BILL-INVOICE-DE-R-015.
Guards:
- New bothCountriesAre(cc) helper for the common Peppol pattern
"rule applies when both supplier and customer are in country X".
- New customerCountryIs(cc) helper for rules that target only the
customer's country (e.g. NL-R-008).
- NL-R-001..R-005 now use bothCountriesAre(NL). Previously the outer
guard was supplierCountryIs(NL), which over-triggered for NL
suppliers with foreign customers. NL-R-008 uses customerCountryIs.
Removed the unused customerIsDK helper (its only caller was DK-R-017,
which was dropped as warning-level).
Coverage: 96.9%.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the noisy "PE" suffix from firstAddressHasLocalityPE / firstAddressHasCodePE — they weren't disambiguating anything. Rename one-off helpers that used the country's full adjective to match the 2-letter prefix convention used elsewhere (dk*, de*, gr*, nl*, etc.): italianTaxIDLength → itTaxIDLength norwegianVATFormat → noVATFormat swedishVATLength → seVATLength swedishVATTrailingDigits → seVATTrailingDigits swedishOrgLength → seOrgLength swedishOrgLuhn → seOrgLuhn Inlined valAsParty — a one-liner type assertion with a single caller in is.go. Coverage: 96.7%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
What this adds
New addon
peppol-bis-v3ataddons/peppol/bis/that enforces the fatal-level Peppol BIS Billing 3.0 rules on top ofeu-en16931-v2017. Without this,gobl.ublcan produce documents that pass EN 16931 but get rejected at a Peppol access point.Rules enforced
PEPPOL-COMMONidentifier checksums (GLN, NO, DK CVR, BE, SE, AU), plus the fatalPEPPOL-EN16931-R*andP*rules that map to GOBL document content (buyer reference, note cardinality, VATEX/tax-category coherence, allowed UNTDID document types, line-period bounds, direct-debit mandate, etc.).#SKONTO#format onpay.Terms.Notes, IS-R-008/R-009/R-010 EINDAGI onorg.Note{Src: "eindagi"}, GR-R-001-1 invoice-ID segment count, etc.Rules not enforced
gobl.ubl(documented inline at each rule site):@listVersionIDis a UBL attribute; no structured GOBL slot.PEPPOL-EN16931-R001/R004/R007(ProfileID/CustomizationID — set bygobl.ublContext), UBL-shape concerns (R008/R043/R051), formulas already enforced bybill.Invoice.Calculate()(R040/R041/R042/R046/R120), and allUBL-CR-*warnings.Key decisions
addons/peppol/<profile>/so a futureaddons/peppol/pint/can sit alongsidebis/.Requires: en16931.V2017— no duplication of EN 16931 normalization/validation.iso-scheme-id, not regimeid.Type, so they fire whether the caller used a typed identity, a key, or set the scheme directly.IdentityKeyFSkattnormalizer populates Scope=tax / Type / Code sogobl.ubl's existing tax-scope identity path emits the F-skattcac:PartyTaxSchemeblock without converter changes (SE-R-005).Pre-Review Checklist
go generate .to ensure that the Schemas and Regime data are up to date.And if you are part of the org: