use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
use super::ids::CategoryId;
use super::money::Money;
use super::period::BudgetPeriod;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BudgetAllocation {
pub category_id: CategoryId,
pub period: BudgetPeriod,
pub budgeted: Money,
pub carryover: Money,
#[serde(default)]
pub notes: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl BudgetAllocation {
pub fn new(category_id: CategoryId, period: BudgetPeriod) -> Self {
let now = Utc::now();
Self {
category_id,
period,
budgeted: Money::zero(),
carryover: Money::zero(),
notes: String::new(),
created_at: now,
updated_at: now,
}
}
pub fn with_budget(category_id: CategoryId, period: BudgetPeriod, budgeted: Money) -> Self {
let mut allocation = Self::new(category_id, period);
allocation.budgeted = budgeted;
allocation
}
pub fn set_budgeted(&mut self, amount: Money) {
self.budgeted = amount;
self.updated_at = Utc::now();
}
pub fn add_budgeted(&mut self, amount: Money) {
self.budgeted += amount;
self.updated_at = Utc::now();
}
pub fn set_carryover(&mut self, amount: Money) {
self.carryover = amount;
self.updated_at = Utc::now();
}
pub fn total_budgeted(&self) -> Money {
self.budgeted + self.carryover
}
pub fn validate(&self) -> Result<(), BudgetValidationError> {
if self.budgeted.is_negative() {
return Err(BudgetValidationError::NegativeBudget);
}
Ok(())
}
}
impl fmt::Display for BudgetAllocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} budgeted: {} (carryover: {})",
self.period, self.budgeted, self.carryover
)
}
}
#[derive(Debug, Clone)]
pub struct CategoryBudgetSummary {
pub category_id: CategoryId,
pub period: BudgetPeriod,
pub budgeted: Money,
pub carryover: Money,
pub activity: Money,
pub available: Money,
}
impl CategoryBudgetSummary {
pub fn new(
category_id: CategoryId,
period: BudgetPeriod,
budgeted: Money,
carryover: Money,
activity: Money,
) -> Self {
let available = budgeted + carryover + activity;
Self {
category_id,
period,
budgeted,
carryover,
activity,
available,
}
}
pub fn empty(category_id: CategoryId) -> Self {
Self {
category_id,
period: BudgetPeriod::current_month(),
budgeted: Money::zero(),
carryover: Money::zero(),
activity: Money::zero(),
available: Money::zero(),
}
}
pub fn from_allocation(allocation: &BudgetAllocation, activity: Money) -> Self {
Self::new(
allocation.category_id,
allocation.period.clone(),
allocation.budgeted,
allocation.carryover,
activity,
)
}
pub fn is_overspent(&self) -> bool {
self.available.is_negative()
}
pub fn is_underfunded(&self, goal: Option<Money>) -> bool {
if let Some(goal_amount) = goal {
self.budgeted < goal_amount
} else {
false
}
}
pub fn rollover_amount(&self) -> Money {
self.available
}
}
impl fmt::Display for CategoryBudgetSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Budgeted: {} | Activity: {} | Available: {}",
self.budgeted, self.activity, self.available
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BudgetValidationError {
NegativeBudget,
}
impl fmt::Display for BudgetValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NegativeBudget => write!(f, "Budget amount cannot be negative"),
}
}
}
impl std::error::Error for BudgetValidationError {}
#[cfg(test)]
mod tests {
use super::*;
fn test_category_id() -> CategoryId {
CategoryId::new()
}
fn test_period() -> BudgetPeriod {
BudgetPeriod::monthly(2025, 1)
}
#[test]
fn test_new_allocation() {
let category_id = test_category_id();
let period = test_period();
let allocation = BudgetAllocation::new(category_id, period.clone());
assert_eq!(allocation.category_id, category_id);
assert_eq!(allocation.period, period);
assert_eq!(allocation.budgeted, Money::zero());
assert_eq!(allocation.carryover, Money::zero());
}
#[test]
fn test_with_budget() {
let category_id = test_category_id();
let period = test_period();
let allocation =
BudgetAllocation::with_budget(category_id, period, Money::from_cents(50000));
assert_eq!(allocation.budgeted.cents(), 50000);
}
#[test]
fn test_total_budgeted() {
let category_id = test_category_id();
let period = test_period();
let mut allocation = BudgetAllocation::new(category_id, period);
allocation.budgeted = Money::from_cents(50000);
allocation.carryover = Money::from_cents(10000);
assert_eq!(allocation.total_budgeted().cents(), 60000);
}
#[test]
fn test_negative_carryover() {
let category_id = test_category_id();
let period = test_period();
let mut allocation = BudgetAllocation::new(category_id, period);
allocation.budgeted = Money::from_cents(50000);
allocation.carryover = Money::from_cents(-20000);
assert_eq!(allocation.total_budgeted().cents(), 30000);
}
#[test]
fn test_validation() {
let category_id = test_category_id();
let period = test_period();
let mut allocation = BudgetAllocation::new(category_id, period);
allocation.budgeted = Money::from_cents(50000);
assert!(allocation.validate().is_ok());
allocation.budgeted = Money::from_cents(-100);
assert_eq!(
allocation.validate(),
Err(BudgetValidationError::NegativeBudget)
);
}
#[test]
fn test_category_summary() {
let category_id = test_category_id();
let period = test_period();
let budgeted = Money::from_cents(50000);
let carryover = Money::from_cents(10000);
let activity = Money::from_cents(-30000);
let summary =
CategoryBudgetSummary::new(category_id, period, budgeted, carryover, activity);
assert_eq!(summary.budgeted.cents(), 50000);
assert_eq!(summary.carryover.cents(), 10000);
assert_eq!(summary.activity.cents(), -30000);
assert_eq!(summary.available.cents(), 30000); assert!(!summary.is_overspent());
}
#[test]
fn test_overspent_summary() {
let category_id = test_category_id();
let period = test_period();
let budgeted = Money::from_cents(50000);
let carryover = Money::zero();
let activity = Money::from_cents(-60000);
let summary =
CategoryBudgetSummary::new(category_id, period, budgeted, carryover, activity);
assert!(summary.is_overspent());
assert_eq!(summary.available.cents(), -10000);
assert_eq!(summary.rollover_amount().cents(), -10000);
}
#[test]
fn test_serialization() {
let category_id = test_category_id();
let period = test_period();
let allocation =
BudgetAllocation::with_budget(category_id, period, Money::from_cents(50000));
let json = serde_json::to_string(&allocation).unwrap();
let deserialized: BudgetAllocation = serde_json::from_str(&json).unwrap();
assert_eq!(allocation.category_id, deserialized.category_id);
assert_eq!(allocation.budgeted, deserialized.budgeted);
}
}