Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
### Added

- `bill`: `PaymentDetails.Payer` party — the party responsible for making payment of the invoice if not the customer, the counterpart of the existing `Payee`.
- `org`: new identity scopes `class`, `seller`, and `buyer`, and the `legal` scope extended to items. Each scope declares who issued the code and what it identifies: `legal` for a registered scheme such as the GS1 GTIN (EN 16931 BT-157); `class` for a classification scheme such as UNSPSC, CPV, or HS (BT-158); `seller` for the seller's own article number (BT-155); `buyer` for the buyer's own catalogue code (BT-156). Addons bind and enforce the extensions each scope requires.
- `data/catalogues/untdid`: new `untdid-item-type-version` extension to carry the version of the scheme referenced by `untdid-item-type` (BT-158-2).
- `eu-en16931`: identity rules — `class` identities require the `untdid-item-type` extension (BT-158); items allow at most one `legal` identity (BT-157) and item `legal` identities require `iso-scheme-id` (BR-64). The scheme requirement is enforced at the item level since party identities also use the `legal` scope without one.

### Fixed

- `eu-en16931`: regenerated the rules data to drop the stale `GOBL-EU-EN16931-TAX-COMBO-06` (BR-E-10) assertion whose Go rule was already removed in v0.501.0.

## [v0.501.0] - 2026-06-16

Expand Down
1 change: 1 addition & 0 deletions addons/eu/en16931/en16931.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func init() {
payInstructionsRules(),
payTermsRules(),
orgItemRules(),
orgIdentityRules(),
orgAttachmentRules(),
orgPartyRules(),
orgInboxRules(),
Expand Down
66 changes: 66 additions & 0 deletions addons/eu/en16931/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,75 @@ func orgItemRules() *rules.Set {
// BR-23: unit of measure is required
rules.Assert("01", "unit is required (BR-23)", is.Present),
),
rules.Field("identities",
// BT-157 may only appear once per item
rules.Assert("02", "cannot have more than one identity with the 'legal' scope (BT-157)",
is.Func("max one legal-scoped identity", itemHasMaxOneLegalIdentity),
),
// The `legal` scope is also used by party identities, where no
// scheme is required, so the binding is enforced here at the
// item level rather than on the identity itself.
rules.Assert("03", "legal identities require the 'iso-scheme-id' extension (BR-64)",
is.Func("legal-scoped identities have iso-scheme-id", itemLegalIdentitiesHaveScheme),
),
),
)
}

func itemHasMaxOneLegalIdentity(val any) bool {
ids, ok := val.([]*org.Identity)
if !ok {
return true
}
count := 0
for _, id := range ids {
if id != nil && id.Scope == org.IdentityScopeLegal {
count++
}
}
return count <= 1
}

func itemLegalIdentitiesHaveScheme(val any) bool {
ids, ok := val.([]*org.Identity)
if !ok {
return true
}
for _, id := range ids {
if id != nil && id.Scope == org.IdentityScopeLegal && !id.Ext.Has(iso.ExtKeySchemeID) {
return false
}
}
return true
}

func orgIdentityRules() *rules.Set {
return rules.For(new(org.Identity),
// The scope declares what the identity is; the extension provides the
// binding the scope requires in EN 16931 outputs.
rules.When(identityScopeIs(org.IdentityScopeClass),
rules.Field("ext",
rules.Assert("01",
"classification identities require the 'untdid-item-type' extension (BT-158)",
tax.ExtensionsRequire(untdid.ExtKeyItemType),
),
),
),
)
}

func identityScopeIs(scope cbc.Key) rules.Test {
return is.Func("identity scope is '"+scope.String()+"'", func(val any) bool {
switch v := val.(type) {
case *org.Identity:
return v != nil && v.Scope == scope
case org.Identity:
return v.Scope == scope
}
return false
})
}

