Skip to main content

use_reconciliation/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::{error::Error, slice};
6
7use use_amount::Amount;
8
9/// Common reconciliation primitives.
10pub mod prelude {
11    pub use crate::{
12        ExceptionReason, MatchConfidence, MatchScore, MatchStatus, ReconciliationCandidate,
13        ReconciliationError, ReconciliationResult,
14    };
15}
16
17/// Match lifecycle status.
18#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub enum MatchStatus {
20    /// No match has been selected.
21    Unmatched,
22    /// A candidate match exists.
23    Candidate,
24    /// The match has been confirmed.
25    Matched,
26    /// The item was excluded from matching.
27    Ignored,
28}
29
30/// Human-readable confidence band for a deterministic match score.
31#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub enum MatchConfidence {
33    /// No confidence.
34    None,
35    /// Low confidence.
36    Low,
37    /// Medium confidence.
38    Medium,
39    /// High confidence.
40    High,
41    /// Exact confidence.
42    Exact,
43}
44
45/// A bounded deterministic match score from 0 to 10,000 basis points.
46#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct MatchScore {
48    basis_points: u16,
49}
50
51impl MatchScore {
52    /// Creates a match score from basis points in the inclusive range 0..=10,000.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`ReconciliationError::ScoreOutOfRange`] when the value is greater than 10,000.
57    pub const fn from_basis_points(basis_points: u16) -> Result<Self, ReconciliationError> {
58        if basis_points > 10_000 {
59            Err(ReconciliationError::ScoreOutOfRange)
60        } else {
61            Ok(Self { basis_points })
62        }
63    }
64
65    /// Returns an exact match score.
66    #[must_use]
67    pub const fn exact() -> Self {
68        Self {
69            basis_points: 10_000,
70        }
71    }
72
73    /// Returns the score in basis points.
74    #[must_use]
75    pub const fn basis_points(self) -> u16 {
76        self.basis_points
77    }
78
79    /// Returns the confidence band for this score.
80    #[must_use]
81    pub const fn confidence(self) -> MatchConfidence {
82        match self.basis_points {
83            10_000 => MatchConfidence::Exact,
84            8_000..=u16::MAX => MatchConfidence::High,
85            5_000..=7_999 => MatchConfidence::Medium,
86            1..=4_999 => MatchConfidence::Low,
87            0 => MatchConfidence::None,
88        }
89    }
90}
91
92/// A deterministic reconciliation candidate.
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct ReconciliationCandidate {
95    source_id: String,
96    target_id: String,
97    amount_delta: Amount,
98    score: MatchScore,
99    status: MatchStatus,
100}
101
102impl ReconciliationCandidate {
103    /// Creates a reconciliation candidate.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`ReconciliationError::EmptySourceId`] or [`ReconciliationError::EmptyTargetId`]
108    /// when identifiers are empty after trimming whitespace.
109    pub fn new(
110        source_id: impl AsRef<str>,
111        target_id: impl AsRef<str>,
112        amount_delta: Amount,
113        score: MatchScore,
114    ) -> Result<Self, ReconciliationError> {
115        Ok(Self {
116            source_id: non_empty(source_id, ReconciliationError::EmptySourceId)?,
117            target_id: non_empty(target_id, ReconciliationError::EmptyTargetId)?,
118            amount_delta,
119            score,
120            status: MatchStatus::Candidate,
121        })
122    }
123
124    /// Returns the source identifier.
125    #[must_use]
126    pub fn source_id(&self) -> &str {
127        &self.source_id
128    }
129
130    /// Returns the target identifier.
131    #[must_use]
132    pub fn target_id(&self) -> &str {
133        &self.target_id
134    }
135
136    /// Returns the amount delta between source and target.
137    #[must_use]
138    pub const fn amount_delta(&self) -> Amount {
139        self.amount_delta
140    }
141
142    /// Returns the match score.
143    #[must_use]
144    pub const fn score(&self) -> MatchScore {
145        self.score
146    }
147
148    /// Returns the match status.
149    #[must_use]
150    pub const fn status(&self) -> MatchStatus {
151        self.status
152    }
153
154    /// Returns whether the amount delta is zero.
155    #[must_use]
156    pub const fn is_exact_amount_match(&self) -> bool {
157        self.amount_delta.is_zero()
158    }
159
160    /// Sets the candidate status.
161    #[must_use]
162    pub const fn with_status(mut self, status: MatchStatus) -> Self {
163        self.status = status;
164        self
165    }
166}
167
168/// A deterministic reconciliation result.
169#[derive(Clone, Debug, Default, Eq, PartialEq)]
170pub struct ReconciliationResult {
171    candidates: Vec<ReconciliationCandidate>,
172    exceptions: Vec<ExceptionReason>,
173}
174
175impl ReconciliationResult {
176    /// Creates an empty reconciliation result.
177    #[must_use]
178    pub const fn new() -> Self {
179        Self {
180            candidates: Vec::new(),
181            exceptions: Vec::new(),
182        }
183    }
184
185    /// Adds a candidate.
186    pub fn push_candidate(&mut self, candidate: ReconciliationCandidate) {
187        self.candidates.push(candidate);
188    }
189
190    /// Adds an exception reason.
191    pub fn push_exception(&mut self, exception: ExceptionReason) {
192        self.exceptions.push(exception);
193    }
194
195    /// Returns reconciliation candidates.
196    #[must_use]
197    pub fn candidates(&self) -> &[ReconciliationCandidate] {
198        &self.candidates
199    }
200
201    /// Returns exception reasons.
202    #[must_use]
203    pub fn exceptions(&self) -> &[ExceptionReason] {
204        &self.exceptions
205    }
206
207    /// Iterates over candidates.
208    pub fn iter(&self) -> slice::Iter<'_, ReconciliationCandidate> {
209        self.candidates.iter()
210    }
211}
212
213/// Reconciliation exception reason vocabulary.
214#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
215pub enum ExceptionReason {
216    /// Amounts differ beyond the caller's tolerance.
217    AmountMismatch,
218    /// Dates differ beyond the caller's tolerance.
219    DateMismatch,
220    /// Duplicate candidate was found.
221    DuplicateCandidate,
222    /// Required reference was missing.
223    MissingReference,
224    /// Currency differed between items.
225    CurrencyMismatch,
226    /// Caller-defined exception reason.
227    Other(String),
228}
229
230/// Errors returned by reconciliation primitives.
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232pub enum ReconciliationError {
233    /// The source identifier was empty.
234    EmptySourceId,
235    /// The target identifier was empty.
236    EmptyTargetId,
237    /// The score was outside the inclusive 0..=10,000 range.
238    ScoreOutOfRange,
239}
240
241impl fmt::Display for ReconciliationError {
242    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
243        match self {
244            Self::EmptySourceId => formatter.write_str("source identifier cannot be empty"),
245            Self::EmptyTargetId => formatter.write_str("target identifier cannot be empty"),
246            Self::ScoreOutOfRange => {
247                formatter.write_str("match score must be between 0 and 10000 basis points")
248            },
249        }
250    }
251}
252
253impl Error for ReconciliationError {}
254
255impl<'a> IntoIterator for &'a ReconciliationResult {
256    type Item = &'a ReconciliationCandidate;
257    type IntoIter = slice::Iter<'a, ReconciliationCandidate>;
258
259    fn into_iter(self) -> Self::IntoIter {
260        self.iter()
261    }
262}
263
264fn non_empty(
265    value: impl AsRef<str>,
266    error: ReconciliationError,
267) -> Result<String, ReconciliationError> {
268    let trimmed = value.as_ref().trim();
269    if trimmed.is_empty() {
270        Err(error)
271    } else {
272        Ok(trimmed.to_string())
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use use_amount::Amount;
279
280    use super::{
281        ExceptionReason, MatchConfidence, MatchScore, MatchStatus, ReconciliationCandidate,
282        ReconciliationError, ReconciliationResult,
283    };
284
285    #[test]
286    fn creates_candidate_with_exact_score() -> Result<(), Box<dyn std::error::Error>> {
287        let candidate = ReconciliationCandidate::new(
288            "bank-line-1",
289            "invoice-1001",
290            Amount::zero(2)?,
291            MatchScore::exact(),
292        )?;
293
294        assert!(candidate.is_exact_amount_match());
295        assert_eq!(candidate.score().confidence(), MatchConfidence::Exact);
296        assert_eq!(candidate.status(), MatchStatus::Candidate);
297        Ok(())
298    }
299
300    #[test]
301    fn rejects_invalid_score_and_empty_ids() -> Result<(), Box<dyn std::error::Error>> {
302        assert_eq!(
303            MatchScore::from_basis_points(10_001),
304            Err(ReconciliationError::ScoreOutOfRange)
305        );
306        assert_eq!(
307            ReconciliationCandidate::new("", "target", Amount::zero(2)?, MatchScore::exact()),
308            Err(ReconciliationError::EmptySourceId)
309        );
310        Ok(())
311    }
312
313    #[test]
314    fn collects_candidates_and_exceptions() -> Result<(), Box<dyn std::error::Error>> {
315        let mut result = ReconciliationResult::new();
316        result.push_candidate(ReconciliationCandidate::new(
317            "a",
318            "b",
319            Amount::zero(2)?,
320            MatchScore::from_basis_points(8_000)?,
321        )?);
322        result.push_exception(ExceptionReason::MissingReference);
323
324        assert_eq!(result.candidates().len(), 1);
325        assert_eq!(result.exceptions(), &[ExceptionReason::MissingReference]);
326        Ok(())
327    }
328}