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