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;
14use chrono::Datelike;
15
16pub struct BudgetService<'a> {
18 storage: &'a Storage,
19}
20
21#[derive(Debug, Clone)]
23pub struct BudgetOverview {
24 pub period: BudgetPeriod,
25 pub total_budgeted: Money,
26 pub total_activity: Money,
27 pub total_available: Money,
28 pub available_to_budget: Money,
29 pub categories: Vec<CategoryBudgetSummary>,
30 pub expected_income: Option<Money>,
32 pub over_budget_amount: Option<Money>,
34}
35
36impl<'a> BudgetService<'a> {
37 pub fn new(storage: &'a Storage) -> Self {
39 Self { storage }
40 }
41
42 pub fn assign_to_category(
44 &self,
45 category_id: CategoryId,
46 period: &BudgetPeriod,
47 amount: Money,
48 ) -> EnvelopeResult<BudgetAllocation> {
49 let category = self
51 .storage
52 .categories
53 .get_category(category_id)?
54 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
55
56 let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
58 let before = allocation.clone();
59
60 allocation.set_budgeted(amount);
61
62 allocation
64 .validate()
65 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
66
67 self.storage.budget.upsert(allocation.clone())?;
69 self.storage.budget.save()?;
70
71 self.storage.log_update(
73 EntityType::BudgetAllocation,
74 format!("{}:{}", category_id, period),
75 Some(category.name),
76 &before,
77 &allocation,
78 Some(format!(
79 "budgeted: {} -> {}",
80 before.budgeted, allocation.budgeted
81 )),
82 )?;
83
84 Ok(allocation)
85 }
86
87 pub fn add_to_category(
89 &self,
90 category_id: CategoryId,
91 period: &BudgetPeriod,
92 amount: Money,
93 ) -> EnvelopeResult<BudgetAllocation> {
94 let category = self
96 .storage
97 .categories
98 .get_category(category_id)?
99 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
100
101 let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
103 let before = allocation.clone();
104
105 allocation.add_budgeted(amount);
106
107 allocation
109 .validate()
110 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
111
112 self.storage.budget.upsert(allocation.clone())?;
114 self.storage.budget.save()?;
115
116 self.storage.log_update(
118 EntityType::BudgetAllocation,
119 format!("{}:{}", category_id, period),
120 Some(category.name),
121 &before,
122 &allocation,
123 Some(format!(
124 "budgeted: {} -> {} (+{})",
125 before.budgeted, allocation.budgeted, amount
126 )),
127 )?;
128
129 Ok(allocation)
130 }
131
132 pub fn move_between_categories(
134 &self,
135 from_category_id: CategoryId,
136 to_category_id: CategoryId,
137 period: &BudgetPeriod,
138 amount: Money,
139 ) -> EnvelopeResult<()> {
140 if amount.is_zero() {
141 return Ok(());
142 }
143
144 if amount.is_negative() {
145 return Err(EnvelopeError::Budget(
146 "Amount to move must be positive".into(),
147 ));
148 }
149
150 let from_category = self
152 .storage
153 .categories
154 .get_category(from_category_id)?
155 .ok_or_else(|| EnvelopeError::category_not_found(from_category_id.to_string()))?;
156
157 let to_category = self
158 .storage
159 .categories
160 .get_category(to_category_id)?
161 .ok_or_else(|| EnvelopeError::category_not_found(to_category_id.to_string()))?;
162
163 let mut from_alloc = self
165 .storage
166 .budget
167 .get_or_default(from_category_id, period)?;
168 let mut to_alloc = self.storage.budget.get_or_default(to_category_id, period)?;
169
170 let from_before = from_alloc.clone();
171 let to_before = to_alloc.clone();
172
173 if from_alloc.budgeted < amount {
175 return Err(EnvelopeError::InsufficientFunds {
176 category: from_category.name.clone(),
177 needed: amount.cents(),
178 available: from_alloc.budgeted.cents(),
179 });
180 }
181
182 from_alloc.add_budgeted(-amount);
184 to_alloc.add_budgeted(amount);
185
186 from_alloc
188 .validate()
189 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
190 to_alloc
191 .validate()
192 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
193
194 self.storage.budget.upsert(from_alloc.clone())?;
196 self.storage.budget.upsert(to_alloc.clone())?;
197 self.storage.budget.save()?;
198
199 self.storage.log_update(
201 EntityType::BudgetAllocation,
202 format!("{}:{}", from_category_id, period),
203 Some(from_category.name.clone()),
204 &from_before,
205 &from_alloc,
206 Some(format!("moved {} to '{}'", amount, to_category.name)),
207 )?;
208
209 self.storage.log_update(
210 EntityType::BudgetAllocation,
211 format!("{}:{}", to_category_id, period),
212 Some(to_category.name.clone()),
213 &to_before,
214 &to_alloc,
215 Some(format!("received {} from '{}'", amount, from_category.name)),
216 )?;
217
218 Ok(())
219 }
220
221 pub fn get_allocation(
223 &self,
224 category_id: CategoryId,
225 period: &BudgetPeriod,
226 ) -> EnvelopeResult<BudgetAllocation> {
227 self.storage.budget.get_or_default(category_id, period)
228 }
229
230 pub fn get_category_summary(
232 &self,
233 category_id: CategoryId,
234 period: &BudgetPeriod,
235 ) -> EnvelopeResult<CategoryBudgetSummary> {
236 let allocation = self.storage.budget.get_or_default(category_id, period)?;
237
238 let activity = self.calculate_category_activity(category_id, period)?;
240
241 Ok(CategoryBudgetSummary::from_allocation(
242 &allocation,
243 activity,
244 ))
245 }
246
247 pub fn calculate_category_activity(
249 &self,
250 category_id: CategoryId,
251 period: &BudgetPeriod,
252 ) -> EnvelopeResult<Money> {
253 let transactions = self.storage.transactions.get_by_category(category_id)?;
254
255 let period_start = period.start_date();
257 let period_end = period.end_date();
258
259 let activity: Money = transactions
260 .iter()
261 .filter(|t| t.date >= period_start && t.date <= period_end)
262 .map(|t| {
263 if t.is_split() {
265 t.splits
267 .iter()
268 .filter(|s| s.category_id == category_id)
269 .map(|s| s.amount)
270 .sum()
271 } else {
272 t.amount
273 }
274 })
275 .sum();
276
277 Ok(activity)
278 }
279
280 pub fn calculate_income_for_period(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
282 let period_start = period.start_date();
283 let period_end = period.end_date();
284
285 let transactions = self
286 .storage
287 .transactions
288 .get_by_date_range(period_start, period_end)?;
289
290 let income: Money = transactions
291 .iter()
292 .filter(|t| t.amount.is_positive())
293 .map(|t| t.amount)
294 .sum();
295
296 Ok(income)
297 }
298
299 pub fn get_available_to_budget(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
303 let account_service = crate::services::AccountService::new(self.storage);
305 let total_balance = account_service.total_on_budget_balance()?;
306
307 let allocations = self.storage.budget.get_for_period(period)?;
309 let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
310
311 Ok(total_balance - total_budgeted)
312 }
313
314 pub fn get_expected_income(&self, period: &BudgetPeriod) -> Option<Money> {
316 self.storage
317 .income
318 .get_for_period(period)
319 .map(|e| e.expected_amount)
320 }
321
322 pub fn is_over_expected_income(&self, period: &BudgetPeriod) -> EnvelopeResult<Option<Money>> {
326 let expected = match self.get_expected_income(period) {
327 Some(e) => e,
328 None => return Ok(None), };
330
331 let allocations = self.storage.budget.get_for_period(period)?;
332 let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
333
334 if total_budgeted > expected {
335 Ok(Some(total_budgeted - expected)) } else {
337 Ok(None)
338 }
339 }
340
341 pub fn get_remaining_to_budget_from_income(
346 &self,
347 period: &BudgetPeriod,
348 ) -> EnvelopeResult<Option<Money>> {
349 let expected = match self.get_expected_income(period) {
350 Some(e) => e,
351 None => return Ok(None),
352 };
353
354 let allocations = self.storage.budget.get_for_period(period)?;
355 let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
356
357 Ok(Some(expected - total_budgeted))
358 }
359
360 pub fn get_budget_overview(&self, period: &BudgetPeriod) -> EnvelopeResult<BudgetOverview> {
362 let category_service = CategoryService::new(self.storage);
363 let categories = category_service.list_categories()?;
364
365 let mut summaries = Vec::with_capacity(categories.len());
366 let mut total_budgeted = Money::zero();
367 let mut total_activity = Money::zero();
368 let mut total_available = Money::zero();
369
370 for category in &categories {
371 let summary = self.get_category_summary(category.id, period)?;
372 total_budgeted += summary.budgeted;
373 total_activity += summary.activity;
374 total_available += summary.available;
375 summaries.push(summary);
376 }
377
378 let available_to_budget = self.get_available_to_budget(period)?;
379
380 let expected_income = self.get_expected_income(period);
382 let over_budget_amount = expected_income.and_then(|expected| {
383 if total_budgeted > expected {
384 Some(total_budgeted - expected)
385 } else {
386 None
387 }
388 });
389
390 Ok(BudgetOverview {
391 period: period.clone(),
392 total_budgeted,
393 total_activity,
394 total_available,
395 available_to_budget,
396 categories: summaries,
397 expected_income,
398 over_budget_amount,
399 })
400 }
401
402 pub fn get_allocation_history(
404 &self,
405 category_id: CategoryId,
406 ) -> EnvelopeResult<Vec<BudgetAllocation>> {
407 self.storage.budget.get_for_category(category_id)
408 }
409
410 pub fn calculate_cumulative_budgeted(
416 &self,
417 category_id: CategoryId,
418 up_to_period: &BudgetPeriod,
419 ) -> EnvelopeResult<Money> {
420 let allocations = self.storage.budget.get_for_category(category_id)?;
421
422 let total: Money = allocations
423 .iter()
424 .filter(|a| &a.period <= up_to_period)
425 .map(|a| a.budgeted)
426 .sum();
427
428 Ok(total)
429 }
430
431 pub fn calculate_cumulative_paid(
438 &self,
439 category_id: CategoryId,
440 up_to_period: &BudgetPeriod,
441 ) -> EnvelopeResult<Money> {
442 let transactions = self.storage.transactions.get_by_category(category_id)?;
443 let end_date = up_to_period.end_date();
444
445 let total_paid: i64 = transactions
446 .iter()
447 .filter(|t| t.date <= end_date)
448 .map(|t| {
449 if t.is_split() {
450 t.splits
452 .iter()
453 .filter(|s| s.category_id == category_id)
454 .map(|s| s.amount.cents())
455 .sum::<i64>()
456 } else {
457 t.amount.cents()
458 }
459 })
460 .filter(|¢s| cents < 0) .map(|cents| cents.abs()) .sum();
463
464 Ok(Money::from_cents(total_paid))
465 }
466
467 pub fn get_carryover(
474 &self,
475 category_id: CategoryId,
476 period: &BudgetPeriod,
477 ) -> EnvelopeResult<Money> {
478 let prev_period = period.prev();
479 let summary = self.get_category_summary(category_id, &prev_period)?;
480 Ok(summary.rollover_amount())
481 }
482
483 pub fn apply_rollover(
488 &self,
489 category_id: CategoryId,
490 period: &BudgetPeriod,
491 ) -> EnvelopeResult<BudgetAllocation> {
492 let carryover = self.get_carryover(category_id, period)?;
494
495 let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
497
498 if allocation.carryover != carryover {
500 let before = allocation.clone();
501 allocation.set_carryover(carryover);
502
503 self.storage.budget.upsert(allocation.clone())?;
505 self.storage.budget.save()?;
506
507 let category = self.storage.categories.get_category(category_id)?;
509 let category_name = category.map(|c| c.name);
510
511 self.storage.log_update(
513 EntityType::BudgetAllocation,
514 format!("{}:{}", category_id, period),
515 category_name,
516 &before,
517 &allocation,
518 Some(format!(
519 "carryover: {} -> {}",
520 before.carryover, allocation.carryover
521 )),
522 )?;
523 }
524
525 Ok(allocation)
526 }
527
528 pub fn apply_rollover_all(
533 &self,
534 period: &BudgetPeriod,
535 ) -> EnvelopeResult<Vec<BudgetAllocation>> {
536 let category_service = CategoryService::new(self.storage);
537 let categories = category_service.list_categories()?;
538
539 let mut allocations = Vec::with_capacity(categories.len());
540 for category in &categories {
541 let allocation = self.apply_rollover(category.id, period)?;
542 allocations.push(allocation);
543 }
544
545 Ok(allocations)
546 }
547
548 pub fn get_overspent_categories(
550 &self,
551 period: &BudgetPeriod,
552 ) -> EnvelopeResult<Vec<CategoryBudgetSummary>> {
553 let category_service = CategoryService::new(self.storage);
554 let categories = category_service.list_categories()?;
555
556 let mut overspent = Vec::new();
557 for category in &categories {
558 let summary = self.get_category_summary(category.id, period)?;
559 if summary.is_overspent() {
560 overspent.push(summary);
561 }
562 }
563
564 Ok(overspent)
565 }
566
567 pub fn set_target(
571 &self,
572 category_id: CategoryId,
573 amount: Money,
574 cadence: TargetCadence,
575 ) -> EnvelopeResult<BudgetTarget> {
576 let category = self
577 .storage
578 .categories
579 .get_category(category_id)?
580 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
581
582 if let Some(mut existing) = self.storage.targets.get_for_category(category_id)? {
584 existing.deactivate();
585 self.storage.targets.upsert(existing)?;
586 }
587
588 let target = BudgetTarget::new(category_id, amount, cadence);
589 target
590 .validate()
591 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
592
593 self.storage.targets.upsert(target.clone())?;
594 self.storage.targets.save()?;
595
596 self.storage.log_create(
597 EntityType::BudgetTarget,
598 target.id.to_string(),
599 Some(category.name),
600 &target,
601 )?;
602
603 Ok(target)
604 }
605
606 pub fn update_target(
608 &self,
609 target_id: BudgetTargetId,
610 amount: Option<Money>,
611 cadence: Option<TargetCadence>,
612 ) -> EnvelopeResult<BudgetTarget> {
613 let mut target = self
614 .storage
615 .targets
616 .get(target_id)?
617 .ok_or_else(|| EnvelopeError::Budget(format!("Target {} not found", target_id)))?;
618
619 let before = target.clone();
620
621 if let Some(amt) = amount {
622 target.set_amount(amt);
623 }
624 if let Some(cad) = cadence {
625 target.set_cadence(cad);
626 }
627
628 target
629 .validate()
630 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
631
632 self.storage.targets.upsert(target.clone())?;
633 self.storage.targets.save()?;
634
635 let category = self.storage.categories.get_category(target.category_id)?;
636 let category_name = category.map(|c| c.name);
637
638 self.storage.log_update(
639 EntityType::BudgetTarget,
640 target.id.to_string(),
641 category_name,
642 &before,
643 &target,
644 Some(format!("{} -> {}", before, target)),
645 )?;
646
647 Ok(target)
648 }
649
650 pub fn get_target(&self, category_id: CategoryId) -> EnvelopeResult<Option<BudgetTarget>> {
652 self.storage.targets.get_for_category(category_id)
653 }
654
655 pub fn get_suggested_budget(
657 &self,
658 category_id: CategoryId,
659 period: &BudgetPeriod,
660 ) -> EnvelopeResult<Option<Money>> {
661 if let Some(target) = self.storage.targets.get_for_category(category_id)? {
662 Ok(Some(target.calculate_for_period(period)))
663 } else {
664 Ok(None)
665 }
666 }
667
668 pub fn get_suggested_budget_with_progress(
677 &self,
678 category_id: CategoryId,
679 period: &BudgetPeriod,
680 ) -> EnvelopeResult<Option<Money>> {
681 let target = match self.storage.targets.get_for_category(category_id)? {
682 Some(t) => t,
683 None => return Ok(None),
684 };
685
686 match &target.cadence {
687 TargetCadence::ByDate { target_date } => {
688 let period_start = period.start_date();
689
690 if *target_date < period_start {
692 return Ok(Some(Money::zero()));
693 }
694
695 let target_period = BudgetPeriod::monthly(target_date.year(), target_date.month());
697 let cumulative_paid =
698 self.calculate_cumulative_paid(category_id, &target_period)?;
699
700 let remaining = (target.amount.cents() - cumulative_paid.cents()).max(0);
702
703 if remaining == 0 {
705 return Ok(Some(Money::zero()));
706 }
707
708 let months = self.months_between(period_start, *target_date);
710
711 if months <= 0 {
712 Ok(Some(Money::from_cents(remaining)))
714 } else {
715 Ok(Some(Money::from_cents(
717 (remaining as f64 / months as f64).ceil() as i64,
718 )))
719 }
720 }
721 _ => Ok(Some(target.calculate_for_period(period))),
723 }
724 }
725
726 fn months_between(&self, start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 {
728 let years = end.year() - start.year();
729 let months = end.month() as i32 - start.month() as i32;
730 years * 12 + months
731 }
732
733 pub fn delete_target(&self, target_id: BudgetTargetId) -> EnvelopeResult<bool> {
735 if let Some(target) = self.storage.targets.get(target_id)? {
736 let category = self.storage.categories.get_category(target.category_id)?;
737 let category_name = category.map(|c| c.name);
738
739 self.storage.targets.delete(target_id)?;
740 self.storage.targets.save()?;
741
742 self.storage.log_delete(
743 EntityType::BudgetTarget,
744 target.id.to_string(),
745 category_name,
746 &target,
747 )?;
748
749 Ok(true)
750 } else {
751 Ok(false)
752 }
753 }
754
755 pub fn remove_target(&self, category_id: CategoryId) -> EnvelopeResult<bool> {
757 if let Some(target) = self.storage.targets.get_for_category(category_id)? {
758 self.delete_target(target.id)
759 } else {
760 Ok(false)
761 }
762 }
763
764 pub fn get_all_targets(&self) -> EnvelopeResult<Vec<BudgetTarget>> {
766 self.storage.targets.get_all_active()
767 }
768
769 pub fn auto_fill_from_target(
774 &self,
775 category_id: CategoryId,
776 period: &BudgetPeriod,
777 ) -> EnvelopeResult<Option<BudgetAllocation>> {
778 if let Some(suggested) = self.get_suggested_budget_with_progress(category_id, period)? {
779 let allocation = self.assign_to_category(category_id, period, suggested)?;
780 Ok(Some(allocation))
781 } else {
782 Ok(None)
783 }
784 }
785
786 pub fn auto_fill_all_targets(
791 &self,
792 period: &BudgetPeriod,
793 ) -> EnvelopeResult<Vec<BudgetAllocation>> {
794 let targets = self.storage.targets.get_all_active()?;
795 let mut allocations = Vec::with_capacity(targets.len());
796
797 for target in &targets {
798 if let Some(suggested) =
799 self.get_suggested_budget_with_progress(target.category_id, period)?
800 {
801 let allocation = self.assign_to_category(target.category_id, period, suggested)?;
802 allocations.push(allocation);
803 }
804 }
805
806 Ok(allocations)
807 }
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use crate::config::paths::EnvelopePaths;
814 use crate::models::{Account, AccountType, Category, CategoryGroup, Transaction};
815 use chrono::NaiveDate;
816 use tempfile::TempDir;
817
818 fn create_test_storage() -> (TempDir, Storage) {
819 let temp_dir = TempDir::new().unwrap();
820 let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
821 let mut storage = Storage::new(paths).unwrap();
822 storage.load_all().unwrap();
823 (temp_dir, storage)
824 }
825
826 fn setup_test_data(storage: &Storage) -> (CategoryId, CategoryId, BudgetPeriod) {
827 let group = CategoryGroup::new("Test Group");
829 storage.categories.upsert_group(group.clone()).unwrap();
830
831 let cat1 = Category::new("Groceries", group.id);
833 let cat2 = Category::new("Dining Out", group.id);
834 let cat1_id = cat1.id;
835 let cat2_id = cat2.id;
836 storage.categories.upsert_category(cat1).unwrap();
837 storage.categories.upsert_category(cat2).unwrap();
838 storage.categories.save().unwrap();
839
840 let period = BudgetPeriod::monthly(2025, 1);
841
842 (cat1_id, cat2_id, period)
843 }
844
845 #[test]
846 fn test_assign_to_category() {
847 let (_temp_dir, storage) = create_test_storage();
848 let (cat_id, _, period) = setup_test_data(&storage);
849 let service = BudgetService::new(&storage);
850
851 let allocation = service
852 .assign_to_category(cat_id, &period, Money::from_cents(50000))
853 .unwrap();
854
855 assert_eq!(allocation.budgeted.cents(), 50000);
856 }
857
858 #[test]
859 fn test_add_to_category() {
860 let (_temp_dir, storage) = create_test_storage();
861 let (cat_id, _, period) = setup_test_data(&storage);
862 let service = BudgetService::new(&storage);
863
864 service
866 .assign_to_category(cat_id, &period, Money::from_cents(30000))
867 .unwrap();
868
869 let allocation = service
871 .add_to_category(cat_id, &period, Money::from_cents(20000))
872 .unwrap();
873
874 assert_eq!(allocation.budgeted.cents(), 50000);
875 }
876
877 #[test]
878 fn test_move_between_categories() {
879 let (_temp_dir, storage) = create_test_storage();
880 let (cat1_id, cat2_id, period) = setup_test_data(&storage);
881 let service = BudgetService::new(&storage);
882
883 service
885 .assign_to_category(cat1_id, &period, Money::from_cents(50000))
886 .unwrap();
887
888 service
890 .move_between_categories(cat1_id, cat2_id, &period, Money::from_cents(20000))
891 .unwrap();
892
893 let alloc1 = service.get_allocation(cat1_id, &period).unwrap();
894 let alloc2 = service.get_allocation(cat2_id, &period).unwrap();
895
896 assert_eq!(alloc1.budgeted.cents(), 30000);
897 assert_eq!(alloc2.budgeted.cents(), 20000);
898 }
899
900 #[test]
901 fn test_move_insufficient_funds() {
902 let (_temp_dir, storage) = create_test_storage();
903 let (cat1_id, cat2_id, period) = setup_test_data(&storage);
904 let service = BudgetService::new(&storage);
905
906 service
908 .assign_to_category(cat1_id, &period, Money::from_cents(10000))
909 .unwrap();
910
911 let result =
913 service.move_between_categories(cat1_id, cat2_id, &period, Money::from_cents(20000));
914
915 assert!(matches!(
916 result,
917 Err(EnvelopeError::InsufficientFunds { .. })
918 ));
919 }
920
921 #[test]
922 fn test_category_activity() {
923 let (_temp_dir, storage) = create_test_storage();
924 let (cat_id, _, period) = setup_test_data(&storage);
925
926 let account = Account::new("Checking", AccountType::Checking);
928 storage.accounts.upsert(account.clone()).unwrap();
929
930 let mut txn = Transaction::new(
931 account.id,
932 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
933 Money::from_cents(-5000),
934 );
935 txn.category_id = Some(cat_id);
936 storage.transactions.upsert(txn).unwrap();
937
938 let service = BudgetService::new(&storage);
939 let activity = service
940 .calculate_category_activity(cat_id, &period)
941 .unwrap();
942
943 assert_eq!(activity.cents(), -5000);
944 }
945
946 #[test]
947 fn test_available_to_budget() {
948 let (_temp_dir, storage) = create_test_storage();
949 let (cat_id, _, period) = setup_test_data(&storage);
950
951 let account = Account::with_starting_balance(
953 "Checking",
954 AccountType::Checking,
955 Money::from_cents(100000),
956 );
957 storage.accounts.upsert(account.clone()).unwrap();
958 storage.accounts.save().unwrap();
959
960 let service = BudgetService::new(&storage);
961
962 let atb = service.get_available_to_budget(&period).unwrap();
964 assert_eq!(atb.cents(), 100000);
965
966 service
968 .assign_to_category(cat_id, &period, Money::from_cents(50000))
969 .unwrap();
970
971 let atb = service.get_available_to_budget(&period).unwrap();
972 assert_eq!(atb.cents(), 50000); }
974
975 #[test]
976 fn test_positive_carryover() {
977 let (_temp_dir, storage) = create_test_storage();
978 let (cat_id, _, jan) = setup_test_data(&storage);
979 let feb = jan.next();
980
981 let service = BudgetService::new(&storage);
982
983 service
985 .assign_to_category(cat_id, &jan, Money::from_cents(50000))
986 .unwrap();
987
988 let carryover = service.get_carryover(cat_id, &feb).unwrap();
990 assert_eq!(carryover.cents(), 50000);
991 }
992
993 #[test]
994 fn test_negative_carryover() {
995 let (_temp_dir, storage) = create_test_storage();
996 let (cat_id, _, jan) = setup_test_data(&storage);
997 let feb = jan.next();
998
999 let account = Account::new("Checking", AccountType::Checking);
1001 storage.accounts.upsert(account.clone()).unwrap();
1002
1003 let mut txn = Transaction::new(
1004 account.id,
1005 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1006 Money::from_cents(-60000), );
1008 txn.category_id = Some(cat_id);
1009 storage.transactions.upsert(txn).unwrap();
1010
1011 let service = BudgetService::new(&storage);
1012
1013 service
1015 .assign_to_category(cat_id, &jan, Money::from_cents(50000))
1016 .unwrap();
1017
1018 let carryover = service.get_carryover(cat_id, &feb).unwrap();
1020 assert_eq!(carryover.cents(), -10000);
1021 }
1022
1023 #[test]
1024 fn test_apply_rollover() {
1025 let (_temp_dir, storage) = create_test_storage();
1026 let (cat_id, _, jan) = setup_test_data(&storage);
1027 let feb = jan.next();
1028
1029 let service = BudgetService::new(&storage);
1030
1031 service
1033 .assign_to_category(cat_id, &jan, Money::from_cents(50000))
1034 .unwrap();
1035
1036 let feb_alloc = service.apply_rollover(cat_id, &feb).unwrap();
1038
1039 assert_eq!(feb_alloc.carryover.cents(), 50000);
1041 assert_eq!(feb_alloc.budgeted.cents(), 0);
1042 assert_eq!(feb_alloc.total_budgeted().cents(), 50000);
1043 }
1044
1045 #[test]
1046 fn test_apply_rollover_all() {
1047 let (_temp_dir, storage) = create_test_storage();
1048 let (cat1_id, cat2_id, jan) = setup_test_data(&storage);
1049 let feb = jan.next();
1050
1051 let service = BudgetService::new(&storage);
1052
1053 service
1055 .assign_to_category(cat1_id, &jan, Money::from_cents(50000))
1056 .unwrap();
1057 service
1058 .assign_to_category(cat2_id, &jan, Money::from_cents(20000))
1059 .unwrap();
1060
1061 let allocations = service.apply_rollover_all(&feb).unwrap();
1063 assert_eq!(allocations.len(), 2);
1064
1065 let cat1_alloc = service.get_allocation(cat1_id, &feb).unwrap();
1067 let cat2_alloc = service.get_allocation(cat2_id, &feb).unwrap();
1068
1069 assert_eq!(cat1_alloc.carryover.cents(), 50000);
1070 assert_eq!(cat2_alloc.carryover.cents(), 20000);
1071 }
1072
1073 #[test]
1074 fn test_overspent_categories() {
1075 let (_temp_dir, storage) = create_test_storage();
1076 let (cat1_id, cat2_id, period) = setup_test_data(&storage);
1077
1078 let account = Account::new("Checking", AccountType::Checking);
1080 storage.accounts.upsert(account.clone()).unwrap();
1081
1082 let mut txn = Transaction::new(
1083 account.id,
1084 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1085 Money::from_cents(-60000), );
1087 txn.category_id = Some(cat1_id);
1088 storage.transactions.upsert(txn).unwrap();
1089
1090 let service = BudgetService::new(&storage);
1091
1092 service
1094 .assign_to_category(cat1_id, &period, Money::from_cents(50000))
1095 .unwrap();
1096
1097 service
1099 .assign_to_category(cat2_id, &period, Money::from_cents(20000))
1100 .unwrap();
1101
1102 let overspent = service.get_overspent_categories(&period).unwrap();
1103 assert_eq!(overspent.len(), 1);
1104 assert_eq!(overspent[0].category_id, cat1_id);
1105 assert_eq!(overspent[0].available.cents(), -10000);
1106 }
1107
1108 #[test]
1109 fn test_cumulative_budgeted_for_bydate_progress() {
1110 let (_temp_dir, storage) = create_test_storage();
1111 let (cat_id, _, _) = setup_test_data(&storage);
1112 let service = BudgetService::new(&storage);
1113
1114 let nov = BudgetPeriod::monthly(2025, 11);
1116 service
1117 .assign_to_category(cat_id, &nov, Money::from_cents(20000))
1118 .unwrap();
1119
1120 let dec = BudgetPeriod::monthly(2025, 12);
1122 service
1123 .assign_to_category(cat_id, &dec, Money::from_cents(20000))
1124 .unwrap();
1125
1126 let cumulative_nov = service.calculate_cumulative_budgeted(cat_id, &nov).unwrap();
1128 assert_eq!(cumulative_nov.cents(), 20000);
1129
1130 let cumulative_dec = service.calculate_cumulative_budgeted(cat_id, &dec).unwrap();
1132 assert_eq!(cumulative_dec.cents(), 40000);
1133
1134 let dec_2026 = BudgetPeriod::monthly(2026, 12);
1136 let cumulative_future = service
1137 .calculate_cumulative_budgeted(cat_id, &dec_2026)
1138 .unwrap();
1139 assert_eq!(cumulative_future.cents(), 40000);
1140 }
1141
1142 #[test]
1143 fn test_cumulative_paid_for_bydate_progress() {
1144 let (_temp_dir, storage) = create_test_storage();
1145 let (cat_id, _, _) = setup_test_data(&storage);
1146
1147 let account = Account::new("Checking", AccountType::Checking);
1149 storage.accounts.upsert(account.clone()).unwrap();
1150
1151 let mut txn1 = Transaction::new(
1153 account.id,
1154 NaiveDate::from_ymd_opt(2025, 11, 15).unwrap(),
1155 Money::from_cents(-10000), );
1157 txn1.category_id = Some(cat_id);
1158 storage.transactions.upsert(txn1).unwrap();
1159
1160 let mut txn2 = Transaction::new(
1162 account.id,
1163 NaiveDate::from_ymd_opt(2025, 12, 15).unwrap(),
1164 Money::from_cents(-5000), );
1166 txn2.category_id = Some(cat_id);
1167 storage.transactions.upsert(txn2).unwrap();
1168
1169 let service = BudgetService::new(&storage);
1170
1171 let nov = BudgetPeriod::monthly(2025, 11);
1173 let cumulative_nov = service.calculate_cumulative_paid(cat_id, &nov).unwrap();
1174 assert_eq!(cumulative_nov.cents(), 10000);
1175
1176 let dec = BudgetPeriod::monthly(2025, 12);
1178 let cumulative_dec = service.calculate_cumulative_paid(cat_id, &dec).unwrap();
1179 assert_eq!(cumulative_dec.cents(), 15000);
1180
1181 let cumulative_budgeted = service.calculate_cumulative_budgeted(cat_id, &dec).unwrap();
1183 assert_eq!(cumulative_budgeted.cents(), 0);
1184
1185 let progress_amount = if cumulative_dec.cents() > 0 {
1187 cumulative_dec.cents()
1188 } else {
1189 cumulative_budgeted.cents().max(0)
1190 };
1191 assert_eq!(progress_amount, 15000); }
1193
1194 #[test]
1195 fn test_paid_wins_over_budgeted() {
1196 let (_temp_dir, storage) = create_test_storage();
1197 let (cat_id, _, _) = setup_test_data(&storage);
1198
1199 let account = Account::new("Checking", AccountType::Checking);
1201 storage.accounts.upsert(account.clone()).unwrap();
1202
1203 let service = BudgetService::new(&storage);
1204 let dec = BudgetPeriod::monthly(2025, 12);
1205
1206 service
1208 .assign_to_category(cat_id, &dec, Money::from_cents(20000))
1209 .unwrap();
1210
1211 let mut txn = Transaction::new(
1213 account.id,
1214 NaiveDate::from_ymd_opt(2025, 12, 15).unwrap(),
1215 Money::from_cents(-10000), );
1217 txn.category_id = Some(cat_id);
1218 storage.transactions.upsert(txn).unwrap();
1219
1220 let cumulative_budgeted = service.calculate_cumulative_budgeted(cat_id, &dec).unwrap();
1221 let cumulative_paid = service.calculate_cumulative_paid(cat_id, &dec).unwrap();
1222
1223 assert_eq!(cumulative_budgeted.cents(), 20000); assert_eq!(cumulative_paid.cents(), 10000); let progress_amount = if cumulative_paid.cents() > 0 {
1228 cumulative_paid.cents()
1229 } else {
1230 cumulative_budgeted.cents().max(0)
1231 };
1232 assert_eq!(progress_amount, 10000); }
1234
1235 #[test]
1236 fn test_suggested_budget_accounts_for_cumulative_paid() {
1237 let (_temp_dir, storage) = create_test_storage();
1238 let (cat_id, _, _) = setup_test_data(&storage);
1239
1240 let account = Account::new("Checking", AccountType::Checking);
1242 storage.accounts.upsert(account.clone()).unwrap();
1243
1244 let service = BudgetService::new(&storage);
1245
1246 let target_date = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
1248 service
1249 .set_target(
1250 cat_id,
1251 Money::from_cents(200000),
1252 TargetCadence::by_date(target_date),
1253 )
1254 .unwrap();
1255
1256 let jan_2026 = BudgetPeriod::monthly(2026, 1);
1259
1260 let suggested = service
1262 .get_suggested_budget_with_progress(cat_id, &jan_2026)
1263 .unwrap()
1264 .unwrap();
1265 assert_eq!(suggested.cents(), 18182); let mut txn = Transaction::new(
1269 account.id,
1270 NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
1271 Money::from_cents(-50000), );
1273 txn.category_id = Some(cat_id);
1274 storage.transactions.upsert(txn).unwrap();
1275
1276 let feb_2026 = BudgetPeriod::monthly(2026, 2);
1279 let suggested = service
1280 .get_suggested_budget_with_progress(cat_id, &feb_2026)
1281 .unwrap()
1282 .unwrap();
1283 assert_eq!(suggested.cents(), 15000); let mut txn2 = Transaction::new(
1288 account.id,
1289 NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(),
1290 Money::from_cents(-50000), );
1292 txn2.category_id = Some(cat_id);
1293 storage.transactions.upsert(txn2).unwrap();
1294
1295 let mar_2026 = BudgetPeriod::monthly(2026, 3);
1298 let suggested = service
1299 .get_suggested_budget_with_progress(cat_id, &mar_2026)
1300 .unwrap()
1301 .unwrap();
1302 assert_eq!(suggested.cents(), 11112); }
1304
1305 #[test]
1306 fn test_suggested_budget_fully_paid_suggests_zero() {
1307 let (_temp_dir, storage) = create_test_storage();
1308 let (cat_id, _, _) = setup_test_data(&storage);
1309
1310 let account = Account::new("Checking", AccountType::Checking);
1312 storage.accounts.upsert(account.clone()).unwrap();
1313
1314 let service = BudgetService::new(&storage);
1315
1316 let target_date = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
1318 service
1319 .set_target(
1320 cat_id,
1321 Money::from_cents(50000),
1322 TargetCadence::by_date(target_date),
1323 )
1324 .unwrap();
1325
1326 let mut txn = Transaction::new(
1328 account.id,
1329 NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
1330 Money::from_cents(-50000), );
1332 txn.category_id = Some(cat_id);
1333 storage.transactions.upsert(txn).unwrap();
1334
1335 let feb_2026 = BudgetPeriod::monthly(2026, 2);
1337 let suggested = service
1338 .get_suggested_budget_with_progress(cat_id, &feb_2026)
1339 .unwrap()
1340 .unwrap();
1341 assert_eq!(suggested.cents(), 0);
1342 }
1343
1344 #[test]
1345 fn test_suggested_budget_recurring_targets_unchanged() {
1346 let (_temp_dir, storage) = create_test_storage();
1347 let (cat_id, _, _) = setup_test_data(&storage);
1348
1349 let service = BudgetService::new(&storage);
1350
1351 service
1353 .set_target(cat_id, Money::from_cents(30000), TargetCadence::Monthly)
1354 .unwrap();
1355
1356 let jan_2026 = BudgetPeriod::monthly(2026, 1);
1357
1358 let suggested = service
1360 .get_suggested_budget_with_progress(cat_id, &jan_2026)
1361 .unwrap()
1362 .unwrap();
1363 assert_eq!(suggested.cents(), 30000);
1364 }
1365}