1use chrono::{NaiveDate, Utc};
8
9use crate::audit::EntityType;
10use crate::error::{EnvelopeError, EnvelopeResult};
11use crate::models::{Account, AccountId, Money, Transaction, TransactionId};
12
13use crate::storage::Storage;
16
17pub struct TransferService<'a> {
19 storage: &'a Storage,
20}
21
22#[derive(Debug, Clone)]
24pub struct TransferResult {
25 pub from_transaction: Transaction,
27 pub to_transaction: Transaction,
29}
30
31impl<'a> TransferService<'a> {
32 pub fn new(storage: &'a Storage) -> Self {
34 Self { storage }
35 }
36
37 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 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 if from_account_id == to_account_id {
64 return Err(EnvelopeError::Validation(
65 "Cannot transfer to the same account".into(),
66 ));
67 }
68
69 let from_account = self.get_active_account(from_account_id)?;
71 let to_account = self.get_active_account(to_account_id)?;
72
73 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 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 from_txn.transfer_transaction_id = Some(to_txn.id);
89 to_txn.transfer_transaction_id = Some(from_txn.id);
90
91 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 self.storage.transactions.upsert(from_txn.clone())?;
101 self.storage.transactions.upsert(to_txn.clone())?;
102 self.storage.transactions.save()?;
103
104 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 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 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 let amount = new_amount.abs();
198 if txn.amount.is_negative() {
199 txn.amount = -amount;
201 linked_txn.amount = amount;
202 } else {
203 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 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 self.storage.transactions.upsert(txn.clone())?;
220 self.storage.transactions.upsert(linked_txn.clone())?;
221 self.storage.transactions.save()?;
222
223 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 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 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 self.storage.transactions.upsert(txn.clone())?;
316 self.storage.transactions.upsert(linked_txn.clone())?;
317 self.storage.transactions.save()?;
318
319 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 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 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 self.storage.transactions.delete(txn.id)?;
395 self.storage.transactions.delete(linked_txn.id)?;
396 self.storage.transactions.save()?;
397
398 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 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 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 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 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 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}