Skip to main content

core_invoice/
profile.rs

1use crate::tax::TaxSystem;
2
3/// EN 16931 edition. 2026 is classified until CEN artefacts exist.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
5pub enum Edition {
6    En2017A1,
7    En2026,
8}
9
10impl Edition {
11    pub fn is_implemented(self) -> bool {
12        matches!(self, Self::En2017A1)
13    }
14}
15
16/// Result of matching BT-24. Profiles are siblings, not a ladder.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ProfileLookup {
19    Profile(Profile),
20    /// Self-billing (or other) process URN; do not validate as billing.
21    WrongProcess,
22    Unknown,
23}
24
25/// Usage specification (BT-24). Not a ladder: Peppol BIS and PINT are siblings.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Profile {
28    /// CEN EN 16931-1 core (2017+A1 until 2026 artefacts exist).
29    En16931,
30    /// Peppol BIS Billing 3.0 (EU VAT CIUS).
31    PeppolBis3,
32    /// Peppol International base (tax is not only VAT).
33    Pint,
34    /// PINT-MY specialisation (SST, TIN/BRN schemes).
35    PintMy,
36    /// Parsed BT-24 did not match a shipped billing profile. Do not invent En16931.
37    Unknown,
38}
39
40impl Profile {
41    pub fn slug(self) -> &'static str {
42        match self {
43            Self::En16931 => "en16931",
44            Self::PeppolBis3 => "peppol",
45            Self::Pint => "pint",
46            Self::PintMy => "pint-my",
47            Self::Unknown => "unknown",
48        }
49    }
50
51    pub fn parse(s: &str) -> Option<Self> {
52        match s.trim().to_ascii_lowercase().as_str() {
53            "en16931" | "en-16931" | "core" => Some(Self::En16931),
54            "peppol" | "bis3" | "peppol-bis-3" => Some(Self::PeppolBis3),
55            "pint" => Some(Self::Pint),
56            "pint-my" | "pintmy" | "my" => Some(Self::PintMy),
57            _ => None,
58        }
59    }
60
61    pub fn specification_id(self) -> &'static str {
62        match self {
63            Self::En16931 => "urn:cen.eu:en16931:2017",
64            Self::PeppolBis3 => {
65                "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"
66            }
67            Self::Pint => "urn:peppol:pint:billing-1",
68            Self::PintMy => "urn:peppol:pint:billing-1@my-1",
69            // Unknown BT-24 stays unknown; CORE-SPEC-01 is Fatal. Do not silently select En16931.
70            Self::Unknown => "",
71        }
72    }
73
74    /// BT-23 / ProfileID required by this profile. EN 16931 has none (omit, do not invent).
75    pub fn process_id(self) -> Option<&'static str> {
76        match self {
77            Self::PeppolBis3 => Some("urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"),
78            Self::Pint | Self::PintMy => Some("urn:peppol:bis:billing"),
79            Self::En16931 | Self::Unknown => None,
80        }
81    }
82
83    /// Tax systems this profile accepts.
84    pub fn tax_systems(self) -> &'static [TaxSystem] {
85        match self {
86            Self::En16931 | Self::PeppolBis3 => &[TaxSystem::Vat],
87            Self::Pint => &[
88                TaxSystem::Vat,
89                TaxSystem::Gst,
90                TaxSystem::Sst,
91                TaxSystem::Consumption,
92            ],
93            // SST in memory; wire TaxScheme is VAT / AAL. GST is Pint, not PintMy.
94            Self::PintMy => &[TaxSystem::Sst],
95            Self::Unknown => &[],
96        }
97    }
98
99    pub fn allows(self, system: TaxSystem) -> bool {
100        self.tax_systems().contains(&system)
101    }
102
103    pub fn known_slugs() -> &'static str {
104        "en16931, peppol, pint, pint-my"
105    }
106
107    pub fn edition(self) -> Edition {
108        Edition::En2017A1
109    }
110
111    /// Peppol BIS 3.0 is a CIUS of EN 16931. PINT is not.
112    pub fn is_conformant_cius(self) -> bool {
113        matches!(self, Self::PeppolBis3)
114    }
115
116    /// Peppol extra_rules. PINT / PINT-MY / EN core do **not** inherit them.
117    pub fn extra_rules(self) -> &'static [crate::rules::Rule] {
118        match self {
119            Self::PeppolBis3 => crate::peppol::RULES,
120            _ => &[],
121        }
122    }
123
124    pub const PEPPOL_BIS3_PREFIX: &'static str =
125        "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0";
126
127    /// Prefix match for BT-24. Never `contains("pint")`.
128    pub fn for_specification_id(id: &str) -> ProfileLookup {
129        let id = id.trim();
130        if id.contains('*') {
131            return ProfileLookup::Unknown;
132        }
133        if id.starts_with("urn:peppol:pint:selfbilling-1@my-1")
134            || id.starts_with(
135                "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:selfbilling:3.0",
136            )
137        {
138            return ProfileLookup::WrongProcess;
139        }
140        if id.starts_with("urn:peppol:pint:billing-1@my-1") {
141            return ProfileLookup::Profile(Self::PintMy);
142        }
143        if id.starts_with("urn:peppol:pint:billing-1@") {
144            return ProfileLookup::Profile(Self::Pint);
145        }
146        if id.starts_with("urn:peppol:pint:billing-1") {
147            return ProfileLookup::Profile(Self::Pint);
148        }
149        if id.starts_with(Self::PEPPOL_BIS3_PREFIX) {
150            return ProfileLookup::Profile(Self::PeppolBis3);
151        }
152        if id.starts_with("urn:cen.eu:en16931:2017#compliant#") || id == "urn:cen.eu:en16931:2017" {
153            return ProfileLookup::Profile(Self::En16931);
154        }
155        ProfileLookup::Unknown
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn lookup_is_prefix_not_contains() {
165        assert_eq!(
166            Profile::for_specification_id("urn:peppol:pint:billing-1@my-1"),
167            ProfileLookup::Profile(Profile::PintMy)
168        );
169        assert_eq!(
170            Profile::for_specification_id("urn:peppol:pint:billing-1"),
171            ProfileLookup::Profile(Profile::Pint)
172        );
173        assert_eq!(
174            Profile::for_specification_id(Profile::PEPPOL_BIS3_PREFIX),
175            ProfileLookup::Profile(Profile::PeppolBis3)
176        );
177        assert_eq!(
178            Profile::for_specification_id("urn:cen.eu:en16931:2017"),
179            ProfileLookup::Profile(Profile::En16931)
180        );
181        assert_eq!(
182            Profile::for_specification_id(
183                "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0"
184            ),
185            ProfileLookup::Profile(Profile::En16931)
186        );
187        assert_eq!(
188            Profile::for_specification_id("urn:peppol:pint:selfbilling-1@my-1"),
189            ProfileLookup::WrongProcess
190        );
191        assert_eq!(
192            Profile::for_specification_id(
193                "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:selfbilling:3.0"
194            ),
195            ProfileLookup::WrongProcess
196        );
197        assert_eq!(
198            Profile::for_specification_id("urn:example:painting"),
199            ProfileLookup::Unknown
200        );
201        assert!(!Profile::PintMy.is_conformant_cius());
202        assert!(Profile::PeppolBis3.is_conformant_cius());
203        assert_eq!(
204            Profile::PeppolBis3.process_id(),
205            Some("urn:fdc:peppol.eu:2017:poacc:billing:01:1.0")
206        );
207        assert_eq!(Profile::En16931.process_id(), None);
208        assert!(Profile::En16931.edition().is_implemented());
209        assert!(!Edition::En2026.is_implemented());
210    }
211
212    #[test]
213    fn siblings_sst() {
214        assert!(!Profile::PeppolBis3.allows(TaxSystem::Sst));
215        assert!(Profile::PintMy.allows(TaxSystem::Sst));
216    }
217}