Initial commit: Purrse personal finance app
Self-hosted, plugin-extensible personal finance manager built with ASP.NET Core 9.0, Vue 3 + Vuetify 3, and PostgreSQL 17. Backend (8 .NET projects): - Core: 19 domain models, 6 enums, 14 DTOs, 12 service interfaces - Data: EF Core DbContext, 16 entity configurations, category seeder - API: 14 controllers, 15 services, JWT auth, SignalR, middleware - Plugins: Abstractions + OFX/CSV/QIF file parsers - Tests: 28 xUnit tests (fingerprinting, duplicate detection, parsers) Frontend (Vue 3 + Vuetify 3 + TypeScript): - 13 views, Pinia stores, Axios API services with JWT interceptors - Dashboard, accounts, transactions, categories, imports, and more Deployment: - Docker Compose (PostgreSQL 17 + .NET API + nginx frontend) - Auto-migration on startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class AccountService : IAccountService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
|
||||
public AccountService(PurrseDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<AccountResponse>> GetAllAsync(Guid userId)
|
||||
{
|
||||
return await _db.Accounts
|
||||
.Where(a => a.UserId == userId)
|
||||
.OrderBy(a => a.SortOrder).ThenBy(a => a.Name)
|
||||
.Select(a => MapToResponse(a))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<AccountResponse?> GetByIdAsync(Guid userId, Guid accountId)
|
||||
{
|
||||
var account = await _db.Accounts
|
||||
.Include(a => a.LoanDetail)
|
||||
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId);
|
||||
return account == null ? null : MapToResponse(account);
|
||||
}
|
||||
|
||||
public async Task<AccountResponse> CreateAsync(Guid userId, CreateAccountRequest request)
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = request.Name,
|
||||
Type = request.Type,
|
||||
Institution = request.Institution,
|
||||
AccountNumber = request.AccountNumber,
|
||||
Balance = request.Balance,
|
||||
CreditLimit = request.CreditLimit,
|
||||
InterestRate = request.InterestRate,
|
||||
Notes = request.Notes,
|
||||
SortOrder = await _db.Accounts.CountAsync(a => a.UserId == userId)
|
||||
};
|
||||
|
||||
_db.Accounts.Add(account);
|
||||
await _db.SaveChangesAsync();
|
||||
return MapToResponse(account);
|
||||
}
|
||||
|
||||
public async Task<AccountResponse> UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request)
|
||||
{
|
||||
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Account not found");
|
||||
|
||||
account.Name = request.Name;
|
||||
account.Institution = request.Institution;
|
||||
account.AccountNumber = request.AccountNumber;
|
||||
account.CreditLimit = request.CreditLimit;
|
||||
account.InterestRate = request.InterestRate;
|
||||
account.Notes = request.Notes;
|
||||
account.IsActive = request.IsActive;
|
||||
account.SortOrder = request.SortOrder;
|
||||
account.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return MapToResponse(account);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid accountId)
|
||||
{
|
||||
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Account not found");
|
||||
|
||||
_db.Accounts.Remove(account);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<List<BalanceHistoryEntry>> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Account not found");
|
||||
|
||||
var transactions = await _db.Transactions
|
||||
.Where(t => t.AccountId == accountId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid)
|
||||
.OrderBy(t => t.Date)
|
||||
.ToListAsync();
|
||||
|
||||
var balanceBefore = account.Balance - await _db.Transactions
|
||||
.Where(t => t.AccountId == accountId && !t.IsVoid)
|
||||
.SumAsync(t => t.Amount);
|
||||
|
||||
var priorSum = await _db.Transactions
|
||||
.Where(t => t.AccountId == accountId && t.Date < startDate && !t.IsVoid)
|
||||
.SumAsync(t => t.Amount);
|
||||
|
||||
var runningBalance = balanceBefore + priorSum;
|
||||
var entries = new List<BalanceHistoryEntry>();
|
||||
|
||||
foreach (var group in transactions.GroupBy(t => t.Date.Date))
|
||||
{
|
||||
runningBalance += group.Sum(t => t.Amount);
|
||||
entries.Add(new BalanceHistoryEntry(group.Key, runningBalance));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
public async Task RecalculateBalanceAsync(Guid accountId)
|
||||
{
|
||||
var account = await _db.Accounts.FindAsync(accountId);
|
||||
if (account == null) return;
|
||||
|
||||
var sum = await _db.Transactions
|
||||
.Where(t => t.AccountId == accountId && !t.IsVoid)
|
||||
.SumAsync(t => t.Amount);
|
||||
|
||||
account.Balance = sum;
|
||||
account.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static AccountResponse MapToResponse(Account a) => new(
|
||||
a.Id, a.Name, a.Type, a.Institution, a.AccountNumber,
|
||||
a.Balance, a.CreditLimit, a.InterestRate,
|
||||
a.IsActive, a.IsClosed, a.Notes, a.SortOrder, a.CreatedAt,
|
||||
a.LoanDetail != null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ICategoryService _categoryService;
|
||||
|
||||
public AuthService(PurrseDbContext db, IConfiguration config, ICategoryService categoryService)
|
||||
{
|
||||
_db = db;
|
||||
_config = config;
|
||||
_categoryService = categoryService;
|
||||
}
|
||||
|
||||
public async Task<AuthResponse> RegisterAsync(RegisterRequest request)
|
||||
{
|
||||
if (await _db.Users.AnyAsync(u => u.Username == request.Username))
|
||||
throw new InvalidOperationException("Username already exists");
|
||||
if (await _db.Users.AnyAsync(u => u.Email == request.Email))
|
||||
throw new InvalidOperationException("Email already exists");
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = request.Username,
|
||||
Email = request.Email,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.Users.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _categoryService.SeedDefaultCategoriesAsync(user.Id);
|
||||
|
||||
return GenerateAuthResponse(user);
|
||||
}
|
||||
|
||||
public async Task<AuthResponse> LoginAsync(LoginRequest request)
|
||||
{
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username)
|
||||
?? throw new UnauthorizedAccessException("Invalid credentials");
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||
throw new UnauthorizedAccessException("Invalid credentials");
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return GenerateAuthResponse(user);
|
||||
}
|
||||
|
||||
public async Task<AuthResponse> RefreshTokenAsync(string refreshToken)
|
||||
{
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u =>
|
||||
u.RefreshToken == refreshToken && u.RefreshTokenExpiresAt > DateTime.UtcNow)
|
||||
?? throw new UnauthorizedAccessException("Invalid or expired refresh token");
|
||||
|
||||
return GenerateAuthResponse(user);
|
||||
}
|
||||
|
||||
public async Task RevokeRefreshTokenAsync(Guid userId)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId);
|
||||
if (user != null)
|
||||
{
|
||||
user.RefreshToken = null;
|
||||
user.RefreshTokenExpiresAt = null;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private AuthResponse GenerateAuthResponse(User user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(12);
|
||||
var token = GenerateJwtToken(user, expiresAt);
|
||||
var refreshToken = GenerateRefreshToken();
|
||||
|
||||
user.RefreshToken = refreshToken;
|
||||
user.RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(30);
|
||||
_db.SaveChanges();
|
||||
|
||||
return new AuthResponse(token, refreshToken, expiresAt, user.Username);
|
||||
}
|
||||
|
||||
private string GenerateJwtToken(User user, DateTime expiresAt)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
|
||||
_config["Jwt:Key"] ?? throw new InvalidOperationException("JWT key not configured")));
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim(ClaimTypes.Email, user.Email)
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _config["Jwt:Issuer"],
|
||||
audience: _config["Jwt:Audience"],
|
||||
claims: claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private static string GenerateRefreshToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(64);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Enums;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class BudgetService : IBudgetService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
|
||||
public BudgetService(PurrseDbContext db) => _db = db;
|
||||
|
||||
public async Task<BudgetResponse?> GetByMonthAsync(Guid userId, int year, int month)
|
||||
{
|
||||
var budget = await _db.Budgets
|
||||
.Include(b => b.Items).ThenInclude(i => i.Category)
|
||||
.FirstOrDefaultAsync(b => b.UserId == userId && b.Year == year && b.Month == month);
|
||||
|
||||
if (budget == null) return null;
|
||||
|
||||
var startDate = new DateTime(year, month, 1);
|
||||
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||
var actuals = await GetActualSpending(userId, startDate, endDate);
|
||||
|
||||
return MapToResponse(budget, actuals);
|
||||
}
|
||||
|
||||
public async Task<BudgetResponse> CreateAsync(Guid userId, CreateBudgetRequest request)
|
||||
{
|
||||
var budget = new Budget
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Year = request.Year,
|
||||
Month = request.Month,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
budget.Items.Add(new BudgetItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CategoryId = item.CategoryId,
|
||||
BudgetedAmount = item.BudgetedAmount
|
||||
});
|
||||
}
|
||||
|
||||
_db.Budgets.Add(budget);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return MapToResponse(budget, new Dictionary<Guid, decimal>());
|
||||
}
|
||||
|
||||
public async Task<BudgetResponse> UpdateAsync(Guid userId, Guid budgetId, UpdateBudgetRequest request)
|
||||
{
|
||||
var budget = await _db.Budgets.Include(b => b.Items).ThenInclude(i => i.Category)
|
||||
.FirstOrDefaultAsync(b => b.Id == budgetId && b.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Budget not found");
|
||||
|
||||
budget.Notes = request.Notes;
|
||||
_db.BudgetItems.RemoveRange(budget.Items);
|
||||
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
budget.Items.Add(new BudgetItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BudgetId = budgetId,
|
||||
CategoryId = item.CategoryId,
|
||||
BudgetedAmount = item.BudgetedAmount
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var startDate = new DateTime(budget.Year, budget.Month, 1);
|
||||
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||
var actuals = await GetActualSpending(userId, startDate, endDate);
|
||||
|
||||
return MapToResponse(budget, actuals);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid budgetId)
|
||||
{
|
||||
var budget = await _db.Budgets.FirstOrDefaultAsync(b => b.Id == budgetId && b.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Budget not found");
|
||||
_db.Budgets.Remove(budget);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<List<BudgetItemResponse>> GetActualsAsync(Guid userId, Guid budgetId)
|
||||
{
|
||||
var budget = await _db.Budgets.Include(b => b.Items).ThenInclude(i => i.Category)
|
||||
.FirstOrDefaultAsync(b => b.Id == budgetId && b.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Budget not found");
|
||||
|
||||
var startDate = new DateTime(budget.Year, budget.Month, 1);
|
||||
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||
var actuals = await GetActualSpending(userId, startDate, endDate);
|
||||
|
||||
return budget.Items.Select(i => new BudgetItemResponse(
|
||||
i.Id, i.CategoryId, i.Category?.Name ?? "", i.BudgetedAmount,
|
||||
actuals.GetValueOrDefault(i.CategoryId)
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, decimal>> GetActualSpending(Guid userId, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
return await _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate
|
||||
&& t.CategoryId.HasValue && !t.IsVoid && t.Type == TransactionType.Debit)
|
||||
.GroupBy(t => t.CategoryId!.Value)
|
||||
.ToDictionaryAsync(g => g.Key, g => Math.Abs(g.Sum(t => t.Amount)));
|
||||
}
|
||||
|
||||
private static BudgetResponse MapToResponse(Budget b, Dictionary<Guid, decimal> actuals) => new(
|
||||
b.Id, b.Year, b.Month, b.Notes,
|
||||
b.Items.Select(i => new BudgetItemResponse(
|
||||
i.Id, i.CategoryId, i.Category?.Name ?? "", i.BudgetedAmount,
|
||||
actuals.GetValueOrDefault(i.CategoryId)
|
||||
)).ToList(),
|
||||
b.CreatedAt
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
using Purrse.Data.Seeds;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class CategoryService : ICategoryService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
|
||||
public CategoryService(PurrseDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<CategoryResponse>> GetAllAsync(Guid userId)
|
||||
{
|
||||
return await _db.Categories
|
||||
.Include(c => c.Parent)
|
||||
.Where(c => c.UserId == userId)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.Select(c => new CategoryResponse(c.Id, c.Name, c.Type, c.ParentId, c.Parent != null ? c.Parent.Name : null, c.SortOrder, c.IsSystem, c.IsActive))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<CategoryTreeResponse>> GetTreeAsync(Guid userId)
|
||||
{
|
||||
var categories = await _db.Categories
|
||||
.Where(c => c.UserId == userId && c.IsActive)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ToListAsync();
|
||||
|
||||
return BuildTree(categories, null);
|
||||
}
|
||||
|
||||
public async Task<CategoryResponse?> GetByIdAsync(Guid userId, Guid categoryId)
|
||||
{
|
||||
var c = await _db.Categories.Include(c => c.Parent)
|
||||
.FirstOrDefaultAsync(c => c.Id == categoryId && c.UserId == userId);
|
||||
return c == null ? null : new CategoryResponse(c.Id, c.Name, c.Type, c.ParentId, c.Parent?.Name, c.SortOrder, c.IsSystem, c.IsActive);
|
||||
}
|
||||
|
||||
public async Task<CategoryResponse> CreateAsync(Guid userId, CreateCategoryRequest request)
|
||||
{
|
||||
var category = new Category
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = request.Name,
|
||||
Type = request.Type,
|
||||
ParentId = request.ParentId,
|
||||
SortOrder = await _db.Categories.CountAsync(c => c.UserId == userId)
|
||||
};
|
||||
|
||||
_db.Categories.Add(category);
|
||||
await _db.SaveChangesAsync();
|
||||
return new CategoryResponse(category.Id, category.Name, category.Type, category.ParentId, null, category.SortOrder, false, true);
|
||||
}
|
||||
|
||||
public async Task<CategoryResponse> UpdateAsync(Guid userId, Guid categoryId, UpdateCategoryRequest request)
|
||||
{
|
||||
var category = await _db.Categories.FirstOrDefaultAsync(c => c.Id == categoryId && c.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Category not found");
|
||||
|
||||
category.Name = request.Name;
|
||||
category.ParentId = request.ParentId;
|
||||
category.SortOrder = request.SortOrder;
|
||||
category.IsActive = request.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new CategoryResponse(category.Id, category.Name, category.Type, category.ParentId, null, category.SortOrder, category.IsSystem, category.IsActive);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid categoryId)
|
||||
{
|
||||
var category = await _db.Categories.FirstOrDefaultAsync(c => c.Id == categoryId && c.UserId == userId && !c.IsSystem)
|
||||
?? throw new KeyNotFoundException("Category not found or is a system category");
|
||||
|
||||
_db.Categories.Remove(category);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SeedDefaultCategoriesAsync(Guid userId)
|
||||
{
|
||||
if (await _db.Categories.AnyAsync(c => c.UserId == userId))
|
||||
return;
|
||||
|
||||
var categories = CategorySeeder.GetDefaultCategories(userId);
|
||||
_db.Categories.AddRange(categories);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static List<CategoryTreeResponse> BuildTree(List<Category> all, Guid? parentId)
|
||||
{
|
||||
return all.Where(c => c.ParentId == parentId)
|
||||
.Select(c => new CategoryTreeResponse(
|
||||
c.Id, c.Name, c.Type, c.SortOrder, c.IsSystem, c.IsActive,
|
||||
BuildTree(all, c.Id)))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Enums;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class DashboardService : IDashboardService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
|
||||
public DashboardService(PurrseDbContext db) => _db = db;
|
||||
|
||||
public async Task<DashboardResponse> GetDashboardAsync(Guid userId)
|
||||
{
|
||||
var accounts = await _db.Accounts
|
||||
.Where(a => a.UserId == userId && a.IsActive)
|
||||
.OrderBy(a => a.SortOrder)
|
||||
.ToListAsync();
|
||||
|
||||
var assetTypes = new[] { AccountType.Checking, AccountType.Savings, AccountType.Cash, AccountType.Brokerage, AccountType.Retirement401k, AccountType.IRA };
|
||||
var totalAssets = accounts.Where(a => assetTypes.Contains(a.Type)).Sum(a => a.Balance);
|
||||
var totalLiabilities = accounts.Where(a => !assetTypes.Contains(a.Type)).Sum(a => Math.Abs(a.Balance));
|
||||
var netWorth = totalAssets - totalLiabilities;
|
||||
|
||||
var accountSummaries = accounts.Select(a => new AccountSummaryResponse(a.Id, a.Name, a.Type, a.Institution, a.Balance, a.CreditLimit)).ToList();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var startOfMonth = new DateTime(now.Year, now.Month, 1);
|
||||
var startOfLastMonth = startOfMonth.AddMonths(-1);
|
||||
|
||||
var recentTxns = await _db.Transactions
|
||||
.Include(t => t.Account).Include(t => t.Category)
|
||||
.Where(t => t.Account.UserId == userId && !t.IsVoid)
|
||||
.OrderByDescending(t => t.Date).ThenByDescending(t => t.CreatedAt)
|
||||
.Take(10)
|
||||
.Select(t => new TransactionResponse(t.Id, t.AccountId, t.Account.Name, t.Date, t.Amount,
|
||||
t.PayeeId, t.PayeeName, t.CategoryId, t.Category != null ? t.Category.Name : null,
|
||||
t.Memo, t.ReferenceNumber, t.CheckNumber, t.Type, t.Status, t.IsVoid,
|
||||
t.TransferTransactionId, t.ImportBatchId, null, t.CreatedAt))
|
||||
.ToListAsync();
|
||||
|
||||
var thisMonthSpending = await _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startOfMonth && t.Amount < 0 && !t.IsVoid)
|
||||
.SumAsync(t => Math.Abs(t.Amount));
|
||||
|
||||
var lastMonthSpending = await _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startOfLastMonth && t.Date < startOfMonth && t.Amount < 0 && !t.IsVoid)
|
||||
.SumAsync(t => Math.Abs(t.Amount));
|
||||
|
||||
var changePercent = lastMonthSpending == 0 ? 0 : ((thisMonthSpending - lastMonthSpending) / lastMonthSpending) * 100;
|
||||
|
||||
var topCategories = await _db.Transactions
|
||||
.Include(t => t.Category)
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startOfMonth && t.Amount < 0 && !t.IsVoid && t.CategoryId.HasValue)
|
||||
.GroupBy(t => new { t.CategoryId, CategoryName = t.Category!.Name })
|
||||
.Select(g => new CategorySpending(g.Key.CategoryName, Math.Abs(g.Sum(t => t.Amount)), 0, null))
|
||||
.OrderByDescending(c => c.Amount)
|
||||
.Take(5)
|
||||
.ToListAsync();
|
||||
|
||||
var totalSpending = topCategories.Sum(c => c.Amount);
|
||||
topCategories = topCategories.Select(c => new CategorySpending(c.CategoryName, c.Amount,
|
||||
totalSpending > 0 ? (c.Amount / totalSpending) * 100 : 0, null)).ToList();
|
||||
|
||||
var spendingSummary = new SpendingSummary(thisMonthSpending, lastMonthSpending, changePercent, topCategories);
|
||||
|
||||
var upcomingBills = await _db.ScheduledTransactions
|
||||
.Include(s => s.Account)
|
||||
.Where(s => s.UserId == userId && s.IsActive && s.NextDueDate <= now.AddDays(14))
|
||||
.OrderBy(s => s.NextDueDate)
|
||||
.Take(10)
|
||||
.Select(s => new UpcomingBill(s.Id, s.PayeeName ?? "", s.Amount, s.NextDueDate, s.Account.Name, s.AutoPost))
|
||||
.ToListAsync();
|
||||
|
||||
return new DashboardResponse(netWorth, totalAssets, totalLiabilities, accountSummaries,
|
||||
recentTxns, spendingSummary, upcomingBills);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Helpers;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class DuplicateDetectionService : IDuplicateDetectionService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
|
||||
public DuplicateDetectionService(PurrseDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<DuplicateMatch?> FindDuplicateAsync(Guid accountId, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber)
|
||||
{
|
||||
var fingerprint = TransactionFingerprint.Generate(accountId, date, amount, fitId);
|
||||
|
||||
// Check exact fingerprint match
|
||||
var fpMatch = await _db.Transactions.FirstOrDefaultAsync(t => t.AccountId == accountId && t.Fingerprint == fingerprint);
|
||||
if (fpMatch != null)
|
||||
return new DuplicateMatch(fpMatch.Id, 1.0, "Exact fingerprint match");
|
||||
|
||||
// Check FITID match
|
||||
if (!string.IsNullOrEmpty(fitId))
|
||||
{
|
||||
var fitIdMatch = await _db.Transactions.FirstOrDefaultAsync(t => t.AccountId == accountId && t.FitId == fitId);
|
||||
if (fitIdMatch != null)
|
||||
return new DuplicateMatch(fitIdMatch.Id, 0.95, "Matching FITID/Reference");
|
||||
}
|
||||
|
||||
// Multi-factor scoring on nearby transactions
|
||||
var candidates = await _db.Transactions
|
||||
.Where(t => t.AccountId == accountId && t.Date >= date.AddDays(-3) && t.Date <= date.AddDays(3) && !t.IsVoid)
|
||||
.ToListAsync();
|
||||
|
||||
DuplicateMatch? bestMatch = null;
|
||||
|
||||
foreach (var existing in candidates)
|
||||
{
|
||||
var confidence = CalculateConfidence(existing, date, amount, payeeName, fitId, checkNumber);
|
||||
if (confidence >= 0.50 && (bestMatch == null || confidence > bestMatch.Confidence))
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
if (existing.Amount == amount) reasons.Add("amount");
|
||||
if (existing.Date.Date == date.Date) reasons.Add("date");
|
||||
if (!string.IsNullOrEmpty(payeeName) && !string.IsNullOrEmpty(existing.PayeeName))
|
||||
reasons.Add("payee");
|
||||
|
||||
bestMatch = new DuplicateMatch(existing.Id, confidence, $"Matched on: {string.Join(", ", reasons)}");
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
public double CalculateConfidence(Transaction existing, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber)
|
||||
{
|
||||
double score = 0;
|
||||
|
||||
// Amount matching
|
||||
if (existing.Amount == amount)
|
||||
score += 0.40;
|
||||
else if (Math.Abs(existing.Amount - amount) <= 0.01m)
|
||||
score += 0.35;
|
||||
|
||||
// Date matching
|
||||
var daysDiff = Math.Abs((existing.Date.Date - date.Date).Days);
|
||||
if (daysDiff == 0) score += 0.30;
|
||||
else if (daysDiff <= 1) score += 0.20;
|
||||
else if (daysDiff <= 2) score += 0.10;
|
||||
|
||||
// Payee matching (Jaro-Winkler)
|
||||
if (!string.IsNullOrEmpty(payeeName) && !string.IsNullOrEmpty(existing.PayeeName))
|
||||
{
|
||||
var similarity = StringSimilarityHelper.Calculate(payeeName, existing.PayeeName);
|
||||
if (similarity >= 0.85) score += 0.20;
|
||||
else if (similarity >= 0.70) score += 0.10;
|
||||
}
|
||||
|
||||
// Check number matching
|
||||
if (!string.IsNullOrEmpty(checkNumber) && existing.CheckNumber == checkNumber)
|
||||
score += 0.10;
|
||||
|
||||
return score;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class FileWatcherService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<FileWatcherService> _logger;
|
||||
private readonly string _importsPath;
|
||||
private FileSystemWatcher? _watcher;
|
||||
|
||||
public FileWatcherService(IServiceScopeFactory scopeFactory, ILogger<FileWatcherService> logger, IConfiguration config)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
_importsPath = config["Imports:WatchPath"] ?? Path.Combine(AppContext.BaseDirectory, "imports");
|
||||
}
|
||||
|
||||
protected override Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!Directory.Exists(_importsPath))
|
||||
Directory.CreateDirectory(_importsPath);
|
||||
|
||||
_watcher = new FileSystemWatcher(_importsPath)
|
||||
{
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
|
||||
_watcher.Created += OnFileCreated;
|
||||
|
||||
_logger.LogInformation("File watcher started, monitoring: {Path}", _importsPath);
|
||||
|
||||
stoppingToken.Register(() =>
|
||||
{
|
||||
_watcher?.Dispose();
|
||||
_logger.LogInformation("File watcher stopped");
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnFileCreated(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
_logger.LogInformation("New file detected: {FileName}", e.Name);
|
||||
// Files dropped here can be processed by the import service
|
||||
// A SignalR notification would be sent to connected clients
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Enums;
|
||||
using Purrse.Core.Helpers;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Plugins.Abstractions;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class ImportService : IImportService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
private readonly IDuplicateDetectionService _duplicateService;
|
||||
private readonly IPayeeService _payeeService;
|
||||
private readonly IEnumerable<IFileParser> _parsers;
|
||||
|
||||
public ImportService(PurrseDbContext db, IDuplicateDetectionService duplicateService, IPayeeService payeeService, IEnumerable<IFileParser> parsers)
|
||||
{
|
||||
_db = db;
|
||||
_duplicateService = duplicateService;
|
||||
_payeeService = payeeService;
|
||||
_parsers = parsers;
|
||||
}
|
||||
|
||||
public async Task<ImportUploadResponse> UploadAsync(Guid userId, Guid accountId, string fileName, Stream fileStream)
|
||||
{
|
||||
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Account not found");
|
||||
|
||||
var parser = _parsers.FirstOrDefault(p => p.CanParse(fileName, fileStream))
|
||||
?? throw new InvalidOperationException($"No parser found for file: {fileName}");
|
||||
|
||||
fileStream.Position = 0;
|
||||
var parseResult = await parser.ParseAsync(fileStream, fileName);
|
||||
|
||||
if (!parseResult.Success)
|
||||
throw new InvalidOperationException(parseResult.ErrorMessage ?? "Failed to parse file");
|
||||
|
||||
var batch = new ImportBatch
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
AccountId = accountId,
|
||||
FileName = fileName,
|
||||
FileType = Path.GetExtension(fileName).TrimStart('.').ToUpperInvariant(),
|
||||
TotalCount = parseResult.Transactions.Count,
|
||||
Status = ImportStatus.Pending
|
||||
};
|
||||
|
||||
var previews = new List<ImportedTransactionPreview>();
|
||||
|
||||
for (int i = 0; i < parseResult.Transactions.Count; i++)
|
||||
{
|
||||
var parsed = parseResult.Transactions[i];
|
||||
var duplicate = await _duplicateService.FindDuplicateAsync(
|
||||
accountId, parsed.Date, parsed.Amount, parsed.PayeeName, parsed.FitId, parsed.CheckNumber);
|
||||
|
||||
previews.Add(new ImportedTransactionPreview(
|
||||
i, parsed.Date, parsed.Amount, parsed.PayeeName, parsed.Memo, parsed.FitId, duplicate));
|
||||
}
|
||||
|
||||
_db.ImportBatches.Add(batch);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var newCount = previews.Count(p => p.DuplicateMatch == null);
|
||||
var dupCount = previews.Count(p => p.DuplicateMatch != null);
|
||||
|
||||
return new ImportUploadResponse(batch.Id, parseResult.Transactions.Count, newCount, dupCount, previews);
|
||||
}
|
||||
|
||||
public async Task<ImportBatchResponse> ConfirmBatchAsync(Guid userId, Guid batchId)
|
||||
{
|
||||
var batch = await _db.ImportBatches.FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Import batch not found");
|
||||
|
||||
batch.Status = ImportStatus.Confirmed;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new ImportBatchResponse(batch.Id, batch.FileName, batch.FileType, batch.TotalCount,
|
||||
batch.ImportedCount, batch.DuplicateCount, batch.SkippedCount, batch.Status.ToString(), batch.ImportedAt);
|
||||
}
|
||||
|
||||
public async Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request)
|
||||
{
|
||||
var batch = await _db.ImportBatches.FirstOrDefaultAsync(b => b.Id == batchId && b.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Import batch not found");
|
||||
|
||||
foreach (var resolution in request.Resolutions)
|
||||
{
|
||||
switch (resolution.Action)
|
||||
{
|
||||
case DuplicateAction.Skip:
|
||||
batch.SkippedCount++;
|
||||
break;
|
||||
case DuplicateAction.Import:
|
||||
batch.ImportedCount++;
|
||||
break;
|
||||
case DuplicateAction.Merge:
|
||||
batch.DuplicateCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class LoanService : ILoanService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
|
||||
public LoanService(PurrseDbContext db) => _db = db;
|
||||
|
||||
public async Task<LoanDetailResponse?> GetLoanDetailAsync(Guid userId, Guid accountId)
|
||||
{
|
||||
var account = await _db.Accounts.Include(a => a.LoanDetail).ThenInclude(l => l!.AmortizationEntries)
|
||||
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId);
|
||||
|
||||
if (account?.LoanDetail == null) return null;
|
||||
|
||||
var l = account.LoanDetail;
|
||||
var schedule = l.AmortizationEntries.OrderBy(e => e.PaymentNumber)
|
||||
.Select(e => new AmortizationEntryResponse(e.PaymentNumber, e.PaymentDate, e.PaymentAmount, e.PrincipalAmount, e.InterestAmount, e.EscrowAmount, e.RemainingBalance))
|
||||
.ToList();
|
||||
|
||||
return new LoanDetailResponse(l.Id, l.AccountId, account.Name, l.OriginalBalance, account.Balance,
|
||||
l.InterestRate, l.TermMonths, l.MonthlyPayment, l.OriginationDate, l.MaturityDate,
|
||||
l.EscrowAmount, l.ExtraPayment, schedule);
|
||||
}
|
||||
|
||||
public async Task<LoanDetailResponse> CreateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request)
|
||||
{
|
||||
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Account not found");
|
||||
|
||||
var loanDetail = new LoanDetail
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = accountId,
|
||||
OriginalBalance = request.OriginalBalance,
|
||||
InterestRate = request.InterestRate,
|
||||
TermMonths = request.TermMonths,
|
||||
MonthlyPayment = request.MonthlyPayment,
|
||||
OriginationDate = request.OriginationDate,
|
||||
MaturityDate = request.MaturityDate,
|
||||
EscrowAmount = request.EscrowAmount,
|
||||
ExtraPayment = request.ExtraPayment
|
||||
};
|
||||
|
||||
var entries = GenerateAmortizationSchedule(loanDetail);
|
||||
loanDetail.AmortizationEntries = entries;
|
||||
|
||||
_db.LoanDetails.Add(loanDetail);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var schedule = entries.Select(e => new AmortizationEntryResponse(e.PaymentNumber, e.PaymentDate, e.PaymentAmount, e.PrincipalAmount, e.InterestAmount, e.EscrowAmount, e.RemainingBalance)).ToList();
|
||||
|
||||
return new LoanDetailResponse(loanDetail.Id, accountId, account.Name, loanDetail.OriginalBalance,
|
||||
account.Balance, loanDetail.InterestRate, loanDetail.TermMonths, loanDetail.MonthlyPayment,
|
||||
loanDetail.OriginationDate, loanDetail.MaturityDate, loanDetail.EscrowAmount, loanDetail.ExtraPayment, schedule);
|
||||
}
|
||||
|
||||
public async Task<LoanDetailResponse> UpdateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request)
|
||||
{
|
||||
var account = await _db.Accounts.Include(a => a.LoanDetail).ThenInclude(l => l!.AmortizationEntries)
|
||||
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Account not found");
|
||||
|
||||
var l = account.LoanDetail ?? throw new KeyNotFoundException("No loan detail found");
|
||||
|
||||
_db.AmortizationEntries.RemoveRange(l.AmortizationEntries);
|
||||
|
||||
l.OriginalBalance = request.OriginalBalance;
|
||||
l.InterestRate = request.InterestRate;
|
||||
l.TermMonths = request.TermMonths;
|
||||
l.MonthlyPayment = request.MonthlyPayment;
|
||||
l.OriginationDate = request.OriginationDate;
|
||||
l.MaturityDate = request.MaturityDate;
|
||||
l.EscrowAmount = request.EscrowAmount;
|
||||
l.ExtraPayment = request.ExtraPayment;
|
||||
|
||||
var entries = GenerateAmortizationSchedule(l);
|
||||
l.AmortizationEntries = entries;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var schedule = entries.Select(e => new AmortizationEntryResponse(e.PaymentNumber, e.PaymentDate, e.PaymentAmount, e.PrincipalAmount, e.InterestAmount, e.EscrowAmount, e.RemainingBalance)).ToList();
|
||||
|
||||
return new LoanDetailResponse(l.Id, accountId, account.Name, l.OriginalBalance, account.Balance,
|
||||
l.InterestRate, l.TermMonths, l.MonthlyPayment, l.OriginationDate, l.MaturityDate,
|
||||
l.EscrowAmount, l.ExtraPayment, schedule);
|
||||
}
|
||||
|
||||
public async Task<List<AmortizationEntryResponse>> GetAmortizationScheduleAsync(Guid userId, Guid accountId)
|
||||
{
|
||||
var detail = await GetLoanDetailAsync(userId, accountId)
|
||||
?? throw new KeyNotFoundException("Loan detail not found");
|
||||
return detail.AmortizationSchedule;
|
||||
}
|
||||
|
||||
public async Task<PayoffScenarioResponse> CalculatePayoffScenarioAsync(Guid userId, Guid accountId, PayoffScenarioRequest request)
|
||||
{
|
||||
var account = await _db.Accounts.Include(a => a.LoanDetail).ThenInclude(l => l!.AmortizationEntries)
|
||||
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Account not found");
|
||||
|
||||
var l = account.LoanDetail ?? throw new KeyNotFoundException("No loan detail found");
|
||||
|
||||
var originalSchedule = l.AmortizationEntries.OrderBy(e => e.PaymentNumber).ToList();
|
||||
var totalInterestOriginal = originalSchedule.Sum(e => e.InterestAmount);
|
||||
var originalPayoffDate = originalSchedule.Last().PaymentDate;
|
||||
|
||||
// Build new schedule with extra payments
|
||||
var balance = Math.Abs(account.Balance);
|
||||
var monthlyRate = l.InterestRate / 100 / 12;
|
||||
var extraMonthly = request.ExtraMonthlyPayment ?? 0;
|
||||
var basePayment = l.MonthlyPayment - (l.EscrowAmount ?? 0);
|
||||
var paymentDate = DateTime.UtcNow.Date;
|
||||
paymentDate = new DateTime(paymentDate.Year, paymentDate.Month, l.OriginationDate.Day);
|
||||
|
||||
var newEntries = new List<AmortizationEntryResponse>();
|
||||
int paymentNum = 0;
|
||||
|
||||
// Apply lump sum if specified
|
||||
if (request.LumpSumPayment.HasValue && request.LumpSumDate.HasValue)
|
||||
{
|
||||
balance -= request.LumpSumPayment.Value;
|
||||
if (balance < 0) balance = 0;
|
||||
}
|
||||
|
||||
while (balance > 0 && paymentNum < 600)
|
||||
{
|
||||
paymentNum++;
|
||||
paymentDate = paymentDate.AddMonths(1);
|
||||
|
||||
var interest = balance * monthlyRate;
|
||||
var principal = Math.Min(basePayment + extraMonthly - interest, balance);
|
||||
if (principal < 0) principal = 0;
|
||||
|
||||
balance -= principal;
|
||||
if (balance < 0.01m) balance = 0;
|
||||
|
||||
newEntries.Add(new AmortizationEntryResponse(
|
||||
paymentNum, paymentDate, principal + interest + (l.EscrowAmount ?? 0),
|
||||
principal, interest, l.EscrowAmount, balance));
|
||||
}
|
||||
|
||||
var totalInterestNew = newEntries.Sum(e => e.InterestAmount);
|
||||
var newPayoffDate = newEntries.LastOrDefault()?.PaymentDate ?? originalPayoffDate;
|
||||
var monthsSaved = ((originalPayoffDate.Year - newPayoffDate.Year) * 12) + (originalPayoffDate.Month - newPayoffDate.Month);
|
||||
|
||||
return new PayoffScenarioResponse(
|
||||
originalPayoffDate, newPayoffDate, totalInterestOriginal, totalInterestNew,
|
||||
totalInterestOriginal - totalInterestNew, monthsSaved, newEntries);
|
||||
}
|
||||
|
||||
private static List<AmortizationEntry> GenerateAmortizationSchedule(LoanDetail loan)
|
||||
{
|
||||
var entries = new List<AmortizationEntry>();
|
||||
var balance = loan.OriginalBalance;
|
||||
var monthlyRate = loan.InterestRate / 100 / 12;
|
||||
var payment = loan.MonthlyPayment - (loan.EscrowAmount ?? 0);
|
||||
var extra = loan.ExtraPayment ?? 0;
|
||||
var paymentDate = loan.OriginationDate;
|
||||
|
||||
for (int i = 1; i <= loan.TermMonths && balance > 0; i++)
|
||||
{
|
||||
paymentDate = paymentDate.AddMonths(1);
|
||||
var interest = balance * monthlyRate;
|
||||
var principal = Math.Min(payment + extra - interest, balance);
|
||||
balance -= principal;
|
||||
if (balance < 0.01m) balance = 0;
|
||||
|
||||
entries.Add(new AmortizationEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PaymentNumber = i,
|
||||
PaymentDate = paymentDate,
|
||||
PaymentAmount = principal + interest + (loan.EscrowAmount ?? 0),
|
||||
PrincipalAmount = principal,
|
||||
InterestAmount = interest,
|
||||
EscrowAmount = loan.EscrowAmount,
|
||||
RemainingBalance = balance
|
||||
});
|
||||
|
||||
if (balance == 0) break;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Helpers;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class PayeeService : IPayeeService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
|
||||
public PayeeService(PurrseDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<List<PayeeResponse>> GetAllAsync(Guid userId)
|
||||
{
|
||||
return await _db.Payees
|
||||
.Include(p => p.Aliases).Include(p => p.DefaultCategory)
|
||||
.Where(p => p.UserId == userId)
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory != null ? p.DefaultCategory.Name : null, p.IsActive, p.Aliases.Select(a => a.Alias).ToList()))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<PayeeResponse?> GetByIdAsync(Guid userId, Guid payeeId)
|
||||
{
|
||||
var p = await _db.Payees.Include(p => p.Aliases).Include(p => p.DefaultCategory)
|
||||
.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId);
|
||||
return p == null ? null : new PayeeResponse(p.Id, p.Name, p.DefaultCategoryId, p.DefaultCategory?.Name, p.IsActive, p.Aliases.Select(a => a.Alias).ToList());
|
||||
}
|
||||
|
||||
public async Task<PayeeResponse> CreateAsync(Guid userId, CreatePayeeRequest request)
|
||||
{
|
||||
var payee = new Payee
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = request.Name,
|
||||
DefaultCategoryId = request.DefaultCategoryId
|
||||
};
|
||||
|
||||
if (request.Aliases?.Any() == true)
|
||||
{
|
||||
foreach (var alias in request.Aliases)
|
||||
payee.Aliases.Add(new PayeeAlias { Id = Guid.NewGuid(), Alias = alias });
|
||||
}
|
||||
|
||||
_db.Payees.Add(payee);
|
||||
await _db.SaveChangesAsync();
|
||||
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, true, request.Aliases ?? new());
|
||||
}
|
||||
|
||||
public async Task<PayeeResponse> UpdateAsync(Guid userId, Guid payeeId, UpdatePayeeRequest request)
|
||||
{
|
||||
var payee = await _db.Payees.Include(p => p.Aliases)
|
||||
.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Payee not found");
|
||||
|
||||
payee.Name = request.Name;
|
||||
payee.DefaultCategoryId = request.DefaultCategoryId;
|
||||
payee.IsActive = request.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new PayeeResponse(payee.Id, payee.Name, payee.DefaultCategoryId, null, payee.IsActive, payee.Aliases.Select(a => a.Alias).ToList());
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid payeeId)
|
||||
{
|
||||
var payee = await _db.Payees.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Payee not found");
|
||||
_db.Payees.Remove(payee);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task AddAliasAsync(Guid userId, Guid payeeId, string alias)
|
||||
{
|
||||
var payee = await _db.Payees.FirstOrDefaultAsync(p => p.Id == payeeId && p.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Payee not found");
|
||||
_db.PayeeAliases.Add(new PayeeAlias { Id = Guid.NewGuid(), PayeeId = payeeId, Alias = alias });
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task RemoveAliasAsync(Guid userId, Guid payeeId, Guid aliasId)
|
||||
{
|
||||
var alias = await _db.PayeeAliases.FirstOrDefaultAsync(a => a.Id == aliasId && a.PayeeId == payeeId)
|
||||
?? throw new KeyNotFoundException("Alias not found");
|
||||
_db.PayeeAliases.Remove(alias);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<Payee?> MatchPayeeAsync(Guid userId, string importedName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(importedName)) return null;
|
||||
|
||||
// Check exact match on name
|
||||
var exact = await _db.Payees.FirstOrDefaultAsync(p => p.UserId == userId && p.Name == importedName && p.IsActive);
|
||||
if (exact != null) return exact;
|
||||
|
||||
// Check aliases
|
||||
var aliasMatch = await _db.PayeeAliases
|
||||
.Include(a => a.Payee)
|
||||
.FirstOrDefaultAsync(a => a.Payee.UserId == userId && a.Alias == importedName);
|
||||
if (aliasMatch != null) return aliasMatch.Payee;
|
||||
|
||||
// Fuzzy match
|
||||
var payees = await _db.Payees.Include(p => p.Aliases)
|
||||
.Where(p => p.UserId == userId && p.IsActive).ToListAsync();
|
||||
|
||||
Payee? bestMatch = null;
|
||||
double bestScore = 0;
|
||||
|
||||
foreach (var payee in payees)
|
||||
{
|
||||
var score = StringSimilarityHelper.Calculate(importedName, payee.Name);
|
||||
if (score > bestScore) { bestScore = score; bestMatch = payee; }
|
||||
|
||||
foreach (var alias in payee.Aliases)
|
||||
{
|
||||
score = StringSimilarityHelper.Calculate(importedName, alias.Alias);
|
||||
if (score > bestScore) { bestScore = score; bestMatch = payee; }
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore >= 0.85 ? bestMatch : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using System.Text.Json;
|
||||
using Purrse.Plugins.Abstractions;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class PluginService
|
||||
{
|
||||
private readonly ILogger<PluginService> _logger;
|
||||
private readonly string _pluginsPath;
|
||||
private readonly Dictionary<string, LoadedPlugin> _loadedPlugins = new();
|
||||
|
||||
public PluginService(ILogger<PluginService> logger, IConfiguration config)
|
||||
{
|
||||
_logger = logger;
|
||||
_pluginsPath = config["Plugins:Path"] ?? Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
}
|
||||
|
||||
public IEnumerable<IPlugin> GetPlugins() => _loadedPlugins.Values.Select(p => p.Instance);
|
||||
public IEnumerable<T> GetPlugins<T>() where T : IPlugin => _loadedPlugins.Values.Select(p => p.Instance).OfType<T>();
|
||||
|
||||
public async Task DiscoverAndLoadPluginsAsync()
|
||||
{
|
||||
if (!Directory.Exists(_pluginsPath))
|
||||
{
|
||||
Directory.CreateDirectory(_pluginsPath);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(_pluginsPath))
|
||||
{
|
||||
var manifestPath = Path.Combine(dir, "plugin.json");
|
||||
if (!File.Exists(manifestPath)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
|
||||
if (manifest == null) continue;
|
||||
|
||||
await LoadPluginAsync(dir, manifest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load plugin from {Directory}", dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPluginAsync(string directory, PluginManifest manifest)
|
||||
{
|
||||
var assemblyPath = Path.Combine(directory, manifest.EntryAssembly);
|
||||
if (!File.Exists(assemblyPath))
|
||||
{
|
||||
_logger.LogWarning("Plugin assembly not found: {Path}", assemblyPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var loadContext = new AssemblyLoadContext(manifest.Id, isCollectible: true);
|
||||
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
|
||||
var pluginType = assembly.GetType(manifest.EntryType);
|
||||
|
||||
if (pluginType == null)
|
||||
{
|
||||
_logger.LogWarning("Plugin type not found: {Type}", manifest.EntryType);
|
||||
loadContext.Unload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Activator.CreateInstance(pluginType) is not IPlugin plugin)
|
||||
{
|
||||
_logger.LogWarning("Type {Type} does not implement IPlugin", manifest.EntryType);
|
||||
loadContext.Unload();
|
||||
return;
|
||||
}
|
||||
|
||||
var context = new DefaultPluginContext(directory);
|
||||
await plugin.InitializeAsync(context);
|
||||
|
||||
_loadedPlugins[manifest.Id] = new LoadedPlugin(manifest, plugin, loadContext);
|
||||
_logger.LogInformation("Loaded plugin: {Name} v{Version}", manifest.Name, manifest.Version);
|
||||
}
|
||||
|
||||
public async Task UnloadPluginAsync(string pluginId)
|
||||
{
|
||||
if (_loadedPlugins.TryGetValue(pluginId, out var loaded))
|
||||
{
|
||||
await loaded.Instance.ShutdownAsync();
|
||||
loaded.LoadContext.Unload();
|
||||
_loadedPlugins.Remove(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
private record LoadedPlugin(PluginManifest Manifest, IPlugin Instance, AssemblyLoadContext LoadContext);
|
||||
}
|
||||
|
||||
internal class DefaultPluginContext : IPluginContext
|
||||
{
|
||||
public string PluginDataDirectory { get; }
|
||||
public IServiceProvider Services { get; } = null!;
|
||||
|
||||
public DefaultPluginContext(string dataDir)
|
||||
{
|
||||
PluginDataDirectory = dataDir;
|
||||
}
|
||||
|
||||
public void Log(string message, Purrse.Plugins.Abstractions.LogLevel level = Purrse.Plugins.Abstractions.LogLevel.Information)
|
||||
{
|
||||
Console.WriteLine($"[{level}] {message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Enums;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class ReportService : IReportService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
|
||||
public ReportService(PurrseDbContext db) => _db = db;
|
||||
|
||||
public async Task<SpendingByCategoryReport> GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null)
|
||||
{
|
||||
var query = _db.Transactions
|
||||
.Include(t => t.Category).ThenInclude(c => c!.Parent)
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate
|
||||
&& t.Amount < 0 && !t.IsVoid && t.CategoryId.HasValue);
|
||||
|
||||
if (accountId.HasValue)
|
||||
query = query.Where(t => t.AccountId == accountId.Value);
|
||||
|
||||
var groups = await query
|
||||
.GroupBy(t => new { t.CategoryId, CategoryName = t.Category!.Name, ParentName = t.Category.Parent != null ? t.Category.Parent.Name : (string?)null })
|
||||
.Select(g => new { g.Key.CategoryName, g.Key.ParentName, Amount = Math.Abs(g.Sum(t => t.Amount)) })
|
||||
.OrderByDescending(g => g.Amount)
|
||||
.ToListAsync();
|
||||
|
||||
var total = groups.Sum(g => g.Amount);
|
||||
var categories = groups.Select(g => new CategorySpending(
|
||||
g.CategoryName, g.Amount, total > 0 ? (g.Amount / total) * 100 : 0, g.ParentName
|
||||
)).ToList();
|
||||
|
||||
return new SpendingByCategoryReport(startDate, endDate, total, categories);
|
||||
}
|
||||
|
||||
public async Task<IncomeVsExpenseReport> GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var transactions = await _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer)
|
||||
.ToListAsync();
|
||||
|
||||
var months = transactions
|
||||
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||
.Select(g =>
|
||||
{
|
||||
var income = g.Where(t => t.Amount > 0).Sum(t => t.Amount);
|
||||
var expenses = Math.Abs(g.Where(t => t.Amount < 0).Sum(t => t.Amount));
|
||||
return new MonthlyIncomeExpense(g.Key.Year, g.Key.Month, income, expenses, income - expenses);
|
||||
})
|
||||
.OrderBy(m => m.Year).ThenBy(m => m.Month)
|
||||
.ToList();
|
||||
|
||||
return new IncomeVsExpenseReport(startDate, endDate, months);
|
||||
}
|
||||
|
||||
public async Task<NetWorthReport> GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var accounts = await _db.Accounts.Where(a => a.UserId == userId && a.IsActive).ToListAsync();
|
||||
var assetTypes = new[] { AccountType.Checking, AccountType.Savings, AccountType.Cash, AccountType.Brokerage, AccountType.Retirement401k, AccountType.IRA };
|
||||
|
||||
var transactions = await _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid)
|
||||
.OrderBy(t => t.Date)
|
||||
.ToListAsync();
|
||||
|
||||
var entries = new List<NetWorthEntry>();
|
||||
var currentDate = startDate;
|
||||
|
||||
while (currentDate <= endDate)
|
||||
{
|
||||
var endOfMonth = new DateTime(currentDate.Year, currentDate.Month, DateTime.DaysInMonth(currentDate.Year, currentDate.Month));
|
||||
var txnsToDate = transactions.Where(t => t.Date <= endOfMonth).Sum(t => t.Amount);
|
||||
|
||||
// Simplified: using current balances adjusted by future transactions
|
||||
var assets = accounts.Where(a => assetTypes.Contains(a.Type)).Sum(a => a.Balance);
|
||||
var liabilities = accounts.Where(a => !assetTypes.Contains(a.Type)).Sum(a => Math.Abs(a.Balance));
|
||||
|
||||
entries.Add(new NetWorthEntry(endOfMonth, assets, liabilities, assets - liabilities));
|
||||
currentDate = currentDate.AddMonths(1);
|
||||
}
|
||||
|
||||
return new NetWorthReport(entries);
|
||||
}
|
||||
|
||||
public async Task<CashFlowReport> GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate)
|
||||
{
|
||||
var transactions = await _db.Transactions
|
||||
.Where(t => t.Account.UserId == userId && t.Date >= startDate && t.Date <= endDate && !t.IsVoid && t.Type != TransactionType.Transfer)
|
||||
.OrderBy(t => t.Date)
|
||||
.ToListAsync();
|
||||
|
||||
var groups = transactions.GroupBy(t => t.Date.Date)
|
||||
.Select(g =>
|
||||
{
|
||||
var inflow = g.Where(t => t.Amount > 0).Sum(t => t.Amount);
|
||||
var outflow = Math.Abs(g.Where(t => t.Amount < 0).Sum(t => t.Amount));
|
||||
return new CashFlowEntry(g.Key, inflow, outflow, inflow - outflow);
|
||||
})
|
||||
.OrderBy(e => e.Date)
|
||||
.ToList();
|
||||
|
||||
var totalInflow = groups.Sum(e => e.Inflow);
|
||||
var totalOutflow = groups.Sum(e => e.Outflow);
|
||||
|
||||
return new CashFlowReport(startDate, endDate, totalInflow, totalOutflow, totalInflow - totalOutflow, groups);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class ScheduledTransactionProcessor : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<ScheduledTransactionProcessor> _logger;
|
||||
|
||||
public ScheduledTransactionProcessor(IServiceScopeFactory scopeFactory, ILogger<ScheduledTransactionProcessor> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<IScheduledTransactionService>();
|
||||
await service.ProcessDueTransactionsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing scheduled transactions");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Enums;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class ScheduledTransactionService : IScheduledTransactionService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
private readonly ITransactionService _transactionService;
|
||||
|
||||
public ScheduledTransactionService(PurrseDbContext db, ITransactionService transactionService)
|
||||
{
|
||||
_db = db;
|
||||
_transactionService = transactionService;
|
||||
}
|
||||
|
||||
public async Task<List<ScheduledTransactionResponse>> GetAllAsync(Guid userId)
|
||||
{
|
||||
return await _db.ScheduledTransactions
|
||||
.Include(s => s.Account).Include(s => s.Category)
|
||||
.Where(s => s.UserId == userId)
|
||||
.OrderBy(s => s.NextDueDate)
|
||||
.Select(s => MapToResponse(s))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ScheduledTransactionResponse?> GetByIdAsync(Guid userId, Guid id)
|
||||
{
|
||||
var s = await _db.ScheduledTransactions.Include(s => s.Account).Include(s => s.Category)
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId);
|
||||
return s == null ? null : MapToResponse(s);
|
||||
}
|
||||
|
||||
public async Task<ScheduledTransactionResponse> CreateAsync(Guid userId, CreateScheduledTransactionRequest request)
|
||||
{
|
||||
var scheduled = new ScheduledTransaction
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
AccountId = request.AccountId,
|
||||
Amount = request.Amount,
|
||||
PayeeName = request.PayeeName,
|
||||
PayeeId = request.PayeeId,
|
||||
CategoryId = request.CategoryId,
|
||||
Memo = request.Memo,
|
||||
Type = request.Type,
|
||||
Frequency = request.Frequency,
|
||||
NextDueDate = request.NextDueDate,
|
||||
EndDate = request.EndDate,
|
||||
AutoPost = request.AutoPost,
|
||||
ReminderDaysBefore = request.ReminderDaysBefore
|
||||
};
|
||||
|
||||
_db.ScheduledTransactions.Add(scheduled);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
scheduled = await _db.ScheduledTransactions.Include(s => s.Account).Include(s => s.Category)
|
||||
.FirstAsync(s => s.Id == scheduled.Id);
|
||||
|
||||
return MapToResponse(scheduled);
|
||||
}
|
||||
|
||||
public async Task<ScheduledTransactionResponse> UpdateAsync(Guid userId, Guid id, UpdateScheduledTransactionRequest request)
|
||||
{
|
||||
var s = await _db.ScheduledTransactions.Include(s => s.Account).Include(s => s.Category)
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Scheduled transaction not found");
|
||||
|
||||
s.Amount = request.Amount;
|
||||
s.PayeeName = request.PayeeName;
|
||||
s.PayeeId = request.PayeeId;
|
||||
s.CategoryId = request.CategoryId;
|
||||
s.Memo = request.Memo;
|
||||
s.Type = request.Type;
|
||||
s.Frequency = request.Frequency;
|
||||
s.NextDueDate = request.NextDueDate;
|
||||
s.EndDate = request.EndDate;
|
||||
s.AutoPost = request.AutoPost;
|
||||
s.ReminderDaysBefore = request.ReminderDaysBefore;
|
||||
s.IsActive = request.IsActive;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return MapToResponse(s);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid id)
|
||||
{
|
||||
var s = await _db.ScheduledTransactions.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Scheduled transaction not found");
|
||||
_db.ScheduledTransactions.Remove(s);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SkipAsync(Guid userId, Guid id)
|
||||
{
|
||||
var s = await _db.ScheduledTransactions.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Scheduled transaction not found");
|
||||
|
||||
s.NextDueDate = CalculateNextDate(s.NextDueDate, s.Frequency);
|
||||
if (s.EndDate.HasValue && s.NextDueDate > s.EndDate.Value)
|
||||
s.IsActive = false;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<TransactionResponse> PostNowAsync(Guid userId, Guid id)
|
||||
{
|
||||
var s = await _db.ScheduledTransactions.FirstOrDefaultAsync(s => s.Id == id && s.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Scheduled transaction not found");
|
||||
|
||||
var result = await _transactionService.CreateAsync(userId, new CreateTransactionRequest(
|
||||
s.AccountId, s.NextDueDate, s.Amount, s.PayeeName, s.PayeeId,
|
||||
s.CategoryId, s.Memo, null, null, s.Type, null));
|
||||
|
||||
s.NextDueDate = CalculateNextDate(s.NextDueDate, s.Frequency);
|
||||
if (s.EndDate.HasValue && s.NextDueDate > s.EndDate.Value)
|
||||
s.IsActive = false;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task ProcessDueTransactionsAsync()
|
||||
{
|
||||
var due = await _db.ScheduledTransactions
|
||||
.Where(s => s.IsActive && s.AutoPost && s.NextDueDate <= DateTime.UtcNow.Date)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var s in due)
|
||||
{
|
||||
await PostNowAsync(s.UserId, s.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime CalculateNextDate(DateTime current, Frequency frequency) => frequency switch
|
||||
{
|
||||
Frequency.Daily => current.AddDays(1),
|
||||
Frequency.Weekly => current.AddDays(7),
|
||||
Frequency.BiWeekly => current.AddDays(14),
|
||||
Frequency.SemiMonthly => current.Day <= 15 ? new DateTime(current.Year, current.Month, Math.Min(28, DateTime.DaysInMonth(current.Year, current.Month))) : current.AddMonths(1).AddDays(-(current.Day - 1)),
|
||||
Frequency.Monthly => current.AddMonths(1),
|
||||
Frequency.Quarterly => current.AddMonths(3),
|
||||
Frequency.SemiAnnually => current.AddMonths(6),
|
||||
Frequency.Annually => current.AddYears(1),
|
||||
_ => current.AddMonths(1)
|
||||
};
|
||||
|
||||
private static ScheduledTransactionResponse MapToResponse(ScheduledTransaction s) => new(
|
||||
s.Id, s.AccountId, s.Account?.Name ?? "", s.Amount, s.PayeeName,
|
||||
s.CategoryId, s.Category?.Name, s.Memo, s.Type, s.Frequency,
|
||||
s.NextDueDate, s.EndDate, s.AutoPost, s.ReminderDaysBefore, s.IsActive);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Enums;
|
||||
using Purrse.Core.Helpers;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Core.Models;
|
||||
using Purrse.Data;
|
||||
|
||||
namespace Purrse.Api.Services;
|
||||
|
||||
public class TransactionService : ITransactionService
|
||||
{
|
||||
private readonly PurrseDbContext _db;
|
||||
private readonly IAccountService _accountService;
|
||||
|
||||
public TransactionService(PurrseDbContext db, IAccountService accountService)
|
||||
{
|
||||
_db = db;
|
||||
_accountService = accountService;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<TransactionResponse>> GetByAccountAsync(Guid userId, Guid accountId, int page, int pageSize)
|
||||
{
|
||||
var query = _db.Transactions
|
||||
.Include(t => t.Payee).Include(t => t.Category).Include(t => t.Splits).ThenInclude(s => s.Category)
|
||||
.Where(t => t.AccountId == accountId && t.Account.UserId == userId)
|
||||
.OrderByDescending(t => t.Date).ThenByDescending(t => t.CreatedAt);
|
||||
|
||||
return await PaginateAsync(query, page, pageSize);
|
||||
}
|
||||
|
||||
public async Task<TransactionResponse?> GetByIdAsync(Guid userId, Guid transactionId)
|
||||
{
|
||||
var t = await _db.Transactions
|
||||
.Include(t => t.Account).Include(t => t.Payee).Include(t => t.Category)
|
||||
.Include(t => t.Splits).ThenInclude(s => s.Category)
|
||||
.FirstOrDefaultAsync(t => t.Id == transactionId && t.Account.UserId == userId);
|
||||
return t == null ? null : MapToResponse(t);
|
||||
}
|
||||
|
||||
public async Task<TransactionResponse> CreateAsync(Guid userId, CreateTransactionRequest request)
|
||||
{
|
||||
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.AccountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Account not found");
|
||||
|
||||
var transaction = new Transaction
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = request.AccountId,
|
||||
Date = request.Date,
|
||||
Amount = request.Amount,
|
||||
PayeeName = request.PayeeName,
|
||||
PayeeId = request.PayeeId,
|
||||
CategoryId = request.CategoryId,
|
||||
Memo = request.Memo,
|
||||
ReferenceNumber = request.ReferenceNumber,
|
||||
CheckNumber = request.CheckNumber,
|
||||
Type = request.Type,
|
||||
Fingerprint = TransactionFingerprint.Generate(request.AccountId, request.Date, request.Amount, null)
|
||||
};
|
||||
|
||||
if (request.Splits?.Any() == true)
|
||||
{
|
||||
foreach (var split in request.Splits)
|
||||
{
|
||||
transaction.Splits.Add(new TransactionSplit
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CategoryId = split.CategoryId,
|
||||
Amount = split.Amount,
|
||||
Memo = split.Memo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_db.Transactions.Add(transaction);
|
||||
account.Balance += request.Amount;
|
||||
account.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return MapToResponse(transaction);
|
||||
}
|
||||
|
||||
public async Task<TransactionResponse> UpdateAsync(Guid userId, Guid transactionId, UpdateTransactionRequest request)
|
||||
{
|
||||
var transaction = await _db.Transactions
|
||||
.Include(t => t.Account).Include(t => t.Splits)
|
||||
.FirstOrDefaultAsync(t => t.Id == transactionId && t.Account.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Transaction not found");
|
||||
|
||||
var oldAmount = transaction.Amount;
|
||||
transaction.Date = request.Date;
|
||||
transaction.Amount = request.Amount;
|
||||
transaction.PayeeName = request.PayeeName;
|
||||
transaction.PayeeId = request.PayeeId;
|
||||
transaction.CategoryId = request.CategoryId;
|
||||
transaction.Memo = request.Memo;
|
||||
transaction.ReferenceNumber = request.ReferenceNumber;
|
||||
transaction.CheckNumber = request.CheckNumber;
|
||||
transaction.Type = request.Type;
|
||||
transaction.Status = request.Status;
|
||||
transaction.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Update splits
|
||||
_db.TransactionSplits.RemoveRange(transaction.Splits);
|
||||
if (request.Splits?.Any() == true)
|
||||
{
|
||||
foreach (var split in request.Splits)
|
||||
{
|
||||
transaction.Splits.Add(new TransactionSplit
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TransactionId = transactionId,
|
||||
CategoryId = split.CategoryId,
|
||||
Amount = split.Amount,
|
||||
Memo = split.Memo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
transaction.Account.Balance += (request.Amount - oldAmount);
|
||||
transaction.Account.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return MapToResponse(transaction);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid userId, Guid transactionId)
|
||||
{
|
||||
var transaction = await _db.Transactions.Include(t => t.Account)
|
||||
.FirstOrDefaultAsync(t => t.Id == transactionId && t.Account.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Transaction not found");
|
||||
|
||||
transaction.Account.Balance -= transaction.Amount;
|
||||
_db.Transactions.Remove(transaction);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<PagedResult<TransactionResponse>> SearchAsync(Guid userId, TransactionSearchRequest request)
|
||||
{
|
||||
var query = _db.Transactions
|
||||
.Include(t => t.Account).Include(t => t.Payee).Include(t => t.Category)
|
||||
.Include(t => t.Splits).ThenInclude(s => s.Category)
|
||||
.Where(t => t.Account.UserId == userId);
|
||||
|
||||
if (request.AccountId.HasValue)
|
||||
query = query.Where(t => t.AccountId == request.AccountId.Value);
|
||||
if (request.StartDate.HasValue)
|
||||
query = query.Where(t => t.Date >= request.StartDate.Value);
|
||||
if (request.EndDate.HasValue)
|
||||
query = query.Where(t => t.Date <= request.EndDate.Value);
|
||||
if (request.MinAmount.HasValue)
|
||||
query = query.Where(t => t.Amount >= request.MinAmount.Value);
|
||||
if (request.MaxAmount.HasValue)
|
||||
query = query.Where(t => t.Amount <= request.MaxAmount.Value);
|
||||
if (request.CategoryId.HasValue)
|
||||
query = query.Where(t => t.CategoryId == request.CategoryId.Value);
|
||||
if (request.Status.HasValue)
|
||||
query = query.Where(t => t.Status == request.Status.Value);
|
||||
if (!string.IsNullOrEmpty(request.PayeeName))
|
||||
query = query.Where(t => t.PayeeName != null && t.PayeeName.Contains(request.PayeeName));
|
||||
if (!string.IsNullOrEmpty(request.SearchText))
|
||||
query = query.Where(t =>
|
||||
(t.PayeeName != null && t.PayeeName.Contains(request.SearchText)) ||
|
||||
(t.Memo != null && t.Memo.Contains(request.SearchText)) ||
|
||||
(t.ReferenceNumber != null && t.ReferenceNumber.Contains(request.SearchText)));
|
||||
|
||||
query = query.OrderByDescending(t => t.Date).ThenByDescending(t => t.CreatedAt);
|
||||
|
||||
return await PaginateAsync(query, request.Page, request.PageSize);
|
||||
}
|
||||
|
||||
public async Task<(TransactionResponse From, TransactionResponse To)> CreateTransferAsync(Guid userId, CreateTransferRequest request)
|
||||
{
|
||||
var fromAccount = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.FromAccountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Source account not found");
|
||||
var toAccount = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == request.ToAccountId && a.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Destination account not found");
|
||||
|
||||
var fromTxn = new Transaction
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = request.FromAccountId,
|
||||
Date = request.Date,
|
||||
Amount = -Math.Abs(request.Amount),
|
||||
PayeeName = $"Transfer to {toAccount.Name}",
|
||||
Memo = request.Memo,
|
||||
Type = TransactionType.Transfer,
|
||||
Fingerprint = TransactionFingerprint.Generate(request.FromAccountId, request.Date, -Math.Abs(request.Amount), null)
|
||||
};
|
||||
|
||||
var toTxn = new Transaction
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
AccountId = request.ToAccountId,
|
||||
Date = request.Date,
|
||||
Amount = Math.Abs(request.Amount),
|
||||
PayeeName = $"Transfer from {fromAccount.Name}",
|
||||
Memo = request.Memo,
|
||||
Type = TransactionType.Transfer,
|
||||
Fingerprint = TransactionFingerprint.Generate(request.ToAccountId, request.Date, Math.Abs(request.Amount), null)
|
||||
};
|
||||
|
||||
fromTxn.TransferTransactionId = toTxn.Id;
|
||||
toTxn.TransferTransactionId = fromTxn.Id;
|
||||
|
||||
fromAccount.Balance += fromTxn.Amount;
|
||||
toAccount.Balance += toTxn.Amount;
|
||||
|
||||
_db.Transactions.AddRange(fromTxn, toTxn);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return (MapToResponse(fromTxn), MapToResponse(toTxn));
|
||||
}
|
||||
|
||||
public async Task VoidAsync(Guid userId, Guid transactionId)
|
||||
{
|
||||
var transaction = await _db.Transactions.Include(t => t.Account)
|
||||
.FirstOrDefaultAsync(t => t.Id == transactionId && t.Account.UserId == userId)
|
||||
?? throw new KeyNotFoundException("Transaction not found");
|
||||
|
||||
if (!transaction.IsVoid)
|
||||
{
|
||||
transaction.IsVoid = true;
|
||||
transaction.Account.Balance -= transaction.Amount;
|
||||
transaction.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PagedResult<TransactionResponse>> PaginateAsync(IQueryable<Transaction> query, int page, int pageSize)
|
||||
{
|
||||
var totalCount = await query.CountAsync();
|
||||
var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
|
||||
return new PagedResult<TransactionResponse>(
|
||||
items.Select(MapToResponse).ToList(), totalCount, page, pageSize, totalPages);
|
||||
}
|
||||
|
||||
private static TransactionResponse MapToResponse(Transaction t) => new(
|
||||
t.Id, t.AccountId, t.Account?.Name, t.Date, t.Amount,
|
||||
t.PayeeId, t.PayeeName, t.CategoryId, t.Category?.Name,
|
||||
t.Memo, t.ReferenceNumber, t.CheckNumber, t.Type, t.Status, t.IsVoid,
|
||||
t.TransferTransactionId, t.ImportBatchId,
|
||||
t.Splits?.Select(s => new SplitResponse(s.Id, s.CategoryId, s.Category?.Name, s.Amount, s.Memo)).ToList(),
|
||||
t.CreatedAt
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user