1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9use super::ids::CategoryId;
10use super::money::Money;
11use super::period::BudgetPeriod;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct BudgetAllocation {
16 pub category_id: CategoryId,
18
19 pub period: BudgetPeriod,
21
22 pub budgeted: Money,
24
25 pub carryover: Money,
27
28 #[serde(default)]
30 pub notes: String,
31
32 pub created_at: DateTime<Utc>,
34
35 pub updated_at: DateTime<Utc>,
37}
38
39impl BudgetAllocation {
40 pub fn new(category_id: CategoryId, period: BudgetPeriod) -> Self {
42 let now = Utc::now();
43 Self {
44 category_id,
45 period,
46 budgeted: Money::zero(),
47 carryover: Money::zero(),
48 notes: String::new(),
49 created_at: now,
50 updated_at: now,
51 }
52 }
53
54 pub fn with_budget(category_id: CategoryId, period: BudgetPeriod, budgeted: Money) -> Self {
56 let mut allocation = Self::new(category_id, period);
57 allocation.budgeted = budgeted;
58 allocation
59 }
60
61 pub fn set_budgeted(&mut self, amount: Money) {
63 self.budgeted = amount;
64 self.updated_at = Utc::now();
65 }
66
67 pub fn add_budgeted(&mut self, amount: Money) {
69 self.budgeted += amount;
70 self.updated_at = Utc::now();
71 }
72
73 pub fn set_carryover(&mut self, amount: Money) {
75 self.carryover = amount;
76 self.updated_at = Utc::now();
77 }
78
79 pub fn total_budgeted(&self) -> Money {
82 self.budgeted + self.carryover
83 }
84
85 pub fn validate(&self) -> Result<(), BudgetValidationError> {
87 if self.budgeted.is_negative() {
89 return Err(BudgetValidationError::NegativeBudget);
90 }
91
92 Ok(())
93 }
94}
95
96impl fmt::Display for BudgetAllocation {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 write!(
99 f,
100 "{} budgeted: {} (carryover: {})",
101 self.period, self.budgeted, self.carryover
102 )
103 }
104}
105
106#[derive(Debug, Clone)]
108pub struct CategoryBudgetSummary {
109 pub category_id: CategoryId,
111
112 pub period: BudgetPeriod,
114
115 pub budgeted: Money,
117
118 pub carryover: Money,
120
121 pub activity: Money,
123
124 pub available: Money,
126}
127
128impl CategoryBudgetSummary {
129 pub fn new(
131 category_id: CategoryId,
132 period: BudgetPeriod,
133 budgeted: Money,
134 carryover: Money,
135 activity: Money,
136 ) -> Self {
137 let available = budgeted + carryover + activity;
138 Self {
139 category_id,
140 period,
141 budgeted,
142 carryover,
143 activity,
144 available,
145 }
146 }
147
148 pub fn empty(category_id: CategoryId) -> Self {
150 Self {
151 category_id,
152 period: BudgetPeriod::current_month(),
153 budgeted: Money::zero(),
154 carryover: Money::zero(),
155 activity: Money::zero(),
156 available: Money::zero(),
157 }
158 }
159
160 pub fn from_allocation(allocation: &BudgetAllocation, activity: Money) -> Self {
162 Self::new(
163 allocation.category_id,
164 allocation.period.clone(),
165 allocation.budgeted,
166 allocation.carryover,
167 activity,
168 )
169 }
170
171 pub fn is_overspent(&self) -> bool {
173 self.available.is_negative()
174 }
175
176 pub fn is_underfunded(&self, goal: Option<Money>) -> bool {
178 if let Some(goal_amount) = goal {
179 self.budgeted < goal_amount
180 } else {
181 false
182 }
183 }
184
185 pub fn rollover_amount(&self) -> Money {
187 self.available
188 }
189}
190
191impl fmt::Display for CategoryBudgetSummary {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 write!(
194 f,
195 "Budgeted: {} | Activity: {} | Available: {}",
196 self.budgeted, self.activity, self.available
197 )
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum BudgetValidationError {
204 NegativeBudget,
205}
206
207impl fmt::Display for BudgetValidationError {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 match self {
210 Self::NegativeBudget => write!(f, "Budget amount cannot be negative"),
211 }
212 }
213}
214
215impl std::error::Error for BudgetValidationError {}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 fn test_category_id() -> CategoryId {
222 CategoryId::new()
223 }
224
225 fn test_period() -> BudgetPeriod {
226 BudgetPeriod::monthly(2025, 1)
227 }
228
229 #[test]
230 fn test_new_allocation() {
231 let category_id = test_category_id();
232 let period = test_period();
233 let allocation = BudgetAllocation::new(category_id, period.clone());
234
235 assert_eq!(allocation.category_id, category_id);
236 assert_eq!(allocation.period, period);
237 assert_eq!(allocation.budgeted, Money::zero());
238 assert_eq!(allocation.carryover, Money::zero());
239 }
240
241 #[test]
242 fn test_with_budget() {
243 let category_id = test_category_id();
244 let period = test_period();
245 let allocation =
246 BudgetAllocation::with_budget(category_id, period, Money::from_cents(50000));
247
248 assert_eq!(allocation.budgeted.cents(), 50000);
249 }
250
251 #[test]
252 fn test_total_budgeted() {
253 let category_id = test_category_id();
254 let period = test_period();
255 let mut allocation = BudgetAllocation::new(category_id, period);
256 allocation.budgeted = Money::from_cents(50000);
257 allocation.carryover = Money::from_cents(10000);
258
259 assert_eq!(allocation.total_budgeted().cents(), 60000);
260 }
261
262 #[test]
263 fn test_negative_carryover() {
264 let category_id = test_category_id();
265 let period = test_period();
266 let mut allocation = BudgetAllocation::new(category_id, period);
267 allocation.budgeted = Money::from_cents(50000);
268 allocation.carryover = Money::from_cents(-20000); assert_eq!(allocation.total_budgeted().cents(), 30000);
271 }
272
273 #[test]
274 fn test_validation() {
275 let category_id = test_category_id();
276 let period = test_period();
277 let mut allocation = BudgetAllocation::new(category_id, period);
278
279 allocation.budgeted = Money::from_cents(50000);
280 assert!(allocation.validate().is_ok());
281
282 allocation.budgeted = Money::from_cents(-100);
283 assert_eq!(
284 allocation.validate(),
285 Err(BudgetValidationError::NegativeBudget)
286 );
287 }
288
289 #[test]
290 fn test_category_summary() {
291 let category_id = test_category_id();
292 let period = test_period();
293 let budgeted = Money::from_cents(50000);
294 let carryover = Money::from_cents(10000);
295 let activity = Money::from_cents(-30000); let summary =
298 CategoryBudgetSummary::new(category_id, period, budgeted, carryover, activity);
299
300 assert_eq!(summary.budgeted.cents(), 50000);
301 assert_eq!(summary.carryover.cents(), 10000);
302 assert_eq!(summary.activity.cents(), -30000);
303 assert_eq!(summary.available.cents(), 30000); assert!(!summary.is_overspent());
305 }
306
307 #[test]
308 fn test_overspent_summary() {
309 let category_id = test_category_id();
310 let period = test_period();
311 let budgeted = Money::from_cents(50000);
312 let carryover = Money::zero();
313 let activity = Money::from_cents(-60000); let summary =
316 CategoryBudgetSummary::new(category_id, period, budgeted, carryover, activity);
317
318 assert!(summary.is_overspent());
319 assert_eq!(summary.available.cents(), -10000);
320 assert_eq!(summary.rollover_amount().cents(), -10000);
321 }
322
323 #[test]
324 fn test_serialization() {
325 let category_id = test_category_id();
326 let period = test_period();
327 let allocation =
328 BudgetAllocation::with_budget(category_id, period, Money::from_cents(50000));
329
330 let json = serde_json::to_string(&allocation).unwrap();
331 let deserialized: BudgetAllocation = serde_json::from_str(&json).unwrap();
332 assert_eq!(allocation.category_id, deserialized.category_id);
333 assert_eq!(allocation.budgeted, deserialized.budgeted);
334 }
335}