envelope_cli/services/
reconciliation.rs

1//! Reconciliation service
2//!
3//! Provides business logic for account reconciliation workflow including
4//! starting reconciliation, calculating differences, completing reconciliation,
5//! and creating adjustment transactions.
6
7use 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
14/// Service for reconciliation operations
15pub struct ReconciliationService<'a> {
16    storage: &'a Storage,
17}
18
19/// Represents an active reconciliation session
20#[derive(Debug, Clone)]
21pub struct ReconciliationSession {
22    /// The account being reconciled
23    pub account_id: AccountId,
24    /// Statement date
25    pub statement_date: NaiveDate,
26    /// Statement ending balance
27    pub statement_balance: Money,
28    /// Current cleared balance (before reconciliation changes)
29    pub starting_cleared_balance: Money,
30}
31
32/// Summary of current reconciliation state
33#[derive(Debug, Clone)]
34pub struct ReconciliationSummary {
35    /// The reconciliation session
36    pub session: ReconciliationSession,
37    /// List of uncleared transactions
38    pub uncleared_transactions: Vec<Transaction>,
39    /// List of cleared (but not reconciled) transactions
40    pub cleared_transactions: Vec<Transaction>,
41    /// Current cleared balance (starting + cleared transactions)
42    pub current_cleared_balance: Money,
43    /// Difference between statement and cleared balance
44    pub difference: Money,
45    /// Whether reconciliation can be completed (difference is zero)
46    pub can_complete: bool,
47}
48
49/// Result of completing reconciliation
50#[derive(Debug)]
51pub struct ReconciliationResult {
52    /// Number of transactions marked as reconciled
53    pub transactions_reconciled: usize,
54    /// Whether an adjustment transaction was created
55    pub adjustment_created: bool,
56    /// The adjustment amount (if any)
57    pub adjustment_amount: Option<Money>,
58}
59
60impl<'a> ReconciliationService<'a> {
61    /// Create a new reconciliation service
62    pub fn new(storage: &'a Storage) -> Self {
63        Self { storage }
64    }
65
66    /// Start a reconciliation session for an account
67    pub fn start(
68        &self,
69        account_id: AccountId,
70        statement_date: NaiveDate,
71        statement_balance: Money,
72    ) -> EnvelopeResult<ReconciliationSession> {
73        // Verify account exists
74        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        // Calculate current cleared balance (starting balance + reconciled transactions)
87        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    /// Get the current state of a reconciliation session
98    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                    // Already reconciled, included in starting balance
122                }
123            }
124        }
125
126        // Sort by date
127        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    /// Get uncleared transactions for an account (both pending and cleared but not reconciled)
145    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    /// Calculate the difference between statement balance and current cleared balance
159    pub fn get_difference(&self, session: &ReconciliationSession) -> EnvelopeResult<Money> {
160        let summary = self.get_summary(session)?;
161        Ok(summary.difference)
162    }
163
164    /// Clear a transaction during reconciliation
165    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    /// Unclear a transaction during reconciliation
200    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    /// Complete reconciliation when difference is zero
238    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    /// Complete reconciliation with a discrepancy by creating an adjustment transaction
255    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            // No adjustment needed
264            return self.complete(session);
265        }
266
267        // Verify category exists if provided
268        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        // Create adjustment transaction
276        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        // Now complete with the adjustment included
285        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    /// Create an adjustment transaction for reconciliation discrepancies
298    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        // Validate
312        txn.validate()
313            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
314
315        // Save
316        self.storage.transactions.upsert(txn.clone())?;
317        self.storage.transactions.save()?;
318
319        // Audit log
320        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    /// Internal method to complete reconciliation
334    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        // Update account's reconciliation info
363        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    /// Calculate the reconciled balance for an account
395    /// (starting balance + all reconciled transactions)
396    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), // $1000.00 starting balance
434        );
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); // $950.00
448
449        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        // Add some transactions
466        let date = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
467
468        // Pending transaction
469        let txn1 = Transaction::new(account.id, date, Money::from_cents(-2000));
470        storage.transactions.upsert(txn1).unwrap();
471
472        // Cleared transaction
473        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        // Current cleared = 100000 (starting) + (-5000) (cleared) = 95000
490        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        // Add a cleared transaction
502        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        // Verify transaction is now reconciled
521        let updated_txn = storage.transactions.get(txn.id).unwrap().unwrap();
522        assert_eq!(updated_txn.status, TransactionStatus::Reconciled);
523
524        // Verify account reconciliation info updated
525        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        // No cleared transactions, but statement shows different balance
540        let statement_date = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
541        let statement_balance = Money::from_cents(99000); // $10.00 less than starting
542
543        let session = service
544            .start(account.id, statement_date, statement_balance)
545            .unwrap();
546
547        // Should have a discrepancy
548        let summary = service.get_summary(&session).unwrap();
549        assert!(!summary.can_complete);
550        assert_eq!(summary.difference.cents(), -1000); // Need -$10.00 adjustment
551
552        // Complete with adjustment
553        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); // Different from starting
567
568        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        // Clear the transaction
588        let cleared = service.clear_transaction(txn.id).unwrap();
589        assert_eq!(cleared.status, TransactionStatus::Cleared);
590
591        // Unclear it
592        let uncleared = service.unclear_transaction(txn.id).unwrap();
593        assert_eq!(uncleared.status, TransactionStatus::Pending);
594    }
595}