Skip to main content

core_invoice/
lib.rs

1//! Semantic model for the core electronic invoice: EN 16931 and Peppol PINT.
2//!
3//! Tax is VAT, GST, SST, or consumption — not VAT only. No I/O, no tax-authority
4//! APIs, no accounting UI.
5
6#![warn(missing_docs)]
7
8pub mod amount;
9pub mod arith;
10pub mod attachment;
11pub mod bt;
12pub mod category;
13pub mod code;
14pub mod codes;
15pub mod date;
16pub mod error;
17mod generated_codes;
18pub mod identifier;
19pub mod invoice;
20pub mod kind;
21pub mod numeric;
22pub mod payment;
23pub mod peppol;
24pub mod profile;
25pub mod proof;
26pub mod reconcile;
27pub mod report;
28pub mod rules;
29pub mod tax;
30pub mod validate;
31#[cfg(feature = "xrechnung")]
32pub mod xrechnung;
33
34pub use amount::{Amount, InvoiceAmount, UnitPriceAmount};
35pub use attachment::Attachment;
36pub use bt::{BtId, Group, Path};
37pub use category::{CategoryProfile, VatCategory, pint_gst_category};
38pub use code::Code;
39pub use codes::{
40    ARTEFACT_VERSION, EN16931_GIT, PEPPOL_BIS_VERSION, PINT_MY_VERSION, PINT_VERSION,
41    currency as is_currency,
42};
43pub use date::Date;
44pub use error::{AmountError, AttachmentError, DateError};
45pub use identifier::{DocumentReference, Identifier};
46pub use invoice::{
47    AllowanceCharge, Contact, Delivery, DocumentTotals, Invoice, InvoiceNote, ItemAttribute, Line,
48    LineAllowanceCharge, Party, PartyTax, Payee, PaymentInstructions, Period, PostalAddress,
49    PrecedingInvoice, Price, SupportingDocument, TaxBreakdown, TaxRepresentative,
50};
51pub use kind::DocumentKind;
52pub use numeric::{Percentage, Quantity};
53pub use payment::{CreditTransfer, DirectDebit, PaymentCard, PaymentMeans};
54pub use profile::{Edition, Profile, ProfileLookup};
55pub use proof::{
56    Check, En16931 as En16931Marker, PeppolBis3 as PeppolBis3Marker, Pint as PintMarker,
57    PintMy as PintMyMarker, ProfileMarker, ProveError, Underlies, Validated,
58};
59pub use reconcile::{ReconcileError, Reconciled, Reconciler, reconcile};
60pub use report::{Finding, Report, Severity, Source};
61pub use rules::{catalogue, conformance_matrix, core_rules, explain};
62pub use tax::{TaxCategory, TaxSystem, pint_my_category, wire_scheme};
63pub use validate::validate;
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::invoice::Price;
69    use rust_decimal::Decimal;
70
71    fn sst_invoice(profile: Profile) -> Invoice {
72        let mut inv = Invoice::blank(
73            profile,
74            "INV-1",
75            "MYR",
76            {
77                let mut p = Party::new("Kedai", "MY");
78                p.tax_registration = Some(Identifier::new("C12345678901"));
79                p.legal_registration = Some(Identifier::new("2023010000001"));
80                p.electronic_address = Some(Identifier::schemed("C12345678901", "0230"));
81                p
82            },
83            {
84                let mut b = Party::new("Pembeli", "MY");
85                b.legal_registration = Some(Identifier::new("1999010000001"));
86                b
87            },
88        );
89        inv.lines = vec![{
90            let mut line = Line::new(
91                "1",
92                "Goods",
93                Amount::parse("100.00").unwrap(),
94                TaxCategory::sst("SA", Decimal::from(10)),
95            );
96            line.quantity = Some(Quantity::parse("1").unwrap());
97            line.unit = Some(Code::new("C62"));
98            line.price = Some(Price {
99                net: UnitPriceAmount::parse("100.00").unwrap(),
100                discount: None,
101                gross: None,
102                base_qty: None,
103                base_unit: None,
104            });
105            line
106        }];
107        inv.issue_date = Date::parse("2026-01-15").ok();
108        inv.type_code = Some(Code::new("380"));
109        inv.payment_terms = Some("Net 30".into());
110        let _ = reconcile(&mut inv);
111        inv
112    }
113
114    #[test]
115    fn pint_my_accepts_sst() {
116        let report = validate(&sst_invoice(Profile::PintMy));
117        assert!(report.ok(), "{report}");
118    }
119
120    #[test]
121    fn peppol_bis_rejects_sst() {
122        let report = validate(&sst_invoice(Profile::PeppolBis3));
123        assert!(!report.ok());
124        assert!(
125            report.findings.iter().any(|f| f.id == "PINT-TAX"),
126            "{report}"
127        );
128        assert!(
129            report
130                .findings
131                .iter()
132                .any(|f| f.path.to_string().starts_with("BG-25")),
133            "{report}"
134        );
135    }
136
137    #[test]
138    fn br_co_18_without_reconcile() {
139        let mut inv = Invoice::blank(
140            Profile::En16931,
141            "INV-1",
142            "EUR",
143            {
144                let mut p = Party::new("Seller GmbH", "DE");
145                p.vat_identifier = Some(Identifier::new("DE123456789"));
146                p
147            },
148            Party::new("Buyer SARL", "FR"),
149        );
150        inv.issue_date = Date::parse("2026-01-15").ok();
151        inv.type_code = Some(Code::new("380"));
152        inv.lines = vec![{
153            let mut line = Line::new(
154                "1",
155                "A",
156                Amount::parse("100.00").unwrap(),
157                TaxCategory::vat("S", Decimal::from(19)),
158            );
159            line.quantity = Some(Quantity::parse("1").unwrap());
160            line.unit = Some(Code::new("C62"));
161            line.price = Some(Price {
162                net: UnitPriceAmount::parse("100.00").unwrap(),
163                discount: None,
164                gross: None,
165                base_qty: None,
166                base_unit: None,
167            });
168            line
169        }];
170        let report = validate(&inv);
171        assert!(
172            report.findings.iter().any(|f| f.id == "BR-CO-18"),
173            "{report}"
174        );
175    }
176
177    #[test]
178    fn br_53_bt6_without_bt111() {
179        let mut inv = sst_invoice(Profile::PintMy);
180        inv.tax_currency = Some(Code::new("USD"));
181        let report = validate(&inv);
182        assert!(report.findings.iter().any(|f| f.id == "BR-53"), "{report}");
183        inv.totals.as_mut().unwrap().tax_total_accounting = Some(Amount::parse("10.00").unwrap());
184        let report = validate(&inv);
185        assert!(report.findings.iter().all(|f| f.id != "BR-53"), "{report}");
186    }
187
188    #[test]
189    fn stuffed_payable_emits_br_co_16_when_totals_exist() {
190        let mut inv = sst_invoice(Profile::Pint);
191        inv.totals.as_mut().unwrap().payable = Some(Amount::parse("999.00").unwrap());
192        let report = validate(&inv);
193        assert!(
194            report.findings.iter().any(|f| f.id == "BR-CO-16"),
195            "{report}"
196        );
197    }
198
199    #[test]
200    fn gst_on_pint_my_is_pint_tax() {
201        let mut inv = sst_invoice(Profile::PintMy);
202        inv.lines[0].tax = TaxCategory::gst("SA", Decimal::from(10));
203        let report = validate(&inv);
204        assert!(
205            report.findings.iter().any(|f| f.id == "PINT-TAX"),
206            "{report}"
207        );
208        let text = explain("PINT-TAX").expect("PINT-TAX is registered");
209        assert!(text.contains("PINT-MY: SST only"), "{text}");
210        assert!(!text.contains("PINT-MY: VAT, GST"), "{text}");
211    }
212
213    #[test]
214    fn gst_on_peppol_is_pint_tax() {
215        let mut inv = sst_invoice(Profile::PeppolBis3);
216        inv.lines[0].tax = TaxCategory::gst("SR", Decimal::from(9));
217        let report = validate(&inv);
218        assert!(
219            report.findings.iter().any(|f| f.id == "PINT-TAX"),
220            "{report}"
221        );
222    }
223
224    #[test]
225    fn br_05_is_presence_not_length() {
226        let mut inv = sst_invoice(Profile::PintMy);
227        inv.currency.clear();
228        let report = validate(&inv);
229        assert!(report.findings.iter().any(|f| f.id == "BR-05"));
230        inv.currency = "MYR".into();
231        assert!(validate(&inv).ok(), "{}", validate(&inv));
232    }
233}