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
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AccountsController : ControllerBase
{
private readonly IAccountService _accountService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public AccountsController(IAccountService accountService) => _accountService = accountService;
[HttpGet]
public async Task<ActionResult<List<AccountResponse>>> GetAll()
=> Ok(await _accountService.GetAllAsync(UserId));
[HttpGet("{id}")]
public async Task<ActionResult<AccountResponse>> GetById(Guid id)
{
var account = await _accountService.GetByIdAsync(UserId, id);
return account == null ? NotFound() : Ok(account);
}
[HttpPost]
public async Task<ActionResult<AccountResponse>> Create(CreateAccountRequest request)
{
var account = await _accountService.CreateAsync(UserId, request);
return CreatedAtAction(nameof(GetById), new { id = account.Id }, account);
}
[HttpPut("{id}")]
public async Task<ActionResult<AccountResponse>> Update(Guid id, UpdateAccountRequest request)
=> Ok(await _accountService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _accountService.DeleteAsync(UserId, id);
return NoContent();
}
[HttpGet("{id}/balance-history")]
public async Task<ActionResult<List<BalanceHistoryEntry>>> GetBalanceHistory(Guid id, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
=> Ok(await _accountService.GetBalanceHistoryAsync(UserId, id, startDate, endDate));
}
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService) => _authService = authService;
[HttpPost("register")]
public async Task<ActionResult<AuthResponse>> Register(RegisterRequest request)
{
var result = await _authService.RegisterAsync(request);
return Ok(result);
}
[HttpPost("login")]
public async Task<ActionResult<AuthResponse>> Login(LoginRequest request)
{
var result = await _authService.LoginAsync(request);
return Ok(result);
}
[HttpPost("refresh")]
public async Task<ActionResult<AuthResponse>> Refresh(RefreshRequest request)
{
var result = await _authService.RefreshTokenAsync(request.RefreshToken);
return Ok(result);
}
[Authorize]
[HttpPost("revoke")]
public async Task<ActionResult> Revoke()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
await _authService.RevokeRefreshTokenAsync(userId);
return NoContent();
}
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class BudgetsController : ControllerBase
{
private readonly IBudgetService _budgetService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public BudgetsController(IBudgetService budgetService) => _budgetService = budgetService;
[HttpGet("{year}/{month}")]
public async Task<ActionResult<BudgetResponse>> GetByMonth(int year, int month)
{
var budget = await _budgetService.GetByMonthAsync(UserId, year, month);
return budget == null ? NotFound() : Ok(budget);
}
[HttpPost]
public async Task<ActionResult<BudgetResponse>> Create(CreateBudgetRequest request)
=> Ok(await _budgetService.CreateAsync(UserId, request));
[HttpPut("{id}")]
public async Task<ActionResult<BudgetResponse>> Update(Guid id, UpdateBudgetRequest request)
=> Ok(await _budgetService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _budgetService.DeleteAsync(UserId, id);
return NoContent();
}
[HttpGet("{id}/actual")]
public async Task<ActionResult<List<BudgetItemResponse>>> GetActuals(Guid id)
=> Ok(await _budgetService.GetActualsAsync(UserId, id));
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class CategoriesController : ControllerBase
{
private readonly ICategoryService _categoryService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public CategoriesController(ICategoryService categoryService) => _categoryService = categoryService;
[HttpGet]
public async Task<ActionResult<List<CategoryResponse>>> GetAll()
=> Ok(await _categoryService.GetAllAsync(UserId));
[HttpGet("tree")]
public async Task<ActionResult<List<CategoryTreeResponse>>> GetTree()
=> Ok(await _categoryService.GetTreeAsync(UserId));
[HttpGet("{id}")]
public async Task<ActionResult<CategoryResponse>> GetById(Guid id)
{
var cat = await _categoryService.GetByIdAsync(UserId, id);
return cat == null ? NotFound() : Ok(cat);
}
[HttpPost]
public async Task<ActionResult<CategoryResponse>> Create(CreateCategoryRequest request)
{
var cat = await _categoryService.CreateAsync(UserId, request);
return CreatedAtAction(nameof(GetById), new { id = cat.Id }, cat);
}
[HttpPut("{id}")]
public async Task<ActionResult<CategoryResponse>> Update(Guid id, UpdateCategoryRequest request)
=> Ok(await _categoryService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _categoryService.DeleteAsync(UserId, id);
return NoContent();
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DashboardController : ControllerBase
{
private readonly IDashboardService _dashboardService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public DashboardController(IDashboardService dashboardService) => _dashboardService = dashboardService;
[HttpGet]
public async Task<ActionResult<DashboardResponse>> Get()
=> Ok(await _dashboardService.GetDashboardAsync(UserId));
}
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ImportsController : ControllerBase
{
private readonly IImportService _importService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public ImportsController(IImportService importService) => _importService = importService;
[HttpPost("upload")]
public async Task<ActionResult<ImportUploadResponse>> Upload([FromQuery] Guid accountId, IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new { error = "No file uploaded" });
using var stream = file.OpenReadStream();
var result = await _importService.UploadAsync(UserId, accountId, file.FileName, stream);
return Ok(result);
}
[HttpPost("batches/{batchId}/confirm")]
public async Task<ActionResult<ImportBatchResponse>> ConfirmBatch(Guid batchId)
=> Ok(await _importService.ConfirmBatchAsync(UserId, batchId));
[HttpPost("batches/{batchId}/resolve-duplicates")]
public async Task<ActionResult> ResolveDuplicates(Guid batchId, ResolveDuplicatesRequest request)
{
await _importService.ResolveDuplicatesAsync(UserId, batchId, request);
return NoContent();
}
}
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Purrse.Core.DTOs;
using Purrse.Data;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class InvestmentsController : ControllerBase
{
private readonly PurrseDbContext _db;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public InvestmentsController(PurrseDbContext db) => _db = db;
[HttpGet("{accountId}/holdings")]
public async Task<ActionResult<List<InvestmentHoldingResponse>>> GetHoldings(Guid accountId)
{
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId)
?? throw new KeyNotFoundException("Account not found");
var holdings = await _db.InvestmentHoldings
.Include(h => h.Security).ThenInclude(s => s.Prices)
.Where(h => h.AccountId == accountId)
.ToListAsync();
var responses = holdings.Select(h =>
{
var latestPrice = h.Security.Prices.OrderByDescending(p => p.Date).FirstOrDefault()?.Price ?? 0;
var marketValue = h.Shares * latestPrice;
var gainLoss = marketValue - h.CostBasis;
var gainLossPct = h.CostBasis != 0 ? (gainLoss / h.CostBasis) * 100 : 0;
return new InvestmentHoldingResponse(h.Id, h.Security.Symbol, h.Security.Name,
h.Shares, h.CostBasis, latestPrice, marketValue, gainLoss, gainLossPct, h.AsOfDate);
}).ToList();
return Ok(responses);
}
[HttpGet("{accountId}/performance")]
public async Task<ActionResult<InvestmentPerformanceResponse>> GetPerformance(Guid accountId)
{
var holdingsResult = await GetHoldings(accountId);
var holdings = (holdingsResult.Result as OkObjectResult)?.Value as List<InvestmentHoldingResponse> ?? new();
var totalMarket = holdings.Sum(h => h.MarketValue);
var totalCost = holdings.Sum(h => h.CostBasis);
var totalGL = totalMarket - totalCost;
var totalPct = totalCost != 0 ? (totalGL / totalCost) * 100 : 0;
return Ok(new InvestmentPerformanceResponse(totalMarket, totalCost, totalGL, totalPct, holdings));
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class LoansController : ControllerBase
{
private readonly ILoanService _loanService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public LoansController(ILoanService loanService) => _loanService = loanService;
[HttpGet("{accountId}/amortization")]
public async Task<ActionResult<LoanDetailResponse>> GetLoanDetail(Guid accountId)
{
var detail = await _loanService.GetLoanDetailAsync(UserId, accountId);
return detail == null ? NotFound() : Ok(detail);
}
[HttpPost("{accountId}")]
public async Task<ActionResult<LoanDetailResponse>> CreateLoanDetail(Guid accountId, CreateLoanDetailRequest request)
=> Ok(await _loanService.CreateLoanDetailAsync(UserId, accountId, request));
[HttpPut("{accountId}")]
public async Task<ActionResult<LoanDetailResponse>> UpdateLoanDetail(Guid accountId, CreateLoanDetailRequest request)
=> Ok(await _loanService.UpdateLoanDetailAsync(UserId, accountId, request));
[HttpPost("{accountId}/payoff-scenario")]
public async Task<ActionResult<PayoffScenarioResponse>> PayoffScenario(Guid accountId, PayoffScenarioRequest request)
=> Ok(await _loanService.CalculatePayoffScenarioAsync(UserId, accountId, request));
}
@@ -0,0 +1,61 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class PayeesController : ControllerBase
{
private readonly IPayeeService _payeeService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public PayeesController(IPayeeService payeeService) => _payeeService = payeeService;
[HttpGet]
public async Task<ActionResult<List<PayeeResponse>>> GetAll()
=> Ok(await _payeeService.GetAllAsync(UserId));
[HttpGet("{id}")]
public async Task<ActionResult<PayeeResponse>> GetById(Guid id)
{
var payee = await _payeeService.GetByIdAsync(UserId, id);
return payee == null ? NotFound() : Ok(payee);
}
[HttpPost]
public async Task<ActionResult<PayeeResponse>> Create(CreatePayeeRequest request)
{
var payee = await _payeeService.CreateAsync(UserId, request);
return CreatedAtAction(nameof(GetById), new { id = payee.Id }, payee);
}
[HttpPut("{id}")]
public async Task<ActionResult<PayeeResponse>> Update(Guid id, UpdatePayeeRequest request)
=> Ok(await _payeeService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _payeeService.DeleteAsync(UserId, id);
return NoContent();
}
[HttpPost("{id}/aliases")]
public async Task<ActionResult> AddAlias(Guid id, CreatePayeeAliasRequest request)
{
await _payeeService.AddAliasAsync(UserId, id, request.Alias);
return NoContent();
}
[HttpDelete("{id}/aliases/{aliasId}")]
public async Task<ActionResult> RemoveAlias(Guid id, Guid aliasId)
{
await _payeeService.RemoveAliasAsync(UserId, id, aliasId);
return NoContent();
}
}
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Api.Services;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class PluginsController : ControllerBase
{
private readonly PluginService _pluginService;
public PluginsController(PluginService pluginService) => _pluginService = pluginService;
[HttpGet]
public ActionResult GetAll()
{
var plugins = _pluginService.GetPlugins().Select(p => new
{
p.Id, p.Name, p.Version, p.Description
});
return Ok(plugins);
}
}
@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Purrse.Core.DTOs;
using Purrse.Core.Enums;
using Purrse.Core.Models;
using Purrse.Data;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ReconciliationController : ControllerBase
{
private readonly PurrseDbContext _db;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public ReconciliationController(PurrseDbContext db) => _db = db;
[HttpPost("{accountId}/start")]
public async Task<ActionResult<ReconciliationResponse>> Start(Guid accountId, StartReconciliationRequest request)
{
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId)
?? throw new KeyNotFoundException("Account not found");
var reconciliation = new Reconciliation
{
Id = Guid.NewGuid(),
AccountId = accountId,
StatementDate = request.StatementDate,
StatementBalance = request.StatementBalance
};
_db.Reconciliations.Add(reconciliation);
await _db.SaveChangesAsync();
var uncleared = await _db.Transactions
.Include(t => t.Category)
.Where(t => t.AccountId == accountId && t.Status != TransactionStatus.Reconciled && !t.IsVoid)
.OrderByDescending(t => t.Date)
.Select(t => new TransactionResponse(t.Id, t.AccountId, null, 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 clearedBalance = await _db.Transactions
.Where(t => t.AccountId == accountId && t.Status == TransactionStatus.Cleared && !t.IsVoid)
.SumAsync(t => t.Amount);
return Ok(new ReconciliationResponse(reconciliation.Id, accountId, request.StatementDate,
request.StatementBalance, clearedBalance, request.StatementBalance - clearedBalance, false, uncleared));
}
[HttpPost("{accountId}/complete")]
public async Task<ActionResult> Complete(Guid accountId, [FromQuery] Guid reconciliationId)
{
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId)
?? throw new KeyNotFoundException("Reconciliation not found");
var clearedTxns = await _db.Transactions
.Where(t => t.AccountId == accountId && t.Status == TransactionStatus.Cleared && !t.IsVoid)
.ToListAsync();
foreach (var txn in clearedTxns)
{
txn.Status = TransactionStatus.Reconciled;
txn.ReconciliationId = reconciliationId;
}
rec.IsCompleted = true;
rec.CompletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return NoContent();
}
[HttpPost("{accountId}/cancel")]
public async Task<ActionResult> Cancel(Guid accountId, [FromQuery] Guid reconciliationId)
{
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId && !r.IsCompleted)
?? throw new KeyNotFoundException("Reconciliation not found");
_db.Reconciliations.Remove(rec);
await _db.SaveChangesAsync();
return NoContent();
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
private readonly IReportService _reportService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public ReportsController(IReportService reportService) => _reportService = reportService;
[HttpGet("spending-by-category")]
public async Task<ActionResult<SpendingByCategoryReport>> SpendingByCategory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid? accountId)
=> Ok(await _reportService.GetSpendingByCategoryAsync(UserId, startDate, endDate, accountId));
[HttpGet("income-vs-expense")]
public async Task<ActionResult<IncomeVsExpenseReport>> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
=> Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, startDate, endDate));
[HttpGet("net-worth")]
public async Task<ActionResult<NetWorthReport>> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
=> Ok(await _reportService.GetNetWorthAsync(UserId, startDate, endDate));
[HttpGet("cash-flow")]
public async Task<ActionResult<CashFlowReport>> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
=> Ok(await _reportService.GetCashFlowAsync(UserId, startDate, endDate));
}
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/scheduled-transactions")]
public class ScheduledTransactionsController : ControllerBase
{
private readonly IScheduledTransactionService _service;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public ScheduledTransactionsController(IScheduledTransactionService service) => _service = service;
[HttpGet]
public async Task<ActionResult<List<ScheduledTransactionResponse>>> GetAll()
=> Ok(await _service.GetAllAsync(UserId));
[HttpGet("{id}")]
public async Task<ActionResult<ScheduledTransactionResponse>> GetById(Guid id)
{
var s = await _service.GetByIdAsync(UserId, id);
return s == null ? NotFound() : Ok(s);
}
[HttpPost]
public async Task<ActionResult<ScheduledTransactionResponse>> Create(CreateScheduledTransactionRequest request)
=> Ok(await _service.CreateAsync(UserId, request));
[HttpPut("{id}")]
public async Task<ActionResult<ScheduledTransactionResponse>> Update(Guid id, UpdateScheduledTransactionRequest request)
=> Ok(await _service.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _service.DeleteAsync(UserId, id);
return NoContent();
}
[HttpPost("{id}/skip")]
public async Task<ActionResult> Skip(Guid id)
{
await _service.SkipAsync(UserId, id);
return NoContent();
}
[HttpPost("{id}/post-now")]
public async Task<ActionResult<TransactionResponse>> PostNow(Guid id)
=> Ok(await _service.PostNowAsync(UserId, id));
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class TransactionsController : ControllerBase
{
private readonly ITransactionService _transactionService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public TransactionsController(ITransactionService transactionService) => _transactionService = transactionService;
[HttpGet("account/{accountId}")]
public async Task<ActionResult<PagedResult<TransactionResponse>>> GetByAccount(Guid accountId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await _transactionService.GetByAccountAsync(UserId, accountId, page, pageSize));
[HttpGet("{id}")]
public async Task<ActionResult<TransactionResponse>> GetById(Guid id)
{
var txn = await _transactionService.GetByIdAsync(UserId, id);
return txn == null ? NotFound() : Ok(txn);
}
[HttpPost]
public async Task<ActionResult<TransactionResponse>> Create(CreateTransactionRequest request)
{
var txn = await _transactionService.CreateAsync(UserId, request);
return CreatedAtAction(nameof(GetById), new { id = txn.Id }, txn);
}
[HttpPut("{id}")]
public async Task<ActionResult<TransactionResponse>> Update(Guid id, UpdateTransactionRequest request)
=> Ok(await _transactionService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _transactionService.DeleteAsync(UserId, id);
return NoContent();
}
[HttpPost("search")]
public async Task<ActionResult<PagedResult<TransactionResponse>>> Search(TransactionSearchRequest request)
=> Ok(await _transactionService.SearchAsync(UserId, request));
[HttpPost("transfer")]
public async Task<ActionResult> CreateTransfer(CreateTransferRequest request)
{
var (from, to) = await _transactionService.CreateTransferAsync(UserId, request);
return Ok(new { from, to });
}
[HttpPost("{id}/void")]
public async Task<ActionResult> Void(Guid id)
{
await _transactionService.VoidAsync(UserId, id);
return NoContent();
}
}
+22
View File
@@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["src/Purrse.Core/Purrse.Core.csproj", "Purrse.Core/"]
COPY ["src/Purrse.Data/Purrse.Data.csproj", "Purrse.Data/"]
COPY ["src/Purrse.Api/Purrse.Api.csproj", "Purrse.Api/"]
COPY ["src/Purrse.Plugins.Abstractions/Purrse.Plugins.Abstractions.csproj", "Purrse.Plugins.Abstractions/"]
COPY ["src/Purrse.Plugins.OFX/Purrse.Plugins.OFX.csproj", "Purrse.Plugins.OFX/"]
COPY ["src/Purrse.Plugins.CSV/Purrse.Plugins.CSV.csproj", "Purrse.Plugins.CSV/"]
COPY ["src/Purrse.Plugins.QIF/Purrse.Plugins.QIF.csproj", "Purrse.Plugins.QIF/"]
RUN dotnet restore "Purrse.Api/Purrse.Api.csproj"
COPY src/ .
RUN dotnet publish "Purrse.Api/Purrse.Api.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
RUN mkdir -p /app/plugins /app/imports
ENTRYPOINT ["dotnet", "Purrse.Api.dll"]
+24
View File
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Purrse.Api.Hubs;
[Authorize]
public class NotificationHub : Hub
{
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
if (userId != null)
await Groups.AddToGroupAsync(Context.ConnectionId, userId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.UserIdentifier;
if (userId != null)
await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId);
await base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,51 @@
using System.Net;
using System.Text.Json;
namespace Purrse.Api.Middleware;
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var (statusCode, message) = exception switch
{
UnauthorizedAccessException => (HttpStatusCode.Unauthorized, exception.Message),
KeyNotFoundException => (HttpStatusCode.NotFound, exception.Message),
InvalidOperationException => (HttpStatusCode.BadRequest, exception.Message),
ArgumentException => (HttpStatusCode.BadRequest, exception.Message),
_ => (HttpStatusCode.InternalServerError, "An unexpected error occurred")
};
if (statusCode == HttpStatusCode.InternalServerError)
_logger.LogError(exception, "Unhandled exception");
else
_logger.LogWarning("Handled exception: {Message}", exception.Message);
context.Response.StatusCode = (int)statusCode;
context.Response.ContentType = "application/json";
var response = JsonSerializer.Serialize(new { error = message, statusCode = (int)statusCode });
await context.Response.WriteAsync(response);
}
}
@@ -0,0 +1,25 @@
using System.Diagnostics;
namespace Purrse.Api.Middleware;
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
await _next(context);
sw.Stop();
_logger.LogInformation("{Method} {Path} responded {StatusCode} in {Elapsed}ms",
context.Request.Method, context.Request.Path, context.Response.StatusCode, sw.ElapsedMilliseconds);
}
}
+122
View File
@@ -0,0 +1,122 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Purrse.Api.Hubs;
using Purrse.Api.Middleware;
using Purrse.Api.Services;
using Purrse.Core.Interfaces.Services;
using Purrse.Data;
using Purrse.Plugins.Abstractions;
using Purrse.Plugins.CSV;
using Purrse.Plugins.OFX;
using Purrse.Plugins.QIF;
var builder = WebApplication.CreateBuilder(args);
// Database
builder.Services.AddDbContext<PurrseDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// JWT Authentication
var jwtKey = builder.Configuration["Jwt:Key"] ?? "PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!";
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "Purrse",
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "Purrse",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
// SignalR JWT support
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
// CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(
builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? new[] { "http://localhost:5173", "http://localhost:8080" })
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
// Services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAccountService, AccountService>();
builder.Services.AddScoped<ITransactionService, TransactionService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IPayeeService, PayeeService>();
builder.Services.AddScoped<IBudgetService, BudgetService>();
builder.Services.AddScoped<IScheduledTransactionService, ScheduledTransactionService>();
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<ILoanService, LoanService>();
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>();
// Built-in file parsers
builder.Services.AddSingleton<IFileParser, OFXParserPlugin>();
builder.Services.AddSingleton<IFileParser, CSVParserPlugin>();
builder.Services.AddSingleton<IFileParser, QIFParserPlugin>();
// Plugin system
builder.Services.AddSingleton<PluginService>();
// Background services
builder.Services.AddHostedService<ScheduledTransactionProcessor>();
builder.Services.AddHostedService<FileWatcherService>();
// SignalR
builder.Services.AddSignalR();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
// Auto-migrate database
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<PurrseDbContext>();
await db.Database.MigrateAsync();
}
// Initialize plugin system
var pluginService = app.Services.GetRequiredService<PluginService>();
await pluginService.DiscoverAndLoadPluginsAsync();
// Middleware
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<NotificationHub>("/hubs/notifications");
app.Run();
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5222",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Purrse.Core\Purrse.Core.csproj" />
<ProjectReference Include="..\Purrse.Data\Purrse.Data.csproj" />
<ProjectReference Include="..\Purrse.Plugins.Abstractions\Purrse.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\Purrse.Plugins.OFX\Purrse.Plugins.OFX.csproj" />
<ProjectReference Include="..\Purrse.Plugins.CSV\Purrse.Plugins.CSV.csproj" />
<ProjectReference Include="..\Purrse.Plugins.QIF\Purrse.Plugins.QIF.csproj" />
</ItemGroup>
</Project>
+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
);
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5433;Database=purrse;Username=purrse;Password=purrse_dev"
},
"Jwt": {
"Key": "PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!",
"Issuer": "Purrse",
"Audience": "Purrse"
},
"Cors": {
"Origins": [ "http://localhost:5173", "http://localhost:8080" ]
},
"Plugins": {
"Path": "plugins"
},
"Imports": {
"WatchPath": "imports"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}