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