Skip to main content

core_invoice/
proof.rs

1//! Proof that an invoice passed a named profile. Formats stamp BT-24 from this.
2
3use std::marker::PhantomData;
4
5use crate::invoice::Invoice;
6use crate::profile::Profile;
7use crate::report::Report;
8use crate::validate;
9
10/// A profile at the type level.
11pub trait ProfileMarker {
12    /// The runtime [`Profile`] this marker proves.
13    fn profile() -> Profile;
14}
15
16/// Type-level [`Profile::En16931`].
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct En16931;
19/// Type-level [`Profile::PeppolBis3`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct PeppolBis3;
22/// Type-level [`Profile::Pint`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Pint;
25/// Type-level [`Profile::PintMy`].
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct PintMy;
28
29impl ProfileMarker for En16931 {
30    fn profile() -> Profile {
31        Profile::En16931
32    }
33}
34impl ProfileMarker for PeppolBis3 {
35    fn profile() -> Profile {
36        Profile::PeppolBis3
37    }
38}
39impl ProfileMarker for Pint {
40    fn profile() -> Profile {
41        Profile::Pint
42    }
43}
44impl ProfileMarker for PintMy {
45    fn profile() -> Profile {
46        Profile::PintMy
47    }
48}
49
50/// `Q: Underlies<P>` means every document valid under `P` is valid under `Q`.
51///
52/// Implemented **only** where CEN §4.4.4 holds: Peppol BIS 3.0 is a CIUS of
53/// EN 16931, so a Peppol proof may be widened to core. PINT is Extension-shaped
54/// on the tax axiom, **not** a CIUS — there is no `Underlies<En16931> for Pint`
55/// and no `Underlies<PeppolBis3> for PintMy`.
56pub trait Underlies<P: ProfileMarker>: ProfileMarker {}
57
58impl Underlies<PeppolBis3> for En16931 {}
59
60/// An invoice validated against `P`. Cannot be constructed from a suppressed run.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Validated<P: ProfileMarker> {
63    invoice: Invoice,
64    _profile: PhantomData<P>,
65}
66
67/// Why a proof could not be minted.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ProveError {
70    /// Semantic validation produced a fatal.
71    Rejected(Report),
72    /// `Check::without` was used; prove refuses to mint a proof.
73    Suppressed(String),
74}
75
76impl std::fmt::Display for ProveError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Self::Rejected(r) => write!(f, "invoice failed {r}"),
80            Self::Suppressed(id) => {
81                write!(f, "cannot prove a profile after suppressing {id}")
82            }
83        }
84    }
85}
86impl std::error::Error for ProveError {}
87
88impl<P: ProfileMarker> Validated<P> {
89    /// Stamp BT-24/BT-23 from `P` and validate.
90    ///
91    /// Fails if any fatal, or if the existing BT-24 is a wrong process (self-billing).
92    pub fn new(mut invoice: Invoice) -> Result<Self, Box<(Invoice, Report)>> {
93        invoice.profile = P::profile();
94        if let Some(id) = invoice.specification_id.as_deref() {
95            // Self-billing (and other) process URNs are not billing. Do not
96            // re-stamp BT-24 as this profile and emit a billing Invoice.
97            if matches!(
98                Profile::for_specification_id(id),
99                crate::profile::ProfileLookup::WrongProcess
100            ) {
101                let report = validate(&invoice);
102                return Err(Box::new((invoice, report)));
103            }
104        }
105        // BT-24 and BT-23 come from the proved profile, not leftover fields on Invoice.
106        invoice.stamp_profile(P::profile());
107        let report = validate(&invoice);
108        if report.ok() {
109            Ok(Self {
110                invoice,
111                _profile: PhantomData,
112            })
113        } else {
114            Err(Box::new((invoice, report)))
115        }
116    }
117
118    /// The proved invoice.
119    pub fn invoice(&self) -> &Invoice {
120        &self.invoice
121    }
122
123    /// Unwrap the proved invoice.
124    pub fn into_inner(self) -> Invoice {
125        self.invoice
126    }
127
128    /// Re-badge as a *less* restrictive profile. Only Peppol → EN 16931.
129    pub fn widen<Q: Underlies<P>>(self) -> Validated<Q> {
130        Validated {
131            invoice: self.invoice,
132            _profile: PhantomData,
133        }
134    }
135}
136
137/// Builder that records suppressions. [`prove`](Check::prove) fails if any id was
138/// suppressed — a proof must see every fatal.
139#[derive(Debug, Clone, Default)]
140pub struct Check {
141    suppressed: Vec<String>,
142}
143
144impl Check {
145    /// No suppressions.
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// Record a rule id to suppress. [`Check::prove`] then fails — a proof must see every fatal.
151    pub fn without(mut self, id: impl Into<String>) -> Self {
152        self.suppressed.push(id.into());
153        self
154    }
155
156    /// Mint a [`Validated<P>`] only if nothing was suppressed and validation has no fatal.
157    pub fn prove<P: ProfileMarker>(self, invoice: Invoice) -> Result<Validated<P>, ProveError> {
158        if let Some(id) = self.suppressed.first() {
159            return Err(ProveError::Suppressed(id.clone()));
160        }
161        Validated::new(invoice).map_err(|rejected| ProveError::Rejected(rejected.1))
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::amount::InvoiceAmount;
169    use crate::code::Code;
170    use crate::date::Date;
171    use crate::identifier::Identifier;
172    use crate::invoice::{Invoice, Line, Party};
173    use crate::reconcile::reconcile;
174    use crate::tax::TaxCategory;
175    use rust_decimal::Decimal;
176
177    fn peppol_ok() -> Invoice {
178        let mut inv = Invoice::blank(
179            Profile::PeppolBis3,
180            "EU-1",
181            "EUR",
182            {
183                let mut p = Party::new("Seller GmbH", "DE");
184                p.vat_identifier = Some(Identifier::new("DE123456789"));
185                p
186            },
187            {
188                let mut b = Party::new("Buyer SARL", "FR");
189                b.vat_identifier = Some(Identifier::new("FR12345678901"));
190                b
191            },
192        );
193        inv.issue_date = Date::parse("2026-01-15").ok();
194        inv.type_code = Some(Code::new("380"));
195        inv.business_process = Some("urn:fdc:peppol.eu:2017:poacc:billing:01:1.0".into());
196        inv.buyer_reference = Some(crate::identifier::DocumentReference::new("PO-1"));
197        inv.seller.electronic_address = Some(Identifier::schemed("1234567890128", "0088"));
198        inv.buyer.electronic_address = Some(Identifier::schemed("1234567890135", "0088"));
199        inv.lines = vec![{
200            let mut line = Line::new(
201                "1",
202                "A",
203                InvoiceAmount::parse("100.00").unwrap(),
204                TaxCategory::vat("S", Decimal::from(19)),
205            );
206            line.quantity = Some(crate::numeric::Quantity::parse("1").unwrap());
207            line.unit = Some(Code::new("C62"));
208            line.price = Some(crate::invoice::Price {
209                net: crate::amount::UnitPriceAmount::parse("100.00").unwrap(),
210                discount: None,
211                gross: None,
212                base_qty: None,
213                base_unit: None,
214            });
215            line
216        }];
217        reconcile(&mut inv).unwrap();
218        inv
219    }
220
221    #[test]
222    fn invalid_invoice_cannot_produce_validated() {
223        let mut inv = peppol_ok();
224        inv.number.clear();
225        assert!(Validated::<PeppolBis3>::new(inv).is_err());
226    }
227
228    #[test]
229    fn suppression_cannot_prove() {
230        let inv = peppol_ok();
231        let err = Check::new().without("BR-02").prove::<PeppolBis3>(inv);
232        assert!(matches!(err, Err(ProveError::Suppressed(_))));
233    }
234
235    #[test]
236    fn peppol_proof_widens_to_en16931() {
237        let inv = peppol_ok();
238        let v = Validated::<PeppolBis3>::new(inv).unwrap();
239        let _core: Validated<En16931> = v.widen();
240    }
241
242    #[test]
243    fn underlies_pairs_are_cius_only() {
244        fn _peppol_to_core(v: Validated<PeppolBis3>) -> Validated<En16931> {
245            v.widen()
246        }
247        // Pint / PintMy do not implement Underlies<En16931> or Underlies<PeppolBis3>.
248        // If they did, the following would compile:
249        // fn _pint_to_core(v: Validated<Pint>) -> Validated<En16931> { v.widen() }
250    }
251
252    #[test]
253    fn proved_peppol_overwrites_leftover_pint_bt24() {
254        let mut inv = peppol_ok();
255        inv.specification_id = Some("urn:peppol:pint:billing-1".into());
256        let v = Validated::<PeppolBis3>::new(inv).unwrap();
257        assert!(
258            v.invoice()
259                .specification_id
260                .as_deref()
261                .unwrap()
262                .starts_with(Profile::PEPPOL_BIS3_PREFIX)
263        );
264        assert_eq!(
265            v.invoice().business_process.as_deref(),
266            Profile::PeppolBis3.process_id()
267        );
268    }
269
270    #[test]
271    fn self_billing_cannot_be_proved_as_billing() {
272        let mut inv = peppol_ok();
273        inv.specification_id = Some("urn:peppol:pint:selfbilling-1@my-1".into());
274        let err = Validated::<PeppolBis3>::new(inv).unwrap_err();
275        assert!(
276            err.1.findings.iter().any(|f| f.id == "CORE-PROCESS-01"),
277            "{:?}",
278            err.1
279        );
280    }
281}