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;
14use chrono::Datelike;
15
16/// Service for budget management
17pub struct BudgetService<'a> {
18    storage: &'a Storage,
19}
20
21/// Budget overview for a period
22#[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    /// Expected income for this period (if set)
31    pub expected_income: Option<Money>,
32    /// Amount over expected income (Some if budgeted > expected, None otherwise)
33    pub over_budget_amount: Option<Money>,
34}
35
36impl<'a> BudgetService<'a> {
37    /// Create a new budget service
38    pub fn new(storage: &'a Storage) -> Self {
39        Self { storage }
40    }
41
42    /// Assign funds to a category for a period
43    pub fn assign_to_category(
44        &self,
45        category_id: CategoryId,
46        period: &BudgetPeriod,
47        amount: Money,
48    ) -> EnvelopeResult<BudgetAllocation> {
49        // Verify category exists
50        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        // Get or create allocation
57        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        // Validate
63        allocation
64            .validate()
65            .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
66
67        // Save
68        self.storage.budget.upsert(allocation.clone())?;
69        self.storage.budget.save()?;
70
71        // Audit
72        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    /// Add to a category's budget for a period
88    pub fn add_to_category(
89        &self,
90        category_id: CategoryId,
91        period: &BudgetPeriod,
92        amount: Money,
93    ) -> EnvelopeResult<BudgetAllocation> {
94        // Verify category exists
95        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        // Get or create allocation
102        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        // Validate (check not negative)
108        allocation
109            .validate()
110            .map_err(|e| EnvelopeError::Budget(e.to_string()))?;
111
112        // Save
113        self.storage.budget.upsert(allocation.clone())?;
114        self.storage.budget.save()?;
115
116        // Audit
117        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    /// Move funds between categories for a period
133    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        // Verify both categories exist
151        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        // Get current allocations
164        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        // Check if from has enough budgeted
174        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        // Move funds
183        from_alloc.add_budgeted(-amount);
184        to_alloc.add_budgeted(amount);
185
186        // Validate both
187        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        // Save both
195        self.storage.budget.upsert(from_alloc.clone())?;
196        self.storage.budget.upsert(to_alloc.clone())?;
197        self.storage.budget.save()?;
198
199        // Audit
200        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    /// Get the allocation for a category in a period
222    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    /// Get budget summary for a category in a period
231    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        // Calculate activity (sum of transactions in this category for this period)
239        let activity = self.calculate_category_activity(category_id, period)?;
240
241        Ok(CategoryBudgetSummary::from_allocation(
242            &allocation,
243            activity,
244        ))
245    }
246
247    /// Calculate activity (spending) for a category in a period
248    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        // Filter to transactions within the period
256        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                // Check if this is a split transaction
264                if t.is_split() {
265                    // Sum only the splits for this category
266                    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    /// Calculate total income for a period (sum of all positive transactions)
281    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    /// Calculate Available to Budget for a period
300    ///
301    /// Available to Budget = Total On-Budget Balances - Total Budgeted for current + prior periods
302    pub fn get_available_to_budget(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
303        // Get total balance across all on-budget accounts
304        let account_service = crate::services::AccountService::new(self.storage);
305        let total_balance = account_service.total_on_budget_balance()?;
306
307        // Get total budgeted for this period
308        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    /// Get expected income for a period (if set)
315    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    /// Check if total budgeted exceeds expected income
323    ///
324    /// Returns Some(overage_amount) if over budget, None otherwise
325    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), // No expectation set
329        };
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)) // Return overage amount
336        } else {
337            Ok(None)
338        }
339    }
340
341    /// Get remaining amount that can be budgeted based on expected income
342    ///
343    /// Returns the difference between expected income and total budgeted.
344    /// Positive = room to budget more, Negative = over-budgeted
345    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    /// Get a complete budget overview for a period
361    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        // Get expected income and calculate over-budget amount
381        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    /// Get all allocations for a category (history)
403    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    /// Calculate the cumulative amount budgeted to a category across all periods
411    /// up to and including the specified period.
412    ///
413    /// This is useful for ByDate targets where progress should reflect total
414    /// budgeted over time, not just current available balance.
415    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    /// Calculate the cumulative amount paid/spent from a category across all time
432    /// up to and including the specified period.
433    ///
434    /// This returns the absolute value of negative activity (outflows/payments).
435    /// Useful for ByDate targets where payments should count as progress even
436    /// if no explicit budgeting occurred.
437    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                    // Sum only the splits for this category
451                    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(|&cents| cents < 0) // Only count outflows (payments)
461            .map(|cents| cents.abs()) // Convert to positive
462            .sum();
463
464        Ok(Money::from_cents(total_paid))
465    }
466
467    /// Calculate the carryover amount for a category going into a specific period
468    ///
469    /// This is the "Available" balance from the previous period, which includes:
470    /// - Budgeted amount
471    /// - Previous carryover
472    /// - Activity (spending)
473    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    /// Apply rollover from the previous period to a category's allocation
484    ///
485    /// This should be called when entering a new period to carry forward
486    /// any surplus or deficit from the previous period.
487    pub fn apply_rollover(
488        &self,
489        category_id: CategoryId,
490        period: &BudgetPeriod,
491    ) -> EnvelopeResult<BudgetAllocation> {
492        // Calculate carryover from previous period
493        let carryover = self.get_carryover(category_id, period)?;
494
495        // Get or create allocation for this period
496        let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
497
498        // Only apply if carryover changed
499        if allocation.carryover != carryover {
500            let before = allocation.clone();
501            allocation.set_carryover(carryover);
502
503            // Save
504            self.storage.budget.upsert(allocation.clone())?;
505            self.storage.budget.save()?;
506
507            // Get category name for audit
508            let category = self.storage.categories.get_category(category_id)?;
509            let category_name = category.map(|c| c.name);
510
511            // Audit
512            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    /// Apply rollover for all categories for a period
529    ///
530    /// This calculates and sets the carryover amount for every category
531    /// based on their Available balance from the previous period.
532    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    /// Get a list of overspent categories for a period
549    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    // ==================== Budget Target Methods ====================
568
569    /// Create or update a budget target for a category
570    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        // Deactivate any existing active target for this category
583        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    /// Update an existing budget target
607    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    /// Get the active target for a category
651    pub fn get_target(&self, category_id: CategoryId) -> EnvelopeResult<Option<BudgetTarget>> {
652        self.storage.targets.get_for_category(category_id)
653    }
654
655    /// Get the suggested budget amount for a category based on its target
656    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    /// Get the suggested budget amount for a category, accounting for progress made.
669    ///
670    /// For ByDate targets, this subtracts what's already been paid from the target
671    /// amount before calculating the monthly suggestion. This prevents over-budgeting
672    /// when payments have already been made toward a debt payoff goal.
673    ///
674    /// For other target types (Weekly, Monthly, Yearly, Custom), this delegates
675    /// to the standard calculation since those are recurring targets.
676    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                // Target already passed
691                if *target_date < period_start {
692                    return Ok(Some(Money::zero()));
693                }
694
695                // Calculate cumulative paid toward this target
696                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                // Calculate remaining amount needed
701                let remaining = (target.amount.cents() - cumulative_paid.cents()).max(0);
702
703                // If already fully paid, suggest $0
704                if remaining == 0 {
705                    return Ok(Some(Money::zero()));
706                }
707
708                // Calculate months remaining (including current month)
709                let months = self.months_between(period_start, *target_date);
710
711                if months <= 0 {
712                    // Target is due this period - suggest remaining amount
713                    Ok(Some(Money::from_cents(remaining)))
714                } else {
715                    // Spread remaining over remaining months
716                    Ok(Some(Money::from_cents(
717                        (remaining as f64 / months as f64).ceil() as i64,
718                    )))
719                }
720            }
721            // For recurring targets, use the standard calculation
722            _ => Ok(Some(target.calculate_for_period(period))),
723        }
724    }
725
726    /// Calculate months between two dates
727    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    /// Delete a target
734    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    /// Remove target for a category
756    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    /// Get all active targets
765    pub fn get_all_targets(&self) -> EnvelopeResult<Vec<BudgetTarget>> {
766        self.storage.targets.get_all_active()
767    }
768
769    /// Auto-fill budget for a category based on its target
770    ///
771    /// Uses progress-aware calculation for ByDate targets, accounting for
772    /// payments already made toward the goal.
773    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    /// Auto-fill budgets for all categories with targets
787    ///
788    /// Uses progress-aware calculation for ByDate targets, accounting for
789    /// payments already made toward each goal.
790    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        // Create a group
828        let group = CategoryGroup::new("Test Group");
829        storage.categories.upsert_group(group.clone()).unwrap();
830
831        // Create two categories
832        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        // First assignment
865        service
866            .assign_to_category(cat_id, &period, Money::from_cents(30000))
867            .unwrap();
868
869        // Add more
870        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        // Assign to first category
884        service
885            .assign_to_category(cat1_id, &period, Money::from_cents(50000))
886            .unwrap();
887
888        // Move some to second
889        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        // Assign to first category
907        service
908            .assign_to_category(cat1_id, &period, Money::from_cents(10000))
909            .unwrap();
910
911        // Try to move more than available
912        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        // Create an account and add a transaction
927        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        // Create account with balance
952        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        // Before budgeting
963        let atb = service.get_available_to_budget(&period).unwrap();
964        assert_eq!(atb.cents(), 100000);
965
966        // After budgeting $500
967        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); // 100000 - 50000
973    }
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        // Budget $500 in January, spend nothing
984        service
985            .assign_to_category(cat_id, &jan, Money::from_cents(50000))
986            .unwrap();
987
988        // Get carryover for February (should be $500 - $0 = $500)
989        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        // Create account and add an overspending transaction
1000        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), // Spent $600
1007        );
1008        txn.category_id = Some(cat_id);
1009        storage.transactions.upsert(txn).unwrap();
1010
1011        let service = BudgetService::new(&storage);
1012
1013        // Budget $500 in January, spent $600 (overspent by $100)
1014        service
1015            .assign_to_category(cat_id, &jan, Money::from_cents(50000))
1016            .unwrap();
1017
1018        // Get carryover for February (should be $500 - $600 = -$100)
1019        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        // Budget $500 in January
1032        service
1033            .assign_to_category(cat_id, &jan, Money::from_cents(50000))
1034            .unwrap();
1035
1036        // Apply rollover to February
1037        let feb_alloc = service.apply_rollover(cat_id, &feb).unwrap();
1038
1039        // Carryover should be $500
1040        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        // Budget in January
1054        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        // Apply rollover for all categories
1062        let allocations = service.apply_rollover_all(&feb).unwrap();
1063        assert_eq!(allocations.len(), 2);
1064
1065        // Check carryovers
1066        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        // Create account and add overspending transaction to cat1
1079        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), // Overspent in cat1
1086        );
1087        txn.category_id = Some(cat1_id);
1088        storage.transactions.upsert(txn).unwrap();
1089
1090        let service = BudgetService::new(&storage);
1091
1092        // Budget $500 in cat1 (will be overspent by $100)
1093        service
1094            .assign_to_category(cat1_id, &period, Money::from_cents(50000))
1095            .unwrap();
1096
1097        // Budget $200 in cat2 (not overspent)
1098        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        // Budget $200 in November 2025
1115        let nov = BudgetPeriod::monthly(2025, 11);
1116        service
1117            .assign_to_category(cat_id, &nov, Money::from_cents(20000))
1118            .unwrap();
1119
1120        // Budget $200 in December 2025
1121        let dec = BudgetPeriod::monthly(2025, 12);
1122        service
1123            .assign_to_category(cat_id, &dec, Money::from_cents(20000))
1124            .unwrap();
1125
1126        // Cumulative through November should be $200
1127        let cumulative_nov = service.calculate_cumulative_budgeted(cat_id, &nov).unwrap();
1128        assert_eq!(cumulative_nov.cents(), 20000);
1129
1130        // Cumulative through December should be $400
1131        let cumulative_dec = service.calculate_cumulative_budgeted(cat_id, &dec).unwrap();
1132        assert_eq!(cumulative_dec.cents(), 40000);
1133
1134        // Cumulative through a future month (Dec 2026) should still be $400
1135        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        // Create an account for transactions
1148        let account = Account::new("Checking", AccountType::Checking);
1149        storage.accounts.upsert(account.clone()).unwrap();
1150
1151        // Make a $100 payment in November (no budgeting)
1152        let mut txn1 = Transaction::new(
1153            account.id,
1154            NaiveDate::from_ymd_opt(2025, 11, 15).unwrap(),
1155            Money::from_cents(-10000), // $100 payment (negative = outflow)
1156        );
1157        txn1.category_id = Some(cat_id);
1158        storage.transactions.upsert(txn1).unwrap();
1159
1160        // Make another $50 payment in December
1161        let mut txn2 = Transaction::new(
1162            account.id,
1163            NaiveDate::from_ymd_opt(2025, 12, 15).unwrap(),
1164            Money::from_cents(-5000), // $50 payment
1165        );
1166        txn2.category_id = Some(cat_id);
1167        storage.transactions.upsert(txn2).unwrap();
1168
1169        let service = BudgetService::new(&storage);
1170
1171        // Cumulative paid through November should be $100
1172        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        // Cumulative paid through December should be $150
1177        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        // With $0 budgeted, payments should still count as progress
1182        let cumulative_budgeted = service.calculate_cumulative_budgeted(cat_id, &dec).unwrap();
1183        assert_eq!(cumulative_budgeted.cents(), 0);
1184
1185        // Paid always wins when there are payments (it's the source of truth)
1186        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); // $150 from payments
1192    }
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        // Create an account for transactions
1200        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        // Budget $200 in December
1207        service
1208            .assign_to_category(cat_id, &dec, Money::from_cents(20000))
1209            .unwrap();
1210
1211        // But only pay $100
1212        let mut txn = Transaction::new(
1213            account.id,
1214            NaiveDate::from_ymd_opt(2025, 12, 15).unwrap(),
1215            Money::from_cents(-10000), // $100 payment
1216        );
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); // $200 budgeted
1224        assert_eq!(cumulative_paid.cents(), 10000); // $100 paid
1225
1226        // Paid wins - even though budgeted is higher, paid is the source of truth
1227        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); // $100 from payments, not $200 budgeted
1233    }
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        // Create an account for transactions
1241        let account = Account::new("Checking", AccountType::Checking);
1242        storage.accounts.upsert(account.clone()).unwrap();
1243
1244        let service = BudgetService::new(&storage);
1245
1246        // Create a ByDate target: $2000 by December 2026
1247        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        // Current period: January 2026
1257        // months_between(Jan, Dec) = 12 - 1 = 11 months
1258        let jan_2026 = BudgetPeriod::monthly(2026, 1);
1259
1260        // Without any payments, should suggest $2000/11 = $181.82 (ceil)
1261        let suggested = service
1262            .get_suggested_budget_with_progress(cat_id, &jan_2026)
1263            .unwrap()
1264            .unwrap();
1265        assert_eq!(suggested.cents(), 18182); // ceil($2000/11) = $181.82
1266
1267        // Now make a $500 payment in January
1268        let mut txn = Transaction::new(
1269            account.id,
1270            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
1271            Money::from_cents(-50000), // $500 payment
1272        );
1273        txn.category_id = Some(cat_id);
1274        storage.transactions.upsert(txn).unwrap();
1275
1276        // For February, should suggest ($2000-$500)/10 = $150
1277        // months_between(Feb, Dec) = 12 - 2 = 10 months
1278        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        // Remaining: $1500, Months: 10 (Feb through Dec)
1284        assert_eq!(suggested.cents(), 15000); // $1500/10 = $150
1285
1286        // Make another $500 payment in February
1287        let mut txn2 = Transaction::new(
1288            account.id,
1289            NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(),
1290            Money::from_cents(-50000), // $500 payment
1291        );
1292        txn2.category_id = Some(cat_id);
1293        storage.transactions.upsert(txn2).unwrap();
1294
1295        // For March, should suggest ($2000-$1000)/9 = $111.12 (ceil)
1296        // months_between(Mar, Dec) = 12 - 3 = 9 months
1297        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); // ceil($1000/9) = $111.12
1303    }
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        // Create an account for transactions
1311        let account = Account::new("Checking", AccountType::Checking);
1312        storage.accounts.upsert(account.clone()).unwrap();
1313
1314        let service = BudgetService::new(&storage);
1315
1316        // Create a ByDate target: $500 by June 2026
1317        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        // Pay full amount in January
1327        let mut txn = Transaction::new(
1328            account.id,
1329            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
1330            Money::from_cents(-50000), // $500 payment - full target
1331        );
1332        txn.category_id = Some(cat_id);
1333        storage.transactions.upsert(txn).unwrap();
1334
1335        // For February, should suggest $0 since fully paid
1336        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        // Create a Monthly target: $300/month
1352        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        // Should always suggest $300 regardless of payments (recurring target)
1359        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}