diff --git a/CHANGELOG.md b/CHANGELOG.md index 0984f4a59..1c14e78b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/addons/eu/en16931/en16931.go b/addons/eu/en16931/en16931.go index 3c2e5b1a5..20ad628ab 100644 --- a/addons/eu/en16931/en16931.go +++ b/addons/eu/en16931/en16931.go @@ -36,6 +36,7 @@ func init() { payInstructionsRules(), payTermsRules(), orgItemRules(), + orgIdentityRules(), orgAttachmentRules(), orgPartyRules(), orgInboxRules(), diff --git a/addons/eu/en16931/org.go b/addons/eu/en16931/org.go index f165e3769..e896e7048 100644 --- a/addons/eu/en16931/org.go +++ b/addons/eu/en16931/org.go @@ -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", diff --git a/addons/eu/en16931/org_internal_test.go b/addons/eu/en16931/org_internal_test.go new file mode 100644 index 000000000..7af0de83c --- /dev/null +++ b/addons/eu/en16931/org_internal_test.go @@ -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")) + }) +} diff --git a/addons/eu/en16931/org_test.go b/addons/eu/en16931/org_test.go index 392eead9d..e16518634 100644 --- a/addons/eu/en16931/org_test.go +++ b/addons/eu/en16931/org_test.go @@ -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))) + }) +} diff --git a/catalogues/untdid/untdid.go b/catalogues/untdid/untdid.go index 617dec728..647ce9b3e 100644 --- a/catalogues/untdid/untdid.go +++ b/catalogues/untdid/untdid.go @@ -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" ) diff --git a/data/rules/eu-en16931.json b/data/rules/eu-en16931.json index d2306bdde..376c11717 100644 --- a/data/rules/eu-en16931.json +++ b/data/rules/eu-en16931.json @@ -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]" + } + ] + } + ] } ] }, @@ -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": [ diff --git a/data/rules/org.json b/data/rules/org.json index 0cf6a9ab8..3f5dd6ff5 100644 --- a/data/rules/org.json +++ b/data/rules/org.json @@ -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]" } ] } diff --git a/data/schemas/org/identity.json b/data/schemas/org/identity.json index 48d9949f9..ce189f174 100644 --- a/data/schemas/org/identity.json +++ b/data/schemas/org/identity.json @@ -26,6 +26,18 @@ { "const": "legal", "title": "Legal" + }, + { + "const": "class", + "title": "Classification" + }, + { + "const": "seller", + "title": "Seller" + }, + { + "const": "buyer", + "title": "Buyer" } ], "title": "Scope", diff --git a/examples/gb/invoice-item-identities.yaml b/examples/gb/invoice-item-identities.yaml new file mode 100644 index 000000000..05c87c013 --- /dev/null +++ b/examples/gb/invoice-item-identities.yaml @@ -0,0 +1,57 @@ +$schema: "https://gobl.org/draft-0/bill/invoice" +uuid: "019035bd-4524-73ab-bf44-6037841ce5d9" +issue_date: "2024-07-31" +series: "SAMPLE" +code: "004" + +supplier: + tax_id: + country: "GB" + code: "000472631" + name: "Test Company Ltd." + addresses: + - num: "12" + street: "Main Street" + locality: "Hull" + code: "HU17 7PQ" + country: "GB" + +customer: + tax_id: + country: "GB" + code: "350983637" + name: "Random Company Ltd." + addresses: + - num: "45" + street: "Some Street" + locality: "London" + code: "SW1A 1AA" + country: "GB" + +lines: + - quantity: 5 + item: + name: "Cotton T-Shirt" + price: "12.00" + unit: "item" + identities: + # Registered item scheme: GS1 GTIN (BT-157). + - key: "gtin" + code: "9501101530003" + scope: "legal" + ext: + iso-scheme-id: "0160" + # Classification into a category scheme (BT-158). + - code: "55101500" + scope: "class" + ext: + untdid-item-type: "GN" + # Seller's own article number for the item (BT-155). + - code: "TS-BLACK-L" + scope: "seller" + # Buyer's own catalogue code for the item (BT-156). + - code: "CUST-SKU-9931" + scope: "buyer" + taxes: + - cat: VAT + rate: "standard" diff --git a/examples/gb/out/invoice-item-identities.json b/examples/gb/out/invoice-item-identities.json new file mode 100644 index 000000000..2bad60b03 --- /dev/null +++ b/examples/gb/out/invoice-item-identities.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "0c7a8738a137ec17a1f76f1f423e57084defb20a3d18cb8340af2c240352e53f" + } + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "$regime": "GB", + "uuid": "019035bd-4524-73ab-bf44-6037841ce5d9", + "type": "standard", + "series": "SAMPLE", + "code": "004", + "issue_date": "2024-07-31", + "currency": "GBP", + "supplier": { + "name": "Test Company Ltd.", + "tax_id": { + "country": "GB", + "code": "000472631" + }, + "addresses": [ + { + "num": "12", + "street": "Main Street", + "locality": "Hull", + "code": "HU17 7PQ", + "country": "GB" + } + ] + }, + "customer": { + "name": "Random Company Ltd.", + "tax_id": { + "country": "GB", + "code": "350983637" + }, + "addresses": [ + { + "num": "45", + "street": "Some Street", + "locality": "London", + "code": "SW1A 1AA", + "country": "GB" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "5", + "item": { + "name": "Cotton T-Shirt", + "identities": [ + { + "scope": "legal", + "key": "gtin", + "code": "9501101530003", + "ext": { + "iso-scheme-id": "0160" + } + }, + { + "scope": "class", + "code": "55101500", + "ext": { + "untdid-item-type": "GN" + } + }, + { + "scope": "seller", + "code": "TS-BLACK-L" + }, + { + "scope": "buyer", + "code": "CUST-SKU-9931" + } + ], + "price": "12.00", + "unit": "item" + }, + "sum": "60.00", + "taxes": [ + { + "cat": "VAT", + "key": "standard", + "rate": "general", + "percent": "20.0%" + } + ], + "total": "60.00" + } + ], + "totals": { + "sum": "60.00", + "total": "60.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "60.00", + "percent": "20.0%", + "amount": "12.00" + } + ], + "amount": "12.00" + } + ], + "sum": "12.00" + }, + "tax": "12.00", + "total_with_tax": "72.00", + "payable": "72.00" + } + } +} \ No newline at end of file diff --git a/org/identity.go b/org/identity.go index 4f554b208..e66a5d26e 100644 --- a/org/identity.go +++ b/org/identity.go @@ -40,10 +40,30 @@ const ( IdentityKeyOther cbc.Key = "other" ) -// Identity scopes that may be used to further classify an identity's intended use. +// Identity scopes that may be used to further classify an identity's intended +// use. A scope describes what kind of code this is and who issued it; how a +// particular tax regime or addon treats, maps, or constrains it is defined by +// that addon, not here. const ( - IdentityScopeTax cbc.Key = "tax" + // IdentityScopeTax is for a code issued by a tax authority that identifies + // the parent for tax purposes, such as a VAT or tax registration number. + IdentityScopeTax cbc.Key = "tax" + // IdentityScopeLegal is for a code issued under an official, recognised + // registration scheme — for example a company registration number for a + // party, or a standard product identifier such as the GS1 GTIN for an item. + // Use it when the code originates from a registry rather than from a trading + // party. IdentityScopeLegal cbc.Key = "legal" + // IdentityScopeClass is for a code that groups the parent into a category of + // a classification scheme (such as UNSPSC, CPV, or HS for items) rather than + // uniquely identifying it. + IdentityScopeClass cbc.Key = "class" + // IdentityScopeSeller is for a code the seller (supplier) assigns to identify + // the item, such as their own SKU or article number. + IdentityScopeSeller cbc.Key = "seller" + // IdentityScopeBuyer is for a code the buyer (customer) assigns to identify + // the item, such as the buyer's own catalogue or article number. + IdentityScopeBuyer cbc.Key = "buyer" ) // Identity is used to define a code for a specific context. Identities can be used for @@ -76,8 +96,8 @@ func identityRules() *rules.Set { rules.Assert("01", "identity code must be provided", is.Present), ), rules.Field("scope", - rules.AssertIfPresent("02", "identity scope when provided must be either 'tax' or 'legal'", - is.In(IdentityScopeTax, IdentityScopeLegal), + rules.AssertIfPresent("02", "identity scope when provided must be one of 'tax', 'legal', 'class', 'seller', or 'buyer'", + is.In(IdentityScopeTax, IdentityScopeLegal, IdentityScopeClass, IdentityScopeSeller, IdentityScopeBuyer), ), ), rules.Assert("03", "identity must have either a key or type defined, but not both", @@ -245,6 +265,18 @@ func (Identity) JSONSchemaExtend(js *jsonschema.Schema) { Const: IdentityScopeLegal, Title: "Legal", }, + { + Const: IdentityScopeClass, + Title: "Classification", + }, + { + Const: IdentityScopeSeller, + Title: "Seller", + }, + { + Const: IdentityScopeBuyer, + Title: "Buyer", + }, } } } diff --git a/org/identity_test.go b/org/identity_test.go index 908b2a9f9..e50da2113 100644 --- a/org/identity_test.go +++ b/org/identity_test.go @@ -166,7 +166,7 @@ func TestIdentityRules(t *testing.T) { faults := rules.Validate(id) require.Error(t, faults) assert.True(t, faults.HasCode("GOBL-ORG-IDENTITY-02")) - assert.Contains(t, faults.Error(), "identity scope when provided must be either 'tax' or 'legal'") + assert.Contains(t, faults.Error(), "identity scope when provided must be one of 'tax', 'legal', 'class', 'seller', or 'buyer'") }) t.Run("with no scope", func(t *testing.T) { id := &org.Identity{ @@ -384,9 +384,15 @@ func TestIdentityJSONSchema(t *testing.T) { prop, ok := js.Properties.Get("scope") assert.True(t, ok) - assert.Len(t, prop.OneOf, 2) + assert.Len(t, prop.OneOf, 5) assert.Equal(t, org.IdentityScopeTax, prop.OneOf[0].Const) assert.Equal(t, "Tax", prop.OneOf[0].Title) assert.Equal(t, org.IdentityScopeLegal, prop.OneOf[1].Const) assert.Equal(t, "Legal", prop.OneOf[1].Title) + assert.Equal(t, org.IdentityScopeClass, prop.OneOf[2].Const) + assert.Equal(t, "Classification", prop.OneOf[2].Title) + assert.Equal(t, org.IdentityScopeSeller, prop.OneOf[3].Const) + assert.Equal(t, "Seller", prop.OneOf[3].Title) + assert.Equal(t, org.IdentityScopeBuyer, prop.OneOf[4].Const) + assert.Equal(t, "Buyer", prop.OneOf[4].Title) }