Skip to content

Add peppol-bis-v3 addon (Peppol BIS Billing 3.0)#811

Draft
alvarolivie wants to merge 15 commits intomainfrom
peppol-addon
Draft

Add peppol-bis-v3 addon (Peppol BIS Billing 3.0)#811
alvarolivie wants to merge 15 commits intomainfrom
peppol-addon

Conversation

@alvarolivie
Copy link
Copy Markdown
Contributor

@alvarolivie alvarolivie commented Apr 20, 2026

What this adds

New addon peppol-bis-v3 at addons/peppol/bis/ that enforces the fatal-level Peppol BIS Billing 3.0 rules on top of eu-en16931-v2017. Without this, gobl.ubl can produce documents that pass EN 16931 but get rejected at a Peppol access point.

Rules enforced

  • Base: PEPPOL-COMMON identifier checksums (GLN, NO, DK CVR, BE, SE, AU), plus the fatal PEPPOL-EN16931-R* and P* 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.).
  • National CIUS (guarded on supplier country): fatal rules for DK, DE, GR, IS, IT, NL, NO, SE — including DE-R-018 #SKONTO# format on pay.Terms.Notes, IS-R-008/R-009/R-010 EINDAGI on org.Note{Src: "eindagi"}, GR-R-001-1 invoice-ID segment count, etc.

Rules not enforced

  • Warning-level rules: not implemented as a general policy.
  • Deferred to gobl.ubl (documented inline at each rule site):
    • DE-R-014, DE-R-022 — percent and attachment filenames are UBL serialization concerns the converter controls.
    • DK-R-003 — UNSPSC @listVersionID is a UBL attribute; no structured GOBL slot.
    • GR-R-001-2..-7 — segment contents (TIN, date, sequence) should be built from structured fields at emit time, not parsed back out.
  • Out of scope: PEPPOL-EN16931-R001/R004/R007 (ProfileID/CustomizationID — set by gobl.ubl Context), UBL-shape concerns (R008/R043/R051), formulas already enforced by bill.Invoice.Calculate() (R040/R041/R042/R046/R120), and all UBL-CR-* warnings.

Key decisions

  • Layout addons/peppol/<profile>/ so a future addons/peppol/pint/ can sit alongside bis/.
  • Requires: en16931.V2017 — no duplication of EN 16931 normalization/validation.
  • Identity checks key on iso-scheme-id, not regime id.Type, so they fire whether the caller used a typed identity, a key, or set the scheme directly.
  • IdentityKeyFSkatt normalizer populates Scope=tax / Type / Code so gobl.ubl's existing tax-scope identity path emits the F-skatt cac:PartyTaxScheme block without converter changes (SE-R-005).

Pre-Review Checklist

  • Opened this PR as a draft
  • Read the CONTRIBUTING.md guide.
  • Performed a self-review of my code.
  • Added thorough tests with at least 90% code coverage. (addons/peppol/bis at 97.2%)
  • Modified or created example GOBL documents to show my changes in use, if appropriate.
  • Added links to the source of the changes in tax regimes or addons, either structured or in the comments.
  • Run go generate . to ensure that the Schemas and Regime data are up to date.
  • Reviewed and fixed all linter warnings.
  • Been obsessive with pointer nil checks to avoid panics.
  • Updated the CHANGELOG.md with an overview of my changes.
  • Marked this PR as ready for review.

And if you are part of the org:

  • Requested a review from Copilot and fixed or dismissed (with a reason) all the feedback raised.
  • Requested a review from @samlown.

alvarolivie and others added 2 commits April 20, 2026 19:22
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
Copy link
Copy Markdown

codecov Bot commented Apr 20, 2026

Codecov Report

❌ Patch coverage is 96.84647% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.56%. Comparing base (cec18ad) to head (042d8a2).

Files with missing lines Patch % Lines
addons/peppol/bis/is.go 88.23% 8 Missing and 8 partials ⚠️
addons/peppol/bis/de.go 96.17% 4 Missing and 4 partials ⚠️
addons/peppol/bis/org.go 95.58% 4 Missing and 2 partials ⚠️
addons/peppol/bis/bill_line.go 94.28% 1 Missing and 1 partial ⚠️
addons/peppol/bis/bis.go 97.22% 1 Missing and 1 partial ⚠️
addons/peppol/bis/se.go 97.87% 1 Missing and 1 partial ⚠️
addons/peppol/bis/tax.go 89.47% 1 Missing and 1 partial ⚠️
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.
📢 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

alvarolivie and others added 11 commits April 20, 2026 19:41
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>
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 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/bis Go 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.

Comment thread addons/peppol/bis/de.go
Comment on lines +144 to +146
if len(p.People) > 0 || len(p.Telephones) > 0 || len(p.Emails) > 0 {
return true
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

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(...).

Suggested change
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
}
}

Copilot uses AI. Check for mistakes.
Comment thread addons/peppol/bis/de.go
Comment on lines +179 to +188
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
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment thread addons/peppol/bis/de.go
Comment on lines +193 to +202
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
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +75
- iban: "DE89370400440532013000"
name: "Random Bank Co."
terms:
detail: "Please pay within 30 days"
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
alvarolivie and others added 2 commits April 22, 2026 11:56
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>
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.

2 participants