1use chrono::NaiveDate;
8
9use crate::audit::EntityType;
10use crate::error::{EnvelopeError, EnvelopeResult};
11use crate::models::{AccountId, CategoryId, Money, Transaction, TransactionId, TransactionStatus};
12use crate::storage::Storage;
13
14pub struct ReconciliationService<'a> {
16 storage: &'a Storage,
17}
18
19#[derive(Debug, Clone)]
21pub struct ReconciliationSession {
22 pub account_id: AccountId,
24 pub statement_date: NaiveDate,
26 pub statement_balance: Money,
28 pub starting_cleared_balance: Money,
30}
31
32#[derive(Debug, Clone)]
34pub struct ReconciliationSummary {
35 pub session: ReconciliationSession,
37 pub uncleared_transactions: Vec<Transaction>,
39 pub cleared_transactions: Vec<Transaction>,
41 pub current_cleared_balance: Money,
43 pub difference: Money,
45 pub can_complete: bool,
47}
48
49#[derive(Debug)]
51pub struct ReconciliationResult {
52 pub transactions_reconciled: usize,
54 pub adjustment_created: bool,
56 pub adjustment_amount: Option<Money>,
58}
59
60impl<'a> ReconciliationService<'a> {
61 pub fn new(storage: &'a Storage) -> Self {
63 Self { storage }
64 }
65
66 pub fn start(
68 &self,
69 account_id: AccountId,
70 statement_date: NaiveDate,
71 statement_balance: Money,
72 ) -> EnvelopeResult<ReconciliationSession> {
73 let account = self
75 .storage
76 .accounts
77 .get(account_id)?
78 .ok_or_else(|| EnvelopeError::account_not_found(account_id.to_string()))?;
79
80 if account.archived {
81 return Err(EnvelopeError::Reconciliation(
82 "Cannot reconcile an archived account".into(),
83 ));
84 }
85
86 let starting_cleared_balance = self.calculate_reconciled_balance(account_id)?;
88
89 Ok(ReconciliationSession {
90 account_id,
91 statement_date,
92 statement_balance,
93 starting_cleared_balance,
94 })
95 }
96
97 pub fn get_summary(
99 &self,
100 session: &ReconciliationSession,
101 ) -> EnvelopeResult<ReconciliationSummary> {
102 let transactions = self
103 .storage
104 .transactions
105 .get_by_account(session.account_id)?;
106
107 let mut uncleared_transactions = Vec::new();
108 let mut cleared_transactions = Vec::new();
109 let mut cleared_total = Money::zero();
110
111 for txn in transactions {
112 match txn.status {
113 TransactionStatus::Pending => {
114 uncleared_transactions.push(txn);
115 }
116 TransactionStatus::Cleared => {
117 cleared_total += txn.amount;
118 cleared_transactions.push(txn);
119 }
120 TransactionStatus::Reconciled => {
121 }
123 }
124 }
125
126 uncleared_transactions.sort_by(|a, b| a.date.cmp(&b.date));
128 cleared_transactions.sort_by(|a, b| a.date.cmp(&b.date));
129
130 let current_cleared_balance = session.starting_cleared_balance + cleared_total;
131 let difference = session.statement_balance - current_cleared_balance;
132 let can_complete = difference.is_zero();
133
134 Ok(ReconciliationSummary {
135 session: session.clone(),
136 uncleared_transactions,
137 cleared_transactions,
138 current_cleared_balance,
139 difference,
140 can_complete,
141 })
142 }
143
144 pub fn get_uncleared_transactions(
146 &self,
147 account_id: AccountId,
148 ) -> EnvelopeResult<Vec<Transaction>> {
149 let transactions = self.storage.transactions.get_by_account(account_id)?;
150 let mut result: Vec<Transaction> = transactions
151 .into_iter()
152 .filter(|t| !matches!(t.status, TransactionStatus::Reconciled))
153 .collect();
154 result.sort_by(|a, b| a.date.cmp(&b.date));
155 Ok(result)
156 }
157
158 pub fn get_difference(&self, session: &ReconciliationSession) -> EnvelopeResult<Money> {
160 let summary = self.get_summary(session)?;
161 Ok(summary.difference)
162 }
163
164 pub fn clear_transaction(&self, transaction_id: TransactionId) -> EnvelopeResult<Transaction> {
166 let mut txn = self
167 .storage
168 .transactions
169 .get(transaction_id)?
170 .ok_or_else(|| EnvelopeError::transaction_not_found(transaction_id.to_string()))?;
171
172 if txn.status == TransactionStatus::Reconciled {
173 return Err(EnvelopeError::Reconciliation(
174 "Transaction is already reconciled".into(),
175 ));
176 }
177
178 let before = txn.clone();
179 txn.set_status(TransactionStatus::Cleared);
180
181 self.storage.transactions.upsert(txn.clone())?;
182 self.storage.transactions.save()?;
183
184 self.storage.log_update(
185 EntityType::Transaction,
186 txn.id.to_string(),
187 Some(format!("{} {}", txn.date, txn.payee_name)),
188 &before,
189 &txn,
190 Some(format!(
191 "status: {} -> Cleared (reconciliation)",
192 before.status
193 )),
194 )?;
195
196 Ok(txn)
197 }
198
199 pub fn unclear_transaction(
201 &self,
202 transaction_id: TransactionId,
203 ) -> EnvelopeResult<Transaction> {
204 let mut txn = self
205 .storage
206 .transactions
207 .get(transaction_id)?
208 .ok_or_else(|| EnvelopeError::transaction_not_found(transaction_id.to_string()))?;
209
210 if txn.status == TransactionStatus::Reconciled {
211 return Err(EnvelopeError::Reconciliation(
212 "Cannot unclear a reconciled transaction. Unlock it first.".into(),
213 ));
214 }
215
216 let before = txn.clone();
217 txn.set_status(TransactionStatus::Pending);
218
219 self.storage.transactions.upsert(txn.clone())?;
220 self.storage.transactions.save()?;
221
222 self.storage.log_update(
223 EntityType::Transaction,
224 txn.id.to_string(),
225 Some(format!("{} {}", txn.date, txn.payee_name)),
226 &before,
227 &txn,
228 Some(format!(
229 "status: {} -> Pending (reconciliation)",
230 before.status
231 )),
232 )?;
233
234 Ok(txn)
235 }
236
237 pub fn complete(
239 &self,
240 session: &ReconciliationSession,
241 ) -> EnvelopeResult<ReconciliationResult> {
242 let summary = self.get_summary(session)?;
243
244 if !summary.can_complete {
245 return Err(EnvelopeError::Reconciliation(format!(
246 "Cannot complete reconciliation: difference is {} (must be zero)",
247 summary.difference
248 )));
249 }
250
251 self.complete_internal(session, &summary.cleared_transactions)
252 }
253
254 pub fn complete_with_adjustment(
256 &self,
257 session: &ReconciliationSession,
258 adjustment_category_id: Option<CategoryId>,
259 ) -> EnvelopeResult<ReconciliationResult> {
260 let summary = self.get_summary(session)?;
261
262 if summary.can_complete {
263 return self.complete(session);
265 }
266
267 if let Some(cat_id) = adjustment_category_id {
269 self.storage
270 .categories
271 .get_category(cat_id)?
272 .ok_or_else(|| EnvelopeError::category_not_found(cat_id.to_string()))?;
273 }
274
275 let adjustment_amount = summary.difference;
277 let adjustment = self.create_adjustment_transaction(
278 session.account_id,
279 session.statement_date,
280 adjustment_amount,
281 adjustment_category_id,
282 )?;
283
284 let mut transactions_to_reconcile = summary.cleared_transactions;
286 transactions_to_reconcile.push(adjustment);
287
288 let result = self.complete_internal(session, &transactions_to_reconcile)?;
289
290 Ok(ReconciliationResult {
291 transactions_reconciled: result.transactions_reconciled,
292 adjustment_created: true,
293 adjustment_amount: Some(adjustment_amount),
294 })
295 }
296
297 pub fn create_adjustment_transaction(
299 &self,
300 account_id: AccountId,
301 date: NaiveDate,
302 amount: Money,
303 category_id: Option<CategoryId>,
304 ) -> EnvelopeResult<Transaction> {
305 let mut txn = Transaction::new(account_id, date, amount);
306 txn.payee_name = "Reconciliation Adjustment".to_string();
307 txn.memo = "Created during reconciliation to match statement balance".to_string();
308 txn.category_id = category_id;
309 txn.status = TransactionStatus::Cleared;
310
311 txn.validate()
313 .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
314
315 self.storage.transactions.upsert(txn.clone())?;
317 self.storage.transactions.save()?;
318
319 self.storage.log_create(
321 EntityType::Transaction,
322 txn.id.to_string(),
323 Some(format!(
324 "Reconciliation adjustment {} for account {}",
325 amount, account_id
326 )),
327 &txn,
328 )?;
329
330 Ok(txn)
331 }
332
333 fn complete_internal(
335 &self,
336 session: &ReconciliationSession,
337 transactions_to_reconcile: &[Transaction],
338 ) -> EnvelopeResult<ReconciliationResult> {
339 let mut count = 0;
340
341 for txn in transactions_to_reconcile {
342 let mut updated_txn = txn.clone();
343 let before = txn.clone();
344 updated_txn.set_status(TransactionStatus::Reconciled);
345
346 self.storage.transactions.upsert(updated_txn.clone())?;
347
348 self.storage.log_update(
349 EntityType::Transaction,
350 updated_txn.id.to_string(),
351 Some(format!("{} {}", updated_txn.date, updated_txn.payee_name)),
352 &before,
353 &updated_txn,
354 Some("status: Cleared -> Reconciled (reconciliation complete)".to_string()),
355 )?;
356
357 count += 1;
358 }
359
360 self.storage.transactions.save()?;
361
362 let mut account = self
364 .storage
365 .accounts
366 .get(session.account_id)?
367 .ok_or_else(|| EnvelopeError::account_not_found(session.account_id.to_string()))?;
368
369 let before_account = account.clone();
370 account.reconcile(session.statement_date, session.statement_balance);
371
372 self.storage.accounts.upsert(account.clone())?;
373 self.storage.accounts.save()?;
374
375 self.storage.log_update(
376 EntityType::Account,
377 account.id.to_string(),
378 Some(account.name.clone()),
379 &before_account,
380 &account,
381 Some(format!(
382 "reconciled: date={}, balance={}",
383 session.statement_date, session.statement_balance
384 )),
385 )?;
386
387 Ok(ReconciliationResult {
388 transactions_reconciled: count,
389 adjustment_created: false,
390 adjustment_amount: None,
391 })
392 }
393
394 fn calculate_reconciled_balance(&self, account_id: AccountId) -> EnvelopeResult<Money> {
397 let account = self
398 .storage
399 .accounts
400 .get(account_id)?
401 .ok_or_else(|| EnvelopeError::account_not_found(account_id.to_string()))?;
402
403 let transactions = self.storage.transactions.get_by_account(account_id)?;
404 let reconciled_total: Money = transactions
405 .iter()
406 .filter(|t| t.status == TransactionStatus::Reconciled)
407 .map(|t| t.amount)
408 .sum();
409
410 Ok(account.starting_balance + reconciled_total)
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use crate::config::paths::EnvelopePaths;
418 use crate::models::{Account, AccountType};
419 use tempfile::TempDir;
420
421 fn create_test_storage() -> (TempDir, Storage) {
422 let temp_dir = TempDir::new().unwrap();
423 let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
424 let mut storage = Storage::new(paths).unwrap();
425 storage.load_all().unwrap();
426 (temp_dir, storage)
427 }
428
429 fn create_test_account(storage: &Storage) -> Account {
430 let account = Account::with_starting_balance(
431 "Test Checking",
432 AccountType::Checking,
433 Money::from_cents(100000), );
435 storage.accounts.upsert(account.clone()).unwrap();
436 storage.accounts.save().unwrap();
437 account
438 }
439
440 #[test]
441 fn test_start_reconciliation() {
442 let (_temp_dir, storage) = create_test_storage();
443 let account = create_test_account(&storage);
444 let service = ReconciliationService::new(&storage);
445
446 let statement_date = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
447 let statement_balance = Money::from_cents(95000); let session = service
450 .start(account.id, statement_date, statement_balance)
451 .unwrap();
452
453 assert_eq!(session.account_id, account.id);
454 assert_eq!(session.statement_date, statement_date);
455 assert_eq!(session.statement_balance.cents(), 95000);
456 assert_eq!(session.starting_cleared_balance.cents(), 100000);
457 }
458
459 #[test]
460 fn test_reconciliation_summary() {
461 let (_temp_dir, storage) = create_test_storage();
462 let account = create_test_account(&storage);
463 let service = ReconciliationService::new(&storage);
464
465 let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
467
468 let txn1 = Transaction::new(account.id, date, Money::from_cents(-2000));
470 storage.transactions.upsert(txn1).unwrap();
471
472 let mut txn2 = Transaction::new(account.id, date, Money::from_cents(-5000));
474 txn2.set_status(TransactionStatus::Cleared);
475 storage.transactions.upsert(txn2).unwrap();
476
477 storage.transactions.save().unwrap();
478
479 let statement_date = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
480 let statement_balance = Money::from_cents(95000);
481
482 let session = service
483 .start(account.id, statement_date, statement_balance)
484 .unwrap();
485 let summary = service.get_summary(&session).unwrap();
486
487 assert_eq!(summary.uncleared_transactions.len(), 1);
488 assert_eq!(summary.cleared_transactions.len(), 1);
489 assert_eq!(summary.current_cleared_balance.cents(), 95000);
491 assert!(summary.difference.is_zero());
492 assert!(summary.can_complete);
493 }
494
495 #[test]
496 fn test_complete_reconciliation() {
497 let (_temp_dir, storage) = create_test_storage();
498 let account = create_test_account(&storage);
499 let service = ReconciliationService::new(&storage);
500
501 let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
503 let mut txn = Transaction::new(account.id, date, Money::from_cents(-5000));
504 txn.set_status(TransactionStatus::Cleared);
505 storage.transactions.upsert(txn.clone()).unwrap();
506 storage.transactions.save().unwrap();
507
508 let statement_date = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
509 let statement_balance = Money::from_cents(95000);
510
511 let session = service
512 .start(account.id, statement_date, statement_balance)
513 .unwrap();
514
515 let result = service.complete(&session).unwrap();
516
517 assert_eq!(result.transactions_reconciled, 1);
518 assert!(!result.adjustment_created);
519
520 let updated_txn = storage.transactions.get(txn.id).unwrap().unwrap();
522 assert_eq!(updated_txn.status, TransactionStatus::Reconciled);
523
524 let updated_account = storage.accounts.get(account.id).unwrap().unwrap();
526 assert_eq!(updated_account.last_reconciled_date, Some(statement_date));
527 assert_eq!(
528 updated_account.last_reconciled_balance,
529 Some(statement_balance)
530 );
531 }
532
533 #[test]
534 fn test_complete_with_adjustment() {
535 let (_temp_dir, storage) = create_test_storage();
536 let account = create_test_account(&storage);
537 let service = ReconciliationService::new(&storage);
538
539 let statement_date = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
541 let statement_balance = Money::from_cents(99000); let session = service
544 .start(account.id, statement_date, statement_balance)
545 .unwrap();
546
547 let summary = service.get_summary(&session).unwrap();
549 assert!(!summary.can_complete);
550 assert_eq!(summary.difference.cents(), -1000); let result = service.complete_with_adjustment(&session, None).unwrap();
554
555 assert!(result.adjustment_created);
556 assert_eq!(result.adjustment_amount.unwrap().cents(), -1000);
557 }
558
559 #[test]
560 fn test_cannot_complete_without_zero_difference() {
561 let (_temp_dir, storage) = create_test_storage();
562 let account = create_test_account(&storage);
563 let service = ReconciliationService::new(&storage);
564
565 let statement_date = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
566 let statement_balance = Money::from_cents(99000); let session = service
569 .start(account.id, statement_date, statement_balance)
570 .unwrap();
571
572 let result = service.complete(&session);
573 assert!(matches!(result, Err(EnvelopeError::Reconciliation(_))));
574 }
575
576 #[test]
577 fn test_clear_unclear_transaction() {
578 let (_temp_dir, storage) = create_test_storage();
579 let account = create_test_account(&storage);
580 let service = ReconciliationService::new(&storage);
581
582 let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
583 let txn = Transaction::new(account.id, date, Money::from_cents(-5000));
584 storage.transactions.upsert(txn.clone()).unwrap();
585 storage.transactions.save().unwrap();
586
587 let cleared = service.clear_transaction(txn.id).unwrap();
589 assert_eq!(cleared.status, TransactionStatus::Cleared);
590
591 let uncleared = service.unclear_transaction(txn.id).unwrap();
593 assert_eq!(uncleared.status, TransactionStatus::Pending);
594 }
595}