envelope_cli/services/
budget.rs

1//! Budget service
2//!
3//! Provides business logic for budget management including allocation,
4//! Available to Budget calculation, and budget overview.
5
6use 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
15/// Service for budget management
16pub struct BudgetService<'a> {
17    storage: &'a Storage,
18}
19
20/// Budget overview for a period
21#[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    /// Create a new budget service
33    pub fn new(storage: &'a Storage) -> Self {
34        Self { storage }
35    }
36
37    /// Assign funds to a category for a period
38    pub fn assign_to_category(
39        &self,
40        category_id: CategoryId,
41        period: &BudgetPeriod,
42        amount: Money,
43    ) -> EnvelopeResult<BudgetAllocation> {
44        // Verify category exists
45        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        // Get or create allocation
52        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        // Validate
58        allocation
59            .validate()
60            .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
61
62        // Save
63        self.storage.budget.upsert(allocation.clone())?;
64        self.storage.budget.save()?;
65
66        // Audit
67        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    /// Add to a category's budget for a period
83    pub fn add_to_category(
84        &self,
85        category_id: CategoryId,
86        period: &BudgetPeriod,
87        amount: Money,
88    ) -> EnvelopeResult<BudgetAllocation> {
89        // Verify category exists
90        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        // Get or create allocation
97        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        // Validate (check not negative)
103        allocation
104            .validate()
105            .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
106
107        // Save
108        self.storage.budget.upsert(allocation.clone())?;
109        self.storage.budget.save()?;
110
111        // Audit
112        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    /// Move funds between categories for a period
128    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        // Verify both categories exist
146        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        // Get current allocations
159        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        // Check if from has enough budgeted
169        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        // Move funds
178        from_alloc.add_budgeted(-amount);
179        to_alloc.add_budgeted(amount);
180
181        // Validate both
182        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        // Save both
190        self.storage.budget.upsert(from_alloc.clone())?;
191        self.storage.budget.upsert(to_alloc.clone())?;
192        self.storage.budget.save()?;
193
194        // Audit
195        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    /// Get the allocation for a category in a period
217    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    /// Get budget summary for a category in a period
226    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        // Calculate activity (sum of transactions in this category for this period)
234        let activity = self.calculate_category_activity(category_id, period)?;
235
236        Ok(CategoryBudgetSummary::from_allocation(
237            &allocation,
238            activity,
239        ))
240    }
241
242    /// Calculate activity (spending) for a category in a period
243    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        // Filter to transactions within the period
251        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                // Check if this is a split transaction
259                if t.is_split() {
260                    // Sum only the splits for this category
261                    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    /// Calculate total income for a period (sum of all positive transactions)
276    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    /// Calculate Available to Budget for a period
295    ///
296    /// Available to Budget = Total On-Budget Balances - Total Budgeted for current + prior periods
297    pub fn get_available_to_budget(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
298        // Get total balance across all on-budget accounts
299        let account_service = crate::services::AccountService::new(self.storage);
300        let total_balance = account_service.total_on_budget_balance()?;
301
302        // Get total budgeted for this period
303        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    /// Get a complete budget overview for a period
310    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    /// Get all allocations for a category (history)
340    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    /// Calculate the carryover amount for a category going into a specific period
348    ///
349    /// This is the "Available" balance from the previous period, which includes:
350    /// - Budgeted amount
351    /// - Previous carryover
352    /// - Activity (spending)
353    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    /// Apply rollover from the previous period to a category's allocation
364    ///
365    /// This should be called when entering a new period to carry forward
366    /// any surplus or deficit from the previous period.
367    pub fn apply_rollover(
368        &self,
369        category_id: CategoryId,
370        period: &BudgetPeriod,
371    ) -> EnvelopeResult<BudgetAllocation> {
372        // Calculate carryover from previous period
373        let carryover = self.get_carryover(category_id, period)?;
374
375        // Get or create allocation for this period
376        let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
377
378        // Only apply if carryover changed
379        if allocation.carryover != carryover {
380            let before = allocation.clone();
381            allocation.set_carryover(carryover);
382
383            // Save
384            self.storage.budget.upsert(allocation.clone())?;
385            self.storage.budget.save()?;
386
387            // Get category name for audit
388            let category = self.storage.categories.get_category(category_id)?;
389            let category_name = category.map(|c| c.name);
390
391            // Audit
392            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    /// Apply rollover for all categories for a period
409    ///
410    /// This calculates and sets the carryover amount for every category
411    /// based on their Available balance from the previous period.
412    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    /// Get a list of overspent categories for a period
429    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    // ==================== Budget Target Methods ====================
448
449    /// Create or update a budget target for a category
450    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        // Deactivate any existing active target for this category
463        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    /// Update an existing budget target
487    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    /// Get the active target for a category
531    pub fn get_target(&self, category_id: CategoryId) -> EnvelopeResult<Option<BudgetTarget>> {
532        self.storage.targets.get_for_category(category_id)
533    }
534
535    /// Get the suggested budget amount for a category based on its target
536    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    /// Delete a target
549    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    /// Remove target for a category
571    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    /// Get all active targets
580    pub fn get_all_targets(&self) -> EnvelopeResult<Vec<BudgetTarget>> {
581        self.storage.targets.get_all_active()
582    }
583
584    /// Auto-fill budget for a category based on its target
585    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    /// Auto-fill budgets for all categories with targets
599    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        // Create a group
634        let group = CategoryGroup::new("Test Group");
635        storage.categories.upsert_group(group.clone()).unwrap();
636
637        // Create two categories
638        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        // First assignment
671        service
672            .assign_to_category(cat_id, &period, Money::from_cents(30000))
673            .unwrap();
674
675        // Add more
676        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        // Assign to first category
690        service
691            .assign_to_category(cat1_id, &period, Money::from_cents(50000))
692            .unwrap();
693
694        // Move some to second
695        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        // Assign to first category
713        service
714            .assign_to_category(cat1_id, &period, Money::from_cents(10000))
715            .unwrap();
716
717        // Try to move more than available
718        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        // Create an account and add a transaction
733        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        // Create account with balance
758        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        // Before budgeting
769        let atb = service.get_available_to_budget(&period).unwrap();
770        assert_eq!(atb.cents(), 100000);
771
772        // After budgeting $500
773        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); // 100000 - 50000
779    }
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        // Budget $500 in January, spend nothing
790        service
791            .assign_to_category(cat_id, &jan, Money::from_cents(50000))
792            .unwrap();
793
794        // Get carryover for February (should be $500 - $0 = $500)
795        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        // Create account and add an overspending transaction
806        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), // Spent $600
813        );
814        txn.category_id = Some(cat_id);
815        storage.transactions.upsert(txn).unwrap();
816
817        let service = BudgetService::new(&storage);
818
819        // Budget $500 in January, spent $600 (overspent by $100)
820        service
821            .assign_to_category(cat_id, &jan, Money::from_cents(50000))
822            .unwrap();
823
824        // Get carryover for February (should be $500 - $600 = -$100)
825        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        // Budget $500 in January
838        service
839            .assign_to_category(cat_id, &jan, Money::from_cents(50000))
840            .unwrap();
841
842        // Apply rollover to February
843        let feb_alloc = service.apply_rollover(cat_id, &feb).unwrap();
844
845        // Carryover should be $500
846        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        // Budget in January
860        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        // Apply rollover for all categories
868        let allocations = service.apply_rollover_all(&feb).unwrap();
869        assert_eq!(allocations.len(), 2);
870
871        // Check carryovers
872        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        // Create account and add overspending transaction to cat1
885        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), // Overspent in cat1
892        );
893        txn.category_id = Some(cat1_id);
894        storage.transactions.upsert(txn).unwrap();
895
896        let service = BudgetService::new(&storage);
897
898        // Budget $500 in cat1 (will be overspent by $100)
899        service
900            .assign_to_category(cat1_id, &period, Money::from_cents(50000))
901            .unwrap();
902
903        // Budget $200 in cat2 (not overspent)
904        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}