1use 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#[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#[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#[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 #[test]
376 fn test_weekly_target_for_leap_year_february() {
377 let target = BudgetTarget::weekly(test_category_id(), Money::from_cents(7000)); let period = BudgetPeriod::monthly(2024, 2);
380
381 let suggested = target.calculate_for_period(&period);
382 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 let target = BudgetTarget::weekly(test_category_id(), Money::from_cents(7000)); let period = BudgetPeriod::monthly(2025, 2);
394
395 let suggested = target.calculate_for_period(&period);
396 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 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 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)); let period = BudgetPeriod::weekly(2025, 1);
430
431 let suggested = target.calculate_for_period(&period);
432 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)); let period = BudgetPeriod::bi_weekly(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
441
442 let suggested = target.calculate_for_period(&period);
443 assert_eq!(suggested.cents(), 50000); }
446
447 #[test]
448 fn test_yearly_target_for_weekly_period() {
449 let target = BudgetTarget::yearly(test_category_id(), Money::from_cents(5200000)); let period = BudgetPeriod::weekly(2025, 1);
451
452 let suggested = target.calculate_for_period(&period);
453 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)); let period = BudgetPeriod::bi_weekly(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
462
463 let suggested = target.calculate_for_period(&period);
464 let expected = (2600000.0_f64 / 26.0_f64).round() as i64;
466 assert_eq!(suggested.cents(), expected);
467 }
468
469 #[test]
474 fn test_custom_interval_for_monthly_period() {
475 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); let suggested = target.calculate_for_period(&period);
484 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 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); let suggested = target.calculate_for_period(&period);
502 let period_days: f64 = 7.0; 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 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); let suggested = target.calculate_for_period(&period);
521 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), );
532
533 assert_eq!(
534 target.validate(),
535 Err(TargetValidationError::InvalidCustomInterval)
536 );
537 }
538
539 #[test]
544 fn test_by_date_target_date_in_current_period() {
545 let target = BudgetTarget::new(
547 test_category_id(),
548 Money::from_cents(100000), 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); }
556
557 #[test]
558 fn test_by_date_target_date_passed() {
559 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 let target = BudgetTarget::new(
575 test_category_id(),
576 Money::from_cents(600000), 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 assert_eq!(suggested.cents(), 100000);
585 }
586
587 #[test]
588 fn test_by_date_twelve_months_away() {
589 let target = BudgetTarget::new(
591 test_category_id(),
592 Money::from_cents(1200000), 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 assert_eq!(suggested.cents(), 100000);
600 }
601
602 #[test]
603 fn test_by_date_one_month_away() {
604 let target = BudgetTarget::new(
606 test_category_id(),
607 Money::from_cents(50000), 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 assert_eq!(suggested.cents(), 50000);
615 }
616
617 #[test]
618 fn test_by_date_uneven_distribution() {
619 let target = BudgetTarget::new(
621 test_category_id(),
622 Money::from_cents(100000), 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 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 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); }
646
647 #[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 #[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 ); let suggested = target.calculate_for_period(&period);
726 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)); let period = BudgetPeriod::custom(
736 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
737 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
738 ); let suggested = target.calculate_for_period(&period);
741 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)); let period = BudgetPeriod::custom(
751 NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
752 NaiveDate::from_ymd_opt(2025, 1, 10).unwrap(),
753 ); let suggested = target.calculate_for_period(&period);
756 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 #[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 #[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); }
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 #[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}