Skip to main content

core_invoice/
reconcile.rs

1//! Derive BG-23 and BG-22 from lines, document allowances and charges.
2//!
3//! This is EN/PINT **presentation** of arithmetic, not a billing engine. It
4//! does not invent exemption reasons, due dates, or tax identifiers.
5//!
6//! Grouping is the same table the category `-08` rows check:
7//! - EN / Peppol / PINT VAT: `(category, rate)` for families that may repeat
8//!   (`S`, `L`, `M`, `B`); category alone for zero-tax families.
9//! - PINT-MY: `(scheme, category, rate)` — SST is never grouped as UNCL 5305 `S`.
10//!
11//! Printed tax amounts use **commercial** rounding (half away from zero). The
12//! validator uses [`crate::arith::xpath_round`]; ±1.00 slack on `BR-CO-17` is
13//! what lets those two disagree by a unit.
14//!
15//! Empty document allowances/charges → BT-107/108 **absent**, not zero.
16
17use rust_decimal::Decimal;
18
19use crate::amount::InvoiceAmount;
20use crate::bt::{BtId, Group, Path};
21use crate::code::Code;
22use crate::invoice::{DocumentTotals, Invoice, TaxBreakdown};
23use crate::numeric::Percentage;
24use crate::profile::Profile;
25use crate::tax::{TaxSystem, wire_scheme};
26
27/// Why an invoice could not be reconciled. Not a validation finding.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29#[non_exhaustive]
30pub enum ReconcileError {
31    #[error("{term} overflowed while reconciling; the amounts involved are not representable")]
32    Overflow { term: &'static str },
33    #[error(
34        "{at} is a taxed category with no rate; defaulting it to zero would silently under-declare tax"
35    )]
36    MissingRate { at: Path, category: String },
37}
38
39/// What reconciliation produced, before it is written back.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Reconciled {
42    pub tax_breakdown: Vec<TaxBreakdown>,
43    pub totals: DocumentTotals,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47struct Exemption {
48    scheme: String,
49    category: String,
50    text: Option<String>,
51    code: Option<Code>,
52}
53
54/// Computes BG-23 and BG-22 from an invoice's lines, allowances and charges.
55#[derive(Debug, Clone, Default)]
56pub struct Reconciler {
57    exemptions: Vec<Exemption>,
58    paid: Option<InvoiceAmount>,
59    rounding: Option<InvoiceAmount>,
60    tax_total_accounting: Option<InvoiceAmount>,
61}
62
63impl Reconciler {
64    #[must_use]
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// BT-120 / BT-121 for one category. Categories that forbid a reason drop it.
70    #[must_use]
71    pub fn exemption(
72        mut self,
73        category: impl Into<String>,
74        text: Option<&str>,
75        code: Option<&str>,
76    ) -> Self {
77        self.exemptions.push(Exemption {
78            scheme: String::new(),
79            category: category.into(),
80            text: text.map(str::to_owned),
81            code: code.map(Code::new),
82        });
83        self
84    }
85
86    /// BT-113. Absent is not zero.
87    #[must_use]
88    pub fn paid(mut self, amount: InvoiceAmount) -> Self {
89        self.paid = Some(amount);
90        self
91    }
92
93    /// BT-114. Malaysian 5-sen cash rounding belongs here, not as slack.
94    #[must_use]
95    pub fn rounding(mut self, amount: InvoiceAmount) -> Self {
96        self.rounding = Some(amount);
97        self
98    }
99
100    /// BT-111. Not derived.
101    #[must_use]
102    pub fn tax_total_accounting(mut self, amount: InvoiceAmount) -> Self {
103        self.tax_total_accounting = Some(amount);
104        self
105    }
106
107    pub fn compute(&self, inv: &Invoice) -> Result<Reconciled, ReconcileError> {
108        let tax_breakdown = self.breakdown(inv)?;
109        let totals = self.totals(inv, &tax_breakdown)?;
110        Ok(Reconciled {
111            tax_breakdown,
112            totals,
113        })
114    }
115
116    /// Write BG-23 and BG-22. Invoice is unchanged on error.
117    /// Existing exemption reasons on matching groups are kept unless this
118    /// reconciler supplied a replacement.
119    pub fn apply(&self, inv: &mut Invoice) -> Result<(), ReconcileError> {
120        let r = self.compute(inv)?;
121        inv.tax_breakdown = r.tax_breakdown;
122        // BT-110 / BT-115 live on DocumentTotals. Ghosts on Invoice are not a second identity.
123        inv.totals = Some(r.totals);
124        Ok(())
125    }
126
127    fn breakdown(&self, inv: &Invoice) -> Result<Vec<TaxBreakdown>, ReconcileError> {
128        let mut keys: Vec<GroupKey> = Vec::new();
129        for item in content(inv) {
130            let key = group_key(inv, &item)?;
131            if !keys.contains(&key) {
132                keys.push(key);
133            }
134        }
135        keys.sort();
136
137        let mut rows = Vec::with_capacity(keys.len());
138        for key in keys {
139            let taxable = taxable_for(inv, &key)?;
140            let rate = key.rate;
141            let tax = tax_amount(inv, &key, taxable, rate)?;
142            let (exemption_reason, exemption_code) = self.exemption_for(inv, &key);
143            rows.push(TaxBreakdown {
144                system: key.system,
145                scheme: key.scheme.clone(),
146                category: Code::new(key.category.clone()),
147                rate,
148                taxable,
149                tax,
150                exemption_reason,
151                exemption_code,
152            });
153        }
154        Ok(rows)
155    }
156
157    fn exemption_for(&self, inv: &Invoice, key: &GroupKey) -> (Option<String>, Option<Code>) {
158        if forbids_exemption(&key.category) {
159            return (None, None);
160        }
161        if let Some(ex) = self
162            .exemptions
163            .iter()
164            .find(|e| e.category.eq_ignore_ascii_case(&key.category))
165        {
166            return (ex.text.clone(), ex.code.clone());
167        }
168        inv.tax_breakdown
169            .iter()
170            .find(|e| {
171                e.category.as_str() == key.category
172                    && e.scheme == key.scheme
173                    && (e.exemption_reason.is_some() || e.exemption_code.is_some())
174            })
175            .map_or((None, None), |e| {
176                (e.exemption_reason.clone(), e.exemption_code.clone())
177            })
178    }
179
180    fn totals(
181        &self,
182        inv: &Invoice,
183        breakdown: &[TaxBreakdown],
184    ) -> Result<DocumentTotals, ReconcileError> {
185        let sum = |it: Vec<InvoiceAmount>, term| {
186            InvoiceAmount::checked_sum(it).ok_or(ReconcileError::Overflow { term })
187        };
188
189        let line_net = sum(inv.lines.iter().map(|l| l.net).collect(), "BT-106")?;
190
191        let allowance_total = if inv.document_allowances.is_empty() {
192            None
193        } else {
194            Some(sum(
195                inv.document_allowances.iter().map(|a| a.amount).collect(),
196                "BT-107",
197            )?)
198        };
199        let charge_total = if inv.document_charges.is_empty() {
200            None
201        } else {
202            Some(sum(
203                inv.document_charges.iter().map(|c| c.amount).collect(),
204                "BT-108",
205            )?)
206        };
207
208        let without_tax = line_net
209            .checked_sub(allowance_total.unwrap_or(InvoiceAmount::ZERO))
210            .and_then(|v| v.checked_add(charge_total.unwrap_or(InvoiceAmount::ZERO)))
211            .ok_or(ReconcileError::Overflow { term: "BT-109" })?;
212
213        let vat_rows: Vec<InvoiceAmount> = breakdown
214            .iter()
215            .filter(|e| counts_toward_tax_total(inv.profile, e))
216            .map(|e| e.tax)
217            .collect();
218        let tax_total = if breakdown.is_empty() {
219            None
220        } else {
221            Some(sum(vat_rows, "BT-110")?)
222        };
223
224        let with_tax = without_tax
225            .checked_add(tax_total.unwrap_or(InvoiceAmount::ZERO))
226            .ok_or(ReconcileError::Overflow { term: "BT-112" })?;
227
228        let payable = with_tax
229            .checked_sub(self.paid.unwrap_or(InvoiceAmount::ZERO))
230            .and_then(|v| v.checked_add(self.rounding.unwrap_or(InvoiceAmount::ZERO)))
231            .ok_or(ReconcileError::Overflow { term: "BT-115" })?;
232
233        Ok(DocumentTotals {
234            line_net: Some(line_net),
235            allowance_total,
236            charge_total,
237            without_tax: Some(without_tax),
238            tax_total,
239            tax_total_accounting: self.tax_total_accounting,
240            with_tax: Some(with_tax),
241            paid: self.paid,
242            rounding: self.rounding,
243            payable: Some(payable),
244        })
245    }
246}
247
248/// Reconcile with every default.
249pub fn reconcile(inv: &mut Invoice) -> Result<(), ReconcileError> {
250    Reconciler::new().apply(inv)
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
254struct GroupKey {
255    scheme: String,
256    category: String,
257    rate: Option<Percentage>,
258    system: TaxSystem,
259}
260
261struct ContentRow<'a> {
262    path: Path,
263    system: TaxSystem,
264    category: &'a str,
265    percent: Option<Percentage>,
266    net: InvoiceAmount,
267    is_allowance: bool,
268}
269
270fn content(inv: &Invoice) -> Vec<ContentRow<'_>> {
271    let mut rows = Vec::new();
272    for (i, line) in inv.lines.iter().enumerate() {
273        rows.push(ContentRow {
274            path: Path::at_term(Group::Line, i, BtId(131)),
275            system: line.tax.system,
276            category: &line.tax.code,
277            percent: line.tax.percent,
278            net: line.net,
279            is_allowance: false,
280        });
281    }
282    for (i, a) in inv.document_allowances.iter().enumerate() {
283        let tax = a.tax.as_ref();
284        rows.push(ContentRow {
285            path: Path::at_term(Group::DocumentAllowance, i, BtId(92)),
286            system: tax.map(|t| t.system).unwrap_or(TaxSystem::Vat),
287            category: tax.map(|t| t.code.as_str()).unwrap_or(""),
288            percent: tax.and_then(|t| t.percent),
289            net: a.amount,
290            is_allowance: true,
291        });
292    }
293    for (i, c) in inv.document_charges.iter().enumerate() {
294        let tax = c.tax.as_ref();
295        rows.push(ContentRow {
296            path: Path::at_term(Group::DocumentCharge, i, BtId(99)),
297            system: tax.map(|t| t.system).unwrap_or(TaxSystem::Vat),
298            category: tax.map(|t| t.code.as_str()).unwrap_or(""),
299            percent: tax.and_then(|t| t.percent),
300            net: c.amount,
301            is_allowance: false,
302        });
303    }
304    rows
305}
306
307fn group_key(inv: &Invoice, row: &ContentRow<'_>) -> Result<GroupKey, ReconcileError> {
308    let scheme = wire_scheme(inv.profile, row.system, row.category).to_owned();
309    let rate = if crate::category::grouped_by_rate(inv.profile, row.category) {
310        if needs_rate(row.category)
311            && row.percent.is_none_or(Percentage::is_zero)
312            && !zero_tax_family(row.category)
313        {
314            return Err(ReconcileError::MissingRate {
315                at: row.path,
316                category: row.category.to_owned(),
317            });
318        }
319        row.percent
320    } else if row.category.eq_ignore_ascii_case("O") || row.category.eq_ignore_ascii_case("TTX") {
321        None
322    } else {
323        Some(Percentage::ZERO)
324    };
325    Ok(GroupKey {
326        scheme,
327        category: row.category.to_owned(),
328        rate,
329        system: row.system,
330    })
331}
332
333fn needs_rate(category: &str) -> bool {
334    matches!(
335        category,
336        "S" | "L"
337            | "M"
338            | "B"
339            | "SA"
340            | "SE"
341            | "HVG"
342            | "LVG"
343            | "s"
344            | "l"
345            | "m"
346            | "b"
347            | "sa"
348            | "se"
349            | "hvg"
350            | "lvg"
351    )
352}
353
354fn zero_tax_family(category: &str) -> bool {
355    // PINT-MY SE is service tax (rated). It is not EN category E / zero-rated Z.
356    matches!(
357        category,
358        "Z" | "E" | "AE" | "K" | "G" | "O" | "z" | "e" | "ae" | "k" | "g" | "o"
359    )
360}
361
362fn forbids_exemption(category: &str) -> bool {
363    matches!(
364        category,
365        "S" | "Z"
366            | "L"
367            | "M"
368            | "SA"
369            | "SE"
370            | "HVG"
371            | "LVG"
372            | "s"
373            | "z"
374            | "l"
375            | "m"
376            | "sa"
377            | "se"
378            | "hvg"
379            | "lvg"
380    )
381}
382
383fn same_group(inv: &Invoice, row: &ContentRow<'_>, key: &GroupKey) -> bool {
384    let Ok(k) = group_key(inv, row) else {
385        return false;
386    };
387    k == *key
388}
389
390/// ALIGNED-IBRP-*-08-MY uses this same content (lines + charges − allowances). Exact, no slack.
391pub(crate) fn taxable_for_breakdown(
392    inv: &Invoice,
393    row: &TaxBreakdown,
394) -> Result<InvoiceAmount, ReconcileError> {
395    taxable_for(
396        inv,
397        &GroupKey {
398            scheme: row.scheme.clone(),
399            category: row.category.as_str().to_owned(),
400            rate: row.rate,
401            system: row.system,
402        },
403    )
404}
405
406fn taxable_for(inv: &Invoice, key: &GroupKey) -> Result<InvoiceAmount, ReconcileError> {
407    // Line A/C already sits in BT-131. Do not add them again in taxable_for.
408    let mut pos = InvoiceAmount::ZERO;
409    let mut neg = InvoiceAmount::ZERO;
410    for row in content(inv) {
411        if !same_group(inv, &row, key) {
412            continue;
413        }
414        if row.is_allowance {
415            neg = neg
416                .checked_add(row.net)
417                .ok_or(ReconcileError::Overflow { term: "BT-116" })?;
418        } else {
419            pos = pos
420                .checked_add(row.net)
421                .ok_or(ReconcileError::Overflow { term: "BT-116" })?;
422        }
423    }
424    pos.checked_sub(neg)
425        .ok_or(ReconcileError::Overflow { term: "BT-116" })
426}
427
428fn tax_amount(
429    inv: &Invoice,
430    key: &GroupKey,
431    taxable: InvoiceAmount,
432    rate: Option<Percentage>,
433) -> Result<InvoiceAmount, ReconcileError> {
434    if key.category.eq_ignore_ascii_case("TTX") {
435        return Ok(taxable);
436    }
437    if zero_tax_family(&key.category) {
438        return Ok(InvoiceAmount::ZERO);
439    }
440    let _ = inv;
441    let rate = rate.map_or(Decimal::ZERO, Percentage::as_percent);
442    let exact = taxable
443        .raw()
444        .checked_mul(rate)
445        .map(|v| v / Decimal::ONE_HUNDRED)
446        .ok_or(ReconcileError::Overflow { term: "BT-117" })?;
447    InvoiceAmount::from_decimal_rounded(exact)
448        .map_err(|_| ReconcileError::Overflow { term: "BT-117" })
449}
450
451pub(crate) fn counts_toward_tax_total(_profile: Profile, _row: &TaxBreakdown) -> bool {
452    // IBR-CO-14 / BR-CO-14: BT-110 = Σ every BG-23 / IBG-23 tax amount, including TTX (AAL).
453    true
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::code::Code;
460    use crate::date::Date;
461    use crate::invoice::{Invoice, Line, Party};
462    use crate::kind::DocumentKind;
463    use crate::numeric::Quantity;
464    use crate::tax::TaxCategory;
465    use crate::validate;
466
467    fn amt(s: &str) -> InvoiceAmount {
468        InvoiceAmount::parse(s).unwrap()
469    }
470
471    fn with_price(mut line: Line, price: &str) -> Line {
472        line.quantity = Some(Quantity::parse("1").unwrap());
473        line.unit = Some(Code::new("C62"));
474        line.price = Some(crate::invoice::Price {
475            net: crate::amount::UnitPriceAmount::parse(price).unwrap(),
476            discount: None,
477            gross: None,
478            base_qty: None,
479            base_unit: None,
480        });
481        line
482    }
483
484    fn en_blank() -> Invoice {
485        let mut inv = Invoice::blank(
486            Profile::En16931,
487            "INV-1",
488            "EUR",
489            {
490                let mut p = Party::new("Seller GmbH", "DE");
491                p.vat_identifier = Some(crate::identifier::Identifier::new("DE123456789"));
492                p
493            },
494            Party::new("Buyer SARL", "FR"),
495        );
496        inv.issue_date = Date::parse("2026-01-15").ok();
497        inv.type_code = Some(Code::new("380"));
498        inv
499    }
500
501    #[test]
502    fn two_standard_rates_are_two_breakdown_rows() {
503        let mut inv = en_blank();
504        inv.lines = vec![
505            with_price(
506                Line::new(
507                    "1",
508                    "A",
509                    amt("100.00"),
510                    TaxCategory::vat("S", Decimal::from(19)),
511                ),
512                "100.00",
513            ),
514            with_price(
515                Line::new(
516                    "2",
517                    "B",
518                    amt("50.00"),
519                    TaxCategory::vat("S", Decimal::from(7)),
520                ),
521                "50.00",
522            ),
523        ];
524        reconcile(&mut inv).unwrap();
525        assert_eq!(inv.tax_breakdown.len(), 2);
526        let totals = inv.totals.as_ref().unwrap();
527        assert_eq!(totals.line_net.unwrap(), amt("150.00"));
528        assert_eq!(totals.allowance_total, None);
529        assert_eq!(totals.charge_total, None);
530        assert_eq!(totals.tax_total.unwrap(), amt("22.50"));
531        assert_eq!(totals.with_tax.unwrap(), amt("172.50"));
532        assert_eq!(totals.payable, Some(amt("172.50")));
533        assert!(validate(&inv).ok(), "{}", validate(&inv));
534    }
535
536    #[test]
537    fn empty_document_allowances_leave_bt_107_absent() {
538        let mut inv = en_blank();
539        inv.lines = vec![Line::new(
540            "1",
541            "A",
542            amt("100.00"),
543            TaxCategory::vat("S", Decimal::from(19)),
544        )];
545        reconcile(&mut inv).unwrap();
546        let t = inv.totals.as_ref().unwrap();
547        assert_eq!(t.allowance_total, None);
548        assert_eq!(t.charge_total, None);
549    }
550
551    #[test]
552    fn prepaid_may_make_payable_negative() {
553        let mut inv = en_blank();
554        inv.lines = vec![with_price(
555            Line::new(
556                "1",
557                "A",
558                amt("125.00"),
559                TaxCategory::vat("S", Decimal::from(10)),
560            ),
561            "125.00",
562        )];
563        Reconciler::new()
564            .paid(amt("250.00"))
565            .apply(&mut inv)
566            .unwrap();
567        let t = inv.totals.as_ref().unwrap();
568        assert_eq!(t.with_tax.unwrap(), amt("137.50"));
569        assert_eq!(t.paid, Some(amt("250.00")));
570        assert_eq!(t.payable, Some(amt("-112.50")));
571        assert!(validate(&inv).ok(), "{}", validate(&inv));
572    }
573
574    #[test]
575    fn stuffed_payable_fails_real_br_co_16() {
576        let mut inv = en_blank();
577        inv.lines = vec![with_price(
578            Line::new(
579                "1",
580                "A",
581                amt("125.00"),
582                TaxCategory::vat("S", Decimal::from(10)),
583            ),
584            "125.00",
585        )];
586        Reconciler::new()
587            .paid(amt("250.00"))
588            .apply(&mut inv)
589            .unwrap();
590        inv.totals.as_mut().unwrap().payable = Some(amt("137.50"));
591        let report = validate(&inv);
592        assert!(
593            report.findings.iter().any(|f| f.id == "BR-CO-16"),
594            "{report}"
595        );
596    }
597
598    #[test]
599    fn credit_note_keeps_positive_amounts() {
600        let mut inv = en_blank();
601        inv.lines = vec![Line::new(
602            "1",
603            "A",
604            amt("100.00"),
605            TaxCategory::vat("S", Decimal::from(19)),
606        )];
607        reconcile(&mut inv).unwrap();
608        let cn = inv.to_credit_note("CN-1", Date::parse("2026-01-16").unwrap());
609        assert_eq!(cn.kind, DocumentKind::CreditNote);
610        assert_eq!(cn.payable(), inv.payable());
611    }
612
613    #[test]
614    fn pint_my_sa_and_se_are_two_rows() {
615        let mut inv = Invoice::blank(
616            Profile::PintMy,
617            "MY-1",
618            "MYR",
619            {
620                let mut p = Party::new("Kedai", "MY");
621                p.tax_registration = Some(crate::identifier::Identifier::new("C12345678901"));
622                p.legal_registration = Some(crate::identifier::Identifier::new("2023010000001"));
623                p
624            },
625            {
626                let mut b = Party::new("Pembeli", "MY");
627                b.legal_registration = Some(crate::identifier::Identifier::new("1999010000001"));
628                b
629            },
630        );
631        inv.issue_date = Date::parse("2026-01-15").ok();
632        inv.type_code = Some(Code::new("380"));
633        inv.lines = vec![
634            {
635                let mut l = Line::new(
636                    "1",
637                    "Taxed",
638                    amt("100.00"),
639                    TaxCategory::sst("SA", Decimal::from(10)),
640                );
641                l.quantity = Some(Quantity::parse("1").unwrap());
642                l.unit = Some(Code::new("C62"));
643                l.price = Some(crate::invoice::Price {
644                    net: crate::amount::UnitPriceAmount::parse("100.00").unwrap(),
645                    discount: None,
646                    gross: None,
647                    base_qty: None,
648                    base_unit: None,
649                });
650                l
651            },
652            {
653                let mut l = Line::new(
654                    "2",
655                    "Exempt",
656                    amt("40.00"),
657                    TaxCategory::sst("SE", Decimal::from(8)),
658                );
659                l.quantity = Some(Quantity::parse("1").unwrap());
660                l.unit = Some(Code::new("C62"));
661                l.price = Some(crate::invoice::Price {
662                    net: crate::amount::UnitPriceAmount::parse("40.00").unwrap(),
663                    discount: None,
664                    gross: None,
665                    base_qty: None,
666                    base_unit: None,
667                });
668                l
669            },
670        ];
671        reconcile(&mut inv).unwrap();
672        assert_eq!(inv.tax_breakdown.len(), 2);
673        assert!(
674            inv.tax_breakdown
675                .iter()
676                .any(|r| r.category.as_str() == "SA" && r.tax == amt("10.00"))
677        );
678        assert!(
679            inv.tax_breakdown
680                .iter()
681                .any(|r| r.category.as_str() == "SE" && r.tax == amt("3.20"))
682        );
683        assert!(validate(&inv).ok(), "{}", validate(&inv));
684    }
685
686    #[test]
687    fn o_is_exclusive_one_group() {
688        let mut inv = en_blank();
689        inv.lines = vec![with_price(
690            Line::new("1", "Out", amt("10.00"), TaxCategory::out_of_scope()),
691            "10.00",
692        )];
693        reconcile(&mut inv).unwrap();
694        assert_eq!(inv.tax_breakdown.len(), 1);
695        assert_eq!(inv.tax_breakdown[0].category.as_str(), "O");
696        assert_eq!(inv.tax_breakdown[0].rate, None);
697        assert_eq!(inv.tax_breakdown[0].tax, amt("0.00"));
698    }
699
700    #[test]
701    fn does_not_overwrite_existing_exemption_reason() {
702        let mut inv = en_blank();
703        inv.lines = vec![Line::new(
704            "1",
705            "Exempt",
706            amt("10.00"),
707            TaxCategory::vat("E", Decimal::from(0)),
708        )];
709        inv.tax_breakdown = vec![TaxBreakdown {
710            system: TaxSystem::Vat,
711            scheme: "VAT".into(),
712            category: Code::new("E"),
713            rate: Some(Percentage::ZERO),
714            taxable: amt("10.00"),
715            tax: amt("0.00"),
716            exemption_reason: Some("exempt goods".into()),
717            exemption_code: None,
718        }];
719        reconcile(&mut inv).unwrap();
720        assert_eq!(
721            inv.tax_breakdown[0].exemption_reason.as_deref(),
722            Some("exempt goods")
723        );
724    }
725}