refactor: move services and AITools to MoneyMap.Core
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
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}";
|
||||
}
|
||||
Reference in New Issue
Block a user