envelope_cli/services/
transfer.rs

1//! Transfer service
2//!
3//! Provides business logic for transfers between accounts.
4//! Transfers create linked transaction pairs - an outflow from the source
5//! account and an inflow to the destination account.
6
7use chrono::{NaiveDate, Utc};
8
9use crate::audit::EntityType;
10use crate::error::{EnvelopeError, EnvelopeResult};
11use crate::models::{Account, AccountId, Money, Transaction, TransactionId};
12
13// Note: Transaction model uses transfer_transaction_id to link paired transfer transactions.
14// The target account can be determined by looking up the linked transaction.
15use crate::storage::Storage;
16
17/// Service for managing transfers between accounts
18pub struct TransferService<'a> {
19    storage: &'a Storage,
20}
21
22/// Result of creating a transfer
23#[derive(Debug, Clone)]
24pub struct TransferResult {
25    /// The outflow transaction (from source account)
26    pub from_transaction: Transaction,
27    /// The inflow transaction (to destination account)
28    pub to_transaction: Transaction,
29}
30
31impl<'a> TransferService<'a> {
32    /// Create a new transfer service
33    pub fn new(storage: &'a Storage) -> Self {
34        Self { storage }
35    }
36
37    /// Create a transfer between two accounts
38    ///
39    /// This creates two linked transactions:
40    /// - An outflow (negative amount) from the source account
41    /// - An inflow (positive amount) to the destination account
42    pub fn create_transfer(
43        &self,
44        from_account_id: AccountId,
45        to_account_id: AccountId,
46        amount: Money,
47        date: NaiveDate,
48        memo: Option<String>,
49    ) -> EnvelopeResult<TransferResult> {
50        // Validate amount is positive
51        if amount.is_zero() {
52            return Err(EnvelopeError::Validation(
53                "Transfer amount must be non-zero".into(),
54            ));
55        }
56        if amount.is_negative() {
57            return Err(EnvelopeError::Validation(
58                "Transfer amount must be positive".into(),
59            ));
60        }
61
62        // Can't transfer to the same account
63        if from_account_id == to_account_id {
64            return Err(EnvelopeError::Validation(
65                "Cannot transfer to the same account".into(),
66            ));
67        }
68
69        // Verify both accounts exist and are not archived
70        let from_account = self.get_active_account(from_account_id)?;
71        let to_account = self.get_active_account(to_account_id)?;
72
73        // Create the outflow transaction (from source)
74        let mut from_txn = Transaction::new(from_account_id, date, -amount);
75        from_txn.payee_name = format!("Transfer to {}", to_account.name);
76        if let Some(m) = &memo {
77            from_txn.memo.clone_from(m);
78        }
79
80        // Create the inflow transaction (to destination)
81        let mut to_txn = Transaction::new(to_account_id, date, amount);
82        to_txn.payee_name = format!("Transfer from {}", from_account.name);
83        if let Some(ref m) = memo {
84            to_txn.memo = m.clone();
85        }
86
87        // Link them together
88        from_txn.transfer_transaction_id = Some(to_txn.id);
89        to_txn.transfer_transaction_id = Some(from_txn.id);
90
91        // Validate both transactions
92        from_txn
93            .validate()
94            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
95        to_txn
96            .validate()
97            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
98
99        // Save both transactions
100        self.storage.transactions.upsert(from_txn.clone())?;
101        self.storage.transactions.upsert(to_txn.clone())?;
102        self.storage.transactions.save()?;
103
104        // Audit log for both
105        self.storage.log_create(
106            EntityType::Transaction,
107            from_txn.id.to_string(),
108            Some(format!("Transfer to {}", to_account.name)),
109            &from_txn,
110        )?;
111
112        self.storage.log_create(
113            EntityType::Transaction,
114            to_txn.id.to_string(),
115            Some(format!("Transfer from {}", from_account.name)),
116            &to_txn,
117        )?;
118
119        Ok(TransferResult {
120            from_transaction: from_txn,
121            to_transaction: to_txn,
122        })
123    }
124
125    /// Get the linked transaction for a transfer
126    pub fn get_linked_transaction(
127        &self,
128        transaction_id: TransactionId,
129    ) -> EnvelopeResult<Option<Transaction>> {
130        let txn = self
131            .storage
132            .transactions
133            .get(transaction_id)?
134            .ok_or_else(|| EnvelopeError::transaction_not_found(transaction_id.to_string()))?;
135
136        if let Some(linked_id) = txn.transfer_transaction_id {
137            self.storage.transactions.get(linked_id)
138        } else {
139            Ok(None)
140        }
141    }
142
143    /// Update a transfer's amount
144    ///
145    /// This updates both the source and destination transactions to maintain consistency.
146    pub fn update_transfer_amount(
147        &self,
148        transaction_id: TransactionId,
149        new_amount: Money,
150    ) -> EnvelopeResult<TransferResult> {
151        if new_amount.is_zero() {
152            return Err(EnvelopeError::Validation(
153                "Transfer amount must be non-zero".into(),
154            ));
155        }
156
157        let mut txn = self
158            .storage
159            .transactions
160            .get(transaction_id)?
161            .ok_or_else(|| EnvelopeError::transaction_not_found(transaction_id.to_string()))?;
162
163        if !txn.is_transfer() {
164            return Err(EnvelopeError::Validation(
165                "Transaction is not a transfer".into(),
166            ));
167        }
168
169        if txn.is_locked() {
170            return Err(EnvelopeError::Locked(format!(
171                "Transaction {} is reconciled and cannot be edited",
172                transaction_id
173            )));
174        }
175
176        let linked_id = txn.transfer_transaction_id.ok_or_else(|| {
177            EnvelopeError::Validation("Transfer has no linked transaction".into())
178        })?;
179
180        let mut linked_txn = self
181            .storage
182            .transactions
183            .get(linked_id)?
184            .ok_or_else(|| EnvelopeError::transaction_not_found(linked_id.to_string()))?;
185
186        if linked_txn.is_locked() {
187            return Err(EnvelopeError::Locked(format!(
188                "Linked transaction {} is reconciled and cannot be edited",
189                linked_id
190            )));
191        }
192
193        let txn_before = txn.clone();
194        let linked_before = linked_txn.clone();
195
196        // Determine which transaction is the outflow (negative) and which is the inflow (positive)
197        let amount = new_amount.abs();
198        if txn.amount.is_negative() {
199            // txn is the outflow
200            txn.amount = -amount;
201            linked_txn.amount = amount;
202        } else {
203            // txn is the inflow
204            txn.amount = amount;
205            linked_txn.amount = -amount;
206        }
207
208        txn.updated_at = Utc::now();
209        linked_txn.updated_at = Utc::now();
210
211        // Validate both
212        txn.validate()
213            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
214        linked_txn
215            .validate()
216            .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
217
218        // Save both
219        self.storage.transactions.upsert(txn.clone())?;
220        self.storage.transactions.upsert(linked_txn.clone())?;
221        self.storage.transactions.save()?;
222
223        // Audit log both
224        self.storage.log_update(
225            EntityType::Transaction,
226            txn.id.to_string(),
227            Some(txn.payee_name.clone()),
228            &txn_before,
229            &txn,
230            Some(format!(
231                "transfer amount: {} -> {}",
232                txn_before.amount, txn.amount
233            )),
234        )?;
235
236        self.storage.log_update(
237            EntityType::Transaction,
238            linked_txn.id.to_string(),
239            Some(linked_txn.payee_name.clone()),
240            &linked_before,
241            &linked_txn,
242            Some(format!(
243                "transfer amount: {} -> {}",
244                linked_before.amount, linked_txn.amount
245            )),
246        )?;
247
248        // Return in consistent order (outflow first)
249        if txn.amount.is_negative() {
250            Ok(TransferResult {
251                from_transaction: txn,
252                to_transaction: linked_txn,
253            })
254        } else {
255            Ok(TransferResult {
256                from_transaction: linked_txn,
257                to_transaction: txn,
258            })
259        }
260    }
261
262    /// Update a transfer's date
263    ///
264    /// This updates both the source and destination transactions to maintain consistency.
265    pub fn update_transfer_date(
266        &self,
267        transaction_id: TransactionId,
268        new_date: NaiveDate,
269    ) -> EnvelopeResult<TransferResult> {
270        let mut txn = self
271            .storage
272            .transactions
273            .get(transaction_id)?
274            .ok_or_else(|| EnvelopeError::transaction_not_found(transaction_id.to_string()))?;
275
276        if !txn.is_transfer() {
277            return Err(EnvelopeError::Validation(
278                "Transaction is not a transfer".into(),
279            ));
280        }
281
282        if txn.is_locked() {
283            return Err(EnvelopeError::Locked(format!(
284                "Transaction {} is reconciled and cannot be edited",
285                transaction_id
286            )));
287        }
288
289        let linked_id = txn.transfer_transaction_id.ok_or_else(|| {
290            EnvelopeError::Validation("Transfer has no linked transaction".into())
291        })?;
292
293        let mut linked_txn = self
294            .storage
295            .transactions
296            .get(linked_id)?
297            .ok_or_else(|| EnvelopeError::transaction_not_found(linked_id.to_string()))?;
298
299        if linked_txn.is_locked() {
300            return Err(EnvelopeError::Locked(format!(
301                "Linked transaction {} is reconciled and cannot be edited",
302                linked_id
303            )));
304        }
305
306        let txn_before = txn.clone();
307        let linked_before = linked_txn.clone();
308
309        txn.date = new_date;
310        linked_txn.date = new_date;
311        txn.updated_at = Utc::now();
312        linked_txn.updated_at = Utc::now();
313
314        // Save both
315        self.storage.transactions.upsert(txn.clone())?;
316        self.storage.transactions.upsert(linked_txn.clone())?;
317        self.storage.transactions.save()?;
318
319        // Audit log both
320        self.storage.log_update(
321            EntityType::Transaction,
322            txn.id.to_string(),
323            Some(txn.payee_name.clone()),
324            &txn_before,
325            &txn,
326            Some(format!("date: {} -> {}", txn_before.date, txn.date)),
327        )?;
328
329        self.storage.log_update(
330            EntityType::Transaction,
331            linked_txn.id.to_string(),
332            Some(linked_txn.payee_name.clone()),
333            &linked_before,
334            &linked_txn,
335            Some(format!(
336                "date: {} -> {}",
337                linked_before.date, linked_txn.date
338            )),
339        )?;
340
341        // Return in consistent order (outflow first)
342        if txn.amount.is_negative() {
343            Ok(TransferResult {
344                from_transaction: txn,
345                to_transaction: linked_txn,
346            })
347        } else {
348            Ok(TransferResult {
349                from_transaction: linked_txn,
350                to_transaction: txn,
351            })
352        }
353    }
354
355    /// Delete a transfer (both transactions)
356    pub fn delete_transfer(&self, transaction_id: TransactionId) -> EnvelopeResult<TransferResult> {
357        let txn = self
358            .storage
359            .transactions
360            .get(transaction_id)?
361            .ok_or_else(|| EnvelopeError::transaction_not_found(transaction_id.to_string()))?;
362
363        if !txn.is_transfer() {
364            return Err(EnvelopeError::Validation(
365                "Transaction is not a transfer".into(),
366            ));
367        }
368
369        if txn.is_locked() {
370            return Err(EnvelopeError::Locked(format!(
371                "Transaction {} is reconciled and cannot be deleted",
372                transaction_id
373            )));
374        }
375
376        let linked_id = txn.transfer_transaction_id.ok_or_else(|| {
377            EnvelopeError::Validation("Transfer has no linked transaction".into())
378        })?;
379
380        let linked_txn = self
381            .storage
382            .transactions
383            .get(linked_id)?
384            .ok_or_else(|| EnvelopeError::transaction_not_found(linked_id.to_string()))?;
385
386        if linked_txn.is_locked() {
387            return Err(EnvelopeError::Locked(format!(
388                "Linked transaction {} is reconciled and cannot be deleted",
389                linked_id
390            )));
391        }
392
393        // Delete both
394        self.storage.transactions.delete(txn.id)?;
395        self.storage.transactions.delete(linked_txn.id)?;
396        self.storage.transactions.save()?;
397
398        // Audit log both
399        self.storage.log_delete(
400            EntityType::Transaction,
401            txn.id.to_string(),
402            Some(txn.payee_name.clone()),
403            &txn,
404        )?;
405
406        self.storage.log_delete(
407            EntityType::Transaction,
408            linked_txn.id.to_string(),
409            Some(linked_txn.payee_name.clone()),
410            &linked_txn,
411        )?;
412
413        // Return in consistent order (outflow first)
414        if txn.amount.is_negative() {
415            Ok(TransferResult {
416                from_transaction: txn,
417                to_transaction: linked_txn,
418            })
419        } else {
420            Ok(TransferResult {
421                from_transaction: linked_txn,
422                to_transaction: txn,
423            })
424        }
425    }
426
427    /// Get an active (non-archived) account or return an error
428    fn get_active_account(&self, account_id: AccountId) -> EnvelopeResult<Account> {
429        let account = self
430            .storage
431            .accounts
432            .get(account_id)?
433            .ok_or_else(|| EnvelopeError::account_not_found(account_id.to_string()))?;
434
435        if account.archived {
436            return Err(EnvelopeError::Validation(format!(
437                "Account '{}' is archived and cannot be used for transfers",
438                account.name
439            )));
440        }
441
442        Ok(account)
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use crate::config::paths::EnvelopePaths;
450    use crate::models::{Account, AccountType};
451    use tempfile::TempDir;
452
453    fn create_test_storage() -> (TempDir, Storage) {
454        let temp_dir = TempDir::new().unwrap();
455        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
456        let mut storage = Storage::new(paths).unwrap();
457        storage.load_all().unwrap();
458        (temp_dir, storage)
459    }
460
461    fn setup_test_accounts(storage: &Storage) -> (AccountId, AccountId) {
462        let checking = Account::new("Checking", AccountType::Checking);
463        let savings = Account::new("Savings", AccountType::Savings);
464
465        let checking_id = checking.id;
466        let savings_id = savings.id;
467
468        storage.accounts.upsert(checking).unwrap();
469        storage.accounts.upsert(savings).unwrap();
470        storage.accounts.save().unwrap();
471
472        (checking_id, savings_id)
473    }
474
475    #[test]
476    fn test_create_transfer() {
477        let (_temp_dir, storage) = create_test_storage();
478        let (checking_id, savings_id) = setup_test_accounts(&storage);
479        let service = TransferService::new(&storage);
480
481        let result = service
482            .create_transfer(
483                checking_id,
484                savings_id,
485                Money::from_cents(50000),
486                NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
487                Some("Monthly savings".to_string()),
488            )
489            .unwrap();
490
491        // Verify outflow from checking
492        assert_eq!(result.from_transaction.account_id, checking_id);
493        assert_eq!(result.from_transaction.amount.cents(), -50000);
494        assert!(result.from_transaction.is_transfer());
495
496        // Verify inflow to savings
497        assert_eq!(result.to_transaction.account_id, savings_id);
498        assert_eq!(result.to_transaction.amount.cents(), 50000);
499        assert!(result.to_transaction.is_transfer());
500
501        // Verify they're linked
502        assert_eq!(
503            result.from_transaction.transfer_transaction_id,
504            Some(result.to_transaction.id)
505        );
506        assert_eq!(
507            result.to_transaction.transfer_transaction_id,
508            Some(result.from_transaction.id)
509        );
510    }
511
512    #[test]
513    fn test_transfer_to_same_account_fails() {
514        let (_temp_dir, storage) = create_test_storage();
515        let (checking_id, _) = setup_test_accounts(&storage);
516        let service = TransferService::new(&storage);
517
518        let result = service.create_transfer(
519            checking_id,
520            checking_id,
521            Money::from_cents(50000),
522            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
523            None,
524        );
525
526        assert!(matches!(result, Err(EnvelopeError::Validation(_))));
527    }
528
529    #[test]
530    fn test_transfer_zero_amount_fails() {
531        let (_temp_dir, storage) = create_test_storage();
532        let (checking_id, savings_id) = setup_test_accounts(&storage);
533        let service = TransferService::new(&storage);
534
535        let result = service.create_transfer(
536            checking_id,
537            savings_id,
538            Money::zero(),
539            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
540            None,
541        );
542
543        assert!(matches!(result, Err(EnvelopeError::Validation(_))));
544    }
545
546    #[test]
547    fn test_update_transfer_amount() {
548        let (_temp_dir, storage) = create_test_storage();
549        let (checking_id, savings_id) = setup_test_accounts(&storage);
550        let service = TransferService::new(&storage);
551
552        let created = service
553            .create_transfer(
554                checking_id,
555                savings_id,
556                Money::from_cents(50000),
557                NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
558                None,
559            )
560            .unwrap();
561
562        let updated = service
563            .update_transfer_amount(created.from_transaction.id, Money::from_cents(75000))
564            .unwrap();
565
566        assert_eq!(updated.from_transaction.amount.cents(), -75000);
567        assert_eq!(updated.to_transaction.amount.cents(), 75000);
568    }
569
570    #[test]
571    fn test_update_transfer_date() {
572        let (_temp_dir, storage) = create_test_storage();
573        let (checking_id, savings_id) = setup_test_accounts(&storage);
574        let service = TransferService::new(&storage);
575
576        let created = service
577            .create_transfer(
578                checking_id,
579                savings_id,
580                Money::from_cents(50000),
581                NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
582                None,
583            )
584            .unwrap();
585
586        let new_date = NaiveDate::from_ymd_opt(2025, 1, 20).unwrap();
587        let updated = service
588            .update_transfer_date(created.from_transaction.id, new_date)
589            .unwrap();
590
591        assert_eq!(updated.from_transaction.date, new_date);
592        assert_eq!(updated.to_transaction.date, new_date);
593    }
594
595    #[test]
596    fn test_delete_transfer() {
597        let (_temp_dir, storage) = create_test_storage();
598        let (checking_id, savings_id) = setup_test_accounts(&storage);
599        let service = TransferService::new(&storage);
600
601        let created = service
602            .create_transfer(
603                checking_id,
604                savings_id,
605                Money::from_cents(50000),
606                NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
607                None,
608            )
609            .unwrap();
610
611        assert_eq!(storage.transactions.count().unwrap(), 2);
612
613        service
614            .delete_transfer(created.from_transaction.id)
615            .unwrap();
616
617        assert_eq!(storage.transactions.count().unwrap(), 0);
618    }
619
620    #[test]
621    fn test_get_linked_transaction() {
622        let (_temp_dir, storage) = create_test_storage();
623        let (checking_id, savings_id) = setup_test_accounts(&storage);
624        let service = TransferService::new(&storage);
625
626        let created = service
627            .create_transfer(
628                checking_id,
629                savings_id,
630                Money::from_cents(50000),
631                NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
632                None,
633            )
634            .unwrap();
635
636        let linked = service
637            .get_linked_transaction(created.from_transaction.id)
638            .unwrap()
639            .unwrap();
640
641        assert_eq!(linked.id, created.to_transaction.id);
642    }
643}