1use chrono::{NaiveDate, Utc};
7
8use crate::audit::EntityType;
9use crate::error::{EnvelopeError, EnvelopeResult};
10use crate::models::{
11 AccountId, CategoryId, Money, Split, Transaction, TransactionId, TransactionStatus,
12};
13use crate::storage::Storage;
14
15pub struct TransactionService<'a> {
17 storage: &'a Storage,
18}
19
20#[derive(Debug, Clone, Default)]
22pub struct TransactionFilter {
23 pub account_id: Option<AccountId>,
25 pub category_id: Option<CategoryId>,
27 pub start_date: Option<NaiveDate>,
29 pub end_date: Option<NaiveDate>,
31 pub status: Option<TransactionStatus>,
33 pub limit: Option<usize>,
35}
36
37impl TransactionFilter {
38 pub fn new() -> Self {
40 Self::default()
41 }
42
43 pub fn account(mut self, account_id: AccountId) -> Self {
45 self.account_id = Some(account_id);
46 self
47 }
48
49 pub fn category(mut self, category_id: CategoryId) -> Self {
51 self.category_id = Some(category_id);
52 self
53 }
54
55 pub fn date_range(mut self, start: NaiveDate, end: NaiveDate) -> Self {
57 self.start_date = Some(start);
58 self.end_date = Some(end);
59 self
60 }
61
62 pub fn status(mut self, status: TransactionStatus) -> Self {
64 self.status = Some(status);
65 self
66 }
67
68 pub fn limit(mut self, limit: usize) -> Self {
70 self.limit = Some(limit);
71 self
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct CreateTransactionInput {
78 pub account_id: AccountId,
79 pub date: NaiveDate,
80 pub amount: Money,
81 pub payee_name: Option<String>,
82 pub category_id: Option<CategoryId>,
83 pub memo: Option<String>,
84 pub status: Option<TransactionStatus>,
85}
86
87impl<'a> TransactionService<'a> {
88 pub fn new(storage: &'a Storage) -> Self {
90 Self { storage }
91 }
92
93 pub fn create(&self, input: CreateTransactionInput) -> EnvelopeResult<Transaction> {
95 let account = self
97 .storage
98 .accounts
99 .get(input.account_id)?
100 .ok_or_else(|| EnvelopeError::account_not_found(input.account_id.to_string()))?;
101
102 if account.archived {
103 return Err(EnvelopeError::Validation(
104 "Cannot add transactions to an archived account".into(),
105 ));
106 }
107
108 if let Some(cat_id) = input.category_id {
110 self.storage
111 .categories
112 .get_category(cat_id)?
113 .ok_or_else(|| EnvelopeError::category_not_found(cat_id.to_string()))?;
114 }
115
116 let mut txn = Transaction::new(input.account_id, input.date, input.amount);
118
119 if let Some(payee_name) = input.payee_name {
120 txn.payee_name = payee_name.trim().to_string();
121
122 if !txn.payee_name.is_empty() {
124 let payee = self.storage.payees.get_or_create(&txn.payee_name)?;
125 txn.payee_id = Some(payee.id);
126 }
127 }
128
129 txn.category_id = input.category_id;
130
131 if let Some(memo) = input.memo {
132 txn.memo = memo;
133 }
134
135 if let Some(status) = input.status {
136 txn.status = status;
137 }
138
139 txn.validate()
141 .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
142
143 self.storage.transactions.upsert(txn.clone())?;
145 self.storage.transactions.save()?;
146
147 self.storage.payees.save()?;
149
150 self.storage.log_create(
152 EntityType::Transaction,
153 txn.id.to_string(),
154 Some(format!("{} {}", txn.date, txn.payee_name)),
155 &txn,
156 )?;
157
158 Ok(txn)
159 }
160
161 pub fn get(&self, id: TransactionId) -> EnvelopeResult<Option<Transaction>> {
163 self.storage.transactions.get(id)
164 }
165
166 pub fn find(&self, identifier: &str) -> EnvelopeResult<Option<Transaction>> {
168 if let Ok(id) = identifier.parse::<TransactionId>() {
169 return self.storage.transactions.get(id);
170 }
171 Ok(None)
172 }
173
174 pub fn list(&self, filter: TransactionFilter) -> EnvelopeResult<Vec<Transaction>> {
176 let mut transactions = if let Some(account_id) = filter.account_id {
177 self.storage.transactions.get_by_account(account_id)?
178 } else if let Some(category_id) = filter.category_id {
179 self.storage.transactions.get_by_category(category_id)?
180 } else if let (Some(start), Some(end)) = (filter.start_date, filter.end_date) {
181 self.storage.transactions.get_by_date_range(start, end)?
182 } else {
183 self.storage.transactions.get_all()?
184 };
185
186 if let Some(start) = filter.start_date {
188 transactions.retain(|t| t.date >= start);
189 }
190 if let Some(end) = filter.end_date {
191 transactions.retain(|t| t.date <= end);
192 }
193 if let Some(status) = filter.status {
194 transactions.retain(|t| t.status == status);
195 }
196
197 if let Some(limit) = filter.limit {
199 transactions.truncate(limit);
200 }
201
202 Ok(transactions)
203 }
204
205 pub fn list_for_account(&self, account_id: AccountId) -> EnvelopeResult<Vec<Transaction>> {
207 self.storage.transactions.get_by_account(account_id)
208 }
209
210 pub fn list_for_category(&self, category_id: CategoryId) -> EnvelopeResult<Vec<Transaction>> {
212 self.storage.transactions.get_by_category(category_id)
213 }
214
215 pub fn update(
217 &self,
218 id: TransactionId,
219 date: Option<NaiveDate>,
220 amount: Option<Money>,
221 payee_name: Option<String>,
222 category_id: Option<Option<CategoryId>>,
223 memo: Option<String>,
224 ) -> EnvelopeResult<Transaction> {
225 let mut txn = self
226 .storage
227 .transactions
228 .get(id)?
229 .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
230
231 if txn.is_locked() {
233 return Err(EnvelopeError::Locked(format!(
234 "Transaction {} is reconciled and cannot be edited. Unlock it first.",
235 id
236 )));
237 }
238
239 let before = txn.clone();
240
241 if let Some(new_date) = date {
243 txn.date = new_date;
244 }
245
246 if let Some(new_amount) = amount {
247 txn.amount = new_amount;
248 }
249
250 if let Some(new_payee_name) = payee_name {
251 txn.payee_name = new_payee_name.trim().to_string();
252 if !txn.payee_name.is_empty() {
253 let payee = self.storage.payees.get_or_create(&txn.payee_name)?;
254 txn.payee_id = Some(payee.id);
255 } else {
256 txn.payee_id = None;
257 }
258 }
259
260 if let Some(new_cat_id) = category_id {
265 if let Some(cat_id) = new_cat_id {
266 self.storage
268 .categories
269 .get_category(cat_id)?
270 .ok_or_else(|| EnvelopeError::category_not_found(cat_id.to_string()))?;
271 }
272 txn.category_id = new_cat_id;
273 if new_cat_id.is_some() {
275 txn.splits.clear();
276 }
277 }
278
279 if let Some(new_memo) = memo {
280 txn.memo = new_memo;
281 }
282
283 txn.updated_at = Utc::now();
284
285 txn.validate()
287 .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
288
289 self.storage.transactions.upsert(txn.clone())?;
291 self.storage.transactions.save()?;
292 self.storage.payees.save()?;
293
294 let mut changes = Vec::new();
296 if before.date != txn.date {
297 changes.push(format!("date: {} -> {}", before.date, txn.date));
298 }
299 if before.amount != txn.amount {
300 changes.push(format!("amount: {} -> {}", before.amount, txn.amount));
301 }
302 if before.payee_name != txn.payee_name {
303 changes.push(format!(
304 "payee: '{}' -> '{}'",
305 before.payee_name, txn.payee_name
306 ));
307 }
308 if before.category_id != txn.category_id {
309 changes.push(format!(
310 "category: {:?} -> {:?}",
311 before.category_id, txn.category_id
312 ));
313 }
314 if before.memo != txn.memo {
315 changes.push("memo changed".to_string());
316 }
317
318 let diff = if changes.is_empty() {
319 None
320 } else {
321 Some(changes.join(", "))
322 };
323
324 self.storage.log_update(
326 EntityType::Transaction,
327 txn.id.to_string(),
328 Some(format!("{} {}", txn.date, txn.payee_name)),
329 &before,
330 &txn,
331 diff,
332 )?;
333
334 Ok(txn)
335 }
336
337 pub fn delete(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
339 let txn = self
340 .storage
341 .transactions
342 .get(id)?
343 .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
344
345 if txn.is_locked() {
347 return Err(EnvelopeError::Locked(format!(
348 "Transaction {} is reconciled and cannot be deleted. Unlock it first.",
349 id
350 )));
351 }
352
353 if let Some(linked_id) = txn.transfer_transaction_id {
355 if let Some(linked_txn) = self.storage.transactions.get(linked_id)? {
357 if linked_txn.is_locked() {
358 return Err(EnvelopeError::Locked(format!(
359 "Linked transfer transaction {} is reconciled and cannot be deleted.",
360 linked_id
361 )));
362 }
363 self.storage.transactions.delete(linked_id)?;
364 self.storage.log_delete(
365 EntityType::Transaction,
366 linked_id.to_string(),
367 Some(format!(
368 "{} {} (linked)",
369 linked_txn.date, linked_txn.payee_name
370 )),
371 &linked_txn,
372 )?;
373 }
374 }
375
376 self.storage.transactions.delete(id)?;
378 self.storage.transactions.save()?;
379
380 self.storage.log_delete(
382 EntityType::Transaction,
383 id.to_string(),
384 Some(format!("{} {}", txn.date, txn.payee_name)),
385 &txn,
386 )?;
387
388 Ok(txn)
389 }
390
391 pub fn set_status(
393 &self,
394 id: TransactionId,
395 status: TransactionStatus,
396 ) -> EnvelopeResult<Transaction> {
397 let mut txn = self
398 .storage
399 .transactions
400 .get(id)?
401 .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
402
403 if txn.is_locked() && status != TransactionStatus::Reconciled {
405 return Err(EnvelopeError::Locked(format!(
406 "Transaction {} is reconciled. Unlock it before changing status.",
407 id
408 )));
409 }
410
411 let before = txn.clone();
412 txn.set_status(status);
413
414 self.storage.transactions.upsert(txn.clone())?;
416 self.storage.transactions.save()?;
417
418 self.storage.log_update(
420 EntityType::Transaction,
421 txn.id.to_string(),
422 Some(format!("{} {}", txn.date, txn.payee_name)),
423 &before,
424 &txn,
425 Some(format!("status: {} -> {}", before.status, txn.status)),
426 )?;
427
428 Ok(txn)
429 }
430
431 pub fn clear(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
433 self.set_status(id, TransactionStatus::Cleared)
434 }
435
436 pub fn unclear(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
438 self.set_status(id, TransactionStatus::Pending)
439 }
440
441 pub fn unlock(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
446 let mut txn = self
447 .storage
448 .transactions
449 .get(id)?
450 .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
451
452 if !txn.is_locked() {
453 return Err(EnvelopeError::Validation(format!(
454 "Transaction {} is not locked",
455 id
456 )));
457 }
458
459 let before = txn.clone();
460 txn.set_status(TransactionStatus::Cleared);
461
462 self.storage.transactions.upsert(txn.clone())?;
464 self.storage.transactions.save()?;
465
466 self.storage.log_update(
468 EntityType::Transaction,
469 txn.id.to_string(),
470 Some(format!("{} {}", txn.date, txn.payee_name)),
471 &before,
472 &txn,
473 Some("UNLOCKED: reconciled -> cleared".to_string()),
474 )?;
475
476 Ok(txn)
477 }
478
479 pub fn add_split(
485 &self,
486 id: TransactionId,
487 category_id: CategoryId,
488 amount: Money,
489 memo: Option<String>,
490 ) -> EnvelopeResult<Transaction> {
491 let mut txn = self
492 .storage
493 .transactions
494 .get(id)?
495 .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
496
497 if txn.is_locked() {
498 return Err(EnvelopeError::Locked(format!(
499 "Transaction {} is reconciled and cannot be edited.",
500 id
501 )));
502 }
503
504 self.storage
506 .categories
507 .get_category(category_id)?
508 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
509
510 let before = txn.clone();
511
512 let split = if let Some(memo) = memo {
514 Split::with_memo(category_id, amount, memo)
515 } else {
516 Split::new(category_id, amount)
517 };
518 txn.add_split(split);
519
520 txn.validate()
522 .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
523
524 self.storage.transactions.upsert(txn.clone())?;
526 self.storage.transactions.save()?;
527
528 self.storage.log_update(
530 EntityType::Transaction,
531 txn.id.to_string(),
532 Some(format!("{} {}", txn.date, txn.payee_name)),
533 &before,
534 &txn,
535 Some(format!(
536 "added split: {} to category {}",
537 amount, category_id
538 )),
539 )?;
540
541 Ok(txn)
542 }
543
544 pub fn set_splits(&self, id: TransactionId, splits: Vec<Split>) -> EnvelopeResult<Transaction> {
549 let mut txn = self
550 .storage
551 .transactions
552 .get(id)?
553 .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
554
555 if txn.is_locked() {
556 return Err(EnvelopeError::Locked(format!(
557 "Transaction {} is reconciled and cannot be edited.",
558 id
559 )));
560 }
561
562 for split in &splits {
564 self.storage
565 .categories
566 .get_category(split.category_id)?
567 .ok_or_else(|| EnvelopeError::category_not_found(split.category_id.to_string()))?;
568 }
569
570 let before = txn.clone();
571
572 txn.splits = splits;
574 txn.category_id = None; txn.updated_at = Utc::now();
576
577 txn.validate()
579 .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
580
581 self.storage.transactions.upsert(txn.clone())?;
583 self.storage.transactions.save()?;
584
585 self.storage.log_update(
587 EntityType::Transaction,
588 txn.id.to_string(),
589 Some(format!("{} {}", txn.date, txn.payee_name)),
590 &before,
591 &txn,
592 Some(format!("set {} splits", txn.splits.len())),
593 )?;
594
595 Ok(txn)
596 }
597
598 pub fn clear_splits(&self, id: TransactionId) -> EnvelopeResult<Transaction> {
600 let mut txn = self
601 .storage
602 .transactions
603 .get(id)?
604 .ok_or_else(|| EnvelopeError::transaction_not_found(id.to_string()))?;
605
606 if txn.is_locked() {
607 return Err(EnvelopeError::Locked(format!(
608 "Transaction {} is reconciled and cannot be edited.",
609 id
610 )));
611 }
612
613 if txn.splits.is_empty() {
614 return Ok(txn);
615 }
616
617 let before = txn.clone();
618 txn.splits.clear();
619 txn.updated_at = Utc::now();
620
621 self.storage.transactions.upsert(txn.clone())?;
623 self.storage.transactions.save()?;
624
625 self.storage.log_update(
627 EntityType::Transaction,
628 txn.id.to_string(),
629 Some(format!("{} {}", txn.date, txn.payee_name)),
630 &before,
631 &txn,
632 Some("cleared all splits".to_string()),
633 )?;
634
635 Ok(txn)
636 }
637
638 pub fn learn_from_transaction(&self, txn: &Transaction) -> EnvelopeResult<()> {
640 if let (Some(payee_id), Some(category_id)) = (txn.payee_id, txn.category_id) {
641 if let Some(mut payee) = self.storage.payees.get(payee_id)? {
642 payee.record_category_usage(category_id);
643 self.storage.payees.upsert(payee)?;
644 self.storage.payees.save()?;
645 }
646 }
647 Ok(())
648 }
649
650 pub fn suggest_category(&self, payee_name: &str) -> EnvelopeResult<Option<CategoryId>> {
652 if let Some(payee) = self.storage.payees.get_by_name(payee_name)? {
653 Ok(payee.suggested_category())
654 } else {
655 Ok(None)
656 }
657 }
658
659 pub fn count(&self) -> EnvelopeResult<usize> {
661 self.storage.transactions.count()
662 }
663
664 pub fn get_uncleared(&self, account_id: AccountId) -> EnvelopeResult<Vec<Transaction>> {
666 let transactions = self.storage.transactions.get_by_account(account_id)?;
667 Ok(transactions
668 .into_iter()
669 .filter(|t| t.status == TransactionStatus::Pending)
670 .collect())
671 }
672
673 pub fn get_cleared(&self, account_id: AccountId) -> EnvelopeResult<Vec<Transaction>> {
675 let transactions = self.storage.transactions.get_by_account(account_id)?;
676 Ok(transactions
677 .into_iter()
678 .filter(|t| t.status == TransactionStatus::Cleared)
679 .collect())
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use crate::config::paths::EnvelopePaths;
687 use crate::models::{Account, AccountType, Category, CategoryGroup};
688 use tempfile::TempDir;
689
690 fn create_test_storage() -> (TempDir, Storage) {
691 let temp_dir = TempDir::new().unwrap();
692 let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
693 let mut storage = Storage::new(paths).unwrap();
694 storage.load_all().unwrap();
695 (temp_dir, storage)
696 }
697
698 fn setup_test_data(storage: &Storage) -> (AccountId, CategoryId) {
699 let account = Account::new("Checking", AccountType::Checking);
701 let account_id = account.id;
702 storage.accounts.upsert(account).unwrap();
703 storage.accounts.save().unwrap();
704
705 let group = CategoryGroup::new("Test Group");
707 storage.categories.upsert_group(group.clone()).unwrap();
708
709 let category = Category::new("Groceries", group.id);
710 let category_id = category.id;
711 storage.categories.upsert_category(category).unwrap();
712 storage.categories.save().unwrap();
713
714 (account_id, category_id)
715 }
716
717 #[test]
718 fn test_create_transaction() {
719 let (_temp_dir, storage) = create_test_storage();
720 let (account_id, category_id) = setup_test_data(&storage);
721 let service = TransactionService::new(&storage);
722
723 let input = CreateTransactionInput {
724 account_id,
725 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
726 amount: Money::from_cents(-5000),
727 payee_name: Some("Test Store".to_string()),
728 category_id: Some(category_id),
729 memo: Some("Test purchase".to_string()),
730 status: None,
731 };
732
733 let txn = service.create(input).unwrap();
734
735 assert_eq!(txn.amount.cents(), -5000);
736 assert_eq!(txn.payee_name, "Test Store");
737 assert_eq!(txn.category_id, Some(category_id));
738 assert_eq!(txn.status, TransactionStatus::Pending);
739 }
740
741 #[test]
742 fn test_list_transactions() {
743 let (_temp_dir, storage) = create_test_storage();
744 let (account_id, category_id) = setup_test_data(&storage);
745 let service = TransactionService::new(&storage);
746
747 for i in 1..=3 {
749 let input = CreateTransactionInput {
750 account_id,
751 date: NaiveDate::from_ymd_opt(2025, 1, i as u32).unwrap(),
752 amount: Money::from_cents(-1000 * i),
753 payee_name: Some(format!("Store {}", i)),
754 category_id: Some(category_id),
755 memo: None,
756 status: None,
757 };
758 service.create(input).unwrap();
759 }
760
761 let transactions = service.list(TransactionFilter::new()).unwrap();
762 assert_eq!(transactions.len(), 3);
763
764 let filtered = service
766 .list(TransactionFilter::new().account(account_id))
767 .unwrap();
768 assert_eq!(filtered.len(), 3);
769
770 let limited = service.list(TransactionFilter::new().limit(2)).unwrap();
772 assert_eq!(limited.len(), 2);
773 }
774
775 #[test]
776 fn test_update_transaction() {
777 let (_temp_dir, storage) = create_test_storage();
778 let (account_id, _category_id) = setup_test_data(&storage);
779 let service = TransactionService::new(&storage);
780
781 let input = CreateTransactionInput {
782 account_id,
783 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
784 amount: Money::from_cents(-5000),
785 payee_name: Some("Original Store".to_string()),
786 category_id: None,
787 memo: None,
788 status: None,
789 };
790
791 let txn = service.create(input).unwrap();
792
793 let updated = service
795 .update(
796 txn.id,
797 None,
798 Some(Money::from_cents(-7500)),
799 Some("Updated Store".to_string()),
800 None,
801 Some("Updated memo".to_string()),
802 )
803 .unwrap();
804
805 assert_eq!(updated.amount.cents(), -7500);
806 assert_eq!(updated.payee_name, "Updated Store");
807 assert_eq!(updated.memo, "Updated memo");
808 }
809
810 #[test]
811 fn test_delete_transaction() {
812 let (_temp_dir, storage) = create_test_storage();
813 let (account_id, _category_id) = setup_test_data(&storage);
814 let service = TransactionService::new(&storage);
815
816 let input = CreateTransactionInput {
817 account_id,
818 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
819 amount: Money::from_cents(-5000),
820 payee_name: None,
821 category_id: None,
822 memo: None,
823 status: None,
824 };
825
826 let txn = service.create(input).unwrap();
827 assert_eq!(service.count().unwrap(), 1);
828
829 service.delete(txn.id).unwrap();
830 assert_eq!(service.count().unwrap(), 0);
831 }
832
833 #[test]
834 fn test_status_transitions() {
835 let (_temp_dir, storage) = create_test_storage();
836 let (account_id, _category_id) = setup_test_data(&storage);
837 let service = TransactionService::new(&storage);
838
839 let input = CreateTransactionInput {
840 account_id,
841 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
842 amount: Money::from_cents(-5000),
843 payee_name: None,
844 category_id: None,
845 memo: None,
846 status: None,
847 };
848
849 let txn = service.create(input).unwrap();
850 assert_eq!(txn.status, TransactionStatus::Pending);
851
852 let cleared = service.clear(txn.id).unwrap();
854 assert_eq!(cleared.status, TransactionStatus::Cleared);
855
856 let uncleared = service.unclear(txn.id).unwrap();
858 assert_eq!(uncleared.status, TransactionStatus::Pending);
859 }
860
861 #[test]
862 fn test_locked_transaction() {
863 let (_temp_dir, storage) = create_test_storage();
864 let (account_id, _category_id) = setup_test_data(&storage);
865 let service = TransactionService::new(&storage);
866
867 let input = CreateTransactionInput {
868 account_id,
869 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
870 amount: Money::from_cents(-5000),
871 payee_name: None,
872 category_id: None,
873 memo: None,
874 status: None,
875 };
876
877 let txn = service.create(input).unwrap();
878
879 let reconciled = service
881 .set_status(txn.id, TransactionStatus::Reconciled)
882 .unwrap();
883 assert!(reconciled.is_locked());
884
885 let update_result = service.update(
887 txn.id,
888 None,
889 Some(Money::from_cents(-7500)),
890 None,
891 None,
892 None,
893 );
894 assert!(matches!(update_result, Err(EnvelopeError::Locked(_))));
895
896 let delete_result = service.delete(txn.id);
898 assert!(matches!(delete_result, Err(EnvelopeError::Locked(_))));
899
900 let unlocked = service.unlock(txn.id).unwrap();
902 assert!(!unlocked.is_locked());
903
904 let updated = service
906 .update(
907 txn.id,
908 None,
909 Some(Money::from_cents(-7500)),
910 None,
911 None,
912 None,
913 )
914 .unwrap();
915 assert_eq!(updated.amount.cents(), -7500);
916 }
917
918 #[test]
919 fn test_split_transactions() {
920 let (_temp_dir, storage) = create_test_storage();
921 let (account_id, category_id) = setup_test_data(&storage);
922 let service = TransactionService::new(&storage);
923
924 let category2 = Category::new(
926 "Household",
927 storage
928 .categories
929 .get_all_groups()
930 .unwrap()
931 .first()
932 .unwrap()
933 .id,
934 );
935 let category2_id = category2.id;
936 storage.categories.upsert_category(category2).unwrap();
937 storage.categories.save().unwrap();
938
939 let input = CreateTransactionInput {
940 account_id,
941 date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
942 amount: Money::from_cents(-10000),
943 payee_name: Some("Multi-Store".to_string()),
944 category_id: None,
945 memo: None,
946 status: None,
947 };
948
949 let txn = service.create(input).unwrap();
950
951 let splits = vec![
953 Split::new(category_id, Money::from_cents(-6000)),
954 Split::with_memo(
955 category2_id,
956 Money::from_cents(-4000),
957 "Cleaning supplies".to_string(),
958 ),
959 ];
960
961 let final_txn = service.set_splits(txn.id, splits).unwrap();
962
963 assert!(final_txn.is_split());
964 assert_eq!(final_txn.splits.len(), 2);
965 assert!(final_txn.validate().is_ok());
966 }
967}