Refactor. Add data layer
This commit is contained in:
1 parent
3b7e4e6ec5
commit
b6b794f0b4
82 files changed
+2930
-2811
No files matched your search
@@ -1,70 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BudgetApp.Data;
|
||||
|
||||
namespace BudgetApp.Services;
|
||||
|
||||
public class AccountService(ApplicationDbContext db)
|
||||
{
|
||||
private readonly ApplicationDbContext _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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BudgetApp.Data;
|
||||
|
||||
namespace BudgetApp.Services;
|
||||
|
||||
public class CategoryService(ApplicationDbContext db)
|
||||
{
|
||||
private readonly ApplicationDbContext _db = 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", "🎁"),
|
||||
("Loan", "🏧"),
|
||||
("Credit Card", "💳"),
|
||||
("Childcare", "👶")
|
||||
];
|
||||
|
||||
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,
|
||||
IsDefault = true,
|
||||
UserId = userId
|
||||
});
|
||||
|
||||
_db.Categories.AddRange(defaults);
|
||||
await _db.SaveChangesAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// Backfill icons and IsDefault on existing default categories
|
||||
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 (!category.IsDefault && defaultLookup.ContainsKey(category.Name))
|
||||
{
|
||||
category.IsDefault = true;
|
||||
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)
|
||||
{
|
||||
if (category.IsDefault) return;
|
||||
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)
|
||||
{
|
||||
if (category.IsDefault) return;
|
||||
_db.Categories.Remove(category);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
using BudgetApp.Data;
|
||||
using BudgetApp.Data.Entities;
|
||||
using BudgetApp.Data.Interfaces;
|
||||
using BudgetApp.Services.Models;
|
||||
|
||||
namespace BudgetApp.Services;
|
||||
|
||||
public class DashboardService(TransactionService transactionService, AccountService accountService, CategoryService categoryService)
|
||||
public class DashboardService(ITransactionRepository transactionRepository, IAccountRepository accountRepository, ICategoryRepository categoryRepository)
|
||||
{
|
||||
private readonly TransactionService _transactionService = transactionService;
|
||||
private readonly AccountService _accountService = accountService;
|
||||
private readonly CategoryService _categoryService = categoryService;
|
||||
|
||||
public async Task<DashboardData> GetDashboardDataAsync(string userId, DateTime? from = null, DateTime? to = null)
|
||||
{
|
||||
await _categoryService.EnsureDefaultCategoriesAsync(userId);
|
||||
await categoryRepository.EnsureDefaultCategoriesAsync(userId);
|
||||
var windowEnd = to ?? DateTime.Today.AddDays(30);
|
||||
await _transactionService.MaterializeRecurringTransactionsAsync(userId, windowEnd);
|
||||
await transactionRepository.MaterializeRecurringTransactionsAsync(userId, windowEnd);
|
||||
|
||||
var accounts = await _accountService.GetAccountsAsync(userId);
|
||||
var categories = await _categoryService.GetCategoriesAsync(userId);
|
||||
var accounts = await accountRepository.GetAccountsAsync(userId);
|
||||
var categories = await categoryRepository.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 allTransactions = await transactionRepository.GetTransactionsInRangeAsync(userId, fetchStart, fetchEnd);
|
||||
|
||||
var recent = allTransactions
|
||||
.Where(t => (t.Date.Date >= windowStart.Date && t.Date.Date <= DateTime.Today)
|
||||
@@ -99,10 +97,7 @@ public class DashboardService(TransactionService transactionService, AccountServ
|
||||
for (var d = startDate; d <= endDate; d = d.AddDays(1))
|
||||
{
|
||||
var dayTxns = accountTransactions.Where(t => t.Date.Date == d.Date && !(t.IsPaid && t.Date.Date > today));
|
||||
foreach (var t in dayTxns)
|
||||
{
|
||||
runningBalance += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
|
||||
}
|
||||
runningBalance += dayTxns.Sum(t => t.Type == TransactionType.Income ? t.Amount : -t.Amount);
|
||||
dataPoints.Add(runningBalance);
|
||||
}
|
||||
|
||||
@@ -122,29 +117,4 @@ public class DashboardService(TransactionService transactionService, AccountServ
|
||||
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; } = "$";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace BudgetApp.Services.Models;
|
||||
|
||||
public class ChartData
|
||||
{
|
||||
public List<string> Labels { get; set; } = [];
|
||||
public List<ChartDataset> Datasets { get; set; } = [];
|
||||
public List<List<string>> DayTransactionLabels { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace BudgetApp.Services.Models;
|
||||
|
||||
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; } = "$";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
using BudgetApp.Data.Entities;
|
||||
|
||||
namespace BudgetApp.Services.Models;
|
||||
|
||||
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; } = "$";
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BudgetApp.Data;
|
||||
|
||||
namespace BudgetApp.Services;
|
||||
|
||||
public class SalaryService(ApplicationDbContext db)
|
||||
{
|
||||
private readonly ApplicationDbContext _db = db;
|
||||
|
||||
public async Task<List<Salary>> GetSalariesAsync(string userId)
|
||||
{
|
||||
return await _db.Salaries
|
||||
.Include(s => s.Account)
|
||||
.Include(s => s.Category)
|
||||
.Where(s => s.UserId == userId)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task CreateSalaryAsync(Salary salary, DateTime windowEnd)
|
||||
{
|
||||
var salaryCategoryId = await _db.Categories
|
||||
.Where(c => c.UserId == salary.UserId && c.Name == "Salary")
|
||||
.Select(c => (int?)c.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
salary.CategoryId = salaryCategoryId;
|
||||
|
||||
_db.Salaries.Add(salary);
|
||||
await _db.SaveChangesAsync();
|
||||
await MaterializeSalaryTransactionsAsync(salary.UserId, windowEnd);
|
||||
}
|
||||
|
||||
public async Task DeleteSalaryAsync(int id, string userId)
|
||||
{
|
||||
var exists = await _db.Salaries.AnyAsync(s => s.Id == id && s.UserId == userId);
|
||||
if (!exists) return;
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
// Delete future unpaid transactions generated by this salary
|
||||
await _db.Transactions
|
||||
.Where(t => t.SalaryId == id && t.Date.Date > DateTime.Today && !t.IsPaid)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
// Unlink past / paid transactions so they stay in history without the salary reference
|
||||
await _db.Transactions
|
||||
.Where(t => t.SalaryId == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SalaryId, (int?)null));
|
||||
|
||||
await _db.Salaries
|
||||
.Where(s => s.Id == id && s.UserId == userId)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd)
|
||||
{
|
||||
var salaries = await _db.Salaries
|
||||
.Where(s => s.UserId == userId)
|
||||
.ToListAsync();
|
||||
|
||||
var newTransactions = new List<Transaction>();
|
||||
|
||||
foreach (var salary in salaries)
|
||||
{
|
||||
var latestDate = await _db.Transactions
|
||||
.Where(t => t.SalaryId == salary.Id)
|
||||
.OrderByDescending(t => t.Date)
|
||||
.Select(t => (DateTime?)t.Date)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var nextDate = latestDate.HasValue
|
||||
? GetNextDate(latestDate.Value, salary)
|
||||
: salary.StartDate.Date;
|
||||
|
||||
while (nextDate <= windowEnd)
|
||||
{
|
||||
newTransactions.Add(new Transaction
|
||||
{
|
||||
Description = salary.Name,
|
||||
Amount = salary.Amount,
|
||||
Date = nextDate,
|
||||
Type = TransactionType.Income,
|
||||
AccountId = salary.AccountId,
|
||||
CategoryId = salary.CategoryId,
|
||||
RecurrenceType = RecurrenceType.None,
|
||||
SalaryId = salary.Id
|
||||
});
|
||||
nextDate = GetNextDate(nextDate, salary);
|
||||
}
|
||||
}
|
||||
|
||||
if (newTransactions.Count > 0)
|
||||
{
|
||||
_db.Transactions.AddRange(newTransactions);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DateTime?> GetNextPaydateAsync(int salaryId)
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Where(t => t.SalaryId == salaryId && t.Date.Date >= DateTime.Today)
|
||||
.OrderBy(t => t.Date)
|
||||
.Select(t => (DateTime?)t.Date)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private static DateTime GetNextDate(DateTime from, Salary salary) => salary.RecurrenceType switch
|
||||
{
|
||||
RecurrenceType.Weekly => from.AddDays(7),
|
||||
RecurrenceType.Monthly => from.AddMonths(1),
|
||||
RecurrenceType.Yearly => from.AddYears(1),
|
||||
_ => from.AddMonths(1)
|
||||
};
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BudgetApp.Data;
|
||||
|
||||
namespace BudgetApp.Services;
|
||||
|
||||
public class TransactionService(ApplicationDbContext db)
|
||||
{
|
||||
private readonly ApplicationDbContext _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;
|
||||
|
||||
var isPartOfSeries = transaction.RecurrenceType != RecurrenceType.None
|
||||
|| transaction.ParentTransactionId is not null;
|
||||
|
||||
transaction.Description = description;
|
||||
transaction.Amount = amount;
|
||||
transaction.Date = date;
|
||||
transaction.Type = type;
|
||||
transaction.AccountId = accountId;
|
||||
transaction.CategoryId = categoryId;
|
||||
|
||||
if (isPartOfSeries)
|
||||
{
|
||||
// Cascade edits to all future occurrences in the series
|
||||
var parentId = transaction.ParentTransactionId ?? transaction.Id;
|
||||
var cutoffDate = transaction.Date.Date;
|
||||
|
||||
var futureSiblings = await _db.Transactions
|
||||
.Where(t => (t.Id == parentId || t.ParentTransactionId == parentId)
|
||||
&& t.Id != id
|
||||
&& t.Date.Date >= cutoffDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var sibling in futureSiblings)
|
||||
{
|
||||
sibling.Description = description;
|
||||
sibling.Amount = amount;
|
||||
sibling.Type = type;
|
||||
sibling.AccountId = accountId;
|
||||
sibling.CategoryId = categoryId;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standalone transaction — preserve existing recurrence state (None)
|
||||
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 MarkAsPaidAsync(int id, string userId, bool isPaid = true)
|
||||
{
|
||||
var transaction = await _db.Transactions
|
||||
.Include(t => t.Account)
|
||||
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
||||
|
||||
if (transaction is null) return;
|
||||
|
||||
transaction.IsPaid = isPaid;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
internal 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
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user