3b01efd8a6
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
348 lines
11 KiB
C#
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}";
|
|
}
|