Skip to main content

dpp_domain/identifier/commodity_code/
code.rs

1//! [`CommodityCode`] — the product's customs tariff classification.
2//!
3//! **Where this comes from.** Commission Implementing Regulation (EU) 2026/1778
4//! has the registry verify, "where relevant, the validity of the commodity code
5//! of the product in relation to the permitted ranges for this product group"
6//! (Art. 8(7)(d)), and store it as part of the registration data
7//! (Art. 8(9)(b)). It is also what the registry's storage component holds for
8//! products going under the customs procedure "release for free circulation"
9//! (Art. 3(e)) — which is to say, it is the field customs authorities act on.
10//!
11//! # What is validated here, and what is not
12//!
13//! The code is a **structural** newtype: 6, 8 or 10 ASCII digits, matching the
14//! three nested levels in use —
15//!
16//! - **6 digits** — Harmonised System subheading (WCO, global);
17//! - **8 digits** — Combined Nomenclature, the EU's HS extension and the level
18//!   most product legislation cites;
19//! - **10 digits** — TARIC, the CN plus Union measures.
20//!
21//! Whether a *particular* code is the right one for a product, or falls inside
22//! the range a product group permits, is not checkable here: the permitted
23//! ranges live in the applicable delegated act and the registry applies them on
24//! submission. This type refuses what is definitely malformed and makes no claim
25//! about what is merely wrong — the same division of labour as [`crate::Gtin`],
26//! which checks the check digit and not whether the product exists.
27
28use serde::{Deserialize, Serialize};
29
30use super::error::CommodityCodeError;
31
32/// A validated customs tariff classification: HS-6, CN-8 or TARIC-10.
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(transparent)]
35pub struct CommodityCode(String);
36
37impl CommodityCode {
38    /// Parse a commodity code, accepting the HS, CN and TARIC lengths.
39    ///
40    /// Surrounding whitespace is trimmed; separators are **not**. A code written
41    /// `"8507 60 00"` is rejected rather than silently compacted, because
42    /// stripping characters to make a value parse is how a typo becomes a
43    /// different, valid tariff heading.
44    pub fn parse(s: &str) -> Result<Self, CommodityCodeError> {
45        let trimmed = s.trim();
46        let valid_length = matches!(trimmed.len(), 6 | 8 | 10);
47        if !valid_length || !trimmed.bytes().all(|b| b.is_ascii_digit()) {
48            return Err(CommodityCodeError::InvalidFormat(s.to_owned()));
49        }
50        Ok(Self(trimmed.to_owned()))
51    }
52
53    /// The code as stored.
54    #[must_use]
55    pub fn as_str(&self) -> &str {
56        &self.0
57    }
58
59    /// The HS-6 subheading this code sits under — its own first six digits.
60    ///
61    /// Every CN-8 and TARIC-10 code extends an HS-6 subheading, so this is the
62    /// globally comparable part of any of the three.
63    #[must_use]
64    pub fn hs_subheading(&self) -> &str {
65        &self.0[..6]
66    }
67}
68
69impl std::fmt::Display for CommodityCode {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(&self.0)
72    }
73}