Add project files.

This commit is contained in:
rossmill76 committed 2026-03-01 18:17:42 +00:00
1 parent fd9f318d6a
commit 0ce077ec11
98 files changed
+65707

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore;
using BudgetApp.Data;
namespace BudgetApp.Services;
public class AccountService
{
private readonly ApplicationDbContext _db;
public AccountService(ApplicationDbContext db)
{
_db = db;
}
public async Task<List<Account>> GetAccountsAsync(string userId)
{
return await _db.Accounts
.Where(a => a.UserId == userId)
.OrderBy(a => a.Name)
.ToListAsync();
}
public async Task<Account?> GetAccountAsync(int id, string userId)
{
return await _db.Accounts
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
}
public async Task CreateAccountAsync(Account account)
{
_db.Accounts.Add(account);
await _db.SaveChangesAsync();
}
public async Task UpdateAccountAsync(Account account)
{
_db.Accounts.Update(account);
await _db.SaveChangesAsync();
}
public async Task UpdateBalanceAsync(int id, string userId, decimal newBalance)
{
var account = await _db.Accounts
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
if (account is not null)
{
account.CurrentBalance = newBalance;
await _db.SaveChangesAsync();
}
}
public async Task UpdateAccountDetailsAsync(int id, string userId, string name, string color, string currencyCode)
{
var account = await _db.Accounts
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
if (account is not null)
{
account.Name = name;
account.Color = color;
account.CurrencyCode = currencyCode;
await _db.SaveChangesAsync();
}
}
public async Task DeleteAccountAsync(int id, string userId)
{
var account = await _db.Accounts
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
if (account is not null)
{
_db.Accounts.Remove(account);
await _db.SaveChangesAsync();
}
}
}
+109
View File
@@ -0,0 +1,109 @@
using Microsoft.EntityFrameworkCore;
using BudgetApp.Data;
namespace BudgetApp.Services;
public class CategoryService
{
private readonly ApplicationDbContext _db;
private static readonly (string Name, string Icon)[] DefaultCategories =
[
("Groceries", "🛒"),
("Rent / Mortgage", "🏠"),
("Utilities", "💡"),
("Transport", "🚗"),
("Eating Out", "🍽️"),
("Entertainment", "🎬"),
("Health", "🏥"),
("Insurance", "🛡️"),
("Salary", "💰"),
("Savings", "🏦"),
("Subscriptions", "📱"),
("Shopping", "🛍️"),
("Education", "📚"),
("Travel", "✈️"),
("Gifts", "🎁")
];
public CategoryService(ApplicationDbContext db)
{
_db = db;
}
public async Task<List<Category>> GetCategoriesAsync(string userId)
{
return await _db.Categories
.Where(c => c.UserId == userId)
.OrderBy(c => c.Name)
.ToListAsync();
}
public async Task EnsureDefaultCategoriesAsync(string userId)
{
var existing = await _db.Categories
.Where(c => c.UserId == userId)
.ToListAsync();
if (existing.Count == 0)
{
var defaults = DefaultCategories.Select(d => new Category
{
Name = d.Name,
Icon = d.Icon,
UserId = userId
});
_db.Categories.AddRange(defaults);
await _db.SaveChangesAsync();
return;
}
// Backfill icons on existing default categories that have empty icons
var defaultLookup = DefaultCategories.ToDictionary(d => d.Name, d => d.Icon);
bool updated = false;
foreach (var category in existing)
{
if (string.IsNullOrEmpty(category.Icon) && defaultLookup.TryGetValue(category.Name, out var icon))
{
category.Icon = icon;
updated = true;
}
}
if (updated)
{
await _db.SaveChangesAsync();
}
}
public async Task CreateCategoryAsync(Category category)
{
_db.Categories.Add(category);
await _db.SaveChangesAsync();
}
public async Task UpdateCategoryAsync(int id, string userId, string name, string icon)
{
var category = await _db.Categories
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
if (category is not null)
{
category.Name = name;
category.Icon = icon;
await _db.SaveChangesAsync();
}
}
public async Task DeleteCategoryAsync(int id, string userId)
{
var category = await _db.Categories
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
if (category is not null)
{
_db.Categories.Remove(category);
await _db.SaveChangesAsync();
}
}
}
+154
View File
@@ -0,0 +1,154 @@
using BudgetApp.Data;
namespace BudgetApp.Services;
public class DashboardService
{
private readonly TransactionService _transactionService;
private readonly AccountService _accountService;
private readonly CategoryService _categoryService;
public DashboardService(TransactionService transactionService, AccountService accountService, CategoryService categoryService)
{
_transactionService = transactionService;
_accountService = accountService;
_categoryService = categoryService;
}
public async Task<DashboardData> GetDashboardDataAsync(string userId, DateTime? from = null, DateTime? to = null)
{
await _categoryService.EnsureDefaultCategoriesAsync(userId);
var windowEnd = to ?? DateTime.Today.AddDays(30);
await _transactionService.MaterializeRecurringTransactionsAsync(userId, windowEnd);
var accounts = await _accountService.GetAccountsAsync(userId);
var categories = await _categoryService.GetCategoriesAsync(userId);
var windowStart = from ?? DateTime.Today.AddDays(-30);
var fetchStart = windowStart < DateTime.Today ? windowStart : DateTime.Today;
var fetchEnd = windowEnd > DateTime.Today ? windowEnd : DateTime.Today;
var allTransactions = await _transactionService.GetTransactionsInRangeAsync(userId, fetchStart, fetchEnd);
var recent = allTransactions
.Where(t => t.Date.Date >= windowStart.Date && t.Date.Date <= DateTime.Today)
.OrderByDescending(t => t.Date)
.ToList();
var upcoming = allTransactions
.Where(t => t.Date.Date > DateTime.Today && t.Date.Date <= windowEnd.Date)
.OrderBy(t => t.Date)
.ToList();
var chartData = BuildChartData(accounts, allTransactions, windowStart, windowEnd);
return new DashboardData
{
Accounts = accounts,
Categories = categories,
RecentTransactions = recent,
UpcomingTransactions = upcoming,
ChartData = chartData,
CurrencySymbol = accounts.FirstOrDefault()?.GetSymbol() ?? "£"
};
}
private static ChartData BuildChartData(List<Account> accounts, List<Transaction> transactions, DateTime startDate, DateTime endDate)
{
var today = DateTime.Today;
var labels = new List<string>();
var datasets = new List<ChartDataset>();
var dayTransactionLabels = new List<List<string>>();
for (var d = startDate; d <= endDate; d = d.AddDays(1))
{
labels.Add(d.ToString("MMM dd"));
var dayTxns = transactions
.Where(t => t.Date.Date == d.Date)
.Select(t => $"{(t.Type == TransactionType.Income ? "+" : "-")}{t.Account.FormatAmount(t.Amount)} {t.Description} ({t.Account.Name})")
.ToList();
dayTransactionLabels.Add(dayTxns);
}
foreach (var account in accounts)
{
var accountTransactions = transactions
.Where(t => t.AccountId == account.Id)
.OrderBy(t => t.Date)
.ToList();
var dataPoints = new List<decimal>();
var runningBalance = account.CurrentBalance;
if (startDate.Date <= today)
{
foreach (var t in accountTransactions
.Where(t => t.Date.Date >= startDate.Date && t.Date.Date <= today)
.OrderByDescending(t => t.Date))
{
runningBalance -= t.Type == TransactionType.Income ? t.Amount : -t.Amount;
}
}
else
{
foreach (var t in accountTransactions
.Where(t => t.Date.Date > today && t.Date.Date < startDate.Date)
.OrderBy(t => t.Date))
{
runningBalance += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
}
}
for (var d = startDate; d <= endDate; d = d.AddDays(1))
{
var dayTxns = accountTransactions.Where(t => t.Date.Date == d.Date);
foreach (var t in dayTxns)
{
runningBalance += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
}
dataPoints.Add(runningBalance);
}
datasets.Add(new ChartDataset
{
Label = account.Name,
Color = account.Color,
Data = dataPoints,
CurrencySymbol = account.GetSymbol()
});
}
return new ChartData
{
Labels = labels,
Datasets = datasets,
DayTransactionLabels = dayTransactionLabels
};
}
}
public class DashboardData
{
public List<Account> Accounts { get; set; } = [];
public List<Category> Categories { get; set; } = [];
public List<Transaction> RecentTransactions { get; set; } = [];
public List<Transaction> UpcomingTransactions { get; set; } = [];
public ChartData ChartData { get; set; } = new();
public string CurrencySymbol { get; set; } = "$";
}
public class ChartData
{
public List<string> Labels { get; set; } = [];
public List<ChartDataset> Datasets { get; set; } = [];
public List<List<string>> DayTransactionLabels { get; set; } = [];
}
public class ChartDataset
{
public string Label { get; set; } = string.Empty;
public string Color { get; set; } = "#1b6ec2";
public List<decimal> Data { get; set; } = [];
public string CurrencySymbol { get; set; } = "$";
}
+259
View File
@@ -0,0 +1,259 @@
using Microsoft.EntityFrameworkCore;
using BudgetApp.Data;
namespace BudgetApp.Services;
public class TransactionService
{
private readonly ApplicationDbContext _db;
public TransactionService(ApplicationDbContext db)
{
_db = db;
}
public async Task<List<Transaction>> GetTransactionsAsync(string userId)
{
return await _db.Transactions
.Include(t => t.Account)
.Include(t => t.Category)
.Include(t => t.ParentTransaction)
.Where(t => t.Account.UserId == userId)
.OrderByDescending(t => t.Date)
.ToListAsync();
}
public async Task<List<Transaction>> GetRecentTransactionsAsync(string userId, int count = 5)
{
return await _db.Transactions
.Include(t => t.Account)
.Include(t => t.Category)
.Include(t => t.ParentTransaction)
.Where(t => t.Account.UserId == userId && t.Date <= DateTime.Today)
.OrderByDescending(t => t.Date)
.Take(count)
.ToListAsync();
}
public async Task<List<Transaction>> GetUpcomingTransactionsAsync(string userId, int count = 5)
{
return await _db.Transactions
.Include(t => t.Account)
.Include(t => t.Category)
.Include(t => t.ParentTransaction)
.Where(t => t.Account.UserId == userId && t.Date > DateTime.Today)
.OrderBy(t => t.Date)
.Take(count)
.ToListAsync();
}
public async Task<List<Transaction>> GetTransactionsInRangeAsync(string userId, DateTime from, DateTime to)
{
return await _db.Transactions
.Include(t => t.Account)
.Include(t => t.Category)
.Include(t => t.ParentTransaction)
.Where(t => t.Account.UserId == userId && t.Date.Date >= from.Date && t.Date.Date <= to.Date)
.OrderByDescending(t => t.Date)
.ToListAsync();
}
public async Task<List<Transaction>> GetTransactionsByAccountAsync(int accountId, string userId)
{
return await _db.Transactions
.Include(t => t.Category)
.Where(t => t.AccountId == accountId && t.Account.UserId == userId)
.OrderByDescending(t => t.Date)
.ToListAsync();
}
public async Task CreateTransactionAsync(Transaction transaction)
{
// For recurring transactions: enforce max 1 year end date
if (transaction.RecurrenceType != RecurrenceType.None)
{
var maxEnd = transaction.Date.AddYears(1);
if (transaction.RecurrenceEndDate is null || transaction.RecurrenceEndDate.Value > maxEnd)
{
transaction.RecurrenceEndDate = maxEnd;
}
}
_db.Transactions.Add(transaction);
await _db.SaveChangesAsync();
// Immediately materialize child occurrences for recurring transactions
if (transaction.RecurrenceType != RecurrenceType.None)
{
var endDate = transaction.RecurrenceEndDate ?? transaction.Date.AddYears(1);
var newTransactions = new List<Transaction>();
var nextDate = GetNextOccurrence(transaction.Date, transaction.RecurrenceType);
while (nextDate <= endDate)
{
newTransactions.Add(new Transaction
{
Description = transaction.Description,
Amount = transaction.Amount,
Date = nextDate,
Type = transaction.Type,
AccountId = transaction.AccountId,
CategoryId = transaction.CategoryId,
RecurrenceType = RecurrenceType.None,
ParentTransactionId = transaction.Id
});
nextDate = GetNextOccurrence(nextDate, transaction.RecurrenceType);
}
if (newTransactions.Count > 0)
{
_db.Transactions.AddRange(newTransactions);
await _db.SaveChangesAsync();
}
}
}
public async Task UpdateTransactionAsync(int id, string userId, string description, decimal amount, DateTime date, TransactionType type, int accountId, int? categoryId, RecurrenceType recurrenceType = RecurrenceType.None, DateTime? recurrenceEndDate = null)
{
var transaction = await _db.Transactions
.Include(t => t.Account)
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
if (transaction is null) return;
transaction.Description = description;
transaction.Amount = amount;
transaction.Date = date;
transaction.Type = type;
transaction.AccountId = accountId;
transaction.CategoryId = categoryId;
transaction.RecurrenceType = recurrenceType;
transaction.RecurrenceEndDate = recurrenceEndDate;
await _db.SaveChangesAsync();
}
public async Task DeleteTransactionAsync(int id, string userId)
{
var transaction = await _db.Transactions
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
if (transaction is not null)
{
_db.Transactions.Remove(transaction);
await _db.SaveChangesAsync();
}
}
public async Task DeleteTransactionSeriesAsync(int id, string userId)
{
var transaction = await _db.Transactions
.Include(t => t.Account)
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
if (transaction is null) return;
// Resolve the parent id: if this is a child, use its ParentTransactionId; otherwise use its own Id
var parentId = transaction.ParentTransactionId ?? transaction.Id;
// Use the clicked transaction's date as the cutoff — delete this one and everything after it
var cutoffDate = transaction.Date.Date;
var allChildren = await _db.Transactions
.Where(t => t.ParentTransactionId == parentId)
.ToListAsync();
var toDelete = allChildren.Where(c => c.Date.Date >= cutoffDate).ToList();
var toKeep = allChildren.Where(c => c.Date.Date < cutoffDate).ToList();
_db.Transactions.RemoveRange(toDelete);
// Handle the parent
var parent = parentId == transaction.Id
? transaction
: await _db.Transactions
.FirstOrDefaultAsync(t => t.Id == parentId && t.Account.UserId == userId);
if (parent is not null)
{
if (parent.Date.Date >= cutoffDate)
{
// Parent is also at/after the cutoff — detach earlier children so
// the DB-level ON DELETE SET NULL doesn't clear their link, then delete
foreach (var child in toKeep)
{
child.ParentTransactionId = null;
}
_db.Transactions.Remove(parent);
}
else
{
// Parent is before the cutoff — cap recurrence so materialization stops
parent.RecurrenceEndDate = cutoffDate.AddDays(-1);
}
}
await _db.SaveChangesAsync();
}
public async Task MaterializeRecurringTransactionsAsync(string userId, DateTime? windowEnd = null)
{
var recurringParents = await _db.Transactions
.Include(t => t.Account)
.Where(t => t.Account.UserId == userId
&& t.RecurrenceType != RecurrenceType.None
&& t.ParentTransactionId == null)
.ToListAsync();
var defaultHorizon = DateTime.Today.AddDays(60);
var horizon = windowEnd.HasValue && windowEnd.Value > defaultHorizon ? windowEnd.Value : defaultHorizon;
var newTransactions = new List<Transaction>();
foreach (var parent in recurringParents)
{
var endDate = parent.RecurrenceEndDate.HasValue && parent.RecurrenceEndDate.Value < horizon
? parent.RecurrenceEndDate.Value
: horizon;
var latestChildDate = await _db.Transactions
.Where(t => t.ParentTransactionId == parent.Id)
.OrderByDescending(t => t.Date)
.Select(t => (DateTime?)t.Date)
.FirstOrDefaultAsync();
var lastDate = latestChildDate ?? parent.Date;
var nextDate = GetNextOccurrence(lastDate, parent.RecurrenceType);
while (nextDate <= endDate)
{
newTransactions.Add(new Transaction
{
Description = parent.Description,
Amount = parent.Amount,
Date = nextDate,
Type = parent.Type,
AccountId = parent.AccountId,
CategoryId = parent.CategoryId,
RecurrenceType = RecurrenceType.None,
ParentTransactionId = parent.Id
});
nextDate = GetNextOccurrence(nextDate, parent.RecurrenceType);
}
}
if (newTransactions.Count > 0)
{
_db.Transactions.AddRange(newTransactions);
await _db.SaveChangesAsync();
}
}
private static DateTime GetNextOccurrence(DateTime current, RecurrenceType recurrence) => recurrence switch
{
RecurrenceType.Daily => current.AddDays(1),
RecurrenceType.Weekly => current.AddDays(7),
RecurrenceType.Monthly => current.AddMonths(1),
RecurrenceType.Yearly => current.AddYears(1),
_ => DateTime.MaxValue
};
}