core_invoice/arith.rs
1//! Rounding and slack used by VAT/GST/SST derivation.
2//!
3//! # Four tolerance regimes — never mixed
4//!
5//! Slack is a **rule instance**, never a crate constant applied to every amount,
6//! never [`InvoiceAmount`](crate::InvoiceAmount) policy.
7//!
8//! 1. **Totals `BR-CO-10`…`BR-CO-16`** — exact. CEN. Absent ≠ 0; overflow is a
9//! finding, not wrap. Do not put ±1.00 or ±0.02 here.
10//! 2. **VAT derivation (`BR-CO-17`, family `-09`)** — ±1.00 **exclusive** on
11//! **absolute** values (`|Δ| < 1`; `Δ = 1.00` fails). Artefact-only slack;
12//! EN 16931-1 §6.4.2 writes a plain equation. Credit notes use `abs` so a
13//! negative base still derives.
14//! 3. **Peppol `R120` / `R040`** — ±0.02 **inclusive**. Lives on those Peppol
15//! extra rules (P13), not here. **`R046` is exact** (the classic trap of
16//! copying R120's slack onto line VAT).
17//! 4. **XRechnung HUF 0.5** — those two Peppol-shaped rules only, and **out of
18//! this crate** (P18). No HUF branch in core or Peppol.
19//!
20//! Malaysian 5-sen cash rounding is **BT-114**, not slack. There is no fatal
21//! “payable multiple of 0.05” rule.
22//!
23//! # Why rounding needs its own module
24//!
25//! The CEN artefacts spell derived tax in XPath:
26//!
27//! ```xpath
28//! round(abs(TaxableAmount) * (Percent div 100) * 10 * 10) div 100
29//! ```
30//!
31//! and pick the zero-rate branch with `round(Percent) = 0`. Both `round`s are
32//! XPath's `fn:round`: closest integer, ties toward **+∞** = `floor(x + 0.5)`.
33//!
34//! | | `round(0.5)` | `round(2.5)` | `round(-0.5)` |
35//! |---|---|---|---|
36//! | XPath `fn:round` | `1` | `3` | `0` |
37//! | `Decimal::round` (banker's) | `0` | `2` | `0` |
38//! | half away from zero | `1` | `3` | `-1` |
39//!
40//! A rate of **0.5 %** (Spanish recargo) rounds to `1` for the artefact and to
41//! `0` for banker's rounding, which sent `BR-CO-17` down its zero-rate branch
42//! and rejected a valid invoice.
43//!
44//! The producer ([`crate::reconcile`]) may use commercial rounding for the
45//! printed numbers; the validator uses [`xpath_round`]. The ±1.00 slack is what
46//! lets those two disagree by a unit.
47
48use rust_decimal::Decimal;
49
50/// The ±1.00 the artefacts allow on the VAT derivation family.
51///
52/// **Not in the standard.** Shared by `BR-CO-17` and by the `-08`/`-09` rows.
53/// Not a policy on [`crate::InvoiceAmount`].
54pub const VAT_TOLERANCE: Decimal = Decimal::ONE;
55
56const HALF: Decimal = Decimal::from_parts(5, 0, 0, false, 1);
57
58/// XPath `fn:round` — closest integer, ties toward **+∞**.
59///
60/// `floor(x + 0.5)`. Saturates: if `x + 0.5` overflows, returns `x`.
61pub fn xpath_round(x: Decimal) -> Decimal {
62 x.checked_add(HALF).map_or(x, |shifted| shifted.floor())
63}
64
65/// Artefact derived tax: `round(|base| × rate) / 100`.
66///
67/// `rate` is a per cent (`19`, not `0.19`). `None` only on overflow.
68pub fn derived_vat(base: Decimal, rate: Decimal) -> Option<Decimal> {
69 base.abs()
70 .checked_mul(rate)
71 .map(|product| xpath_round(product) / Decimal::ONE_HUNDRED)
72}
73
74/// Whether `stated` is within the artefacts' ±1.00 of `expected` (**exclusive**).
75///
76/// `stated - 1 < expected` and `stated + 1 > expected`: a difference of exactly
77/// 1.00 is a finding.
78pub fn within_vat_tolerance(stated: Decimal, expected: Decimal) -> bool {
79 (stated - expected).abs() < VAT_TOLERANCE
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use rust_decimal::Decimal;
86 use std::str::FromStr;
87
88 fn d(s: &str) -> Decimal {
89 Decimal::from_str(s).unwrap()
90 }
91
92 #[test]
93 fn ties_go_towards_positive_infinity() {
94 assert_eq!(xpath_round(d("0.5")), d("1"));
95 assert_eq!(xpath_round(d("2.5")), d("3"));
96 assert_eq!(xpath_round(d("-0.5")), d("0"));
97 assert_eq!(xpath_round(d("-1.5")), d("-1"));
98 assert_eq!(xpath_round(d("0.4")), d("0"));
99 assert_eq!(xpath_round(d("0.6")), d("1"));
100 assert_eq!(xpath_round(d("-0.6")), d("-1"));
101 assert_eq!(xpath_round(d("19")), d("19"));
102 }
103
104 #[test]
105 fn a_rate_of_half_a_per_cent_is_not_a_zero_rate() {
106 assert_ne!(xpath_round(d("0.5")), Decimal::ZERO);
107 assert_eq!(derived_vat(d("1000.00"), d("0.5")), Some(d("5")));
108 }
109
110 #[test]
111 fn the_derivation_is_taken_on_absolute_values() {
112 assert_eq!(
113 derived_vat(d("-1000.00"), d("19")),
114 derived_vat(d("1000.00"), d("19"))
115 );
116 }
117
118 #[test]
119 fn a_full_currency_unit_of_slack_excludes_its_own_boundary() {
120 assert!(within_vat_tolerance(d("190.99"), d("190.00")));
121 assert!(!within_vat_tolerance(d("191.00"), d("190.00")));
122 assert!(!within_vat_tolerance(d("189.00"), d("190.00")));
123 }
124}