Files
MoneyMap/MoneyMap.Core/Services/BudgetService.cs
T
2026-04-20 18:18:20 -04:00

348 lines
11 KiB
C#

using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services;
public interface IBudgetService
{
// CRUD operations
Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true);
Task<Budget?> GetBudgetByIdAsync(int id);
Task<BudgetOperationResult> CreateBudgetAsync(Budget budget);
Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget);
Task<BudgetOperationResult> DeleteBudgetAsync(int id);
// Budget status calculations
Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null);
Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null);
// Helper methods
Task<List<string>> GetAvailableCategoriesAsync();
(DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate);
}
public class BudgetService : IBudgetService
{
private readonly MoneyMapContext _db;
public BudgetService(MoneyMapContext db)
{
_db = db;
}
#region CRUD Operations
public async Task<List<Budget>> GetAllBudgetsAsync(bool activeOnly = true)
{
var query = _db.Budgets.AsQueryable();
if (activeOnly)
query = query.Where(b => b.IsActive);
return await query
.OrderBy(b => b.Category == null) // Total budget last
.ThenBy(b => b.Category)
.ThenBy(b => b.Period)
.ToListAsync();
}
public async Task<Budget?> GetBudgetByIdAsync(int id)
{
return await _db.Budgets.FindAsync(id);
}
public async Task<BudgetOperationResult> CreateBudgetAsync(Budget budget)
{
// Validate amount
if (budget.Amount <= 0)
{
return new BudgetOperationResult
{
Success = false,
Message = "Budget amount must be greater than zero."
};
}
// Check for duplicate active budget (same category + period)
var existing = await _db.Budgets
.Where(b => b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
.FirstOrDefaultAsync();
if (existing != null)
{
var categoryName = budget.Category ?? "Total Spending";
return new BudgetOperationResult
{
Success = false,
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
};
}
budget.IsActive = true;
_db.Budgets.Add(budget);
await _db.SaveChangesAsync();
return new BudgetOperationResult
{
Success = true,
Message = "Budget created successfully.",
BudgetId = budget.Id
};
}
public async Task<BudgetOperationResult> UpdateBudgetAsync(Budget budget)
{
var existing = await _db.Budgets.FindAsync(budget.Id);
if (existing == null)
{
return new BudgetOperationResult
{
Success = false,
Message = "Budget not found."
};
}
// Validate amount
if (budget.Amount <= 0)
{
return new BudgetOperationResult
{
Success = false,
Message = "Budget amount must be greater than zero."
};
}
// Check for duplicate if category or period changed
if (budget.IsActive && (existing.Category != budget.Category || existing.Period != budget.Period))
{
var duplicate = await _db.Budgets
.Where(b => b.Id != budget.Id && b.IsActive && b.Category == budget.Category && b.Period == budget.Period)
.FirstOrDefaultAsync();
if (duplicate != null)
{
var categoryName = budget.Category ?? "Total Spending";
return new BudgetOperationResult
{
Success = false,
Message = $"An active {budget.Period} budget for '{categoryName}' already exists."
};
}
}
existing.Category = budget.Category;
existing.Amount = budget.Amount;
existing.Period = budget.Period;
existing.StartDate = budget.StartDate;
existing.IsActive = budget.IsActive;
existing.Notes = budget.Notes;
await _db.SaveChangesAsync();
return new BudgetOperationResult
{
Success = true,
Message = "Budget updated successfully.",
BudgetId = existing.Id
};
}
public async Task<BudgetOperationResult> DeleteBudgetAsync(int id)
{
var budget = await _db.Budgets.FindAsync(id);
if (budget == null)
{
return new BudgetOperationResult
{
Success = false,
Message = "Budget not found."
};
}
_db.Budgets.Remove(budget);
await _db.SaveChangesAsync();
return new BudgetOperationResult
{
Success = true,
Message = "Budget deleted successfully."
};
}
#endregion
#region Budget Status Calculations
public async Task<List<BudgetStatus>> GetAllBudgetStatusesAsync(DateTime? asOfDate = null)
{
var date = asOfDate ?? DateTime.Today;
var budgets = await GetAllBudgetsAsync(activeOnly: true);
var statuses = new List<BudgetStatus>();
foreach (var budget in budgets)
{
var status = await CalculateBudgetStatusAsync(budget, date);
statuses.Add(status);
}
return statuses;
}
public async Task<BudgetStatus?> GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null)
{
var budget = await GetBudgetByIdAsync(budgetId);
if (budget == null)
return null;
var date = asOfDate ?? DateTime.Today;
return await CalculateBudgetStatusAsync(budget, date);
}
private async Task<BudgetStatus> CalculateBudgetStatusAsync(Budget budget, DateTime asOfDate)
{
var (periodStart, periodEnd) = GetPeriodBoundaries(budget.Period, budget.StartDate, asOfDate);
// Calculate spending for the period
var query = _db.Transactions
.Where(t => t.Date >= periodStart && t.Date <= periodEnd)
.Where(t => t.Amount < 0) // Only debits (spending)
.Where(t => t.TransferToAccountId == null); // Exclude transfers
// For category-specific budgets, filter by category (case-insensitive)
if (budget.Category != null)
{
query = query.Where(t => t.Category != null && t.Category.ToLower() == budget.Category.ToLower());
}
var spent = await query.SumAsync(t => Math.Abs(t.Amount));
var remaining = budget.Amount - spent;
var percentUsed = budget.Amount > 0 ? (spent / budget.Amount) * 100 : 0;
return new BudgetStatus
{
Budget = budget,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
Spent = spent,
Remaining = remaining,
PercentUsed = percentUsed,
IsOverBudget = spent > budget.Amount
};
}
public (DateTime Start, DateTime End) GetPeriodBoundaries(BudgetPeriod period, DateTime startDate, DateTime asOfDate)
{
return period switch
{
BudgetPeriod.Weekly => GetWeeklyBoundaries(startDate, asOfDate),
BudgetPeriod.Monthly => GetMonthlyBoundaries(startDate, asOfDate),
BudgetPeriod.Yearly => GetYearlyBoundaries(startDate, asOfDate),
_ => throw new ArgumentOutOfRangeException(nameof(period))
};
}
private (DateTime Start, DateTime End) GetWeeklyBoundaries(DateTime startDate, DateTime asOfDate)
{
// Find which week we're in relative to the start date
var daysSinceStart = (asOfDate - startDate.Date).Days;
if (daysSinceStart < 0)
{
// Before start date - use the week containing start date
return (startDate.Date, startDate.Date.AddDays(6));
}
var weekNumber = daysSinceStart / 7;
var periodStart = startDate.Date.AddDays(weekNumber * 7);
var periodEnd = periodStart.AddDays(6);
return (periodStart, periodEnd);
}
private (DateTime Start, DateTime End) GetMonthlyBoundaries(DateTime startDate, DateTime asOfDate)
{
// Use the start date's day of month as the boundary
var dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, asOfDate.Month));
DateTime periodStart;
if (asOfDate.Day >= dayOfMonth)
{
// We're in the current period
periodStart = new DateTime(asOfDate.Year, asOfDate.Month, dayOfMonth);
}
else
{
// We're before this month's boundary, so use last month
var lastMonth = asOfDate.AddMonths(-1);
dayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(lastMonth.Year, lastMonth.Month));
periodStart = new DateTime(lastMonth.Year, lastMonth.Month, dayOfMonth);
}
// End is the day before the next period starts
var nextPeriodStart = periodStart.AddMonths(1);
var nextDayOfMonth = Math.Min(startDate.Day, DateTime.DaysInMonth(nextPeriodStart.Year, nextPeriodStart.Month));
nextPeriodStart = new DateTime(nextPeriodStart.Year, nextPeriodStart.Month, nextDayOfMonth);
var periodEnd = nextPeriodStart.AddDays(-1);
return (periodStart, periodEnd);
}
private (DateTime Start, DateTime End) GetYearlyBoundaries(DateTime startDate, DateTime asOfDate)
{
// Find which year period we're in
var yearsSinceStart = asOfDate.Year - startDate.Year;
// Check if we're before the anniversary this year
var anniversaryThisYear = new DateTime(asOfDate.Year, startDate.Month,
Math.Min(startDate.Day, DateTime.DaysInMonth(asOfDate.Year, startDate.Month)));
if (asOfDate < anniversaryThisYear)
yearsSinceStart--;
var periodStart = startDate.Date.AddYears(Math.Max(0, yearsSinceStart));
var periodEnd = periodStart.AddYears(1).AddDays(-1);
return (periodStart, periodEnd);
}
#endregion
#region Helper Methods
public async Task<List<string>> GetAvailableCategoriesAsync()
{
return await _db.Transactions
.Where(t => !string.IsNullOrEmpty(t.Category))
.Select(t => t.Category)
.Distinct()
.OrderBy(c => c)
.ToListAsync();
}
#endregion
}
// DTOs
public class BudgetOperationResult
{
public bool Success { get; set; }
public string Message { get; set; } = "";
public int? BudgetId { get; set; }
}
public class BudgetStatus
{
public Budget Budget { get; set; } = null!;
public DateTime PeriodStart { get; set; }
public DateTime PeriodEnd { get; set; }
public decimal Spent { get; set; }
public decimal Remaining { get; set; }
public decimal PercentUsed { get; set; }
public bool IsOverBudget { get; set; }
// Helper for display
public string StatusClass => IsOverBudget ? "danger" : PercentUsed >= 80 ? "warning" : "success";
public string PeriodDisplay => $"{PeriodStart:MMM d} - {PeriodEnd:MMM d, yyyy}";
}