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}
30
31impl<'a> BudgetService<'a> {
32 pub fn new(storage: &'a Storage) -> Self {
34 Self { storage }
35 }
36
37 pub fn assign_to_category(
39 &self,
40 category_id: CategoryId,
41 period: &BudgetPeriod,
42 amount: Money,
43 ) -> EnvelopeResult<BudgetAllocation> {
44 let category = self
46 .storage
47 .categories
48 .get_category(category_id)?
49 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
50
51 let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
53 let before = allocation.clone();
54
55 allocation.set_budgeted(amount);
56
57 allocation
59 .validate()
60 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
61
62 self.storage.budget.upsert(allocation.clone())?;
64 self.storage.budget.save()?;
65
66 self.storage.log_update(
68 EntityType::BudgetAllocation,
69 format!("{}:{}", category_id, period),
70 Some(category.name),
71 &before,
72 &allocation,
73 Some(format!(
74 "budgeted: {} -> {}",
75 before.budgeted, allocation.budgeted
76 )),
77 )?;
78
79 Ok(allocation)
80 }
81
82 pub fn add_to_category(
84 &self,
85 category_id: CategoryId,
86 period: &BudgetPeriod,
87 amount: Money,
88 ) -> EnvelopeResult<BudgetAllocation> {
89 let category = self
91 .storage
92 .categories
93 .get_category(category_id)?
94 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
95
96 let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
98 let before = allocation.clone();
99
100 allocation.add_budgeted(amount);
101
102 allocation
104 .validate()
105 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
106
107 self.storage.budget.upsert(allocation.clone())?;
109 self.storage.budget.save()?;
110
111 self.storage.log_update(
113 EntityType::BudgetAllocation,
114 format!("{}:{}", category_id, period),
115 Some(category.name),
116 &before,
117 &allocation,
118 Some(format!(
119 "budgeted: {} -> {} (+{})",
120 before.budgeted, allocation.budgeted, amount
121 )),
122 )?;
123
124 Ok(allocation)
125 }
126
127 pub fn move_between_categories(
129 &self,
130 from_category_id: CategoryId,
131 to_category_id: CategoryId,
132 period: &BudgetPeriod,
133 amount: Money,
134 ) -> EnvelopeResult<()> {
135 if amount.is_zero() {
136 return Ok(());
137 }
138
139 if amount.is_negative() {
140 return Err(EnvelopeError::Budget(
141 "Amount to move must be positive".into(),
142 ));
143 }
144
145 let from_category = self
147 .storage
148 .categories
149 .get_category(from_category_id)?
150 .ok_or_else(|| EnvelopeError::category_not_found(from_category_id.to_string()))?;
151
152 let to_category = self
153 .storage
154 .categories
155 .get_category(to_category_id)?
156 .ok_or_else(|| EnvelopeError::category_not_found(to_category_id.to_string()))?;
157
158 let mut from_alloc = self
160 .storage
161 .budget
162 .get_or_default(from_category_id, period)?;
163 let mut to_alloc = self.storage.budget.get_or_default(to_category_id, period)?;
164
165 let from_before = from_alloc.clone();
166 let to_before = to_alloc.clone();
167
168 if from_alloc.budgeted < amount {
170 return Err(EnvelopeError::InsufficientFunds {
171 category: from_category.name.clone(),
172 needed: amount.cents(),
173 available: from_alloc.budgeted.cents(),
174 });
175 }
176
177 from_alloc.add_budgeted(-amount);
179 to_alloc.add_budgeted(amount);
180
181 from_alloc
183 .validate()
184 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
185 to_alloc
186 .validate()
187 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
188
189 self.storage.budget.upsert(from_alloc.clone())?;
191 self.storage.budget.upsert(to_alloc.clone())?;
192 self.storage.budget.save()?;
193
194 self.storage.log_update(
196 EntityType::BudgetAllocation,
197 format!("{}:{}", from_category_id, period),
198 Some(from_category.name.clone()),
199 &from_before,
200 &from_alloc,
201 Some(format!("moved {} to '{}'", amount, to_category.name)),
202 )?;
203
204 self.storage.log_update(
205 EntityType::BudgetAllocation,
206 format!("{}:{}", to_category_id, period),
207 Some(to_category.name.clone()),
208 &to_before,
209 &to_alloc,
210 Some(format!("received {} from '{}'", amount, from_category.name)),
211 )?;
212
213 Ok(())
214 }
215
216 pub fn get_allocation(
218 &self,
219 category_id: CategoryId,
220 period: &BudgetPeriod,
221 ) -> EnvelopeResult<BudgetAllocation> {
222 self.storage.budget.get_or_default(category_id, period)
223 }
224
225 pub fn get_category_summary(
227 &self,
228 category_id: CategoryId,
229 period: &BudgetPeriod,
230 ) -> EnvelopeResult<CategoryBudgetSummary> {
231 let allocation = self.storage.budget.get_or_default(category_id, period)?;
232
233 let activity = self.calculate_category_activity(category_id, period)?;
235
236 Ok(CategoryBudgetSummary::from_allocation(
237 &allocation,
238 activity,
239 ))
240 }
241
242 pub fn calculate_category_activity(
244 &self,
245 category_id: CategoryId,
246 period: &BudgetPeriod,
247 ) -> EnvelopeResult<Money> {
248 let transactions = self.storage.transactions.get_by_category(category_id)?;
249
250 let period_start = period.start_date();
252 let period_end = period.end_date();
253
254 let activity: Money = transactions
255 .iter()
256 .filter(|t| t.date >= period_start && t.date <= period_end)
257 .map(|t| {
258 if t.is_split() {
260 t.splits
262 .iter()
263 .filter(|s| s.category_id == category_id)
264 .map(|s| s.amount)
265 .sum()
266 } else {
267 t.amount
268 }
269 })
270 .sum();
271
272 Ok(activity)
273 }
274
275 pub fn calculate_income_for_period(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
277 let period_start = period.start_date();
278 let period_end = period.end_date();
279
280 let transactions = self
281 .storage
282 .transactions
283 .get_by_date_range(period_start, period_end)?;
284
285 let income: Money = transactions
286 .iter()
287 .filter(|t| t.amount.is_positive())
288 .map(|t| t.amount)
289 .sum();
290
291 Ok(income)
292 }
293
294 pub fn get_available_to_budget(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
298 let account_service = crate::services::AccountService::new(self.storage);
300 let total_balance = account_service.total_on_budget_balance()?;
301
302 let allocations = self.storage.budget.get_for_period(period)?;
304 let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
305
306 Ok(total_balance - total_budgeted)
307 }
308
309 pub fn get_budget_overview(&self, period: &BudgetPeriod) -> EnvelopeResult<BudgetOverview> {
311 let category_service = CategoryService::new(self.storage);
312 let categories = category_service.list_categories()?;
313
314 let mut summaries = Vec::with_capacity(categories.len());
315 let mut total_budgeted = Money::zero();
316 let mut total_activity = Money::zero();
317 let mut total_available = Money::zero();
318
319 for category in &categories {
320 let summary = self.get_category_summary(category.id, period)?;
321 total_budgeted += summary.budgeted;
322 total_activity += summary.activity;
323 total_available += summary.available;
324 summaries.push(summary);
325 }
326
327 let available_to_budget = self.get_available_to_budget(period)?;
328
329 Ok(BudgetOverview {
330 period: period.clone(),
331 total_budgeted,
332 total_activity,
333 total_available,
334 available_to_budget,
335 categories: summaries,
336 })
337 }
338
339 pub fn get_allocation_history(
341 &self,
342 category_id: CategoryId,
343 ) -> EnvelopeResult<Vec<BudgetAllocation>> {
344 self.storage.budget.get_for_category(category_id)
345 }
346
347 pub fn get_carryover(
354 &self,
355 category_id: CategoryId,
356 period: &BudgetPeriod,
357 ) -> EnvelopeResult<Money> {
358 let prev_period = period.prev();
359 let summary = self.get_category_summary(category_id, &prev_period)?;
360 Ok(summary.rollover_amount())
361 }
362
363 pub fn apply_rollover(
368 &self,
369 category_id: CategoryId,
370 period: &BudgetPeriod,
371 ) -> EnvelopeResult<BudgetAllocation> {
372 let carryover = self.get_carryover(category_id, period)?;
374
375 let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
377
378 if allocation.carryover != carryover {
380 let before = allocation.clone();
381 allocation.set_carryover(carryover);
382
383 self.storage.budget.upsert(allocation.clone())?;
385 self.storage.budget.save()?;
386
387 let category = self.storage.categories.get_category(category_id)?;
389 let category_name = category.map(|c| c.name);
390
391 self.storage.log_update(
393 EntityType::BudgetAllocation,
394 format!("{}:{}", category_id, period),
395 category_name,
396 &before,
397 &allocation,
398 Some(format!(
399 "carryover: {} -> {}",
400 before.carryover, allocation.carryover
401 )),
402 )?;
403 }
404
405 Ok(allocation)
406 }
407
408 pub fn apply_rollover_all(
413 &self,
414 period: &BudgetPeriod,
415 ) -> EnvelopeResult<Vec<BudgetAllocation>> {
416 let category_service = CategoryService::new(self.storage);
417 let categories = category_service.list_categories()?;
418
419 let mut allocations = Vec::with_capacity(categories.len());
420 for category in &categories {
421 let allocation = self.apply_rollover(category.id, period)?;
422 allocations.push(allocation);
423 }
424
425 Ok(allocations)
426 }
427
428 pub fn get_overspent_categories(
430 &self,
431 period: &BudgetPeriod,
432 ) -> EnvelopeResult<Vec<CategoryBudgetSummary>> {
433 let category_service = CategoryService::new(self.storage);
434 let categories = category_service.list_categories()?;
435
436 let mut overspent = Vec::new();
437 for category in &categories {
438 let summary = self.get_category_summary(category.id, period)?;
439 if summary.is_overspent() {
440 overspent.push(summary);
441 }
442 }
443
444 Ok(overspent)
445 }
446
447 pub fn set_target(
451 &self,
452 category_id: CategoryId,
453 amount: Money,
454 cadence: TargetCadence,
455 ) -> EnvelopeResult<BudgetTarget> {
456 let category = self
457 .storage
458 .categories
459 .get_category(category_id)?
460 .ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
461
462 if let Some(mut existing) = self.storage.targets.get_for_category(category_id)? {
464 existing.deactivate();
465 self.storage.targets.upsert(existing)?;
466 }
467
468 let target = BudgetTarget::new(category_id, amount, cadence);
469 target
470 .validate()
471 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
472
473 self.storage.targets.upsert(target.clone())?;
474 self.storage.targets.save()?;
475
476 self.storage.log_create(
477 EntityType::BudgetTarget,
478 target.id.to_string(),
479 Some(category.name),
480 &target,
481 )?;
482
483 Ok(target)
484 }
485
486 pub fn update_target(
488 &self,
489 target_id: BudgetTargetId,
490 amount: Option<Money>,
491 cadence: Option<TargetCadence>,
492 ) -> EnvelopeResult<BudgetTarget> {
493 let mut target = self
494 .storage
495 .targets
496 .get(target_id)?
497 .ok_or_else(|| EnvelopeError::Budget(format!("Target {} not found", target_id)))?;
498
499 let before = target.clone();
500
501 if let Some(amt) = amount {
502 target.set_amount(amt);
503 }
504 if let Some(cad) = cadence {
505 target.set_cadence(cad);
506 }
507
508 target
509 .validate()
510 .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
511
512 self.storage.targets.upsert(target.clone())?;
513 self.storage.targets.save()?;
514
515 let category = self.storage.categories.get_category(target.category_id)?;
516 let category_name = category.map(|c| c.name);
517
518 self.storage.log_update(
519 EntityType::BudgetTarget,
520 target.id.to_string(),
521 category_name,
522 &before,
523 &target,
524 Some(format!("{} -> {}", before, target)),
525 )?;
526
527 Ok(target)
528 }
529
530 pub fn get_target(&self, category_id: CategoryId) -> EnvelopeResult<Option<BudgetTarget>> {
532 self.storage.targets.get_for_category(category_id)
533 }
534
535 pub fn get_suggested_budget(
537 &self,
538 category_id: CategoryId,
539 period: &BudgetPeriod,
540 ) -> EnvelopeResult<Option<Money>> {
541 if let Some(target) = self.storage.targets.get_for_category(category_id)? {
542 Ok(Some(target.calculate_for_period(period)))
543 } else {
544 Ok(None)
545 }
546 }
547
548 pub fn delete_target(&self, target_id: BudgetTargetId) -> EnvelopeResult<bool> {
550 if let Some(target) = self.storage.targets.get(target_id)? {
551 let category = self.storage.categories.get_category(target.category_id)?;
552 let category_name = category.map(|c| c.name);
553
554 self.storage.targets.delete(target_id)?;
555 self.storage.targets.save()?;
556
557 self.storage.log_delete(
558 EntityType::BudgetTarget,
559 target.id.to_string(),
560 category_name,
561 &target,
562 )?;
563
564 Ok(true)
565 } else {
566 Ok(false)
567 }
568 }
569
570 pub fn remove_target(&self, category_id: CategoryId) -> EnvelopeResult<bool> {
572 if let Some(target) = self.storage.targets.get_for_category(category_id)? {
573 self.delete_target(target.id)
574 } else {
575 Ok(false)
576 }
577 }
578
579 pub fn get_all_targets(&self) -> EnvelopeResult<Vec<BudgetTarget>> {
581 self.storage.targets.get_all_active()
582 }
583
584 pub fn auto_fill_from_target(
586 &self,
587 category_id: CategoryId,
588 period: &BudgetPeriod,
589 ) -> EnvelopeResult<Option<BudgetAllocation>> {
590 if let Some(suggested) = self.get_suggested_budget(category_id, period)? {
591 let allocation = self.assign_to_category(category_id, period, suggested)?;
592 Ok(Some(allocation))
593 } else {
594 Ok(None)
595 }
596 }
597
598 pub fn auto_fill_all_targets(
600 &self,
601 period: &BudgetPeriod,
602 ) -> EnvelopeResult<Vec<BudgetAllocation>> {
603 let targets = self.storage.targets.get_all_active()?;
604 let mut allocations = Vec::with_capacity(targets.len());
605
606 for target in &targets {
607 let suggested = target.calculate_for_period(period);
608 let allocation = self.assign_to_category(target.category_id, period, suggested)?;
609 allocations.push(allocation);
610 }
611
612 Ok(allocations)
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use crate::config::paths::EnvelopePaths;
620 use crate::models::{Account, AccountType, Category, CategoryGroup, Transaction};
621 use chrono::NaiveDate;
622 use tempfile::TempDir;
623
624 fn create_test_storage() -> (TempDir, Storage) {
625 let temp_dir = TempDir::new().unwrap();
626 let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
627 let mut storage = Storage::new(paths).unwrap();
628 storage.load_all().unwrap();
629 (temp_dir, storage)
630 }
631
632 fn setup_test_data(storage: &Storage) -> (CategoryId, CategoryId, BudgetPeriod) {
633 let group = CategoryGroup::new("Test Group");
635 storage.categories.upsert_group(group.clone()).unwrap();
636
637 let cat1 = Category::new("Groceries", group.id);
639 let cat2 = Category::new("Dining Out", group.id);
640 let cat1_id = cat1.id;
641 let cat2_id = cat2.id;
642 storage.categories.upsert_category(cat1).unwrap();
643 storage.categories.upsert_category(cat2).unwrap();
644 storage.categories.save().unwrap();
645
646 let period = BudgetPeriod::monthly(2025, 1);
647
648 (cat1_id, cat2_id, period)
649 }
650
651 #[test]
652 fn test_assign_to_category() {
653 let (_temp_dir, storage) = create_test_storage();
654 let (cat_id, _, period) = setup_test_data(&storage);
655 let service = BudgetService::new(&storage);
656
657 let allocation = service
658 .assign_to_category(cat_id, &period, Money::from_cents(50000))
659 .unwrap();
660
661 assert_eq!(allocation.budgeted.cents(), 50000);
662 }
663
664 #[test]
665 fn test_add_to_category() {
666 let (_temp_dir, storage) = create_test_storage();
667 let (cat_id, _, period) = setup_test_data(&storage);
668 let service = BudgetService::new(&storage);
669
670 service
672 .assign_to_category(cat_id, &period, Money::from_cents(30000))
673 .unwrap();
674
675 let allocation = service
677 .add_to_category(cat_id, &period, Money::from_cents(20000))
678 .unwrap();
679
680 assert_eq!(allocation.budgeted.cents(), 50000);
681 }
682
683 #[test]
684 fn test_move_between_categories() {
685 let (_temp_dir, storage) = create_test_storage();
686 let (cat1_id, cat2_id, period) = setup_test_data(&storage);
687 let service = BudgetService::new(&storage);
688
689 service
691 .assign_to_category(cat1_id, &period, Money::from_cents(50000))
692 .unwrap();
693
694 service
696 .move_between_categories(cat1_id, cat2_id, &period, Money::from_cents(20000))
697 .unwrap();
698
699 let alloc1 = service.get_allocation(cat1_id, &period).unwrap();
700 let alloc2 = service.get_allocation(cat2_id, &period).unwrap();
701
702 assert_eq!(alloc1.budgeted.cents(), 30000);
703 assert_eq!(alloc2.budgeted.cents(), 20000);
704 }
705
706 #[test]
707 fn test_move_insufficient_funds() {
708 let (_temp_dir, storage) = create_test_storage();
709 let (cat1_id, cat2_id, period) = setup_test_data(&storage);
710 let service = BudgetService::new(&storage);
711
712 service
714 .assign_to_category(cat1_id, &period, Money::from_cents(10000))
715 .unwrap();
716
717 let result =
719 service.move_between_categories(cat1_id, cat2_id, &period, Money::from_cents(20000));
720
721 assert!(matches!(
722 result,
723 Err(EnvelopeError::InsufficientFunds { .. })
724 ));
725 }
726
727 #[test]
728 fn test_category_activity() {
729 let (_temp_dir, storage) = create_test_storage();
730 let (cat_id, _, period) = setup_test_data(&storage);
731
732 let account = Account::new("Checking", AccountType::Checking);
734 storage.accounts.upsert(account.clone()).unwrap();
735
736 let mut txn = Transaction::new(
737 account.id,
738 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
739 Money::from_cents(-5000),
740 );
741 txn.category_id = Some(cat_id);
742 storage.transactions.upsert(txn).unwrap();
743
744 let service = BudgetService::new(&storage);
745 let activity = service
746 .calculate_category_activity(cat_id, &period)
747 .unwrap();
748
749 assert_eq!(activity.cents(), -5000);
750 }
751
752 #[test]
753 fn test_available_to_budget() {
754 let (_temp_dir, storage) = create_test_storage();
755 let (cat_id, _, period) = setup_test_data(&storage);
756
757 let account = Account::with_starting_balance(
759 "Checking",
760 AccountType::Checking,
761 Money::from_cents(100000),
762 );
763 storage.accounts.upsert(account.clone()).unwrap();
764 storage.accounts.save().unwrap();
765
766 let service = BudgetService::new(&storage);
767
768 let atb = service.get_available_to_budget(&period).unwrap();
770 assert_eq!(atb.cents(), 100000);
771
772 service
774 .assign_to_category(cat_id, &period, Money::from_cents(50000))
775 .unwrap();
776
777 let atb = service.get_available_to_budget(&period).unwrap();
778 assert_eq!(atb.cents(), 50000); }
780
781 #[test]
782 fn test_positive_carryover() {
783 let (_temp_dir, storage) = create_test_storage();
784 let (cat_id, _, jan) = setup_test_data(&storage);
785 let feb = jan.next();
786
787 let service = BudgetService::new(&storage);
788
789 service
791 .assign_to_category(cat_id, &jan, Money::from_cents(50000))
792 .unwrap();
793
794 let carryover = service.get_carryover(cat_id, &feb).unwrap();
796 assert_eq!(carryover.cents(), 50000);
797 }
798
799 #[test]
800 fn test_negative_carryover() {
801 let (_temp_dir, storage) = create_test_storage();
802 let (cat_id, _, jan) = setup_test_data(&storage);
803 let feb = jan.next();
804
805 let account = Account::new("Checking", AccountType::Checking);
807 storage.accounts.upsert(account.clone()).unwrap();
808
809 let mut txn = Transaction::new(
810 account.id,
811 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
812 Money::from_cents(-60000), );
814 txn.category_id = Some(cat_id);
815 storage.transactions.upsert(txn).unwrap();
816
817 let service = BudgetService::new(&storage);
818
819 service
821 .assign_to_category(cat_id, &jan, Money::from_cents(50000))
822 .unwrap();
823
824 let carryover = service.get_carryover(cat_id, &feb).unwrap();
826 assert_eq!(carryover.cents(), -10000);
827 }
828
829 #[test]
830 fn test_apply_rollover() {
831 let (_temp_dir, storage) = create_test_storage();
832 let (cat_id, _, jan) = setup_test_data(&storage);
833 let feb = jan.next();
834
835 let service = BudgetService::new(&storage);
836
837 service
839 .assign_to_category(cat_id, &jan, Money::from_cents(50000))
840 .unwrap();
841
842 let feb_alloc = service.apply_rollover(cat_id, &feb).unwrap();
844
845 assert_eq!(feb_alloc.carryover.cents(), 50000);
847 assert_eq!(feb_alloc.budgeted.cents(), 0);
848 assert_eq!(feb_alloc.total_budgeted().cents(), 50000);
849 }
850
851 #[test]
852 fn test_apply_rollover_all() {
853 let (_temp_dir, storage) = create_test_storage();
854 let (cat1_id, cat2_id, jan) = setup_test_data(&storage);
855 let feb = jan.next();
856
857 let service = BudgetService::new(&storage);
858
859 service
861 .assign_to_category(cat1_id, &jan, Money::from_cents(50000))
862 .unwrap();
863 service
864 .assign_to_category(cat2_id, &jan, Money::from_cents(20000))
865 .unwrap();
866
867 let allocations = service.apply_rollover_all(&feb).unwrap();
869 assert_eq!(allocations.len(), 2);
870
871 let cat1_alloc = service.get_allocation(cat1_id, &feb).unwrap();
873 let cat2_alloc = service.get_allocation(cat2_id, &feb).unwrap();
874
875 assert_eq!(cat1_alloc.carryover.cents(), 50000);
876 assert_eq!(cat2_alloc.carryover.cents(), 20000);
877 }
878
879 #[test]
880 fn test_overspent_categories() {
881 let (_temp_dir, storage) = create_test_storage();
882 let (cat1_id, cat2_id, period) = setup_test_data(&storage);
883
884 let account = Account::new("Checking", AccountType::Checking);
886 storage.accounts.upsert(account.clone()).unwrap();
887
888 let mut txn = Transaction::new(
889 account.id,
890 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
891 Money::from_cents(-60000), );
893 txn.category_id = Some(cat1_id);
894 storage.transactions.upsert(txn).unwrap();
895
896 let service = BudgetService::new(&storage);
897
898 service
900 .assign_to_category(cat1_id, &period, Money::from_cents(50000))
901 .unwrap();
902
903 service
905 .assign_to_category(cat2_id, &period, Money::from_cents(20000))
906 .unwrap();
907
908 let overspent = service.get_overspent_categories(&period).unwrap();
909 assert_eq!(overspent.len(), 1);
910 assert_eq!(overspent[0].category_id, cat1_id);
911 assert_eq!(overspent[0].available.cents(), -10000);
912 }
913}