use serde::Serialize;
#[derive(Debug)]
pub struct RecurringItem {
pub merchant: String,
pub amount: f64,
pub is_past: bool,
}
#[derive(Debug, Serialize)]
pub struct ForecastResult {
pub projected_month_end_balance: f64,
pub shortfall: bool,
pub shortfall_amount: f64,
pub shortfall_drivers: Vec<String>,
}
pub fn compute_forecast(current_balance: f64, items: &[RecurringItem]) -> ForecastResult {
let upcoming: Vec<&RecurringItem> = items.iter().filter(|i| !i.is_past).collect();
let projected = upcoming
.iter()
.fold(current_balance, |acc, item| acc + item.amount);
let shortfall = projected < 0.0;
let shortfall_amount = if shortfall { projected.abs() } else { 0.0 };
let shortfall_drivers = if shortfall {
upcoming
.iter()
.filter(|i| i.amount < 0.0)
.map(|i| i.merchant.clone())
.collect()
} else {
vec![]
};
ForecastResult {
projected_month_end_balance: projected,
shortfall,
shortfall_amount,
shortfall_drivers,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bill(merchant: &str, amount: f64) -> RecurringItem {
RecurringItem {
merchant: merchant.to_string(),
amount: -amount.abs(),
is_past: false,
}
}
fn past_bill(merchant: &str, amount: f64) -> RecurringItem {
RecurringItem {
merchant: merchant.to_string(),
amount: -amount.abs(),
is_past: true,
}
}
#[test]
fn no_upcoming_bills_leaves_balance_unchanged() {
let result = compute_forecast(2000.0, &[]);
assert!((result.projected_month_end_balance - 2000.0).abs() < 0.01);
assert!(!result.shortfall);
assert_eq!(result.shortfall_amount, 0.0);
assert!(result.shortfall_drivers.is_empty());
}
#[test]
fn positive_projection_when_balance_covers_all_bills() {
let items = vec![
bill("Rent", 1500.0),
bill("Electric", 120.0),
bill("Internet", 80.0),
];
let result = compute_forecast(3000.0, &items);
assert!((result.projected_month_end_balance - 1300.0).abs() < 0.01);
assert!(!result.shortfall);
}
#[test]
fn shortfall_flagged_when_upcoming_bills_exceed_balance() {
let items = vec![bill("Rent", 1500.0), bill("Subscription", 15.0)];
let result = compute_forecast(500.0, &items);
assert!(result.shortfall, "expected shortfall flag");
assert!((result.shortfall_amount - 1015.0).abs() < 0.01);
assert!(result.shortfall_drivers.contains(&"Rent".to_string()));
assert!(result
.shortfall_drivers
.contains(&"Subscription".to_string()));
}
#[test]
fn past_recurring_items_are_excluded_from_projection() {
let items = vec![past_bill("Rent", 1200.0), bill("Electric", 100.0)];
let result = compute_forecast(1800.0, &items);
assert!((result.projected_month_end_balance - 1700.0).abs() < 0.01);
assert!(!result.shortfall);
}
}