1use rust_decimal::Decimal;
8use rustledger_plugin_types::{
9 AmountData, BalanceData, DirectiveData, DirectiveWrapper, MetaValueData,
10};
11use std::str::FromStr;
12
13#[derive(Debug, Clone)]
15pub struct StatementBalance {
16 pub date: String,
18 pub account: String,
20 pub number: Decimal,
22 pub currency: String,
24}
25
26#[derive(Debug)]
28pub struct ReconciliationResult {
29 pub matches: bool,
31 pub expected: Decimal,
33 pub computed: Decimal,
35 pub difference: Decimal,
37 pub balance_directive: DirectiveWrapper,
39}
40
41#[must_use]
50pub fn reconcile(
51 directives: &[DirectiveWrapper],
52 ending_balance: &StatementBalance,
53 opening_balance: Option<Decimal>,
54) -> ReconciliationResult {
55 let mut total = opening_balance.unwrap_or(Decimal::ZERO);
56
57 for d in directives {
58 if let DirectiveData::Transaction(txn) = &d.data {
59 for posting in &txn.postings {
60 if posting.account == ending_balance.account
61 && let Some(units) = &posting.units
62 && units.currency == ending_balance.currency
63 && let Ok(amount) = Decimal::from_str(&units.number)
64 {
65 total += amount;
66 }
67 }
68 }
69 }
70
71 let difference = ending_balance.number - total;
72 let matches = difference.abs() < Decimal::new(1, 2); let balance_directive = create_balance_directive(ending_balance);
75
76 ReconciliationResult {
77 matches,
78 expected: ending_balance.number,
79 computed: total,
80 difference,
81 balance_directive,
82 }
83}
84
85#[must_use]
87pub fn create_balance_directive(balance: &StatementBalance) -> DirectiveWrapper {
88 DirectiveWrapper {
89 directive_type: "balance".to_string(),
90 date: balance.date.clone(),
91 filename: Some("<import-reconcile>".to_string()),
92 lineno: None,
93 data: DirectiveData::Balance(BalanceData {
94 account: balance.account.clone(),
95 amount: AmountData {
96 number: balance.number.to_string(),
97 currency: balance.currency.clone(),
98 },
99 tolerance: None,
100 metadata: vec![("import-reconcile".to_string(), MetaValueData::Bool(true))],
101 }),
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use rustledger_plugin_types::{PostingData, TransactionData};
109
110 fn make_txn(date: &str, account: &str, amount: &str, currency: &str) -> DirectiveWrapper {
111 DirectiveWrapper {
112 directive_type: "transaction".to_string(),
113 date: date.to_string(),
114 filename: None,
115 lineno: None,
116 data: DirectiveData::Transaction(TransactionData {
117 flag: "*".to_string(),
118 payee: None,
119 narration: "Test".to_string(),
120 tags: vec![],
121 links: vec![],
122 metadata: vec![],
123 postings: vec![
124 PostingData {
125 account: account.to_string(),
126 units: Some(AmountData {
127 number: amount.to_string(),
128 currency: currency.to_string(),
129 }),
130 cost: None,
131 price: None,
132 flag: None,
133 metadata: vec![],
134 span: None,
135 },
136 PostingData {
137 account: "Expenses:Unknown".to_string(),
138 units: None,
139 cost: None,
140 price: None,
141 flag: None,
142 metadata: vec![],
143 span: None,
144 },
145 ],
146 }),
147 }
148 }
149
150 #[test]
151 fn reconcile_matches() {
152 let directives = vec![
153 make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD"),
154 make_txn("2024-01-16", "Assets:Checking", "-30.00", "USD"),
155 make_txn("2024-01-17", "Assets:Checking", "100.00", "USD"),
156 ];
157 let balance = StatementBalance {
158 date: "2024-01-31".to_string(),
159 account: "Assets:Checking".to_string(),
160 number: Decimal::new(102_000, 2), currency: "USD".to_string(),
162 };
163 let result = reconcile(&directives, &balance, Some(Decimal::new(100_000, 2)));
164 assert!(result.matches);
165 assert_eq!(result.difference, Decimal::ZERO);
166 }
167
168 #[test]
169 fn reconcile_mismatch() {
170 let directives = vec![make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD")];
171 let balance = StatementBalance {
172 date: "2024-01-31".to_string(),
173 account: "Assets:Checking".to_string(),
174 number: Decimal::new(100_000, 2), currency: "USD".to_string(),
176 };
177 let result = reconcile(&directives, &balance, Some(Decimal::new(100_000, 2)));
179 assert!(!result.matches);
180 assert_eq!(result.difference, Decimal::new(5000, 2)); }
182
183 #[test]
184 fn reconcile_no_opening_balance() {
185 let directives = vec![
186 make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD"),
187 make_txn("2024-01-16", "Assets:Checking", "100.00", "USD"),
188 ];
189 let balance = StatementBalance {
190 date: "2024-01-31".to_string(),
191 account: "Assets:Checking".to_string(),
192 number: Decimal::new(5000, 2), currency: "USD".to_string(),
194 };
195 let result = reconcile(&directives, &balance, None);
196 assert!(result.matches);
197 }
198
199 #[test]
200 fn reconcile_ignores_other_accounts() {
201 let directives = vec![
202 make_txn("2024-01-15", "Assets:Checking", "-50.00", "USD"),
203 make_txn("2024-01-15", "Assets:Savings", "50.00", "USD"),
204 ];
205 let balance = StatementBalance {
206 date: "2024-01-31".to_string(),
207 account: "Assets:Checking".to_string(),
208 number: Decimal::new(-5000, 2), currency: "USD".to_string(),
210 };
211 let result = reconcile(&directives, &balance, None);
212 assert!(result.matches);
213 }
214
215 #[test]
216 fn balance_directive_created() {
217 let balance = StatementBalance {
218 date: "2024-01-31".to_string(),
219 account: "Assets:Checking".to_string(),
220 number: Decimal::new(100_000, 2),
221 currency: "USD".to_string(),
222 };
223 let directive = create_balance_directive(&balance);
224 assert_eq!(directive.date, "2024-01-31");
225 if let DirectiveData::Balance(b) = &directive.data {
226 assert_eq!(b.account, "Assets:Checking");
227 assert_eq!(b.amount.number, "1000.00");
228 assert_eq!(b.amount.currency, "USD");
229 } else {
230 panic!("Expected Balance directive");
231 }
232 }
233
234 #[test]
235 fn balance_directive_has_metadata() {
236 let balance = StatementBalance {
237 date: "2024-01-31".to_string(),
238 account: "Assets:Checking".to_string(),
239 number: Decimal::new(100_000, 2),
240 currency: "USD".to_string(),
241 };
242 let directive = create_balance_directive(&balance);
243 if let DirectiveData::Balance(b) = &directive.data {
244 assert!(b.metadata.iter().any(|(k, _)| k == "import-reconcile"));
245 }
246 }
247}