1use crate::audit::EntityType;
7use crate::error::{EnvelopeError, EnvelopeResult};
8use crate::models::{
9 BudgetAllocation, BudgetPeriod, BudgetTarget, BudgetTargetId, CategoryBudgetSummary,
10 CategoryId, Money, TargetCadence,
11};
12use crate::services::CategoryService;
13use crate::storage::Storage;
14
15pub struct BudgetService<'a> {
17 storage: &'a Storage,
18}
19
20#[derive(Debug, Clone)]
22pub struct BudgetOverview {
23 pub period: BudgetPeriod,
24 pub total_budgeted: Money,
25 pub total_activity: Money,
26 pub total_available: Money,
27 pub available_to_budget: Money,
28 pub categories: Vec<CategoryBudgetSummary>,
29 pub expected_income: Option<Money>,
31 pub over_budget_amount: Option<Money>,
33}
34
35impl<'a> BudgetService<'a> {
36 pub fn new(storage: &'a Storage) -> Self {
38 Self { storage }
39 }
40
41 pub fn assign_to_category(
43 &self,
44 category_id: CategoryId,
45 period: &BudgetPeriod,
46 amount: Money,
47 ) -> EnvelopeResult<BudgetAllocation> {
48 let category = self
50 .storage
51 .categories
52 .get_category(category_id)?
53 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
54
55 let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
57 let before = allocation.clone();
58
59 allocation.set_budgeted(amount);
60
61 allocation
63 .validate()
64 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
65
66 self.storage.budget.upsert(allocation.clone())?;
68 self.storage.budget.save()?;
69
70 self.storage.log_update(
72 EntityType::BudgetAllocation,
73 format!("{}:{}", category_id, period),
74 Some(category.name),
75 &before,
76 &allocation,
77 Some(format!(
78 "budgeted: {} -> {}",
79 before.budgeted, allocation.budgeted
80 )),
81 )?;
82
83 Ok(allocation)
84 }
85
86 pub fn add_to_category(
88 &self,
89 category_id: CategoryId,
90 period: &BudgetPeriod,
91 amount: Money,
92 ) -> EnvelopeResult<BudgetAllocation> {
93 let category = self
95 .storage
96 .categories
97 .get_category(category_id)?
98 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
99
100 let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
102 let before = allocation.clone();
103
104 allocation.add_budgeted(amount);
105
106 allocation
108 .validate()
109 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
110
111 self.storage.budget.upsert(allocation.clone())?;
113 self.storage.budget.save()?;
114
115 self.storage.log_update(
117 EntityType::BudgetAllocation,
118 format!("{}:{}", category_id, period),
119 Some(category.name),
120 &before,
121 &allocation,
122 Some(format!(
123 "budgeted: {} -> {} (+{})",
124 before.budgeted, allocation.budgeted, amount
125 )),
126 )?;
127
128 Ok(allocation)
129 }
130
131 pub fn move_between_categories(
133 &self,
134 from_category_id: CategoryId,
135 to_category_id: CategoryId,
136 period: &BudgetPeriod,
137 amount: Money,
138 ) -> EnvelopeResult<()> {
139 if amount.is_zero() {
140 return Ok(());
141 }
142
143 if amount.is_negative() {
144 return Err(EnvelopeError::Budget(
145 "Amount to move must be positive".into(),
146 ));
147 }
148
149 let from_category = self
151 .storage
152 .categories
153 .get_category(from_category_id)?
154 .ok_or_else(|| EnvelopeError::category_not_found(from_category_id.to_string()))?;
155
156 let to_category = self
157 .storage
158 .categories
159 .get_category(to_category_id)?
160 .ok_or_else(|| EnvelopeError::category_not_found(to_category_id.to_string()))?;
161
162 let mut from_alloc = self
164 .storage
165 .budget
166 .get_or_default(from_category_id, period)?;
167 let mut to_alloc = self.storage.budget.get_or_default(to_category_id, period)?;
168
169 let from_before = from_alloc.clone();
170 let to_before = to_alloc.clone();
171
172 if from_alloc.budgeted < amount {
174 return Err(EnvelopeError::InsufficientFunds {
175 category: from_category.name.clone(),
176 needed: amount.cents(),
177 available: from_alloc.budgeted.cents(),
178 });
179 }
180
181 from_alloc.add_budgeted(-amount);
183 to_alloc.add_budgeted(amount);
184
185 from_alloc
187 .validate()
188 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
189 to_alloc
190 .validate()
191 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
192
193 self.storage.budget.upsert(from_alloc.clone())?;
195 self.storage.budget.upsert(to_alloc.clone())?;
196 self.storage.budget.save()?;
197
198 self.storage.log_update(
200 EntityType::BudgetAllocation,
201 format!("{}:{}", from_category_id, period),
202 Some(from_category.name.clone()),
203 &from_before,
204 &from_alloc,
205 Some(format!("moved {} to '{}'", amount, to_category.name)),
206 )?;
207
208 self.storage.log_update(
209 EntityType::BudgetAllocation,
210 format!("{}:{}", to_category_id, period),
211 Some(to_category.name.clone()),
212 &to_before,
213 &to_alloc,
214 Some(format!("received {} from '{}'", amount, from_category.name)),
215 )?;
216
217 Ok(())
218 }
219
220 pub fn get_allocation(
222 &self,
223 category_id: CategoryId,
224 period: &BudgetPeriod,
225 ) -> EnvelopeResult<BudgetAllocation> {
226 self.storage.budget.get_or_default(category_id, period)
227 }
228
229 pub fn get_category_summary(
231 &self,
232 category_id: CategoryId,
233 period: &BudgetPeriod,
234 ) -> EnvelopeResult<CategoryBudgetSummary> {
235 let allocation = self.storage.budget.get_or_default(category_id, period)?;
236
237 let activity = self.calculate_category_activity(category_id, period)?;
239
240 Ok(CategoryBudgetSummary::from_allocation(
241 &allocation,
242 activity,
243 ))
244 }
245
246 pub fn calculate_category_activity(
248 &self,
249 category_id: CategoryId,
250 period: &BudgetPeriod,
251 ) -> EnvelopeResult<Money> {
252 let transactions = self.storage.transactions.get_by_category(category_id)?;
253
254 let period_start = period.start_date();
256 let period_end = period.end_date();
257
258 let activity: Money = transactions
259 .iter()
260 .filter(|t| t.date >= period_start && t.date <= period_end)
261 .map(|t| {
262 if t.is_split() {
264 t.splits
266 .iter()
267 .filter(|s| s.category_id == category_id)
268 .map(|s| s.amount)
269 .sum()
270 } else {
271 t.amount
272 }
273 })
274 .sum();
275
276 Ok(activity)
277 }
278
279 pub fn calculate_income_for_period(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
281 let period_start = period.start_date();
282 let period_end = period.end_date();
283
284 let transactions = self
285 .storage
286 .transactions
287 .get_by_date_range(period_start, period_end)?;
288
289 let income: Money = transactions
290 .iter()
291 .filter(|t| t.amount.is_positive())
292 .map(|t| t.amount)
293 .sum();
294
295 Ok(income)
296 }
297
298 pub fn get_available_to_budget(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
302 let account_service = crate::services::AccountService::new(self.storage);
304 let total_balance = account_service.total_on_budget_balance()?;
305
306 let allocations = self.storage.budget.get_for_period(period)?;
308 let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
309
310 Ok(total_balance - total_budgeted)
311 }
312
313 pub fn get_expected_income(&self, period: &BudgetPeriod) -> Option<Money> {
315 self.storage
316 .income
317 .get_for_period(period)
318 .map(|e| e.expected_amount)
319 }
320
321 pub fn is_over_expected_income(&self, period: &BudgetPeriod) -> EnvelopeResult<Option<Money>> {
325 let expected = match self.get_expected_income(period) {
326 Some(e) => e,
327 None => return Ok(None), };
329
330 let allocations = self.storage.budget.get_for_period(period)?;
331 let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
332
333 if total_budgeted > expected {
334 Ok(Some(total_budgeted - expected)) } else {
336 Ok(None)
337 }
338 }
339
340 pub fn get_remaining_to_budget_from_income(
345 &self,
346 period: &BudgetPeriod,
347 ) -> EnvelopeResult<Option<Money>> {
348 let expected = match self.get_expected_income(period) {
349 Some(e) => e,
350 None => return Ok(None),
351 };
352
353 let allocations = self.storage.budget.get_for_period(period)?;
354 let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
355
356 Ok(Some(expected - total_budgeted))
357 }
358
359 pub fn get_budget_overview(&self, period: &BudgetPeriod) -> EnvelopeResult<BudgetOverview> {
361 let category_service = CategoryService::new(self.storage);
362 let categories = category_service.list_categories()?;
363
364 let mut summaries = Vec::with_capacity(categories.len());
365 let mut total_budgeted = Money::zero();
366 let mut total_activity = Money::zero();
367 let mut total_available = Money::zero();
368
369 for category in &categories {
370 let summary = self.get_category_summary(category.id, period)?;
371 total_budgeted += summary.budgeted;
372 total_activity += summary.activity;
373 total_available += summary.available;
374 summaries.push(summary);
375 }
376
377 let available_to_budget = self.get_available_to_budget(period)?;
378
379 let expected_income = self.get_expected_income(period);
381 let over_budget_amount = expected_income.and_then(|expected| {
382 if total_budgeted > expected {
383 Some(total_budgeted - expected)
384 } else {
385 None
386 }
387 });
388
389 Ok(BudgetOverview {
390 period: period.clone(),
391 total_budgeted,
392 total_activity,
393 total_available,
394 available_to_budget,
395 categories: summaries,
396 expected_income,
397 over_budget_amount,
398 })
399 }
400
401 pub fn get_allocation_history(
403 &self,
404 category_id: CategoryId,
405 ) -> EnvelopeResult<Vec<BudgetAllocation>> {
406 self.storage.budget.get_for_category(category_id)
407 }
408
409 pub fn get_carryover(
416 &self,
417 category_id: CategoryId,
418 period: &BudgetPeriod,
419 ) -> EnvelopeResult<Money> {
420 let prev_period = period.prev();
421 let summary = self.get_category_summary(category_id, &prev_period)?;
422 Ok(summary.rollover_amount())
423 }
424
425 pub fn apply_rollover(
430 &self,
431 category_id: CategoryId,
432 period: &BudgetPeriod,
433 ) -> EnvelopeResult<BudgetAllocation> {
434 let carryover = self.get_carryover(category_id, period)?;
436
437 let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
439
440 if allocation.carryover != carryover {
442 let before = allocation.clone();
443 allocation.set_carryover(carryover);
444
445 self.storage.budget.upsert(allocation.clone())?;
447 self.storage.budget.save()?;
448
449 let category = self.storage.categories.get_category(category_id)?;
451 let category_name = category.map(|c| c.name);
452
453 self.storage.log_update(
455 EntityType::BudgetAllocation,
456 format!("{}:{}", category_id, period),
457 category_name,
458 &before,
459 &allocation,
460 Some(format!(
461 "carryover: {} -> {}",
462 before.carryover, allocation.carryover
463 )),
464 )?;
465 }
466
467 Ok(allocation)
468 }
469
470 pub fn apply_rollover_all(
475 &self,
476 period: &BudgetPeriod,
477 ) -> EnvelopeResult<Vec<BudgetAllocation>> {
478 let category_service = CategoryService::new(self.storage);
479 let categories = category_service.list_categories()?;
480
481 let mut allocations = Vec::with_capacity(categories.len());
482 for category in &categories {
483 let allocation = self.apply_rollover(category.id, period)?;
484 allocations.push(allocation);
485 }
486
487 Ok(allocations)
488 }
489
490 pub fn get_overspent_categories(
492 &self,
493 period: &BudgetPeriod,
494 ) -> EnvelopeResult<Vec<CategoryBudgetSummary>> {
495 let category_service = CategoryService::new(self.storage);
496 let categories = category_service.list_categories()?;
497
498 let mut overspent = Vec::new();
499 for category in &categories {
500 let summary = self.get_category_summary(category.id, period)?;
501 if summary.is_overspent() {
502 overspent.push(summary);
503 }
504 }
505
506 Ok(overspent)
507 }
508
509 pub fn set_target(
513 &self,
514 category_id: CategoryId,
515 amount: Money,
516 cadence: TargetCadence,
517 ) -> EnvelopeResult<BudgetTarget> {
518 let category = self
519 .storage
520 .categories
521 .get_category(category_id)?
522 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
523
524 if let Some(mut existing) = self.storage.targets.get_for_category(category_id)? {
526 existing.deactivate();
527 self.storage.targets.upsert(existing)?;
528 }
529
530 let target = BudgetTarget::new(category_id, amount, cadence);
531 target
532 .validate()
533 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
534
535 self.storage.targets.upsert(target.clone())?;
536 self.storage.targets.save()?;
537
538 self.storage.log_create(
539 EntityType::BudgetTarget,
540 target.id.to_string(),
541 Some(category.name),
542 &target,
543 )?;
544
545 Ok(target)
546 }
547
548 pub fn update_target(
550 &self,
551 target_id: BudgetTargetId,
552 amount: Option<Money>,
553 cadence: Option<TargetCadence>,
554 ) -> EnvelopeResult<BudgetTarget> {
555 let mut target = self
556 .storage
557 .targets
558 .get(target_id)?
559 .ok_or_else(|| EnvelopeError::Budget(format!("Target {} not found", target_id)))?;
560
561 let before = target.clone();
562
563 if let Some(amt) = amount {
564 target.set_amount(amt);
565 }
566 if let Some(cad) = cadence {
567 target.set_cadence(cad);
568 }
569
570 target
571 .validate()
572 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
573
574 self.storage.targets.upsert(target.clone())?;
575 self.storage.targets.save()?;
576
577 let category = self.storage.categories.get_category(target.category_id)?;
578 let category_name = category.map(|c| c.name);
579
580 self.storage.log_update(
581 EntityType::BudgetTarget,
582 target.id.to_string(),
583 category_name,
584 &before,
585 &target,
586 Some(format!("{} -> {}", before, target)),
587 )?;
588
589 Ok(target)
590 }
591
592 pub fn get_target(&self, category_id: CategoryId) -> EnvelopeResult<Option<BudgetTarget>> {
594 self.storage.targets.get_for_category(category_id)
595 }
596
597 pub fn get_suggested_budget(
599 &self,
600 category_id: CategoryId,
601 period: &BudgetPeriod,
602 ) -> EnvelopeResult<Option<Money>> {
603 if let Some(target) = self.storage.targets.get_for_category(category_id)? {
604 Ok(Some(target.calculate_for_period(period)))
605 } else {
606 Ok(None)
607 }
608 }
609
610 pub fn delete_target(&self, target_id: BudgetTargetId) -> EnvelopeResult<bool> {
612 if let Some(target) = self.storage.targets.get(target_id)? {
613 let category = self.storage.categories.get_category(target.category_id)?;
614 let category_name = category.map(|c| c.name);
615
616 self.storage.targets.delete(target_id)?;
617 self.storage.targets.save()?;
618
619 self.storage.log_delete(
620 EntityType::BudgetTarget,
621 target.id.to_string(),
622 category_name,
623 &target,
624 )?;
625
626 Ok(true)
627 } else {
628 Ok(false)
629 }
630 }
631
632 pub fn remove_target(&self, category_id: CategoryId) -> EnvelopeResult<bool> {
634 if let Some(target) = self.storage.targets.get_for_category(category_id)? {
635 self.delete_target(target.id)
636 } else {
637 Ok(false)
638 }
639 }
640
641 pub fn get_all_targets(&self) -> EnvelopeResult<Vec<BudgetTarget>> {
643 self.storage.targets.get_all_active()
644 }
645
646 pub fn auto_fill_from_target(
648 &self,
649 category_id: CategoryId,
650 period: &BudgetPeriod,
651 ) -> EnvelopeResult<Option<BudgetAllocation>> {
652 if let Some(suggested) = self.get_suggested_budget(category_id, period)? {
653 let allocation = self.assign_to_category(category_id, period, suggested)?;
654 Ok(Some(allocation))
655 } else {
656 Ok(None)
657 }
658 }
659
660 pub fn auto_fill_all_targets(
662 &self,
663 period: &BudgetPeriod,
664 ) -> EnvelopeResult<Vec<BudgetAllocation>> {
665 let targets = self.storage.targets.get_all_active()?;
666 let mut allocations = Vec::with_capacity(targets.len());
667
668 for target in &targets {
669 let suggested = target.calculate_for_period(period);
670 let allocation = self.assign_to_category(target.category_id, period, suggested)?;
671 allocations.push(allocation);
672 }
673
674 Ok(allocations)
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use crate::config::paths::EnvelopePaths;
682 use crate::models::{Account, AccountType, Category, CategoryGroup, Transaction};
683 use chrono::NaiveDate;
684 use tempfile::TempDir;
685
686 fn create_test_storage() -> (TempDir, Storage) {
687 let temp_dir = TempDir::new().unwrap();
688 let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
689 let mut storage = Storage::new(paths).unwrap();
690 storage.load_all().unwrap();
691 (temp_dir, storage)
692 }
693
694 fn setup_test_data(storage: &Storage) -> (CategoryId, CategoryId, BudgetPeriod) {
695 let group = CategoryGroup::new("Test Group");
697 storage.categories.upsert_group(group.clone()).unwrap();
698
699 let cat1 = Category::new("Groceries", group.id);
701 let cat2 = Category::new("Dining Out", group.id);
702 let cat1_id = cat1.id;
703 let cat2_id = cat2.id;
704 storage.categories.upsert_category(cat1).unwrap();
705 storage.categories.upsert_category(cat2).unwrap();
706 storage.categories.save().unwrap();
707
708 let period = BudgetPeriod::monthly(2025, 1);
709
710 (cat1_id, cat2_id, period)
711 }
712
713 #[test]
714 fn test_assign_to_category() {
715 let (_temp_dir, storage) = create_test_storage();
716 let (cat_id, _, period) = setup_test_data(&storage);
717 let service = BudgetService::new(&storage);
718
719 let allocation = service
720 .assign_to_category(cat_id, &period, Money::from_cents(50000))
721 .unwrap();
722
723 assert_eq!(allocation.budgeted.cents(), 50000);
724 }
725
726 #[test]
727 fn test_add_to_category() {
728 let (_temp_dir, storage) = create_test_storage();
729 let (cat_id, _, period) = setup_test_data(&storage);
730 let service = BudgetService::new(&storage);
731
732 service
734 .assign_to_category(cat_id, &period, Money::from_cents(30000))
735 .unwrap();
736
737 let allocation = service
739 .add_to_category(cat_id, &period, Money::from_cents(20000))
740 .unwrap();
741
742 assert_eq!(allocation.budgeted.cents(), 50000);
743 }
744
745 #[test]
746 fn test_move_between_categories() {
747 let (_temp_dir, storage) = create_test_storage();
748 let (cat1_id, cat2_id, period) = setup_test_data(&storage);
749 let service = BudgetService::new(&storage);
750
751 service
753 .assign_to_category(cat1_id, &period, Money::from_cents(50000))
754 .unwrap();
755
756 service
758 .move_between_categories(cat1_id, cat2_id, &period, Money::from_cents(20000))
759 .unwrap();
760
761 let alloc1 = service.get_allocation(cat1_id, &period).unwrap();
762 let alloc2 = service.get_allocation(cat2_id, &period).unwrap();
763
764 assert_eq!(alloc1.budgeted.cents(), 30000);
765 assert_eq!(alloc2.budgeted.cents(), 20000);
766 }
767
768 #[test]
769 fn test_move_insufficient_funds() {
770 let (_temp_dir, storage) = create_test_storage();
771 let (cat1_id, cat2_id, period) = setup_test_data(&storage);
772 let service = BudgetService::new(&storage);
773
774 service
776 .assign_to_category(cat1_id, &period, Money::from_cents(10000))
777 .unwrap();
778
779 let result =
781 service.move_between_categories(cat1_id, cat2_id, &period, Money::from_cents(20000));
782
783 assert!(matches!(
784 result,
785 Err(EnvelopeError::InsufficientFunds { .. })
786 ));
787 }
788
789 #[test]
790 fn test_category_activity() {
791 let (_temp_dir, storage) = create_test_storage();
792 let (cat_id, _, period) = setup_test_data(&storage);
793
794 let account = Account::new("Checking", AccountType::Checking);
796 storage.accounts.upsert(account.clone()).unwrap();
797
798 let mut txn = Transaction::new(
799 account.id,
800 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
801 Money::from_cents(-5000),
802 );
803 txn.category_id = Some(cat_id);
804 storage.transactions.upsert(txn).unwrap();
805
806 let service = BudgetService::new(&storage);
807 let activity = service
808 .calculate_category_activity(cat_id, &period)
809 .unwrap();
810
811 assert_eq!(activity.cents(), -5000);
812 }
813
814 #[test]
815 fn test_available_to_budget() {
816 let (_temp_dir, storage) = create_test_storage();
817 let (cat_id, _, period) = setup_test_data(&storage);
818
819 let account = Account::with_starting_balance(
821 "Checking",
822 AccountType::Checking,
823 Money::from_cents(100000),
824 );
825 storage.accounts.upsert(account.clone()).unwrap();
826 storage.accounts.save().unwrap();
827
828 let service = BudgetService::new(&storage);
829
830 let atb = service.get_available_to_budget(&period).unwrap();
832 assert_eq!(atb.cents(), 100000);
833
834 service
836 .assign_to_category(cat_id, &period, Money::from_cents(50000))
837 .unwrap();
838
839 let atb = service.get_available_to_budget(&period).unwrap();
840 assert_eq!(atb.cents(), 50000); }
842
843 #[test]
844 fn test_positive_carryover() {
845 let (_temp_dir, storage) = create_test_storage();
846 let (cat_id, _, jan) = setup_test_data(&storage);
847 let feb = jan.next();
848
849 let service = BudgetService::new(&storage);
850
851 service
853 .assign_to_category(cat_id, &jan, Money::from_cents(50000))
854 .unwrap();
855
856 let carryover = service.get_carryover(cat_id, &feb).unwrap();
858 assert_eq!(carryover.cents(), 50000);
859 }
860
861 #[test]
862 fn test_negative_carryover() {
863 let (_temp_dir, storage) = create_test_storage();
864 let (cat_id, _, jan) = setup_test_data(&storage);
865 let feb = jan.next();
866
867 let account = Account::new("Checking", AccountType::Checking);
869 storage.accounts.upsert(account.clone()).unwrap();
870
871 let mut txn = Transaction::new(
872 account.id,
873 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
874 Money::from_cents(-60000), );
876 txn.category_id = Some(cat_id);
877 storage.transactions.upsert(txn).unwrap();
878
879 let service = BudgetService::new(&storage);
880
881 service
883 .assign_to_category(cat_id, &jan, Money::from_cents(50000))
884 .unwrap();
885
886 let carryover = service.get_carryover(cat_id, &feb).unwrap();
888 assert_eq!(carryover.cents(), -10000);
889 }
890
891 #[test]
892 fn test_apply_rollover() {
893 let (_temp_dir, storage) = create_test_storage();
894 let (cat_id, _, jan) = setup_test_data(&storage);
895 let feb = jan.next();
896
897 let service = BudgetService::new(&storage);
898
899 service
901 .assign_to_category(cat_id, &jan, Money::from_cents(50000))
902 .unwrap();
903
904 let feb_alloc = service.apply_rollover(cat_id, &feb).unwrap();
906
907 assert_eq!(feb_alloc.carryover.cents(), 50000);
909 assert_eq!(feb_alloc.budgeted.cents(), 0);
910 assert_eq!(feb_alloc.total_budgeted().cents(), 50000);
911 }
912
913 #[test]
914 fn test_apply_rollover_all() {
915 let (_temp_dir, storage) = create_test_storage();
916 let (cat1_id, cat2_id, jan) = setup_test_data(&storage);
917 let feb = jan.next();
918
919 let service = BudgetService::new(&storage);
920
921 service
923 .assign_to_category(cat1_id, &jan, Money::from_cents(50000))
924 .unwrap();
925 service
926 .assign_to_category(cat2_id, &jan, Money::from_cents(20000))
927 .unwrap();
928
929 let allocations = service.apply_rollover_all(&feb).unwrap();
931 assert_eq!(allocations.len(), 2);
932
933 let cat1_alloc = service.get_allocation(cat1_id, &feb).unwrap();
935 let cat2_alloc = service.get_allocation(cat2_id, &feb).unwrap();
936
937 assert_eq!(cat1_alloc.carryover.cents(), 50000);
938 assert_eq!(cat2_alloc.carryover.cents(), 20000);
939 }
940
941 #[test]
942 fn test_overspent_categories() {
943 let (_temp_dir, storage) = create_test_storage();
944 let (cat1_id, cat2_id, period) = setup_test_data(&storage);
945
946 let account = Account::new("Checking", AccountType::Checking);
948 storage.accounts.upsert(account.clone()).unwrap();
949
950 let mut txn = Transaction::new(
951 account.id,
952 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
953 Money::from_cents(-60000), );
955 txn.category_id = Some(cat1_id);
956 storage.transactions.upsert(txn).unwrap();
957
958 let service = BudgetService::new(&storage);
959
960 service
962 .assign_to_category(cat1_id, &period, Money::from_cents(50000))
963 .unwrap();
964
965 service
967 .assign_to_category(cat2_id, &period, Money::from_cents(20000))
968 .unwrap();
969
970 let overspent = service.get_overspent_categories(&period).unwrap();
971 assert_eq!(overspent.len(), 1);
972 assert_eq!(overspent[0].category_id, cat1_id);
973 assert_eq!(overspent[0].available.cents(), -10000);
974 }
975}