envelope_cli/models/
target.rs

1//! Budget target model
2//!
3//! Tracks recurring budget targets for categories, supporting various cadences
4//! like YNAB: weekly, monthly, yearly, custom intervals, and by-date goals.
5
6use chrono::{DateTime, Datelike, NaiveDate, Utc};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10use super::ids::CategoryId;
11use super::money::Money;
12use super::period::BudgetPeriod;
13
14/// Unique identifier for a budget target
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct BudgetTargetId(uuid::Uuid);
18
19impl BudgetTargetId {
20    pub fn new() -> Self {
21        Self(uuid::Uuid::new_v4())
22    }
23
24    pub fn parse(s: &str) -> Result<Self, uuid::Error> {
25        Ok(Self(uuid::Uuid::parse_str(s)?))
26    }
27
28    pub fn as_uuid(&self) -> &uuid::Uuid {
29        &self.0
30    }
31}
32
33impl Default for BudgetTargetId {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl fmt::Display for BudgetTargetId {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        write!(f, "tgt-{}", &self.0.to_string()[..8])
42    }
43}
44
45/// The cadence/frequency at which a budget target repeats
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "type", content = "value")]
48pub enum TargetCadence {
49    Weekly,
50    Monthly,
51    Yearly,
52    Custom { days: u32 },
53    ByDate { target_date: NaiveDate },
54}
55
56impl TargetCadence {
57    pub fn weekly() -> Self {
58        Self::Weekly
59    }
60
61    pub fn monthly() -> Self {
62        Self::Monthly
63    }
64
65    pub fn yearly() -> Self {
66        Self::Yearly
67    }
68
69    pub fn custom(days: u32) -> Self {
70        Self::Custom { days }
71    }
72
73    pub fn by_date(target_date: NaiveDate) -> Self {
74        Self::ByDate { target_date }
75    }
76
77    pub fn description(&self) -> String {
78        match self {
79            Self::Weekly => "Weekly".to_string(),
80            Self::Monthly => "Monthly".to_string(),
81            Self::Yearly => "Yearly".to_string(),
82            Self::Custom { days } => format!("Every {} days", days),
83            Self::ByDate { target_date } => format!("By {}", target_date.format("%Y-%m-%d")),
84        }
85    }
86}
87
88impl fmt::Display for TargetCadence {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(f, "{}", self.description())
91    }
92}
93
94/// A budget target for a category
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct BudgetTarget {
97    pub id: BudgetTargetId,
98    pub category_id: CategoryId,
99    pub amount: Money,
100    pub cadence: TargetCadence,
101    #[serde(default)]
102    pub notes: String,
103    #[serde(default = "default_active")]
104    pub active: bool,
105    pub created_at: DateTime<Utc>,
106    pub updated_at: DateTime<Utc>,
107}
108
109fn default_active() -> bool {
110    true
111}
112
113impl BudgetTarget {
114    pub fn new(category_id: CategoryId, amount: Money, cadence: TargetCadence) -> Self {
115        let now = Utc::now();
116        Self {
117            id: BudgetTargetId::new(),
118            category_id,
119            amount,
120            cadence,
121            notes: String::new(),
122            active: true,
123            created_at: now,
124            updated_at: now,
125        }
126    }
127
128    pub fn monthly(category_id: CategoryId, amount: Money) -> Self {
129        Self::new(category_id, amount, TargetCadence::Monthly)
130    }
131
132    pub fn weekly(category_id: CategoryId, amount: Money) -> Self {
133        Self::new(category_id, amount, TargetCadence::Weekly)
134    }
135
136    pub fn yearly(category_id: CategoryId, amount: Money) -> Self {
137        Self::new(category_id, amount, TargetCadence::Yearly)
138    }
139
140    pub fn calculate_for_period(&self, period: &BudgetPeriod) -> Money {
141        if !self.active {
142            return Money::zero();
143        }
144
145        match &self.cadence {
146            TargetCadence::Weekly => self.calculate_weekly_for_period(period),
147            TargetCadence::Monthly => self.calculate_monthly_for_period(period),
148            TargetCadence::Yearly => self.calculate_yearly_for_period(period),
149            TargetCadence::Custom { days } => self.calculate_custom_for_period(period, *days),
150            TargetCadence::ByDate { target_date } => {
151                self.calculate_by_date_for_period(period, *target_date)
152            }
153        }
154    }
155
156    fn calculate_weekly_for_period(&self, period: &BudgetPeriod) -> Money {
157        match period {
158            BudgetPeriod::Weekly { .. } => self.amount,
159            BudgetPeriod::Monthly { year, month } => {
160                let start = NaiveDate::from_ymd_opt(*year, *month, 1).unwrap();
161                let end = if *month == 12 {
162                    NaiveDate::from_ymd_opt(*year + 1, 1, 1).unwrap()
163                } else {
164                    NaiveDate::from_ymd_opt(*year, *month + 1, 1).unwrap()
165                };
166                let days = (end - start).num_days() as f64;
167                let weeks = days / 7.0;
168                Money::from_cents((self.amount.cents() as f64 * weeks).round() as i64)
169            }
170            BudgetPeriod::BiWeekly { .. } => Money::from_cents(self.amount.cents() * 2),
171            BudgetPeriod::Custom { start, end } => {
172                let days = (*end - *start).num_days() as f64 + 1.0;
173                let weeks = days / 7.0;
174                Money::from_cents((self.amount.cents() as f64 * weeks).round() as i64)
175            }
176        }
177    }
178
179    fn calculate_monthly_for_period(&self, period: &BudgetPeriod) -> Money {
180        match period {
181            BudgetPeriod::Monthly { .. } => self.amount,
182            BudgetPeriod::Weekly { .. } => {
183                Money::from_cents((self.amount.cents() as f64 / 4.33).round() as i64)
184            }
185            BudgetPeriod::BiWeekly { .. } => Money::from_cents(self.amount.cents() / 2),
186            BudgetPeriod::Custom { start, end } => {
187                let days = (*end - *start).num_days() as f64 + 1.0;
188                Money::from_cents((self.amount.cents() as f64 * days / 30.0).round() as i64)
189            }
190        }
191    }
192
193    fn calculate_yearly_for_period(&self, period: &BudgetPeriod) -> Money {
194        match period {
195            BudgetPeriod::Monthly { .. } => Money::from_cents(self.amount.cents() / 12),
196            BudgetPeriod::Weekly { .. } => {
197                Money::from_cents((self.amount.cents() as f64 / 52.0).round() as i64)
198            }
199            BudgetPeriod::BiWeekly { .. } => {
200                Money::from_cents((self.amount.cents() as f64 / 26.0).round() as i64)
201            }
202            BudgetPeriod::Custom { start, end } => {
203                let days = (*end - *start).num_days() as f64 + 1.0;
204                Money::from_cents((self.amount.cents() as f64 * days / 365.0).round() as i64)
205            }
206        }
207    }
208
209    fn calculate_custom_for_period(&self, period: &BudgetPeriod, interval_days: u32) -> Money {
210        let period_days = (period.end_date() - period.start_date()).num_days() as f64 + 1.0;
211        let intervals = period_days / interval_days as f64;
212        Money::from_cents((self.amount.cents() as f64 * intervals).round() as i64)
213    }
214
215    fn calculate_by_date_for_period(&self, period: &BudgetPeriod, target_date: NaiveDate) -> Money {
216        let period_start = period.start_date();
217        let period_end = period.end_date();
218
219        if target_date < period_start {
220            return Money::zero();
221        }
222
223        if target_date <= period_end {
224            return self.amount;
225        }
226
227        let months_remaining = self.months_between(period_start, target_date);
228        if months_remaining <= 0 {
229            return self.amount;
230        }
231
232        Money::from_cents((self.amount.cents() as f64 / months_remaining as f64).ceil() as i64)
233    }
234
235    fn months_between(&self, start: NaiveDate, end: NaiveDate) -> i32 {
236        let years = end.year() - start.year();
237        let months = end.month() as i32 - start.month() as i32;
238        years * 12 + months
239    }
240
241    pub fn set_amount(&mut self, amount: Money) {
242        self.amount = amount;
243        self.updated_at = Utc::now();
244    }
245
246    pub fn set_cadence(&mut self, cadence: TargetCadence) {
247        self.cadence = cadence;
248        self.updated_at = Utc::now();
249    }
250
251    pub fn activate(&mut self) {
252        self.active = true;
253        self.updated_at = Utc::now();
254    }
255
256    pub fn deactivate(&mut self) {
257        self.active = false;
258        self.updated_at = Utc::now();
259    }
260
261    pub fn validate(&self) -> Result<(), TargetValidationError> {
262        if self.amount.is_negative() {
263            return Err(TargetValidationError::NegativeAmount);
264        }
265
266        if self.amount.is_zero() {
267            return Err(TargetValidationError::ZeroAmount);
268        }
269
270        if let TargetCadence::Custom { days } = self.cadence {
271            if days == 0 {
272                return Err(TargetValidationError::InvalidCustomInterval);
273            }
274        }
275
276        Ok(())
277    }
278}
279
280impl fmt::Display for BudgetTarget {
281    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282        write!(f, "{} {}", self.amount, self.cadence)
283    }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub enum TargetValidationError {
288    NegativeAmount,
289    ZeroAmount,
290    InvalidCustomInterval,
291}
292
293impl fmt::Display for TargetValidationError {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        match self {
296            Self::NegativeAmount => write!(f, "Target amount cannot be negative"),
297            Self::ZeroAmount => write!(f, "Target amount cannot be zero"),
298            Self::InvalidCustomInterval => write!(f, "Custom interval must be at least 1 day"),
299        }
300    }
301}
302
303impl std::error::Error for TargetValidationError {}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn test_category_id() -> CategoryId {
310        CategoryId::new()
311    }
312
313    #[test]
314    fn test_new_target() {
315        let category_id = test_category_id();
316        let target = BudgetTarget::monthly(category_id, Money::from_cents(50000));
317
318        assert_eq!(target.category_id, category_id);
319        assert_eq!(target.amount.cents(), 50000);
320        assert!(matches!(target.cadence, TargetCadence::Monthly));
321        assert!(target.active);
322    }
323
324    #[test]
325    fn test_monthly_target_for_monthly_period() {
326        let target = BudgetTarget::monthly(test_category_id(), Money::from_cents(50000));
327        let period = BudgetPeriod::monthly(2025, 1);
328
329        let suggested = target.calculate_for_period(&period);
330        assert_eq!(suggested.cents(), 50000);
331    }
332
333    #[test]
334    fn test_yearly_target_for_monthly_period() {
335        let target = BudgetTarget::yearly(test_category_id(), Money::from_cents(120000));
336        let period = BudgetPeriod::monthly(2025, 1);
337
338        let suggested = target.calculate_for_period(&period);
339        assert_eq!(suggested.cents(), 10000);
340    }
341
342    #[test]
343    fn test_validation() {
344        let target = BudgetTarget::monthly(test_category_id(), Money::from_cents(50000));
345        assert!(target.validate().is_ok());
346
347        let negative_target = BudgetTarget::monthly(test_category_id(), Money::from_cents(-100));
348        assert_eq!(
349            negative_target.validate(),
350            Err(TargetValidationError::NegativeAmount)
351        );
352
353        let zero_target = BudgetTarget::monthly(test_category_id(), Money::zero());
354        assert_eq!(
355            zero_target.validate(),
356            Err(TargetValidationError::ZeroAmount)
357        );
358    }
359
360    #[test]
361    fn test_serialization() {
362        let target = BudgetTarget::monthly(test_category_id(), Money::from_cents(50000));
363        let json = serde_json::to_string(&target).unwrap();
364        let deserialized: BudgetTarget = serde_json::from_str(&json).unwrap();
365
366        assert_eq!(target.id, deserialized.id);
367        assert_eq!(target.amount, deserialized.amount);
368        assert_eq!(target.cadence, deserialized.cadence);
369    }
370
371    // ============================================
372    // Edge Case Tests for Period Calculations
373    // ============================================
374
375    #[test]
376    fn test_weekly_target_for_leap_year_february() {
377        // February 2024 has 29 days (leap year)
378        let target = BudgetTarget::weekly(test_category_id(), Money::from_cents(7000)); // $70/week
379        let period = BudgetPeriod::monthly(2024, 2);
380
381        let suggested = target.calculate_for_period(&period);
382        // 29 days / 7 days = ~4.14 weeks
383        // 7000 cents * 4.14 = ~29000 cents
384        let weeks: f64 = 29.0 / 7.0;
385        let expected = (7000.0_f64 * weeks).round() as i64;
386        assert_eq!(suggested.cents(), expected);
387    }
388
389    #[test]
390    fn test_weekly_target_for_non_leap_year_february() {
391        // February 2025 has 28 days (non-leap year)
392        let target = BudgetTarget::weekly(test_category_id(), Money::from_cents(7000)); // $70/week
393        let period = BudgetPeriod::monthly(2025, 2);
394
395        let suggested = target.calculate_for_period(&period);
396        // 28 days / 7 days = 4 weeks exactly
397        let weeks: f64 = 28.0 / 7.0;
398        let expected = (7000.0_f64 * weeks).round() as i64;
399        assert_eq!(suggested.cents(), expected);
400    }
401
402    #[test]
403    fn test_weekly_target_for_31_day_month() {
404        // January has 31 days
405        let target = BudgetTarget::weekly(test_category_id(), Money::from_cents(7000));
406        let period = BudgetPeriod::monthly(2025, 1);
407
408        let suggested = target.calculate_for_period(&period);
409        let weeks: f64 = 31.0 / 7.0;
410        let expected = (7000.0_f64 * weeks).round() as i64;
411        assert_eq!(suggested.cents(), expected);
412    }
413
414    #[test]
415    fn test_weekly_target_for_30_day_month() {
416        // April has 30 days
417        let target = BudgetTarget::weekly(test_category_id(), Money::from_cents(7000));
418        let period = BudgetPeriod::monthly(2025, 4);
419
420        let suggested = target.calculate_for_period(&period);
421        let weeks: f64 = 30.0 / 7.0;
422        let expected = (7000.0_f64 * weeks).round() as i64;
423        assert_eq!(suggested.cents(), expected);
424    }
425
426    #[test]
427    fn test_monthly_target_for_weekly_period() {
428        let target = BudgetTarget::monthly(test_category_id(), Money::from_cents(43300)); // ~$433/month
429        let period = BudgetPeriod::weekly(2025, 1);
430
431        let suggested = target.calculate_for_period(&period);
432        // Monthly / 4.33 = weekly amount
433        let expected = (43300.0_f64 / 4.33_f64).round() as i64;
434        assert_eq!(suggested.cents(), expected);
435    }
436
437    #[test]
438    fn test_monthly_target_for_biweekly_period() {
439        let target = BudgetTarget::monthly(test_category_id(), Money::from_cents(100000)); // $1000/month
440        let period = BudgetPeriod::bi_weekly(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
441
442        let suggested = target.calculate_for_period(&period);
443        // Monthly / 2 = bi-weekly amount
444        assert_eq!(suggested.cents(), 50000); // $500
445    }
446
447    #[test]
448    fn test_yearly_target_for_weekly_period() {
449        let target = BudgetTarget::yearly(test_category_id(), Money::from_cents(5200000)); // $52,000/year
450        let period = BudgetPeriod::weekly(2025, 1);
451
452        let suggested = target.calculate_for_period(&period);
453        // $52,000 / 52 weeks = $1000/week
454        let expected = (5200000.0_f64 / 52.0_f64).round() as i64;
455        assert_eq!(suggested.cents(), expected);
456    }
457
458    #[test]
459    fn test_yearly_target_for_biweekly_period() {
460        let target = BudgetTarget::yearly(test_category_id(), Money::from_cents(2600000)); // $26,000/year
461        let period = BudgetPeriod::bi_weekly(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
462
463        let suggested = target.calculate_for_period(&period);
464        // $26,000 / 26 bi-weekly periods = $1000 per bi-week
465        let expected = (2600000.0_f64 / 26.0_f64).round() as i64;
466        assert_eq!(suggested.cents(), expected);
467    }
468
469    // ============================================
470    // Custom Interval Tests
471    // ============================================
472
473    #[test]
474    fn test_custom_interval_for_monthly_period() {
475        // Target resets every 14 days with $100 target
476        let target = BudgetTarget::new(
477            test_category_id(),
478            Money::from_cents(10000),
479            TargetCadence::custom(14),
480        );
481        let period = BudgetPeriod::monthly(2025, 1); // 31 days
482
483        let suggested = target.calculate_for_period(&period);
484        // 31 days / 14 days = ~2.21 intervals
485        // 10000 * 2.21 = ~22143 cents
486        let intervals: f64 = 31.0 / 14.0;
487        let expected = (10000.0_f64 * intervals).round() as i64;
488        assert_eq!(suggested.cents(), expected);
489    }
490
491    #[test]
492    fn test_custom_interval_for_weekly_period() {
493        // Target resets every 3 days with $30 target
494        let target = BudgetTarget::new(
495            test_category_id(),
496            Money::from_cents(3000),
497            TargetCadence::custom(3),
498        );
499        let period = BudgetPeriod::weekly(2025, 1); // 7 days
500
501        let suggested = target.calculate_for_period(&period);
502        // Week is 7 days (end - start + 1), so 7/3 = ~2.33 intervals
503        // But the formula uses (end - start).num_days() + 1 = 7
504        let period_days: f64 = 7.0; // Weekly period is 7 days
505        let intervals: f64 = period_days / 3.0;
506        let expected = (3000.0_f64 * intervals).round() as i64;
507        assert_eq!(suggested.cents(), expected);
508    }
509
510    #[test]
511    fn test_custom_interval_one_day() {
512        // Daily target
513        let target = BudgetTarget::new(
514            test_category_id(),
515            Money::from_cents(1000),
516            TargetCadence::custom(1),
517        );
518        let period = BudgetPeriod::monthly(2025, 1); // 31 days
519
520        let suggested = target.calculate_for_period(&period);
521        // 31 intervals at $10 each = $310
522        assert_eq!(suggested.cents(), 31000);
523    }
524
525    #[test]
526    fn test_custom_interval_validation() {
527        let target = BudgetTarget::new(
528            test_category_id(),
529            Money::from_cents(10000),
530            TargetCadence::custom(0), // Invalid: 0 days
531        );
532
533        assert_eq!(
534            target.validate(),
535            Err(TargetValidationError::InvalidCustomInterval)
536        );
537    }
538
539    // ============================================
540    // ByDate Cadence Tests
541    // ============================================
542
543    #[test]
544    fn test_by_date_target_date_in_current_period() {
545        // Target date is within the current period - should return full amount
546        let target = BudgetTarget::new(
547            test_category_id(),
548            Money::from_cents(100000), // $1000
549            TargetCadence::by_date(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()),
550        );
551        let period = BudgetPeriod::monthly(2025, 1);
552
553        let suggested = target.calculate_for_period(&period);
554        assert_eq!(suggested.cents(), 100000); // Full amount needed
555    }
556
557    #[test]
558    fn test_by_date_target_date_passed() {
559        // Target date has already passed - should return zero
560        let target = BudgetTarget::new(
561            test_category_id(),
562            Money::from_cents(100000),
563            TargetCadence::by_date(NaiveDate::from_ymd_opt(2024, 12, 15).unwrap()),
564        );
565        let period = BudgetPeriod::monthly(2025, 1);
566
567        let suggested = target.calculate_for_period(&period);
568        assert_eq!(suggested.cents(), 0);
569    }
570
571    #[test]
572    fn test_by_date_six_months_away() {
573        // Target is 6 months away
574        let target = BudgetTarget::new(
575            test_category_id(),
576            Money::from_cents(600000), // $6000
577            TargetCadence::by_date(NaiveDate::from_ymd_opt(2025, 7, 1).unwrap()),
578        );
579        let period = BudgetPeriod::monthly(2025, 1);
580
581        let suggested = target.calculate_for_period(&period);
582        // 6 months away = $6000 / 6 = $1000 per month
583        // Using ceil, so 600000 / 6 = 100000
584        assert_eq!(suggested.cents(), 100000);
585    }
586
587    #[test]
588    fn test_by_date_twelve_months_away() {
589        // Target is 12 months away
590        let target = BudgetTarget::new(
591            test_category_id(),
592            Money::from_cents(1200000), // $12,000
593            TargetCadence::by_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
594        );
595        let period = BudgetPeriod::monthly(2025, 1);
596
597        let suggested = target.calculate_for_period(&period);
598        // 12 months away = $12,000 / 12 = $1000 per month
599        assert_eq!(suggested.cents(), 100000);
600    }
601
602    #[test]
603    fn test_by_date_one_month_away() {
604        // Target is next month
605        let target = BudgetTarget::new(
606            test_category_id(),
607            Money::from_cents(50000), // $500
608            TargetCadence::by_date(NaiveDate::from_ymd_opt(2025, 2, 15).unwrap()),
609        );
610        let period = BudgetPeriod::monthly(2025, 1);
611
612        let suggested = target.calculate_for_period(&period);
613        // 1 month away = full amount needed this month
614        assert_eq!(suggested.cents(), 50000);
615    }
616
617    #[test]
618    fn test_by_date_uneven_distribution() {
619        // $1000 over 3 months = $333.34 per month (rounded up)
620        let target = BudgetTarget::new(
621            test_category_id(),
622            Money::from_cents(100000), // $1000
623            TargetCadence::by_date(NaiveDate::from_ymd_opt(2025, 4, 1).unwrap()),
624        );
625        let period = BudgetPeriod::monthly(2025, 1);
626
627        let suggested = target.calculate_for_period(&period);
628        // 3 months away, using ceil: ceil(100000 / 3) = 33334
629        let expected = (100000.0_f64 / 3.0_f64).ceil() as i64;
630        assert_eq!(suggested.cents(), expected);
631    }
632
633    #[test]
634    fn test_by_date_target_at_period_end() {
635        // Target date is exactly at the end of the period
636        let target = BudgetTarget::new(
637            test_category_id(),
638            Money::from_cents(100000),
639            TargetCadence::by_date(NaiveDate::from_ymd_opt(2025, 1, 31).unwrap()),
640        );
641        let period = BudgetPeriod::monthly(2025, 1);
642
643        let suggested = target.calculate_for_period(&period);
644        assert_eq!(suggested.cents(), 100000); // Full amount needed
645    }
646
647    // ============================================
648    // Inactive Target Tests
649    // ============================================
650
651    #[test]
652    fn test_inactive_target_returns_zero() {
653        let mut target = BudgetTarget::monthly(test_category_id(), Money::from_cents(50000));
654        target.deactivate();
655
656        let period = BudgetPeriod::monthly(2025, 1);
657        let suggested = target.calculate_for_period(&period);
658
659        assert_eq!(suggested.cents(), 0);
660        assert!(!target.active);
661    }
662
663    #[test]
664    fn test_reactivated_target() {
665        let mut target = BudgetTarget::monthly(test_category_id(), Money::from_cents(50000));
666        target.deactivate();
667        target.activate();
668
669        let period = BudgetPeriod::monthly(2025, 1);
670        let suggested = target.calculate_for_period(&period);
671
672        assert_eq!(suggested.cents(), 50000);
673        assert!(target.active);
674    }
675
676    #[test]
677    fn test_inactive_weekly_target() {
678        let mut target = BudgetTarget::weekly(test_category_id(), Money::from_cents(7000));
679        target.deactivate();
680
681        let period = BudgetPeriod::weekly(2025, 1);
682        let suggested = target.calculate_for_period(&period);
683
684        assert_eq!(suggested.cents(), 0);
685    }
686
687    #[test]
688    fn test_inactive_yearly_target() {
689        let mut target = BudgetTarget::yearly(test_category_id(), Money::from_cents(120000));
690        target.deactivate();
691
692        let period = BudgetPeriod::monthly(2025, 1);
693        let suggested = target.calculate_for_period(&period);
694
695        assert_eq!(suggested.cents(), 0);
696    }
697
698    #[test]
699    fn test_inactive_by_date_target() {
700        let mut target = BudgetTarget::new(
701            test_category_id(),
702            Money::from_cents(100000),
703            TargetCadence::by_date(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap()),
704        );
705        target.deactivate();
706
707        let period = BudgetPeriod::monthly(2025, 1);
708        let suggested = target.calculate_for_period(&period);
709
710        assert_eq!(suggested.cents(), 0);
711    }
712
713    // ============================================
714    // Custom Period Tests
715    // ============================================
716
717    #[test]
718    fn test_weekly_target_for_custom_period() {
719        let target = BudgetTarget::weekly(test_category_id(), Money::from_cents(7000));
720        let period = BudgetPeriod::custom(
721            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
722            NaiveDate::from_ymd_opt(2025, 1, 21).unwrap(),
723        ); // 21 days
724
725        let suggested = target.calculate_for_period(&period);
726        // 21 days / 7 = 3 weeks
727        let weeks: f64 = 21.0 / 7.0;
728        let expected = (7000.0_f64 * weeks).round() as i64;
729        assert_eq!(suggested.cents(), expected);
730    }
731
732    #[test]
733    fn test_monthly_target_for_custom_period() {
734        let target = BudgetTarget::monthly(test_category_id(), Money::from_cents(30000)); // $300/month
735        let period = BudgetPeriod::custom(
736            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
737            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
738        ); // 15 days
739
740        let suggested = target.calculate_for_period(&period);
741        // 15 days out of ~30 = half the monthly amount
742        let days: f64 = 15.0;
743        let expected = (30000.0_f64 * days / 30.0_f64).round() as i64;
744        assert_eq!(suggested.cents(), expected);
745    }
746
747    #[test]
748    fn test_yearly_target_for_custom_period() {
749        let target = BudgetTarget::yearly(test_category_id(), Money::from_cents(3650000)); // $36,500/year
750        let period = BudgetPeriod::custom(
751            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
752            NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
753        ); // 10 days
754
755        let suggested = target.calculate_for_period(&period);
756        // 10 days out of 365 = 10/365 of yearly
757        let days: f64 = 10.0;
758        let expected = (3650000.0_f64 * days / 365.0_f64).round() as i64;
759        assert_eq!(suggested.cents(), expected);
760    }
761
762    // ============================================
763    // Modification Tests
764    // ============================================
765
766    #[test]
767    fn test_set_amount_updates_timestamp() {
768        let mut target = BudgetTarget::monthly(test_category_id(), Money::from_cents(50000));
769        let original_updated_at = target.updated_at;
770
771        std::thread::sleep(std::time::Duration::from_millis(10));
772        target.set_amount(Money::from_cents(75000));
773
774        assert_eq!(target.amount.cents(), 75000);
775        assert!(target.updated_at > original_updated_at);
776    }
777
778    #[test]
779    fn test_set_cadence_updates_timestamp() {
780        let mut target = BudgetTarget::monthly(test_category_id(), Money::from_cents(50000));
781        let original_updated_at = target.updated_at;
782
783        std::thread::sleep(std::time::Duration::from_millis(10));
784        target.set_cadence(TargetCadence::Weekly);
785
786        assert!(matches!(target.cadence, TargetCadence::Weekly));
787        assert!(target.updated_at > original_updated_at);
788    }
789
790    // ============================================
791    // Display and Formatting Tests
792    // ============================================
793
794    #[test]
795    fn test_cadence_display() {
796        assert_eq!(TargetCadence::weekly().description(), "Weekly");
797        assert_eq!(TargetCadence::monthly().description(), "Monthly");
798        assert_eq!(TargetCadence::yearly().description(), "Yearly");
799        assert_eq!(TargetCadence::custom(14).description(), "Every 14 days");
800        assert_eq!(
801            TargetCadence::by_date(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap()).description(),
802            "By 2025-06-01"
803        );
804    }
805
806    #[test]
807    fn test_target_id_display() {
808        let id = BudgetTargetId::new();
809        let display = format!("{}", id);
810        assert!(display.starts_with("tgt-"));
811        assert_eq!(display.len(), 12); // "tgt-" + 8 hex chars
812    }
813
814    #[test]
815    fn test_target_display() {
816        let target = BudgetTarget::monthly(test_category_id(), Money::from_cents(50000));
817        let display = format!("{}", target);
818        assert!(display.contains("Monthly"));
819    }
820
821    // ============================================
822    // Serialization Tests for All Cadence Types
823    // ============================================
824
825    #[test]
826    fn test_weekly_cadence_serialization() {
827        let target = BudgetTarget::weekly(test_category_id(), Money::from_cents(7000));
828        let json = serde_json::to_string(&target).unwrap();
829        let deserialized: BudgetTarget = serde_json::from_str(&json).unwrap();
830
831        assert!(matches!(deserialized.cadence, TargetCadence::Weekly));
832    }
833
834    #[test]
835    fn test_yearly_cadence_serialization() {
836        let target = BudgetTarget::yearly(test_category_id(), Money::from_cents(120000));
837        let json = serde_json::to_string(&target).unwrap();
838        let deserialized: BudgetTarget = serde_json::from_str(&json).unwrap();
839
840        assert!(matches!(deserialized.cadence, TargetCadence::Yearly));
841    }
842
843    #[test]
844    fn test_custom_cadence_serialization() {
845        let target = BudgetTarget::new(
846            test_category_id(),
847            Money::from_cents(10000),
848            TargetCadence::custom(14),
849        );
850        let json = serde_json::to_string(&target).unwrap();
851        let deserialized: BudgetTarget = serde_json::from_str(&json).unwrap();
852
853        match deserialized.cadence {
854            TargetCadence::Custom { days } => assert_eq!(days, 14),
855            _ => panic!("Expected Custom cadence"),
856        }
857    }
858
859    #[test]
860    fn test_by_date_cadence_serialization() {
861        let target_date = NaiveDate::from_ymd_opt(2025, 6, 1).unwrap();
862        let target = BudgetTarget::new(
863            test_category_id(),
864            Money::from_cents(100000),
865            TargetCadence::by_date(target_date),
866        );
867        let json = serde_json::to_string(&target).unwrap();
868        let deserialized: BudgetTarget = serde_json::from_str(&json).unwrap();
869
870        match deserialized.cadence {
871            TargetCadence::ByDate {
872                target_date: date, ..
873            } => assert_eq!(date, target_date),
874            _ => panic!("Expected ByDate cadence"),
875        }
876    }
877}