func orgAttachmentRules() *rules.Set {
return rules.For(new(org.Attachment),
rules.Field("code",
Expand Down
52 changes: 52 additions & 0 deletions addons/eu/en16931/org_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package en16931

import (
"testing"

"github.com/invopop/gobl/catalogues/iso"
"github.com/invopop/gobl/cbc"
"github.com/invopop/gobl/org"
"github.com/invopop/gobl/tax"
"github.com/stretchr/testify/assert"
)

// These white-box tests exercise the defensive guards in the item identity
// helpers, which the rule engine never reaches because it always passes the
// correctly-typed field value.

func TestItemIdentityHelperGuards(t *testing.T) {
t.Run("max-one-legal ignores non-slice input", func(t *testing.T) {
assert.True(t, itemHasMaxOneLegalIdentity("not a slice"))
})
t.Run("max-one-legal skips nil entries", func(t *testing.T) {
ids := []*org.Identity{nil, {Scope: org.IdentityScopeLegal}}
assert.True(t, itemHasMaxOneLegalIdentity(ids))
})
t.Run("legal-scheme ignores non-slice input", func(t *testing.T) {
assert.True(t, itemLegalIdentitiesHaveScheme(42))
})
t.Run("legal-scheme skips nil entries", func(t *testing.T) {
ids := []*org.Identity{
nil,
{Scope: org.IdentityScopeLegal, Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0160"})},
}
assert.True(t, itemLegalIdentitiesHaveScheme(ids))
})
}

func TestIdentityScopeIsGuards(t *testing.T) {
test := identityScopeIs(org.IdentityScopeClass)
t.Run("matches pointer", func(t *testing.T) {
assert.True(t, test.Check(&org.Identity{Scope: org.IdentityScopeClass}))
})
t.Run("nil pointer does not match", func(t *testing.T) {
var id *org.Identity
assert.False(t, test.Check(id))
})
t.Run("matches value", func(t *testing.T) {
assert.True(t, test.Check(org.Identity{Scope: org.IdentityScopeClass}))
})
t.Run("other types do not match", func(t *testing.T) {
assert.False(t, test.Check("nope"))
})
}
146 changes: 146 additions & 0 deletions addons/eu/en16931/org_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,149 @@ func TestOrgInboxValidate(t *testing.T) {
assert.NoError(t, err)
})
}

func TestOrgIdentitySchemeNormalize(t *testing.T) {
// Key-based normalization only sets the ISO 6523 scheme extension; it does
// not derive an identity scope. Scope must be set explicitly by the caller.
t.Run("gtin key sets scheme without a scope", func(t *testing.T) {
id := &org.Identity{
Key: org.IdentityKeyGTIN,
Code: "9501101530003",
}
norm.Normalize(id, tax.AddonContext(en16931.V2017))
assert.Empty(t, id.Scope)
assert.Equal(t, "0160", id.Ext.Get(iso.ExtKeySchemeID).String())
})
t.Run("gln key sets scheme without a scope", func(t *testing.T) {
id := &org.Identity{
Key: org.IdentityKeyGLN,
Code: "1234567890123",
}
norm.Normalize(id, tax.AddonContext(en16931.V2017))
assert.Empty(t, id.Scope)
assert.Equal(t, "0088", id.Ext.Get(iso.ExtKeySchemeID).String())
})
t.Run("ean and upc keys are not normalized", func(t *testing.T) {
for _, key := range []cbc.Key{org.IdentityKeyEAN, org.IdentityKeyUPC} {
id := &org.Identity{
Key: key,
Code: "5012345678900",
}
norm.Normalize(id, tax.AddonContext(en16931.V2017))
assert.Empty(t, id.Scope)
assert.False(t, id.Ext.Has(iso.ExtKeySchemeID))
}
})
}

func TestOrgIdentityScopeValidate(t *testing.T) {
t.Run("classification scope requires item type extension", func(t *testing.T) {
id := &org.Identity{
Scope: org.IdentityScopeClass,
Code: "09348023",
}
err := rules.Validate(id, tax.AddonContext(en16931.V2017))
assert.ErrorContains(t, err, "untdid-item-type")
})
t.Run("classification scope with extension is valid", func(t *testing.T) {
id := &org.Identity{
Scope: org.IdentityScopeClass,
Code: "09348023",
Ext: tax.ExtensionsOf(cbc.CodeMap{
untdid.ExtKeyItemType: "TST",
}),
}
assert.NoError(t, rules.Validate(id, tax.AddonContext(en16931.V2017)))
})
t.Run("legal scope alone has no identity-level requirements", func(t *testing.T) {
// Party identities also use the legal scope without a scheme; the
// iso-scheme-id binding is enforced at the item level instead.
id := &org.Identity{
Scope: org.IdentityScopeLegal,
Code: "9501101530003",
}
assert.NoError(t, rules.Validate(id, tax.AddonContext(en16931.V2017)))
})
t.Run("no scope has no extension requirements", func(t *testing.T) {
id := &org.Identity{
Code: "INTERNAL-123",
}
assert.NoError(t, rules.Validate(id, tax.AddonContext(en16931.V2017)))
})
}

func TestOrgItemLegalIdentities(t *testing.T) {
t.Run("max one legal identity", func(t *testing.T) {
item := &org.Item{
Name: "Test",
Unit: org.UnitOne,
Identities: []*org.Identity{
{
Scope: org.IdentityScopeLegal,
Code: "9501101530003",
Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0160"}),
},
{
Scope: org.IdentityScopeLegal,
Code: "5012345678900",
Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0160"}),
},
},
}
err := rules.Validate(item, tax.AddonContext(en16931.V2017))
assert.ErrorContains(t, err, "cannot have more than one identity with the 'legal' scope")
})
t.Run("legal identity without scheme fails", func(t *testing.T) {
item := &org.Item{
Name: "Test",
Unit: org.UnitOne,
Identities: []*org.Identity{
{
Scope: org.IdentityScopeLegal,
Code: "9501101530003",
},
},
}
err := rules.Validate(item, tax.AddonContext(en16931.V2017))
assert.ErrorContains(t, err, "legal identities require the 'iso-scheme-id' extension")
})
t.Run("ignores nil identity entries", func(t *testing.T) {
item := &org.Item{
Name: "Test",
Unit: org.UnitOne,
Identities: []*org.Identity{
nil,
{
Scope: org.IdentityScopeLegal,
Code: "9501101530003",
Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0160"}),
},
},
}
assert.NoError(t, rules.Validate(item, tax.AddonContext(en16931.V2017)))
})
t.Run("one legal identity with classifications", func(t *testing.T) {
item := &org.Item{
Name: "Test",
Unit: org.UnitOne,
Identities: []*org.Identity{
{
Scope: org.IdentityScopeLegal,
Code: "9501101530003",
Ext: tax.ExtensionsOf(cbc.CodeMap{iso.ExtKeySchemeID: "0160"}),
},
{
Scope: org.IdentityScopeClass,
Code: "09348023",
Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyItemType: "TST"}),
},
{
Scope: org.IdentityScopeClass,
Code: "86776",
Ext: tax.ExtensionsOf(cbc.CodeMap{untdid.ExtKeyItemType: "STI"}),
},
},
}
assert.NoError(t, rules.Validate(item, tax.AddonContext(en16931.V2017)))
})
}
4 changes: 4 additions & 0 deletions catalogues/untdid/untdid.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const (
// ExtKeyItemType is used to identify the UNTDID 7143 item type code.
ExtKeyItemType cbc.Key = "untdid-item-type"

// ExtKeyItemTypeVersion is used to identify the version of the scheme
// referenced by the `untdid-item-type` extension, when relevant.
ExtKeyItemTypeVersion cbc.Key = "untdid-item-type-version"

// ExtKeyCharge is used to identify the UNTDID 7161 charge codes.
ExtKeyCharge cbc.Key = "untdid-charge"
)
51 changes: 36 additions & 15 deletions data/rules/eu-en16931.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,42 @@
"tests": "present"
}
]
},
{
"field": "identities",
"assert": [
{
"id": "GOBL-EU-EN16931-ORG-ITEM-02",
"desc": "cannot have more than one identity with the 'legal' scope (BT-157)",
"tests": "max one legal-scoped identity"
},
{
"id": "GOBL-EU-EN16931-ORG-ITEM-03",
"desc": "legal identities require the 'iso-scheme-id' extension (BR-64)",
"tests": "legal-scoped identities have iso-scheme-id"
}
]
}
]
},
{
"id": "GOBL-EU-EN16931-ORG-IDENTITY",
"object": "org.Identity",
"subsets": [
{
"guard": "identity scope is 'class'",
"subsets": [
{
"field": "ext",
"assert": [
{
"id": "GOBL-EU-EN16931-ORG-IDENTITY-01",
"desc": "classification identities require the 'untdid-item-type' extension (BT-158)",
"tests": "ext require [untdid-item-type]"
}
]
}
]
}
]
},
Expand Down Expand Up @@ -342,21 +378,6 @@
}
]
},
{
"guard": "is exempt",
"subsets": [
{
"field": "ext",
"assert": [
{
"id": "GOBL-EU-EN16931-TAX-COMBO-06",
"desc": "VATEX extension is required for exempt tax (BR-E-10)",
"tests": "ext require [cef-vatex]"
}
]
}
]
},
{
"guard": "is non-exempt",
"subsets": [
Expand Down
4 changes: 2 additions & 2 deletions data/rules/org.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,8 @@
"assert": [
{
"id": "GOBL-ORG-IDENTITY-02",
"desc": "identity scope when provided must be either 'tax' or 'legal'",
"tests": "one of [tax, legal]"
"desc": "identity scope when provided must be one of 'tax', 'legal', 'class', 'seller', or 'buyer'",
"tests": "one of [tax, legal, class, seller, buyer]"
}
]
}
Expand Down
Loading
Loading