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    /// Expected income for this period (if set)
30    pub expected_income: Option<Money>,
31    /// Amount over expected income (Some if budgeted > expected, None otherwise)
32    pub over_budget_amount: Option<Money>,
33}
34
35impl<'a> BudgetService<'a> {
36    /// Create a new budget service
37    pub fn new(storage: &'a Storage) -> Self {
38        Self { storage }
39    }
40
41    /// Assign funds to a category for a period
42    pub fn assign_to_category(
43        &self,
44        category_id: CategoryId,
45        period: &BudgetPeriod,
46        amount: Money,
47    ) -> EnvelopeResult<BudgetAllocation> {
48        // Verify category exists
49        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        // Get or create allocation
56        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        // Validate
62        allocation
63            .validate()
64            .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
65
66        // Save
67        self.storage.budget.upsert(allocation.clone())?;
68        self.storage.budget.save()?;
69
70        // Audit
71        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    /// Add to a category's budget for a period
87    pub fn add_to_category(
88        &self,
89        category_id: CategoryId,
90        period: &BudgetPeriod,
91        amount: Money,
92    ) -> EnvelopeResult<BudgetAllocation> {
93        // Verify category exists
94        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        // Get or create allocation
101        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        // Validate (check not negative)
107        allocation
108            .validate()
109            .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
110
111        // Save
112        self.storage.budget.upsert(allocation.clone())?;
113        self.storage.budget.save()?;
114
115        // Audit
116        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    /// Move funds between categories for a period
132    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        // Verify both categories exist
150        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        // Get current allocations
163        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        // Check if from has enough budgeted
173        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        // Move funds
182        from_alloc.add_budgeted(-amount);
183        to_alloc.add_budgeted(amount);
184
185        // Validate both
186        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        // Save both
194        self.storage.budget.upsert(from_alloc.clone())?;
195        self.storage.budget.upsert(to_alloc.clone())?;
196        self.storage.budget.save()?;
197
198        // Audit
199        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    /// Get the allocation for a category in a period
221    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    /// Get budget summary for a category in a period
230    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        // Calculate activity (sum of transactions in this category for this period)
238        let activity = self.calculate_category_activity(category_id, period)?;
239
240        Ok(CategoryBudgetSummary::from_allocation(
241            &allocation,
242            activity,
243        ))
244    }
245
246    /// Calculate activity (spending) for a category in a period
247    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        // Filter to transactions within the period
255        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                // Check if this is a split transaction
263                if t.is_split() {
264                    // Sum only the splits for this category
265                    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    /// Calculate total income for a period (sum of all positive transactions)
280    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    /// Calculate Available to Budget for a period
299    ///
300    /// Available to Budget = Total On-Budget Balances - Total Budgeted for current + prior periods
301    pub fn get_available_to_budget(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
302        // Get total balance across all on-budget accounts
303        let account_service = crate::services::AccountService::new(self.storage);
304        let total_balance = account_service.total_on_budget_balance()?;
305
306        // Get total budgeted for this period
307        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    /// Get expected income for a period (if set)
314    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    /// Check if total budgeted exceeds expected income
322    ///
323    /// Returns Some(overage_amount) if over budget, None otherwise
324    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), // No expectation set
328        };
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)) // Return overage amount
335        } else {
336            Ok(None)
337        }
338    }
339
340    /// Get remaining amount that can be budgeted based on expected income
341    ///
342    /// Returns the difference between expected income and total budgeted.
343    /// Positive = room to budget more, Negative = over-budgeted
344    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    /// Get a complete budget overview for a period
360    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        // Get expected income and calculate over-budget amount
380        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    /// Get all allocations for a category (history)
402    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    /// Calculate the carryover amount for a category going into a specific period
410    ///
411    /// This is the "Available" balance from the previous period, which includes:
412    /// - Budgeted amount
413    /// - Previous carryover
414    /// - Activity (spending)
415    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    /// Apply rollover from the previous period to a category's allocation
426    ///
427    /// This should be called when entering a new period to carry forward
428    /// any surplus or deficit from the previous period.
429    pub fn apply_rollover(
430        &self,
431        category_id: CategoryId,
432        period: &BudgetPeriod,
433    ) -> EnvelopeResult<BudgetAllocation> {
434        // Calculate carryover from previous period
435        let carryover = self.get_carryover(category_id, period)?;
436
437        // Get or create allocation for this period
438        let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
439
440        // Only apply if carryover changed
441        if allocation.carryover != carryover {
442            let before = allocation.clone();
443            allocation.set_carryover(carryover);
444
445            // Save
446            self.storage.budget.upsert(allocation.clone())?;
447            self.storage.budget.save()?;
448
449            // Get category name for audit
450            let category = self.storage.categories.get_category(category_id)?;
451            let category_name = category.map(|c| c.name);
452
453            // Audit
454            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    /// Apply rollover for all categories for a period
471    ///
472    /// This calculates and sets the carryover amount for every category
473    /// based on their Available balance from the previous period.
474    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    /// Get a list of overspent categories for a period
491    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    // ==================== Budget Target Methods ====================
510
511    /// Create or update a budget target for a category
512    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        // Deactivate any existing active target for this category
525        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    /// Update an existing budget target
549    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    /// Get the active target for a category
593    pub fn get_target(&self, category_id: CategoryId) -> EnvelopeResult<Option<BudgetTarget>> {
594        self.storage.targets.get_for_category(category_id)
595    }
596
597    /// Get the suggested budget amount for a category based on its target
598    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    /// Delete a target
611    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    /// Remove target for a category
633    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    /// Get all active targets
642    pub fn get_all_targets(&self) -> EnvelopeResult<Vec<BudgetTarget>> {
643        self.storage.targets.get_all_active()
644    }
645
646    /// Auto-fill budget for a category based on its target
647    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    /// Auto-fill budgets for all categories with targets
661    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        // Create a group
696        let group = CategoryGroup::new("Test Group");
697        storage.categories.upsert_group(group.clone()).unwrap();
698
699        // Create two categories
700        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        // First assignment
733        service
734            .assign_to_category(cat_id, &period, Money::from_cents(30000))
735            .unwrap();
736
737        // Add more
738        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        // Assign to first category
752        service
753            .assign_to_category(cat1_id, &period, Money::from_cents(50000))
754            .unwrap();
755
756        // Move some to second
757        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        // Assign to first category
775        service
776            .assign_to_category(cat1_id, &period, Money::from_cents(10000))
777            .unwrap();
778
779        // Try to move more than available
780        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        // Create an account and add a transaction
795        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        // Create account with balance
820        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        // Before budgeting
831        let atb = service.get_available_to_budget(&period).unwrap();
832        assert_eq!(atb.cents(), 100000);
833
834        // After budgeting $500
835        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); // 100000 - 50000
841    }
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        // Budget $500 in January, spend nothing
852        service
853            .assign_to_category(cat_id, &jan, Money::from_cents(50000))
854            .unwrap();
855
856        // Get carryover for February (should be $500 - $0 = $500)
857        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        // Create account and add an overspending transaction
868        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), // Spent $600
875        );
876        txn.category_id = Some(cat_id);
877        storage.transactions.upsert(txn).unwrap();
878
879        let service = BudgetService::new(&storage);
880
881        // Budget $500 in January, spent $600 (overspent by $100)
882        service
883            .assign_to_category(cat_id, &jan, Money::from_cents(50000))
884            .unwrap();
885
886        // Get carryover for February (should be $500 - $600 = -$100)
887        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        // Budget $500 in January
900        service
901            .assign_to_category(cat_id, &jan, Money::from_cents(50000))
902            .unwrap();
903
904        // Apply rollover to February
905        let feb_alloc = service.apply_rollover(cat_id, &feb).unwrap();
906
907        // Carryover should be $500
908        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        // Budget in January
922        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        // Apply rollover for all categories
930        let allocations = service.apply_rollover_all(&feb).unwrap();
931        assert_eq!(allocations.len(), 2);
932
933        // Check carryovers
934        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        // Create account and add overspending transaction to cat1
947        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), // Overspent in cat1
954        );
955        txn.category_id = Some(cat1_id);
956        storage.transactions.upsert(txn).unwrap();
957
958        let service = BudgetService::new(&storage);
959
960        // Budget $500 in cat1 (will be overspent by $100)
961        service
962            .assign_to_category(cat1_id, &period, Money::from_cents(50000))
963            .unwrap();
964
965        // Budget $200 in cat2 (not overspent)
966        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}