use rust_decimal::{Decimal, RoundingStrategy};
use crate::bt::{Group, Path};
use crate::invoice::{Code, DocumentTotals, Invoice, VatBreakdown};
use crate::validation::rules::category::{TaxRule, profile};
use crate::{InvoiceAmount, Percentage, VatCategory};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ReconcileError {
#[error(
"{at} carries VAT category {code:?}, which is not in UNCL 5305 — \
BG-23 grouping and the tax rule both depend on the category (BR-CL-18)"
)]
UnknownCategory {
at: Path,
code: String,
},
#[error(
"{at} is VAT category {category} at no rate; a taxed category derives its tax \
amount from the rate, and defaulting it to zero would silently under-declare VAT"
)]
MissingRate {
at: Path,
category: VatCategory,
},
#[error("{term} overflowed while reconciling; the amounts involved are not representable")]
Overflow {
term: &'static str,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reconciled {
pub vat_breakdown: Vec<VatBreakdown>,
pub totals: DocumentTotals,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Exemption {
category: String,
text: Option<String>,
code: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct Reconciler {
exemptions: Vec<Exemption>,
paid: Option<InvoiceAmount>,
rounding: Option<InvoiceAmount>,
vat_total_accounting: Option<InvoiceAmount>,
}
impl Reconciler {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn exemption(
mut self,
category: impl Into<String>,
text: Option<&str>,
code: Option<&str>,
) -> Self {
self.exemptions.push(Exemption {
category: category.into(),
text: text.map(str::to_owned),
code: code.map(str::to_owned),
});
self
}
#[must_use]
pub fn paid(mut self, amount: InvoiceAmount) -> Self {
self.paid = Some(amount);
self
}
#[must_use]
pub fn rounding(mut self, amount: InvoiceAmount) -> Self {
self.rounding = Some(amount);
self
}
#[must_use]
pub fn vat_total_accounting(mut self, amount: InvoiceAmount) -> Self {
self.vat_total_accounting = Some(amount);
self
}
pub fn compute(&self, inv: &Invoice) -> Result<Reconciled, ReconcileError> {
let vat_breakdown = self.breakdown(inv)?;
let totals = self.totals(inv, &vat_breakdown)?;
Ok(Reconciled {
vat_breakdown,
totals,
})
}
pub fn apply(&self, inv: &mut Invoice) -> Result<(), ReconcileError> {
let r = self.compute(inv)?;
inv.vat_breakdown = r.vat_breakdown;
inv.totals = r.totals;
Ok(())
}
fn breakdown(&self, inv: &Invoice) -> Result<Vec<VatBreakdown>, ReconcileError> {
let mut keys: Vec<(VatCategory, Option<Percentage>)> = Vec::new();
for (cat, rate, at) in content(inv) {
let semantics = VatCategory::from_code(cat.as_str()).ok_or_else(|| {
ReconcileError::UnknownCategory {
at,
code: cat.as_str().to_owned(),
}
})?;
if semantics.carries_tax() && rate.is_none() {
return Err(ReconcileError::MissingRate {
at,
category: semantics,
});
}
let key = (semantics, group_rate(semantics, rate));
if !keys.contains(&key) {
keys.push(key);
}
}
keys.sort_by(|a, b| a.0.code().cmp(b.0.code()).then(a.1.cmp(&b.1)));
keys.into_iter()
.map(|(category, rate)| self.group(inv, category, rate))
.collect()
}
fn group(
&self,
inv: &Invoice,
category: VatCategory,
rate: Option<Percentage>,
) -> Result<VatBreakdown, ReconcileError> {
let p = profile(category);
let belongs = |c: &Code, r: Option<Percentage>| {
VatCategory::from_code(c.as_str()) == Some(category)
&& (!p.grouped_by_rate() || group_rate(category, r) == rate)
};
let positive = inv
.lines
.iter()
.filter(|l| belongs(&l.vat.category, l.vat.rate))
.map(|l| l.net_amount)
.chain(
inv.charges
.iter()
.filter(|c| belongs(&c.vat.category, c.vat.rate))
.map(|c| c.amount),
);
let negative = inv
.allowances
.iter()
.filter(|a| belongs(&a.vat.category, a.vat.rate))
.map(|a| a.amount);
let taxable_amount = InvoiceAmount::checked_sum(positive)
.and_then(|pos| {
InvoiceAmount::checked_sum(negative).and_then(|neg| pos.checked_sub(neg))
})
.map_err(|_| ReconcileError::Overflow { term: "BT-116" })?;
let tax_amount = match p.tax {
TaxRule::Zero => InvoiceAmount::ZERO,
TaxRule::Derived => tax_on(taxable_amount, rate)?,
};
let (exemption_reason, exemption_reason_code) = self.reason_for(inv, category);
Ok(VatBreakdown {
taxable_amount,
tax_amount,
category: Code::new(category.code()),
rate: breakdown_rate(rate),
exemption_reason,
exemption_reason_code,
})
}
fn reason_for(&self, inv: &Invoice, category: VatCategory) -> (Option<String>, Option<Code>) {
if category.forbids_exemption_reason() {
return (None, None);
}
if let Some(e) = self
.exemptions
.iter()
.find(|e| e.category == category.code())
{
return (e.text.clone(), e.code.as_deref().map(Code::new));
}
inv.vat_breakdown
.iter()
.find(|e| e.semantics() == Some(category) && e.has_exemption_reason())
.map_or((None, None), |e| {
(e.exemption_reason.clone(), e.exemption_reason_code.clone())
})
}
fn totals(
&self,
inv: &Invoice,
breakdown: &[VatBreakdown],
) -> Result<DocumentTotals, ReconcileError> {
let sum = |it: &mut dyn Iterator<Item = InvoiceAmount>, term| {
InvoiceAmount::checked_sum(it).map_err(|_| ReconcileError::Overflow { term })
};
let line_total = sum(&mut inv.lines.iter().map(|l| l.net_amount), "BT-106")?;
let allowance_total = if inv.allowances.is_empty() {
None
} else {
Some(sum(&mut inv.allowances.iter().map(|a| a.amount), "BT-107")?)
};
let charge_total = if inv.charges.is_empty() {
None
} else {
Some(sum(&mut inv.charges.iter().map(|c| c.amount), "BT-108")?)
};
let taxable_total = line_total
.checked_sub(allowance_total.unwrap_or(InvoiceAmount::ZERO))
.and_then(|v| v.checked_add(charge_total.unwrap_or(InvoiceAmount::ZERO)))
.map_err(|_| ReconcileError::Overflow { term: "BT-109" })?;
let vat_total = if breakdown.is_empty() {
None
} else {
Some(sum(&mut breakdown.iter().map(|e| e.tax_amount), "BT-110")?)
};
let gross_total = taxable_total
.checked_add(vat_total.unwrap_or(InvoiceAmount::ZERO))
.map_err(|_| ReconcileError::Overflow { term: "BT-112" })?;
let due = gross_total
.checked_sub(self.paid.unwrap_or(InvoiceAmount::ZERO))
.and_then(|v| v.checked_add(self.rounding.unwrap_or(InvoiceAmount::ZERO)))
.map_err(|_| ReconcileError::Overflow { term: "BT-115" })?;
Ok(DocumentTotals {
line_total,
allowance_total,
charge_total,
taxable_total,
vat_total,
vat_total_accounting: self.vat_total_accounting,
gross_total,
paid: self.paid,
rounding: self.rounding,
due,
})
}
}
pub fn reconcile(inv: &mut Invoice) -> Result<(), ReconcileError> {
Reconciler::new().apply(inv)
}
fn content(inv: &Invoice) -> impl Iterator<Item = (&Code, Option<Percentage>, Path)> {
let lines = inv
.lines
.iter()
.enumerate()
.map(|(i, l)| (&l.vat.category, l.vat.rate, Path::at(Group::Line, i)));
let allowances = inv.allowances.iter().enumerate().map(|(i, a)| {
(
&a.vat.category,
a.vat.rate,
Path::at(Group::DocumentAllowance, i),
)
});
let charges = inv.charges.iter().enumerate().map(|(i, c)| {
(
&c.vat.category,
c.vat.rate,
Path::at(Group::DocumentCharge, i),
)
});
lines.chain(allowances).chain(charges)
}
fn group_rate(category: VatCategory, rate: Option<Percentage>) -> Option<Percentage> {
if profile(category).grouped_by_rate() {
rate
} else {
None
}
}
fn breakdown_rate(rate: Option<Percentage>) -> Option<Percentage> {
rate.or(Some(Percentage::ZERO))
}
fn tax_on(base: InvoiceAmount, rate: Option<Percentage>) -> Result<InvoiceAmount, ReconcileError> {
let rate = rate.map_or(Decimal::ZERO, Percentage::into_decimal);
let exact = base
.into_decimal()
.checked_mul(rate)
.map(|v| v / Decimal::ONE_HUNDRED)
.ok_or(ReconcileError::Overflow { term: "BT-117" })?;
InvoiceAmount::from_decimal_exact(
exact.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero),
)
.map_err(|_| ReconcileError::Overflow { term: "BT-117" })
}
#[cfg(test)]
mod tests {
use rust_decimal::dec;
use super::*;
use crate::invoice::{DocumentAllowanceCharge, Item, LineVat, PriceDetails};
use crate::{Date, InvoiceLine, Quantity, validate};
fn line(id: &str, net: &str, category: &str, rate: Option<Decimal>) -> InvoiceLine {
InvoiceLine {
id: id.to_owned(),
note: None,
order_line_reference: None,
accounting_reference: None,
object_identifier: None,
quantity: Quantity::new(Decimal::ONE),
unit_code: Code::new("C62"),
net_amount: InvoiceAmount::parse(net).expect("amount"),
period: None,
allowances: Vec::new(),
charges: Vec::new(),
price: PriceDetails::default(),
vat: LineVat {
category: Code::new(category),
rate: rate.map(Percentage::new),
},
item: Item {
name: Some(format!("item {id}")),
..Default::default()
},
}
}
fn invoice(lines: Vec<InvoiceLine>) -> Invoice {
let mut inv = Invoice::builder(
"urn:cen.eu:en16931:2017",
"INV-1",
Date::parse("2026-07-31").expect("date"),
"380",
"EUR",
)
.build();
inv.lines = lines;
inv
}
#[test]
fn two_lines_two_rates_produce_two_groups_and_a_balancing_total() {
let mut inv = invoice(vec![
line("1", "1000.00", "S", Some(dec!(19))),
line("2", "500.00", "S", Some(dec!(7))),
]);
reconcile(&mut inv).expect("reconciles");
assert_eq!(inv.vat_breakdown.len(), 2, "one group per rate");
let by_rate = |r: Decimal| {
inv.vat_breakdown
.iter()
.find(|e| e.rate == Some(Percentage::new(r)))
.expect("group")
};
assert_eq!(by_rate(dec!(19)).taxable_amount.to_string(), "1000.00");
assert_eq!(by_rate(dec!(19)).tax_amount.to_string(), "190.00");
assert_eq!(by_rate(dec!(7)).tax_amount.to_string(), "35.00");
let t = &inv.totals;
assert_eq!(t.line_total.to_string(), "1500.00");
assert_eq!(t.taxable_total.to_string(), "1500.00");
assert_eq!(t.vat_total.expect("BT-110").to_string(), "225.00");
assert_eq!(t.gross_total.to_string(), "1725.00");
assert_eq!(t.due.to_string(), "1725.00");
assert_eq!(t.allowance_total, None, "absent is not zero");
assert_eq!(t.charge_total, None);
}
#[test]
fn rounding_happens_once_on_the_group_not_per_line() {
let mut inv = invoice(vec![
line("1", "0.05", "S", Some(dec!(19))),
line("2", "0.05", "S", Some(dec!(19))),
line("3", "0.05", "S", Some(dec!(19))),
]);
reconcile(&mut inv).expect("reconciles");
assert_eq!(inv.vat_breakdown[0].taxable_amount.to_string(), "0.15");
assert_eq!(inv.vat_breakdown[0].tax_amount.to_string(), "0.03");
assert!(
validate(&inv)
.findings()
.iter()
.all(|f| f.rule != "BR-S-09")
);
}
#[test]
fn the_midpoint_rounds_away_from_zero() {
assert_eq!(
tax_on(
InvoiceAmount::parse("2.50").unwrap(),
Some(Percentage::new(dec!(5)))
)
.unwrap()
.to_string(),
"0.13",
"banker's rounding would give 0.12"
);
assert_eq!(
tax_on(
InvoiceAmount::parse("-2.50").unwrap(),
Some(Percentage::new(dec!(5)))
)
.unwrap()
.to_string(),
"-0.13",
"and symmetrically on a credit note"
);
}
#[test]
fn allowances_and_charges_move_the_base_of_their_own_group() {
let mut inv = invoice(vec![line("1", "1000.00", "S", Some(dec!(19)))]);
inv.allowances.push(DocumentAllowanceCharge {
amount: InvoiceAmount::parse("100.00").unwrap(),
base_amount: None,
percentage: None,
vat: LineVat {
category: Code::new("S"),
rate: Some(Percentage::new(dec!(19))),
},
reason: Some("Skonto".into()),
reason_code: None,
});
inv.charges.push(DocumentAllowanceCharge {
amount: InvoiceAmount::parse("50.00").unwrap(),
base_amount: None,
percentage: None,
vat: LineVat {
category: Code::new("S"),
rate: Some(Percentage::new(dec!(19))),
},
reason: Some("Versand".into()),
reason_code: None,
});
reconcile(&mut inv).expect("reconciles");
assert_eq!(inv.vat_breakdown[0].taxable_amount.to_string(), "950.00");
assert_eq!(inv.vat_breakdown[0].tax_amount.to_string(), "180.50");
let t = &inv.totals;
assert_eq!(t.allowance_total.expect("BT-107").to_string(), "100.00");
assert_eq!(t.charge_total.expect("BT-108").to_string(), "50.00");
assert_eq!(t.taxable_total.to_string(), "950.00");
assert_eq!(t.gross_total.to_string(), "1130.50");
}
#[test]
fn prepayment_and_rounding_reach_the_amount_due() {
let inv = invoice(vec![line("1", "1000.00", "S", Some(dec!(19)))]);
let r = Reconciler::new()
.paid(InvoiceAmount::parse("190.00").unwrap())
.rounding(InvoiceAmount::parse("-0.01").unwrap())
.compute(&inv)
.expect("reconciles");
assert_eq!(r.totals.gross_total.to_string(), "1190.00");
assert_eq!(r.totals.due.to_string(), "999.99");
}
#[test]
fn every_category_reconciles_to_a_valid_breakdown() {
for cat in VatCategory::ALL {
let rate = if cat.states_rate() && cat.carries_tax() {
Some(dec!(19))
} else if cat.states_rate() {
Some(Decimal::ZERO)
} else {
None
};
let mut inv = invoice(vec![line("1", "1000.00", cat.code(), rate)]);
if cat != VatCategory::OutOfScope {
inv.seller.vat_identifier = Some("DE123456789".into());
inv.buyer.vat_identifier = Some("DE987654321".into());
}
if cat == VatCategory::SplitPayment {
inv.seller.address.country = Some(Code::new("IT"));
inv.buyer.address.country = Some(Code::new("IT"));
}
inv.delivery = Some(crate::invoice::Delivery {
party_name: None,
location: None,
date: Some(Date::parse("2026-07-15").expect("date")),
address: Some(crate::invoice::PostalAddress {
country: Some(Code::new("FR")),
..Default::default()
}),
});
Reconciler::new()
.exemption(cat.code(), Some("Steuerbefreiung"), None)
.apply(&mut inv)
.unwrap_or_else(|e| panic!("{cat}: {e}"));
const FAMILIES: [&str; 10] = [
"BR-S-", "BR-Z-", "BR-E-", "BR-AE-", "BR-IC-", "BR-G-", "BR-O-", "BR-AF-",
"BR-AG-", "BR-B-",
];
let arithmetic = |r: &str| {
r.starts_with("BR-CO-1")
|| r == "BR-48"
|| FAMILIES.iter().any(|p| r.starts_with(p))
};
let left: Vec<_> = validate(&inv)
.findings()
.iter()
.filter(|f| arithmetic(&f.rule))
.map(|f| f.to_string())
.collect();
assert!(left.is_empty(), "{cat} left {left:#?}");
}
}
#[test]
fn out_of_scope_states_bt_119_but_not_bt_152() {
let mut inv = invoice(vec![line("1", "1000.00", "O", None)]);
Reconciler::new()
.exemption("O", Some("Nicht steuerbar"), None)
.apply(&mut inv)
.expect("reconciles");
assert_eq!(inv.lines[0].vat.rate, None, "BR-O-05");
assert_eq!(
inv.vat_breakdown[0].rate,
Some(Percentage::ZERO),
"BT-119 is a different term; BR-DE-14 wants it unconditionally"
);
assert_eq!(inv.vat_breakdown[0].tax_amount, InvoiceAmount::ZERO);
}
#[test]
fn exactly_one_group_categories_do_not_split_on_the_rate() {
let mut inv = invoice(vec![
line("1", "100.00", "AE", Some(Decimal::ZERO)),
line("2", "100.00", "AE", None),
]);
Reconciler::new()
.exemption("AE", None, Some("VATEX-EU-AE"))
.apply(&mut inv)
.expect("reconciles");
assert_eq!(inv.vat_breakdown.len(), 1, "BR-AE-01 says exactly one");
assert_eq!(inv.vat_breakdown[0].taxable_amount.to_string(), "200.00");
}
#[test]
fn a_category_outside_uncl_5305_is_refused_by_name() {
let inv = invoice(vec![line("1", "100.00", "Q", Some(dec!(19)))]);
let err = reconcile(&mut inv.clone()).expect_err("Q is not a category");
assert!(matches!(err, ReconcileError::UnknownCategory { .. }));
assert!(err.to_string().contains("BR-CL-18"), "{err}");
}
#[test]
fn a_taxed_category_with_no_rate_is_refused_rather_than_zeroed() {
let inv = invoice(vec![line("1", "100.00", "S", None)]);
let err = reconcile(&mut inv.clone()).expect_err("S needs a rate");
assert!(matches!(err, ReconcileError::MissingRate { .. }));
assert!(err.to_string().contains("under-declare"), "{err}");
}
#[test]
fn existing_exemption_reasons_are_preserved() {
let mut inv = invoice(vec![line("1", "100.00", "E", Some(Decimal::ZERO))]);
inv.vat_breakdown.push(VatBreakdown {
taxable_amount: InvoiceAmount::ZERO, tax_amount: InvoiceAmount::ZERO,
category: Code::new("E"),
rate: Some(Percentage::ZERO),
exemption_reason: Some("§ 4 Nr. 21 UStG".into()),
exemption_reason_code: None,
});
reconcile(&mut inv).expect("reconciles");
assert_eq!(inv.vat_breakdown[0].taxable_amount.to_string(), "100.00");
assert_eq!(
inv.vat_breakdown[0].exemption_reason.as_deref(),
Some("§ 4 Nr. 21 UStG"),
"the numbers are recomputed; the prose is not invented and not lost"
);
}
#[test]
fn a_reason_is_never_written_where_a_rule_forbids_it() {
let mut inv = invoice(vec![line("1", "100.00", "S", Some(dec!(19)))]);
Reconciler::new()
.exemption("S", Some("not allowed here"), None)
.apply(&mut inv)
.expect("reconciles");
assert!(!inv.vat_breakdown[0].has_exemption_reason(), "BR-S-10");
}
#[test]
fn reconciliation_is_idempotent() {
let mut inv = invoice(vec![
line("1", "33.33", "S", Some(dec!(19))),
line("2", "66.67", "S", Some(dec!(7))),
]);
reconcile(&mut inv).expect("first");
let once = inv.clone();
reconcile(&mut inv).expect("second");
assert_eq!(inv, once);
}
}