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
9pub mod prelude {
11 pub use crate::{
12 ExceptionReason, MatchConfidence, MatchScore, MatchStatus, ReconciliationCandidate,
13 ReconciliationError, ReconciliationResult,
14 };
15}
16
17#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub enum MatchStatus {
20 Unmatched,
22 Candidate,
24 Matched,
26 Ignored,
28}
29
30#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
32pub enum MatchConfidence {
33 None,
35 Low,
37 Medium,
39 High,
41 Exact,
43}
44
45#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct MatchScore {
48 basis_points: u16,
49}
50
51impl MatchScore {
52 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 #[must_use]
67 pub const fn exact() -> Self {
68 Self {
69 basis_points: 10_000,
70 }
71 }
72
73 #[must_use]
75 pub const fn basis_points(self) -> u16 {
76 self.basis_points
77 }
78
79 #[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#[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 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 #[must_use]
126 pub fn source_id(&self) -> &str {
127 &self.source_id
128 }
129
130 #[must_use]
132 pub fn target_id(&self) -> &str {
133 &self.target_id
134 }
135
136 #[must_use]
138 pub const fn amount_delta(&self) -> Amount {
139 self.amount_delta
140 }
141
142 #[must_use]
144 pub const fn score(&self) -> MatchScore {
145 self.score
146 }
147
148 #[must_use]
150 pub const fn status(&self) -> MatchStatus {
151 self.status
152 }
153
154 #[must_use]
156 pub const fn is_exact_amount_match(&self) -> bool {
157 self.amount_delta.is_zero()
158 }
159
160 #[must_use]
162 pub const fn with_status(mut self, status: MatchStatus) -> Self {
163 self.status = status;
164 self
165 }
166}
167
168#[derive(Clone, Debug, Default, Eq, PartialEq)]
170pub struct ReconciliationResult {
171 candidates: Vec<ReconciliationCandidate>,
172 exceptions: Vec<ExceptionReason>,
173}
174
175impl ReconciliationResult {
176 #[must_use]
178 pub const fn new() -> Self {
179 Self {
180 candidates: Vec::new(),
181 exceptions: Vec::new(),
182 }
183 }
184
185 pub fn push_candidate(&mut self, candidate: ReconciliationCandidate) {
187 self.candidates.push(candidate);
188 }
189
190 pub fn push_exception(&mut self, exception: ExceptionReason) {
192 self.exceptions.push(exception);
193 }
194
195 #[must_use]
197 pub fn candidates(&self) -> &[ReconciliationCandidate] {
198 &self.candidates
199 }
200
201 #[must_use]
203 pub fn exceptions(&self) -> &[ExceptionReason] {
204 &self.exceptions
205 }
206
207 pub fn iter(&self) -> slice::Iter<'_, ReconciliationCandidate> {
209 self.candidates.iter()
210 }
211}
212
213#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
215pub enum ExceptionReason {
216 AmountMismatch,
218 DateMismatch,
220 DuplicateCandidate,
222 MissingReference,
224 CurrencyMismatch,
226 Other(String),
228}
229
230#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232pub enum ReconciliationError {
233 EmptySourceId,
235 EmptyTargetId,
237 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}