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")]
33 Overflow {
34 term: &'static str,
36 },
37 #[error(
39 "{at} is a taxed category with no rate; defaulting it to zero would silently under-declare tax"
40 )]
41 MissingRate {
42 at: Path,
44 category: String,
46 },
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Reconciled {
52 pub tax_breakdown: Vec<TaxBreakdown>,
54 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#[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 #[must_use]
78 pub fn new() -> Self {
79 Self::default()
80 }
81
82 #[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 #[must_use]
101 pub fn paid(mut self, amount: InvoiceAmount) -> Self {
102 self.paid = Some(amount);
103 self
104 }
105
106 #[must_use]
108 pub fn rounding(mut self, amount: InvoiceAmount) -> Self {
109 self.rounding = Some(amount);
110 self
111 }
112
113 #[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 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 pub fn apply(&self, inv: &mut Invoice) -> Result<(), ReconcileError> {
134 let r = self.compute(inv)?;
135 inv.tax_breakdown = r.tax_breakdown;
136 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
262pub 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 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
404pub(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 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 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}