Skip to main content

core_invoice/
profile.rs

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