1use 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#[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#[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#[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 #[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 #[must_use]
88 pub fn paid(mut self, amount: InvoiceAmount) -> Self {
89 self.paid = Some(amount);
90 self
91 }
92
93 #[must_use]
95 pub fn rounding(mut self, amount: InvoiceAmount) -> Self {
96 self.rounding = Some(amount);
97 self
98 }
99
100 #[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 pub fn apply(&self, inv: &mut Invoice) -> Result<(), ReconcileError> {
120 let r = self.compute(inv)?;
121 inv.tax_breakdown = r.tax_breakdown;
122 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
248pub 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 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
390pub(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 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 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}