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