use crate::audit::EntityType;
use crate::error::{EnvelopeError, EnvelopeResult};
use crate::models::{
BudgetAllocation, BudgetPeriod, BudgetTarget, BudgetTargetId, CategoryBudgetSummary,
CategoryId, Money, TargetCadence,
};
use crate::services::CategoryService;
use crate::storage::Storage;
use chrono::Datelike;
pub struct BudgetService<'a> {
storage: &'a Storage,
}
#[derive(Debug, Clone)]
pub struct BudgetOverview {
pub period: BudgetPeriod,
pub total_budgeted: Money,
pub total_activity: Money,
pub total_available: Money,
pub available_to_budget: Money,
pub categories: Vec<CategoryBudgetSummary>,
pub expected_income: Option<Money>,
pub over_budget_amount: Option<Money>,
}
impl<'a> BudgetService<'a> {
pub fn new(storage: &'a Storage) -> Self {
Self { storage }
}
pub fn assign_to_category(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
amount: Money,
) -> EnvelopeResult<BudgetAllocation> {
let category = self
.storage
.categories
.get_category(category_id)?
.ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
let before = allocation.clone();
allocation.set_budgeted(amount);
allocation
.validate()
.map_err(|e| EnvelopeError::Budget(e.to_string()))?;
self.storage.budget.upsert(allocation.clone())?;
self.storage.budget.save()?;
self.storage.log_update(
EntityType::BudgetAllocation,
format!("{}:{}", category_id, period),
Some(category.name),
&before,
&allocation,
Some(format!(
"budgeted: {} -> {}",
before.budgeted, allocation.budgeted
)),
)?;
Ok(allocation)
}
pub fn add_to_category(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
amount: Money,
) -> EnvelopeResult<BudgetAllocation> {
let category = self
.storage
.categories
.get_category(category_id)?
.ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
let before = allocation.clone();
allocation.add_budgeted(amount);
allocation
.validate()
.map_err(|e| EnvelopeError::Budget(e.to_string()))?;
self.storage.budget.upsert(allocation.clone())?;
self.storage.budget.save()?;
self.storage.log_update(
EntityType::BudgetAllocation,
format!("{}:{}", category_id, period),
Some(category.name),
&before,
&allocation,
Some(format!(
"budgeted: {} -> {} (+{})",
before.budgeted, allocation.budgeted, amount
)),
)?;
Ok(allocation)
}
pub fn move_between_categories(
&self,
from_category_id: CategoryId,
to_category_id: CategoryId,
period: &BudgetPeriod,
amount: Money,
) -> EnvelopeResult<()> {
if amount.is_zero() {
return Ok(());
}
if amount.is_negative() {
return Err(EnvelopeError::Budget(
"Amount to move must be positive".into(),
));
}
let from_category = self
.storage
.categories
.get_category(from_category_id)?
.ok_or_else(|| EnvelopeError::category_not_found(from_category_id.to_string()))?;
let to_category = self
.storage
.categories
.get_category(to_category_id)?
.ok_or_else(|| EnvelopeError::category_not_found(to_category_id.to_string()))?;
let mut from_alloc = self
.storage
.budget
.get_or_default(from_category_id, period)?;
let mut to_alloc = self.storage.budget.get_or_default(to_category_id, period)?;
let from_before = from_alloc.clone();
let to_before = to_alloc.clone();
if from_alloc.budgeted < amount {
return Err(EnvelopeError::InsufficientFunds {
category: from_category.name.clone(),
needed: amount.cents(),
available: from_alloc.budgeted.cents(),
});
}
from_alloc.add_budgeted(-amount);
to_alloc.add_budgeted(amount);
from_alloc
.validate()
.map_err(|e| EnvelopeError::Budget(e.to_string()))?;
to_alloc
.validate()
.map_err(|e| EnvelopeError::Budget(e.to_string()))?;
self.storage.budget.upsert(from_alloc.clone())?;
self.storage.budget.upsert(to_alloc.clone())?;
self.storage.budget.save()?;
self.storage.log_update(
EntityType::BudgetAllocation,
format!("{}:{}", from_category_id, period),
Some(from_category.name.clone()),
&from_before,
&from_alloc,
Some(format!("moved {} to '{}'", amount, to_category.name)),
)?;
self.storage.log_update(
EntityType::BudgetAllocation,
format!("{}:{}", to_category_id, period),
Some(to_category.name.clone()),
&to_before,
&to_alloc,
Some(format!("received {} from '{}'", amount, from_category.name)),
)?;
Ok(())
}
pub fn get_allocation(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
) -> EnvelopeResult<BudgetAllocation> {
self.storage.budget.get_or_default(category_id, period)
}
pub fn get_category_summary(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
) -> EnvelopeResult<CategoryBudgetSummary> {
let allocation = self.storage.budget.get_or_default(category_id, period)?;
let activity = self.calculate_category_activity(category_id, period)?;
Ok(CategoryBudgetSummary::from_allocation(
&allocation,
activity,
))
}
pub fn calculate_category_activity(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
) -> EnvelopeResult<Money> {
let transactions = self.storage.transactions.get_by_category(category_id)?;
let period_start = period.start_date();
let period_end = period.end_date();
let activity: Money = transactions
.iter()
.filter(|t| t.date >= period_start && t.date <= period_end)
.map(|t| {
if t.is_split() {
t.splits
.iter()
.filter(|s| s.category_id == category_id)
.map(|s| s.amount)
.sum()
} else {
t.amount
}
})
.sum();
Ok(activity)
}
pub fn calculate_income_for_period(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
let period_start = period.start_date();
let period_end = period.end_date();
let transactions = self
.storage
.transactions
.get_by_date_range(period_start, period_end)?;
let income: Money = transactions
.iter()
.filter(|t| t.amount.is_positive())
.map(|t| t.amount)
.sum();
Ok(income)
}
pub fn get_available_to_budget(&self, period: &BudgetPeriod) -> EnvelopeResult<Money> {
let account_service = crate::services::AccountService::new(self.storage);
let total_balance = account_service.total_on_budget_balance()?;
let allocations = self.storage.budget.get_for_period(period)?;
let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
Ok(total_balance - total_budgeted)
}
pub fn get_expected_income(&self, period: &BudgetPeriod) -> Option<Money> {
self.storage
.income
.get_for_period(period)
.map(|e| e.expected_amount)
}
pub fn is_over_expected_income(&self, period: &BudgetPeriod) -> EnvelopeResult<Option<Money>> {
let expected = match self.get_expected_income(period) {
Some(e) => e,
None => return Ok(None), };
let allocations = self.storage.budget.get_for_period(period)?;
let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
if total_budgeted > expected {
Ok(Some(total_budgeted - expected)) } else {
Ok(None)
}
}
pub fn get_remaining_to_budget_from_income(
&self,
period: &BudgetPeriod,
) -> EnvelopeResult<Option<Money>> {
let expected = match self.get_expected_income(period) {
Some(e) => e,
None => return Ok(None),
};
let allocations = self.storage.budget.get_for_period(period)?;
let total_budgeted: Money = allocations.iter().map(|a| a.budgeted).sum();
Ok(Some(expected - total_budgeted))
}
pub fn get_budget_overview(&self, period: &BudgetPeriod) -> EnvelopeResult<BudgetOverview> {
let category_service = CategoryService::new(self.storage);
let categories = category_service.list_categories()?;
let mut summaries = Vec::with_capacity(categories.len());
let mut total_budgeted = Money::zero();
let mut total_activity = Money::zero();
let mut total_available = Money::zero();
for category in &categories {
let summary = self.get_category_summary(category.id, period)?;
total_budgeted += summary.budgeted;
total_activity += summary.activity;
total_available += summary.available;
summaries.push(summary);
}
let available_to_budget = self.get_available_to_budget(period)?;
let expected_income = self.get_expected_income(period);
let over_budget_amount = expected_income.and_then(|expected| {
if total_budgeted > expected {
Some(total_budgeted - expected)
} else {
None
}
});
Ok(BudgetOverview {
period: period.clone(),
total_budgeted,
total_activity,
total_available,
available_to_budget,
categories: summaries,
expected_income,
over_budget_amount,
})
}
pub fn get_allocation_history(
&self,
category_id: CategoryId,
) -> EnvelopeResult<Vec<BudgetAllocation>> {
self.storage.budget.get_for_category(category_id)
}
pub fn calculate_cumulative_budgeted(
&self,
category_id: CategoryId,
up_to_period: &BudgetPeriod,
) -> EnvelopeResult<Money> {
let allocations = self.storage.budget.get_for_category(category_id)?;
let total: Money = allocations
.iter()
.filter(|a| &a.period <= up_to_period)
.map(|a| a.budgeted)
.sum();
Ok(total)
}
pub fn calculate_cumulative_paid(
&self,
category_id: CategoryId,
up_to_period: &BudgetPeriod,
) -> EnvelopeResult<Money> {
let transactions = self.storage.transactions.get_by_category(category_id)?;
let end_date = up_to_period.end_date();
let total_paid: i64 = transactions
.iter()
.filter(|t| t.date <= end_date)
.map(|t| {
if t.is_split() {
t.splits
.iter()
.filter(|s| s.category_id == category_id)
.map(|s| s.amount.cents())
.sum::<i64>()
} else {
t.amount.cents()
}
})
.filter(|¢s| cents < 0) .map(|cents| cents.abs()) .sum();
Ok(Money::from_cents(total_paid))
}
pub fn get_carryover(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
) -> EnvelopeResult<Money> {
let prev_period = period.prev();
let summary = self.get_category_summary(category_id, &prev_period)?;
Ok(summary.rollover_amount())
}
pub fn apply_rollover(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
) -> EnvelopeResult<BudgetAllocation> {
let carryover = self.get_carryover(category_id, period)?;
let mut allocation = self.storage.budget.get_or_default(category_id, period)?;
if allocation.carryover != carryover {
let before = allocation.clone();
allocation.set_carryover(carryover);
self.storage.budget.upsert(allocation.clone())?;
self.storage.budget.save()?;
let category = self.storage.categories.get_category(category_id)?;
let category_name = category.map(|c| c.name);
self.storage.log_update(
EntityType::BudgetAllocation,
format!("{}:{}", category_id, period),
category_name,
&before,
&allocation,
Some(format!(
"carryover: {} -> {}",
before.carryover, allocation.carryover
)),
)?;
}
Ok(allocation)
}
pub fn apply_rollover_all(
&self,
period: &BudgetPeriod,
) -> EnvelopeResult<Vec<BudgetAllocation>> {
let category_service = CategoryService::new(self.storage);
let categories = category_service.list_categories()?;
let mut allocations = Vec::with_capacity(categories.len());
for category in &categories {
let allocation = self.apply_rollover(category.id, period)?;
allocations.push(allocation);
}
Ok(allocations)
}
pub fn get_overspent_categories(
&self,
period: &BudgetPeriod,
) -> EnvelopeResult<Vec<CategoryBudgetSummary>> {
let category_service = CategoryService::new(self.storage);
let categories = category_service.list_categories()?;
let mut overspent = Vec::new();
for category in &categories {
let summary = self.get_category_summary(category.id, period)?;
if summary.is_overspent() {
overspent.push(summary);
}
}
Ok(overspent)
}
pub fn set_target(
&self,
category_id: CategoryId,
amount: Money,
cadence: TargetCadence,
) -> EnvelopeResult<BudgetTarget> {
let category = self
.storage
.categories
.get_category(category_id)?
.ok_or_else(|| EnvelopeError::category_not_found(category_id.to_string()))?;
if let Some(mut existing) = self.storage.targets.get_for_category(category_id)? {
existing.deactivate();
self.storage.targets.upsert(existing)?;
}
let target = BudgetTarget::new(category_id, amount, cadence);
target
.validate()
.map_err(|e| EnvelopeError::Budget(e.to_string()))?;
self.storage.targets.upsert(target.clone())?;
self.storage.targets.save()?;
self.storage.log_create(
EntityType::BudgetTarget,
target.id.to_string(),
Some(category.name),
&target,
)?;
Ok(target)
}
pub fn update_target(
&self,
target_id: BudgetTargetId,
amount: Option<Money>,
cadence: Option<TargetCadence>,
) -> EnvelopeResult<BudgetTarget> {
let mut target = self
.storage
.targets
.get(target_id)?
.ok_or_else(|| EnvelopeError::Budget(format!("Target {} not found", target_id)))?;
let before = target.clone();
if let Some(amt) = amount {
target.set_amount(amt);
}
if let Some(cad) = cadence {
target.set_cadence(cad);
}
target
.validate()
.map_err(|e| EnvelopeError::Budget(e.to_string()))?;
self.storage.targets.upsert(target.clone())?;
self.storage.targets.save()?;
let category = self.storage.categories.get_category(target.category_id)?;
let category_name = category.map(|c| c.name);
self.storage.log_update(
EntityType::BudgetTarget,
target.id.to_string(),
category_name,
&before,
&target,
Some(format!("{} -> {}", before, target)),
)?;
Ok(target)
}
pub fn get_target(&self, category_id: CategoryId) -> EnvelopeResult<Option<BudgetTarget>> {
self.storage.targets.get_for_category(category_id)
}
pub fn get_suggested_budget(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
) -> EnvelopeResult<Option<Money>> {
if let Some(target) = self.storage.targets.get_for_category(category_id)? {
Ok(Some(target.calculate_for_period(period)))
} else {
Ok(None)
}
}
pub fn get_suggested_budget_with_progress(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
) -> EnvelopeResult<Option<Money>> {
let target = match self.storage.targets.get_for_category(category_id)? {
Some(t) => t,
None => return Ok(None),
};
match &target.cadence {
TargetCadence::ByDate { target_date } => {
let period_start = period.start_date();
if *target_date < period_start {
return Ok(Some(Money::zero()));
}
let target_period = BudgetPeriod::monthly(target_date.year(), target_date.month());
let cumulative_paid =
self.calculate_cumulative_paid(category_id, &target_period)?;
let remaining = (target.amount.cents() - cumulative_paid.cents()).max(0);
if remaining == 0 {
return Ok(Some(Money::zero()));
}
let months = self.months_between(period_start, *target_date);
if months <= 0 {
Ok(Some(Money::from_cents(remaining)))
} else {
Ok(Some(Money::from_cents(
(remaining as f64 / months as f64).ceil() as i64,
)))
}
}
_ => Ok(Some(target.calculate_for_period(period))),
}
}
fn months_between(&self, start: chrono::NaiveDate, end: chrono::NaiveDate) -> i32 {
let years = end.year() - start.year();
let months = end.month() as i32 - start.month() as i32;
years * 12 + months
}
pub fn delete_target(&self, target_id: BudgetTargetId) -> EnvelopeResult<bool> {
if let Some(target) = self.storage.targets.get(target_id)? {
let category = self.storage.categories.get_category(target.category_id)?;
let category_name = category.map(|c| c.name);
self.storage.targets.delete(target_id)?;
self.storage.targets.save()?;
self.storage.log_delete(
EntityType::BudgetTarget,
target.id.to_string(),
category_name,
&target,
)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn remove_target(&self, category_id: CategoryId) -> EnvelopeResult<bool> {
if let Some(target) = self.storage.targets.get_for_category(category_id)? {
self.delete_target(target.id)
} else {
Ok(false)
}
}
pub fn get_all_targets(&self) -> EnvelopeResult<Vec<BudgetTarget>> {
self.storage.targets.get_all_active()
}
pub fn auto_fill_from_target(
&self,
category_id: CategoryId,
period: &BudgetPeriod,
) -> EnvelopeResult<Option<BudgetAllocation>> {
if let Some(suggested) = self.get_suggested_budget_with_progress(category_id, period)? {
let allocation = self.assign_to_category(category_id, period, suggested)?;
Ok(Some(allocation))
} else {
Ok(None)
}
}
pub fn auto_fill_all_targets(
&self,
period: &BudgetPeriod,
) -> EnvelopeResult<Vec<BudgetAllocation>> {
let targets = self.storage.targets.get_all_active()?;
let mut allocations = Vec::with_capacity(targets.len());
for target in &targets {
if let Some(suggested) =
self.get_suggested_budget_with_progress(target.category_id, period)?
{
let allocation = self.assign_to_category(target.category_id, period, suggested)?;
allocations.push(allocation);
}
}
Ok(allocations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::paths::EnvelopePaths;
use crate::models::{Account, AccountType, Category, CategoryGroup, Transaction};
use chrono::NaiveDate;
use tempfile::TempDir;
fn create_test_storage() -> (TempDir, Storage) {
let temp_dir = TempDir::new().unwrap();
let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
let mut storage = Storage::new(paths).unwrap();
storage.load_all().unwrap();
(temp_dir, storage)
}
fn setup_test_data(storage: &Storage) -> (CategoryId, CategoryId, BudgetPeriod) {
let group = CategoryGroup::new("Test Group");
storage.categories.upsert_group(group.clone()).unwrap();
let cat1 = Category::new("Groceries", group.id);
let cat2 = Category::new("Dining Out", group.id);
let cat1_id = cat1.id;
let cat2_id = cat2.id;
storage.categories.upsert_category(cat1).unwrap();
storage.categories.upsert_category(cat2).unwrap();
storage.categories.save().unwrap();
let period = BudgetPeriod::monthly(2025, 1);
(cat1_id, cat2_id, period)
}
#[test]
fn test_assign_to_category() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, period) = setup_test_data(&storage);
let service = BudgetService::new(&storage);
let allocation = service
.assign_to_category(cat_id, &period, Money::from_cents(50000))
.unwrap();
assert_eq!(allocation.budgeted.cents(), 50000);
}
#[test]
fn test_add_to_category() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, period) = setup_test_data(&storage);
let service = BudgetService::new(&storage);
service
.assign_to_category(cat_id, &period, Money::from_cents(30000))
.unwrap();
let allocation = service
.add_to_category(cat_id, &period, Money::from_cents(20000))
.unwrap();
assert_eq!(allocation.budgeted.cents(), 50000);
}
#[test]
fn test_move_between_categories() {
let (_temp_dir, storage) = create_test_storage();
let (cat1_id, cat2_id, period) = setup_test_data(&storage);
let service = BudgetService::new(&storage);
service
.assign_to_category(cat1_id, &period, Money::from_cents(50000))
.unwrap();
service
.move_between_categories(cat1_id, cat2_id, &period, Money::from_cents(20000))
.unwrap();
let alloc1 = service.get_allocation(cat1_id, &period).unwrap();
let alloc2 = service.get_allocation(cat2_id, &period).unwrap();
assert_eq!(alloc1.budgeted.cents(), 30000);
assert_eq!(alloc2.budgeted.cents(), 20000);
}
#[test]
fn test_move_insufficient_funds() {
let (_temp_dir, storage) = create_test_storage();
let (cat1_id, cat2_id, period) = setup_test_data(&storage);
let service = BudgetService::new(&storage);
service
.assign_to_category(cat1_id, &period, Money::from_cents(10000))
.unwrap();
let result =
service.move_between_categories(cat1_id, cat2_id, &period, Money::from_cents(20000));
assert!(matches!(
result,
Err(EnvelopeError::InsufficientFunds { .. })
));
}
#[test]
fn test_category_activity() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, period) = setup_test_data(&storage);
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
let mut txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
Money::from_cents(-5000),
);
txn.category_id = Some(cat_id);
storage.transactions.upsert(txn).unwrap();
let service = BudgetService::new(&storage);
let activity = service
.calculate_category_activity(cat_id, &period)
.unwrap();
assert_eq!(activity.cents(), -5000);
}
#[test]
fn test_available_to_budget() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, period) = setup_test_data(&storage);
let account = Account::with_starting_balance(
"Checking",
AccountType::Checking,
Money::from_cents(100000),
);
storage.accounts.upsert(account.clone()).unwrap();
storage.accounts.save().unwrap();
let service = BudgetService::new(&storage);
let atb = service.get_available_to_budget(&period).unwrap();
assert_eq!(atb.cents(), 100000);
service
.assign_to_category(cat_id, &period, Money::from_cents(50000))
.unwrap();
let atb = service.get_available_to_budget(&period).unwrap();
assert_eq!(atb.cents(), 50000); }
#[test]
fn test_positive_carryover() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, jan) = setup_test_data(&storage);
let feb = jan.next();
let service = BudgetService::new(&storage);
service
.assign_to_category(cat_id, &jan, Money::from_cents(50000))
.unwrap();
let carryover = service.get_carryover(cat_id, &feb).unwrap();
assert_eq!(carryover.cents(), 50000);
}
#[test]
fn test_negative_carryover() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, jan) = setup_test_data(&storage);
let feb = jan.next();
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
let mut txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
Money::from_cents(-60000), );
txn.category_id = Some(cat_id);
storage.transactions.upsert(txn).unwrap();
let service = BudgetService::new(&storage);
service
.assign_to_category(cat_id, &jan, Money::from_cents(50000))
.unwrap();
let carryover = service.get_carryover(cat_id, &feb).unwrap();
assert_eq!(carryover.cents(), -10000);
}
#[test]
fn test_apply_rollover() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, jan) = setup_test_data(&storage);
let feb = jan.next();
let service = BudgetService::new(&storage);
service
.assign_to_category(cat_id, &jan, Money::from_cents(50000))
.unwrap();
let feb_alloc = service.apply_rollover(cat_id, &feb).unwrap();
assert_eq!(feb_alloc.carryover.cents(), 50000);
assert_eq!(feb_alloc.budgeted.cents(), 0);
assert_eq!(feb_alloc.total_budgeted().cents(), 50000);
}
#[test]
fn test_apply_rollover_all() {
let (_temp_dir, storage) = create_test_storage();
let (cat1_id, cat2_id, jan) = setup_test_data(&storage);
let feb = jan.next();
let service = BudgetService::new(&storage);
service
.assign_to_category(cat1_id, &jan, Money::from_cents(50000))
.unwrap();
service
.assign_to_category(cat2_id, &jan, Money::from_cents(20000))
.unwrap();
let allocations = service.apply_rollover_all(&feb).unwrap();
assert_eq!(allocations.len(), 2);
let cat1_alloc = service.get_allocation(cat1_id, &feb).unwrap();
let cat2_alloc = service.get_allocation(cat2_id, &feb).unwrap();
assert_eq!(cat1_alloc.carryover.cents(), 50000);
assert_eq!(cat2_alloc.carryover.cents(), 20000);
}
#[test]
fn test_overspent_categories() {
let (_temp_dir, storage) = create_test_storage();
let (cat1_id, cat2_id, period) = setup_test_data(&storage);
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
let mut txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
Money::from_cents(-60000), );
txn.category_id = Some(cat1_id);
storage.transactions.upsert(txn).unwrap();
let service = BudgetService::new(&storage);
service
.assign_to_category(cat1_id, &period, Money::from_cents(50000))
.unwrap();
service
.assign_to_category(cat2_id, &period, Money::from_cents(20000))
.unwrap();
let overspent = service.get_overspent_categories(&period).unwrap();
assert_eq!(overspent.len(), 1);
assert_eq!(overspent[0].category_id, cat1_id);
assert_eq!(overspent[0].available.cents(), -10000);
}
#[test]
fn test_cumulative_budgeted_for_bydate_progress() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, _) = setup_test_data(&storage);
let service = BudgetService::new(&storage);
let nov = BudgetPeriod::monthly(2025, 11);
service
.assign_to_category(cat_id, &nov, Money::from_cents(20000))
.unwrap();
let dec = BudgetPeriod::monthly(2025, 12);
service
.assign_to_category(cat_id, &dec, Money::from_cents(20000))
.unwrap();
let cumulative_nov = service.calculate_cumulative_budgeted(cat_id, &nov).unwrap();
assert_eq!(cumulative_nov.cents(), 20000);
let cumulative_dec = service.calculate_cumulative_budgeted(cat_id, &dec).unwrap();
assert_eq!(cumulative_dec.cents(), 40000);
let dec_2026 = BudgetPeriod::monthly(2026, 12);
let cumulative_future = service
.calculate_cumulative_budgeted(cat_id, &dec_2026)
.unwrap();
assert_eq!(cumulative_future.cents(), 40000);
}
#[test]
fn test_cumulative_paid_for_bydate_progress() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, _) = setup_test_data(&storage);
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
let mut txn1 = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 11, 15).unwrap(),
Money::from_cents(-10000), );
txn1.category_id = Some(cat_id);
storage.transactions.upsert(txn1).unwrap();
let mut txn2 = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 12, 15).unwrap(),
Money::from_cents(-5000), );
txn2.category_id = Some(cat_id);
storage.transactions.upsert(txn2).unwrap();
let service = BudgetService::new(&storage);
let nov = BudgetPeriod::monthly(2025, 11);
let cumulative_nov = service.calculate_cumulative_paid(cat_id, &nov).unwrap();
assert_eq!(cumulative_nov.cents(), 10000);
let dec = BudgetPeriod::monthly(2025, 12);
let cumulative_dec = service.calculate_cumulative_paid(cat_id, &dec).unwrap();
assert_eq!(cumulative_dec.cents(), 15000);
let cumulative_budgeted = service.calculate_cumulative_budgeted(cat_id, &dec).unwrap();
assert_eq!(cumulative_budgeted.cents(), 0);
let progress_amount = if cumulative_dec.cents() > 0 {
cumulative_dec.cents()
} else {
cumulative_budgeted.cents().max(0)
};
assert_eq!(progress_amount, 15000); }
#[test]
fn test_paid_wins_over_budgeted() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, _) = setup_test_data(&storage);
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
let service = BudgetService::new(&storage);
let dec = BudgetPeriod::monthly(2025, 12);
service
.assign_to_category(cat_id, &dec, Money::from_cents(20000))
.unwrap();
let mut txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 12, 15).unwrap(),
Money::from_cents(-10000), );
txn.category_id = Some(cat_id);
storage.transactions.upsert(txn).unwrap();
let cumulative_budgeted = service.calculate_cumulative_budgeted(cat_id, &dec).unwrap();
let cumulative_paid = service.calculate_cumulative_paid(cat_id, &dec).unwrap();
assert_eq!(cumulative_budgeted.cents(), 20000); assert_eq!(cumulative_paid.cents(), 10000);
let progress_amount = if cumulative_paid.cents() > 0 {
cumulative_paid.cents()
} else {
cumulative_budgeted.cents().max(0)
};
assert_eq!(progress_amount, 10000); }
#[test]
fn test_suggested_budget_accounts_for_cumulative_paid() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, _) = setup_test_data(&storage);
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
let service = BudgetService::new(&storage);
let target_date = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
service
.set_target(
cat_id,
Money::from_cents(200000),
TargetCadence::by_date(target_date),
)
.unwrap();
let jan_2026 = BudgetPeriod::monthly(2026, 1);
let suggested = service
.get_suggested_budget_with_progress(cat_id, &jan_2026)
.unwrap()
.unwrap();
assert_eq!(suggested.cents(), 18182);
let mut txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
Money::from_cents(-50000), );
txn.category_id = Some(cat_id);
storage.transactions.upsert(txn).unwrap();
let feb_2026 = BudgetPeriod::monthly(2026, 2);
let suggested = service
.get_suggested_budget_with_progress(cat_id, &feb_2026)
.unwrap()
.unwrap();
assert_eq!(suggested.cents(), 15000);
let mut txn2 = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(),
Money::from_cents(-50000), );
txn2.category_id = Some(cat_id);
storage.transactions.upsert(txn2).unwrap();
let mar_2026 = BudgetPeriod::monthly(2026, 3);
let suggested = service
.get_suggested_budget_with_progress(cat_id, &mar_2026)
.unwrap()
.unwrap();
assert_eq!(suggested.cents(), 11112); }
#[test]
fn test_suggested_budget_fully_paid_suggests_zero() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, _) = setup_test_data(&storage);
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
let service = BudgetService::new(&storage);
let target_date = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
service
.set_target(
cat_id,
Money::from_cents(50000),
TargetCadence::by_date(target_date),
)
.unwrap();
let mut txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
Money::from_cents(-50000), );
txn.category_id = Some(cat_id);
storage.transactions.upsert(txn).unwrap();
let feb_2026 = BudgetPeriod::monthly(2026, 2);
let suggested = service
.get_suggested_budget_with_progress(cat_id, &feb_2026)
.unwrap()
.unwrap();
assert_eq!(suggested.cents(), 0);
}
#[test]
fn test_suggested_budget_recurring_targets_unchanged() {
let (_temp_dir, storage) = create_test_storage();
let (cat_id, _, _) = setup_test_data(&storage);
let service = BudgetService::new(&storage);
service
.set_target(cat_id, Money::from_cents(30000), TargetCadence::Monthly)
.unwrap();
let jan_2026 = BudgetPeriod::monthly(2026, 1);
let suggested = service
.get_suggested_budget_with_progress(cat_id, &jan_2026)
.unwrap()
.unwrap();
assert_eq!(suggested.cents(), 30000);
}
}