using Microsoft.EntityFrameworkCore; using MoneyMap.Data; using MoneyMap.Models; namespace MoneyMap.Services; public interface IBudgetService { // CRUD operations Task> GetAllBudgetsAsync(bool activeOnly = true); Task GetBudgetByIdAsync(int id); Task CreateBudgetAsync(Budget budget); Task UpdateBudgetAsync(Budget budget); Task DeleteBudgetAsync(int id); // Budget status calculations Task> GetAllBudgetStatusesAsync(DateTime? asOfDate = null); Task GetBudgetStatusAsync(int budgetId, DateTime? asOfDate = null); // Helper methods Task> 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> 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 GetBudgetByIdAsync(int id) { return await _db.Budgets.FindAsync(id); } public async Task 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 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 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> GetAllBudgetStatusesAsync(DateTime? asOfDate = null) { var date = asOfDate ?? DateTime.Today; var budgets = await GetAllBudgetsAsync(activeOnly: true); var statuses = new List(); foreach (var budget in budgets) { var status = await CalculateBudgetStatusAsync(budget, date); statuses.Add(status); } return statuses; } public async Task 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 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> 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}"; }