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