Private
Public Access
1
0

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:
Catherine Renelle
2026-02-08 08:56:46 -05:00
commit 6520ebf221
169 changed files with 7180 additions and 0 deletions
+135
View File
@@ -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
);
}
+125
View File
@@ -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);
}
}
+128
View File
@@ -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
);
}
+104
View File
@@ -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
}
}
+108
View File
@@ -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();
}
}
+191
View File
@@ -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;
}
}
+130
View File
@@ -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;
}
}
+112
View File
@@ -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}");
}
}
+110
View File
@@ -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
);
}