Skip to main content

core_invoice/
validate.rs

1use crate::invoice::Invoice;
2use crate::report::{Finding, Report};
3
4/// Semantic checks on the in-memory invoice. Syntax (UBL/CII) lives in `core-invoice-formats`.
5pub fn validate(invoice: &Invoice) -> Report {
6    let mut report = Report::default();
7
8    if invoice.number.trim().is_empty() {
9        report.push(Finding::fatal("BR-02", "Invoice number (BT-1) shall be present"));
10    }
11
12    if invoice.currency.trim().len() != 3 {
13        report.push(Finding::fatal(
14            "BR-05",
15            "Invoice currency code (BT-5) shall be a 3-letter code",
16        ));
17    }
18
19    if invoice.seller.name.trim().is_empty() {
20        report.push(Finding::fatal("BR-06", "Seller name (BT-27) shall be present"));
21    }
22
23    if invoice.buyer.name.trim().is_empty() {
24        report.push(Finding::fatal("BR-07", "Buyer name (BT-44) shall be present"));
25    }
26
27    if invoice.lines.is_empty() {
28        report.push(Finding::fatal(
29            "BR-16",
30            "An invoice shall have at least one line (BG-25)",
31        ));
32    }
33
34    for (i, line) in invoice.lines.iter().enumerate() {
35        if !invoice.profile.allows(line.tax.system) {
36            report.push(Finding::fatal(
37                "PINT-TAX",
38                format!(
39                    "Line {} tax system {} is not allowed on profile {}",
40                    i + 1,
41                    line.tax.system.as_str(),
42                    invoice.profile.slug()
43                ),
44            ));
45        }
46    }
47
48    let net = invoice.line_net_sum();
49    let expected_payable = net.saturating_add(invoice.tax_total);
50    if expected_payable != invoice.payable {
51        report.push(Finding::fatal(
52            "BR-CO-16",
53            format!(
54                "Payable ({}) shall equal line net ({}) + tax total ({})",
55                invoice.payable, net, invoice.tax_total
56            ),
57        ));
58    }
59
60    if invoice.profile == crate::profile::Profile::PintMy {
61        match invoice.seller.id_scheme.as_deref() {
62            Some("TIN") | Some("BRN") | Some("NRIC") | Some("PASSPORT") => {}
63            Some(other) => report.push(Finding::fatal(
64                "PINT-MY-ID",
65                format!("Seller id scheme {other} is not a PINT-MY identification type"),
66            )),
67            None if invoice.seller.tax_id.is_some() => report.push(Finding::fatal(
68                "PINT-MY-ID",
69                "Seller tax id on PINT-MY requires a scheme (TIN, BRN, NRIC, PASSPORT)",
70            )),
71            None => {}
72        }
73    }
74
75    report
76}