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"
}
}
}
+53
View File
@@ -0,0 +1,53 @@
using Purrse.Core.Enums;
namespace Purrse.Core.DTOs;
public record CreateAccountRequest(
string Name,
AccountType Type,
string? Institution,
string? AccountNumber,
decimal Balance,
decimal? CreditLimit,
decimal? InterestRate,
string? Notes
);
public record UpdateAccountRequest(
string Name,
string? Institution,
string? AccountNumber,
decimal? CreditLimit,
decimal? InterestRate,
string? Notes,
bool IsActive,
int SortOrder
);
public record AccountResponse(
Guid Id,
string Name,
AccountType Type,
string? Institution,
string? AccountNumber,
decimal Balance,
decimal? CreditLimit,
decimal? InterestRate,
bool IsActive,
bool IsClosed,
string? Notes,
int SortOrder,
DateTime CreatedAt,
bool HasLoanDetail
);
public record AccountSummaryResponse(
Guid Id,
string Name,
AccountType Type,
string? Institution,
decimal Balance,
decimal? CreditLimit
);
public record BalanceHistoryEntry(DateTime Date, decimal Balance);
+6
View File
@@ -0,0 +1,6 @@
namespace Purrse.Core.DTOs;
public record RegisterRequest(string Username, string Email, string Password);
public record LoginRequest(string Username, string Password);
public record AuthResponse(string Token, string RefreshToken, DateTime ExpiresAt, string Username);
public record RefreshRequest(string RefreshToken);
+22
View File
@@ -0,0 +1,22 @@
namespace Purrse.Core.DTOs;
public record CreateBudgetRequest(int Year, int Month, string? Notes, List<BudgetItemRequest> Items);
public record UpdateBudgetRequest(string? Notes, List<BudgetItemRequest> Items);
public record BudgetItemRequest(Guid CategoryId, decimal BudgetedAmount);
public record BudgetResponse(
Guid Id,
int Year,
int Month,
string? Notes,
List<BudgetItemResponse> Items,
DateTime CreatedAt
);
public record BudgetItemResponse(
Guid Id,
Guid CategoryId,
string CategoryName,
decimal BudgetedAmount,
decimal ActualAmount
);
+27
View File
@@ -0,0 +1,27 @@
using Purrse.Core.Enums;
namespace Purrse.Core.DTOs;
public record CreateCategoryRequest(string Name, CategoryType Type, Guid? ParentId);
public record UpdateCategoryRequest(string Name, Guid? ParentId, int SortOrder, bool IsActive);
public record CategoryResponse(
Guid Id,
string Name,
CategoryType Type,
Guid? ParentId,
string? ParentName,
int SortOrder,
bool IsSystem,
bool IsActive
);
public record CategoryTreeResponse(
Guid Id,
string Name,
CategoryType Type,
int SortOrder,
bool IsSystem,
bool IsActive,
List<CategoryTreeResponse> Children
);
+27
View File
@@ -0,0 +1,27 @@
namespace Purrse.Core.DTOs;
public record DashboardResponse(
decimal NetWorth,
decimal TotalAssets,
decimal TotalLiabilities,
List<AccountSummaryResponse> Accounts,
List<TransactionResponse> RecentTransactions,
SpendingSummary SpendingSummary,
List<UpcomingBill> UpcomingBills
);
public record SpendingSummary(
decimal ThisMonth,
decimal LastMonth,
decimal ChangePercent,
List<CategorySpending> TopCategories
);
public record UpcomingBill(
Guid Id,
string PayeeName,
decimal Amount,
DateTime DueDate,
string AccountName,
bool AutoPost
);
+38
View File
@@ -0,0 +1,38 @@
using Purrse.Core.Enums;
namespace Purrse.Core.DTOs;
public record ImportUploadResponse(
Guid BatchId,
int TotalTransactions,
int NewTransactions,
int PossibleDuplicates,
List<ImportedTransactionPreview> Previews
);
public record ImportedTransactionPreview(
int Index,
DateTime Date,
decimal Amount,
string? PayeeName,
string? Memo,
string? FitId,
DuplicateMatch? DuplicateMatch
);
public record DuplicateMatch(
Guid ExistingTransactionId,
double Confidence,
string MatchReason
);
public record ResolveDuplicatesRequest(List<DuplicateResolution> Resolutions);
public record DuplicateResolution(int Index, DuplicateAction Action);
public enum DuplicateAction
{
Import,
Skip,
Merge
}
+22
View File
@@ -0,0 +1,22 @@
namespace Purrse.Core.DTOs;
public record InvestmentHoldingResponse(
Guid Id,
string Symbol,
string SecurityName,
decimal Shares,
decimal CostBasis,
decimal CurrentPrice,
decimal MarketValue,
decimal GainLoss,
decimal GainLossPercent,
DateTime AsOfDate
);
public record InvestmentPerformanceResponse(
decimal TotalMarketValue,
decimal TotalCostBasis,
decimal TotalGainLoss,
decimal TotalGainLossPercent,
List<InvestmentHoldingResponse> Holdings
);
+50
View File
@@ -0,0 +1,50 @@
namespace Purrse.Core.DTOs;
public record CreateLoanDetailRequest(
decimal OriginalBalance,
decimal InterestRate,
int TermMonths,
decimal MonthlyPayment,
DateTime OriginationDate,
DateTime MaturityDate,
decimal? EscrowAmount,
decimal? ExtraPayment
);
public record LoanDetailResponse(
Guid Id,
Guid AccountId,
string AccountName,
decimal OriginalBalance,
decimal CurrentBalance,
decimal InterestRate,
int TermMonths,
decimal MonthlyPayment,
DateTime OriginationDate,
DateTime MaturityDate,
decimal? EscrowAmount,
decimal? ExtraPayment,
List<AmortizationEntryResponse> AmortizationSchedule
);
public record AmortizationEntryResponse(
int PaymentNumber,
DateTime PaymentDate,
decimal PaymentAmount,
decimal PrincipalAmount,
decimal InterestAmount,
decimal? EscrowAmount,
decimal RemainingBalance
);
public record PayoffScenarioRequest(decimal? ExtraMonthlyPayment, decimal? LumpSumPayment, DateTime? LumpSumDate);
public record PayoffScenarioResponse(
DateTime OriginalPayoffDate,
DateTime NewPayoffDate,
decimal TotalInterestOriginal,
decimal TotalInterestNew,
decimal InterestSaved,
int MonthsSaved,
List<AmortizationEntryResponse> NewSchedule
);
+15
View File
@@ -0,0 +1,15 @@
namespace Purrse.Core.DTOs;
public record CreatePayeeRequest(string Name, Guid? DefaultCategoryId, List<string>? Aliases);
public record UpdatePayeeRequest(string Name, Guid? DefaultCategoryId, bool IsActive);
public record PayeeResponse(
Guid Id,
string Name,
Guid? DefaultCategoryId,
string? DefaultCategoryName,
bool IsActive,
List<string> Aliases
);
public record CreatePayeeAliasRequest(string Alias);
+14
View File
@@ -0,0 +1,14 @@
namespace Purrse.Core.DTOs;
public record PluginResponse(
Guid Id,
string PluginId,
string Name,
string Version,
bool IsEnabled,
DateTime InstalledAt,
List<string> Capabilities,
Dictionary<string, string> Configuration
);
public record PluginConfigurationRequest(Dictionary<string, string> Settings);
@@ -0,0 +1,14 @@
namespace Purrse.Core.DTOs;
public record StartReconciliationRequest(DateTime StatementDate, decimal StatementBalance);
public record ReconciliationResponse(
Guid Id,
Guid AccountId,
DateTime StatementDate,
decimal StatementBalance,
decimal ClearedBalance,
decimal Difference,
bool IsCompleted,
List<TransactionResponse> UnclearedTransactions
);
+32
View File
@@ -0,0 +1,32 @@
namespace Purrse.Core.DTOs;
public record SpendingByCategoryReport(
DateTime StartDate,
DateTime EndDate,
decimal TotalSpending,
List<CategorySpending> Categories
);
public record CategorySpending(string CategoryName, decimal Amount, decimal Percentage, string? ParentCategoryName);
public record IncomeVsExpenseReport(
DateTime StartDate,
DateTime EndDate,
List<MonthlyIncomeExpense> Months
);
public record MonthlyIncomeExpense(int Year, int Month, decimal Income, decimal Expenses, decimal Net);
public record NetWorthReport(List<NetWorthEntry> Entries);
public record NetWorthEntry(DateTime Date, decimal Assets, decimal Liabilities, decimal NetWorth);
public record CashFlowReport(
DateTime StartDate,
DateTime EndDate,
decimal TotalInflow,
decimal TotalOutflow,
decimal NetCashFlow,
List<CashFlowEntry> Entries
);
public record CashFlowEntry(DateTime Date, decimal Inflow, decimal Outflow, decimal Net);
@@ -0,0 +1,51 @@
using Purrse.Core.Enums;
namespace Purrse.Core.DTOs;
public record CreateScheduledTransactionRequest(
Guid AccountId,
decimal Amount,
string? PayeeName,
Guid? PayeeId,
Guid? CategoryId,
string? Memo,
TransactionType Type,
Frequency Frequency,
DateTime NextDueDate,
DateTime? EndDate,
bool AutoPost,
int ReminderDaysBefore
);
public record UpdateScheduledTransactionRequest(
decimal Amount,
string? PayeeName,
Guid? PayeeId,
Guid? CategoryId,
string? Memo,
TransactionType Type,
Frequency Frequency,
DateTime NextDueDate,
DateTime? EndDate,
bool AutoPost,
int ReminderDaysBefore,
bool IsActive
);
public record ScheduledTransactionResponse(
Guid Id,
Guid AccountId,
string AccountName,
decimal Amount,
string? PayeeName,
Guid? CategoryId,
string? CategoryName,
string? Memo,
TransactionType Type,
Frequency Frequency,
DateTime NextDueDate,
DateTime? EndDate,
bool AutoPost,
int ReminderDaysBefore,
bool IsActive
);
+81
View File
@@ -0,0 +1,81 @@
using Purrse.Core.Enums;
namespace Purrse.Core.DTOs;
public record CreateTransactionRequest(
Guid AccountId,
DateTime Date,
decimal Amount,
string? PayeeName,
Guid? PayeeId,
Guid? CategoryId,
string? Memo,
string? ReferenceNumber,
string? CheckNumber,
TransactionType Type,
List<SplitRequest>? Splits
);
public record UpdateTransactionRequest(
DateTime Date,
decimal Amount,
string? PayeeName,
Guid? PayeeId,
Guid? CategoryId,
string? Memo,
string? ReferenceNumber,
string? CheckNumber,
TransactionType Type,
TransactionStatus Status,
List<SplitRequest>? Splits
);
public record SplitRequest(Guid? CategoryId, decimal Amount, string? Memo);
public record TransactionResponse(
Guid Id,
Guid AccountId,
string? AccountName,
DateTime Date,
decimal Amount,
Guid? PayeeId,
string? PayeeName,
Guid? CategoryId,
string? CategoryName,
string? Memo,
string? ReferenceNumber,
string? CheckNumber,
TransactionType Type,
TransactionStatus Status,
bool IsVoid,
Guid? TransferTransactionId,
Guid? ImportBatchId,
List<SplitResponse>? Splits,
DateTime CreatedAt
);
public record SplitResponse(Guid Id, Guid? CategoryId, string? CategoryName, decimal Amount, string? Memo);
public record CreateTransferRequest(
Guid FromAccountId,
Guid ToAccountId,
DateTime Date,
decimal Amount,
string? Memo
);
public record TransactionSearchRequest(
Guid? AccountId,
DateTime? StartDate,
DateTime? EndDate,
decimal? MinAmount,
decimal? MaxAmount,
string? PayeeName,
Guid? CategoryId,
TransactionStatus? Status,
string? SearchText,
int Page = 1,
int PageSize = 50
);
public record PagedResult<T>(List<T> Items, int TotalCount, int Page, int PageSize, int TotalPages);
+16
View File
@@ -0,0 +1,16 @@
namespace Purrse.Core.Enums;
public enum AccountType
{
Checking,
Savings,
CreditCard,
AutoLoan,
PersonalLoan,
Mortgage,
LineOfCredit,
Retirement401k,
IRA,
Brokerage,
Cash
}
+8
View File
@@ -0,0 +1,8 @@
namespace Purrse.Core.Enums;
public enum CategoryType
{
Income,
Expense,
Transfer
}
+13
View File
@@ -0,0 +1,13 @@
namespace Purrse.Core.Enums;
public enum Frequency
{
Daily,
Weekly,
BiWeekly,
SemiMonthly,
Monthly,
Quarterly,
SemiAnnually,
Annually
}
+8
View File
@@ -0,0 +1,8 @@
namespace Purrse.Core.Enums;
public enum ImportStatus
{
Pending,
Confirmed,
Rejected
}
@@ -0,0 +1,8 @@
namespace Purrse.Core.Enums;
public enum TransactionStatus
{
Uncleared,
Cleared,
Reconciled
}
+8
View File
@@ -0,0 +1,8 @@
namespace Purrse.Core.Enums;
public enum TransactionType
{
Debit,
Credit,
Transfer
}
@@ -0,0 +1,15 @@
using F23.StringSimilarity;
namespace Purrse.Core.Helpers;
public static class StringSimilarityHelper
{
private static readonly JaroWinkler _jaroWinkler = new();
public static double Calculate(string s1, string s2)
{
if (string.IsNullOrEmpty(s1) || string.IsNullOrEmpty(s2))
return 0;
return _jaroWinkler.Similarity(s1.ToUpperInvariant(), s2.ToUpperInvariant());
}
}
@@ -0,0 +1,14 @@
using System.Security.Cryptography;
using System.Text;
namespace Purrse.Core.Helpers;
public static class TransactionFingerprint
{
public static string Generate(Guid accountId, DateTime date, decimal amount, string? fitId)
{
var input = $"{accountId}|{date:yyyyMMdd}|{amount:F2}|{fitId ?? ""}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexStringLower(bytes);
}
}
@@ -0,0 +1,14 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface IAccountService
{
Task<List<AccountResponse>> GetAllAsync(Guid userId);
Task<AccountResponse?> GetByIdAsync(Guid userId, Guid accountId);
Task<AccountResponse> CreateAsync(Guid userId, CreateAccountRequest request);
Task<AccountResponse> UpdateAsync(Guid userId, Guid accountId, UpdateAccountRequest request);
Task DeleteAsync(Guid userId, Guid accountId);
Task<List<BalanceHistoryEntry>> GetBalanceHistoryAsync(Guid userId, Guid accountId, DateTime startDate, DateTime endDate);
Task RecalculateBalanceAsync(Guid accountId);
}
@@ -0,0 +1,11 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface IAuthService
{
Task<AuthResponse> RegisterAsync(RegisterRequest request);
Task<AuthResponse> LoginAsync(LoginRequest request);
Task<AuthResponse> RefreshTokenAsync(string refreshToken);
Task RevokeRefreshTokenAsync(Guid userId);
}
@@ -0,0 +1,12 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface IBudgetService
{
Task<BudgetResponse?> GetByMonthAsync(Guid userId, int year, int month);
Task<BudgetResponse> CreateAsync(Guid userId, CreateBudgetRequest request);
Task<BudgetResponse> UpdateAsync(Guid userId, Guid budgetId, UpdateBudgetRequest request);
Task DeleteAsync(Guid userId, Guid budgetId);
Task<List<BudgetItemResponse>> GetActualsAsync(Guid userId, Guid budgetId);
}
@@ -0,0 +1,14 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface ICategoryService
{
Task<List<CategoryResponse>> GetAllAsync(Guid userId);
Task<List<CategoryTreeResponse>> GetTreeAsync(Guid userId);
Task<CategoryResponse?> GetByIdAsync(Guid userId, Guid categoryId);
Task<CategoryResponse> CreateAsync(Guid userId, CreateCategoryRequest request);
Task<CategoryResponse> UpdateAsync(Guid userId, Guid categoryId, UpdateCategoryRequest request);
Task DeleteAsync(Guid userId, Guid categoryId);
Task SeedDefaultCategoriesAsync(Guid userId);
}
@@ -0,0 +1,8 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface IDashboardService
{
Task<DashboardResponse> GetDashboardAsync(Guid userId);
}
@@ -0,0 +1,10 @@
using Purrse.Core.DTOs;
using Purrse.Core.Models;
namespace Purrse.Core.Interfaces.Services;
public interface IDuplicateDetectionService
{
Task<DuplicateMatch?> FindDuplicateAsync(Guid accountId, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber);
double CalculateConfidence(Transaction existing, DateTime date, decimal amount, string? payeeName, string? fitId, string? checkNumber);
}
@@ -0,0 +1,22 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface IImportService
{
Task<ImportUploadResponse> UploadAsync(Guid userId, Guid accountId, string fileName, Stream fileStream);
Task<ImportBatchResponse> ConfirmBatchAsync(Guid userId, Guid batchId);
Task ResolveDuplicatesAsync(Guid userId, Guid batchId, ResolveDuplicatesRequest request);
}
public record ImportBatchResponse(
Guid Id,
string FileName,
string FileType,
int TotalCount,
int ImportedCount,
int DuplicateCount,
int SkippedCount,
string Status,
DateTime ImportedAt
);
@@ -0,0 +1,12 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface ILoanService
{
Task<LoanDetailResponse?> GetLoanDetailAsync(Guid userId, Guid accountId);
Task<LoanDetailResponse> CreateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request);
Task<LoanDetailResponse> UpdateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request);
Task<List<AmortizationEntryResponse>> GetAmortizationScheduleAsync(Guid userId, Guid accountId);
Task<PayoffScenarioResponse> CalculatePayoffScenarioAsync(Guid userId, Guid accountId, PayoffScenarioRequest request);
}
@@ -0,0 +1,16 @@
using Purrse.Core.DTOs;
using Purrse.Core.Models;
namespace Purrse.Core.Interfaces.Services;
public interface IPayeeService
{
Task<List<PayeeResponse>> GetAllAsync(Guid userId);
Task<PayeeResponse?> GetByIdAsync(Guid userId, Guid payeeId);
Task<PayeeResponse> CreateAsync(Guid userId, CreatePayeeRequest request);
Task<PayeeResponse> UpdateAsync(Guid userId, Guid payeeId, UpdatePayeeRequest request);
Task DeleteAsync(Guid userId, Guid payeeId);
Task AddAliasAsync(Guid userId, Guid payeeId, string alias);
Task RemoveAliasAsync(Guid userId, Guid payeeId, Guid aliasId);
Task<Payee?> MatchPayeeAsync(Guid userId, string importedName);
}
@@ -0,0 +1,11 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface IReportService
{
Task<SpendingByCategoryReport> GetSpendingByCategoryAsync(Guid userId, DateTime startDate, DateTime endDate, Guid? accountId = null);
Task<IncomeVsExpenseReport> GetIncomeVsExpenseAsync(Guid userId, DateTime startDate, DateTime endDate);
Task<NetWorthReport> GetNetWorthAsync(Guid userId, DateTime startDate, DateTime endDate);
Task<CashFlowReport> GetCashFlowAsync(Guid userId, DateTime startDate, DateTime endDate);
}
@@ -0,0 +1,15 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface IScheduledTransactionService
{
Task<List<ScheduledTransactionResponse>> GetAllAsync(Guid userId);
Task<ScheduledTransactionResponse?> GetByIdAsync(Guid userId, Guid id);
Task<ScheduledTransactionResponse> CreateAsync(Guid userId, CreateScheduledTransactionRequest request);
Task<ScheduledTransactionResponse> UpdateAsync(Guid userId, Guid id, UpdateScheduledTransactionRequest request);
Task DeleteAsync(Guid userId, Guid id);
Task SkipAsync(Guid userId, Guid id);
Task<TransactionResponse> PostNowAsync(Guid userId, Guid id);
Task ProcessDueTransactionsAsync();
}
@@ -0,0 +1,15 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface ITransactionService
{
Task<PagedResult<TransactionResponse>> GetByAccountAsync(Guid userId, Guid accountId, int page, int pageSize);
Task<TransactionResponse?> GetByIdAsync(Guid userId, Guid transactionId);
Task<TransactionResponse> CreateAsync(Guid userId, CreateTransactionRequest request);
Task<TransactionResponse> UpdateAsync(Guid userId, Guid transactionId, UpdateTransactionRequest request);
Task DeleteAsync(Guid userId, Guid transactionId);
Task<PagedResult<TransactionResponse>> SearchAsync(Guid userId, TransactionSearchRequest request);
Task<(TransactionResponse From, TransactionResponse To)> CreateTransferAsync(Guid userId, CreateTransferRequest request);
Task VoidAsync(Guid userId, Guid transactionId);
}
+28
View File
@@ -0,0 +1,28 @@
using Purrse.Core.Enums;
namespace Purrse.Core.Models;
public class Account
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Name { get; set; } = string.Empty;
public AccountType Type { get; set; }
public string? Institution { get; set; }
public string? AccountNumber { get; set; }
public decimal Balance { get; set; }
public decimal? CreditLimit { get; set; }
public decimal? InterestRate { get; set; }
public bool IsActive { get; set; } = true;
public bool IsClosed { get; set; }
public string? Notes { get; set; }
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
public LoanDetail? LoanDetail { get; set; }
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
public ICollection<InvestmentHolding> InvestmentHoldings { get; set; } = new List<InvestmentHolding>();
public ICollection<Reconciliation> Reconciliations { get; set; } = new List<Reconciliation>();
}
@@ -0,0 +1,16 @@
namespace Purrse.Core.Models;
public class AmortizationEntry
{
public Guid Id { get; set; }
public Guid LoanDetailId { get; set; }
public int PaymentNumber { get; set; }
public DateTime PaymentDate { get; set; }
public decimal PaymentAmount { get; set; }
public decimal PrincipalAmount { get; set; }
public decimal InterestAmount { get; set; }
public decimal? EscrowAmount { get; set; }
public decimal RemainingBalance { get; set; }
public LoanDetail LoanDetail { get; set; } = null!;
}
+14
View File
@@ -0,0 +1,14 @@
namespace Purrse.Core.Models;
public class Budget
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public int Year { get; set; }
public int Month { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
public ICollection<BudgetItem> Items { get; set; } = new List<BudgetItem>();
}
+12
View File
@@ -0,0 +1,12 @@
namespace Purrse.Core.Models;
public class BudgetItem
{
public Guid Id { get; set; }
public Guid BudgetId { get; set; }
public Guid CategoryId { get; set; }
public decimal BudgetedAmount { get; set; }
public Budget Budget { get; set; } = null!;
public Category Category { get; set; } = null!;
}
+21
View File
@@ -0,0 +1,21 @@
using Purrse.Core.Enums;
namespace Purrse.Core.Models;
public class Category
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Name { get; set; } = string.Empty;
public CategoryType Type { get; set; }
public Guid? ParentId { get; set; }
public int SortOrder { get; set; }
public bool IsSystem { get; set; }
public bool IsActive { get; set; } = true;
public User User { get; set; } = null!;
public Category? Parent { get; set; }
public ICollection<Category> Children { get; set; } = new List<Category>();
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
public ICollection<BudgetItem> BudgetItems { get; set; } = new List<BudgetItem>();
}
+22
View File
@@ -0,0 +1,22 @@
using Purrse.Core.Enums;
namespace Purrse.Core.Models;
public class ImportBatch
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid AccountId { get; set; }
public string FileName { get; set; } = string.Empty;
public string FileType { get; set; } = string.Empty;
public int TotalCount { get; set; }
public int ImportedCount { get; set; }
public int DuplicateCount { get; set; }
public int SkippedCount { get; set; }
public ImportStatus Status { get; set; } = ImportStatus.Pending;
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
public Account Account { get; set; } = null!;
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
}
@@ -0,0 +1,14 @@
namespace Purrse.Core.Models;
public class InvestmentHolding
{
public Guid Id { get; set; }
public Guid AccountId { get; set; }
public Guid SecurityId { get; set; }
public decimal Shares { get; set; }
public decimal CostBasis { get; set; }
public DateTime AsOfDate { get; set; }
public Account Account { get; set; } = null!;
public Security Security { get; set; } = null!;
}
+18
View File
@@ -0,0 +1,18 @@
namespace Purrse.Core.Models;
public class LoanDetail
{
public Guid Id { get; set; }
public Guid AccountId { get; set; }
public decimal OriginalBalance { get; set; }
public decimal InterestRate { get; set; }
public int TermMonths { get; set; }
public decimal MonthlyPayment { get; set; }
public DateTime OriginationDate { get; set; }
public DateTime MaturityDate { get; set; }
public decimal? EscrowAmount { get; set; }
public decimal? ExtraPayment { get; set; }
public Account Account { get; set; } = null!;
public ICollection<AmortizationEntry> AmortizationEntries { get; set; } = new List<AmortizationEntry>();
}
+15
View File
@@ -0,0 +1,15 @@
namespace Purrse.Core.Models;
public class Payee
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Name { get; set; } = string.Empty;
public Guid? DefaultCategoryId { get; set; }
public bool IsActive { get; set; } = true;
public User User { get; set; } = null!;
public Category? DefaultCategory { get; set; }
public ICollection<PayeeAlias> Aliases { get; set; } = new List<PayeeAlias>();
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
}
+10
View File
@@ -0,0 +1,10 @@
namespace Purrse.Core.Models;
public class PayeeAlias
{
public Guid Id { get; set; }
public Guid PayeeId { get; set; }
public string Alias { get; set; } = string.Empty;
public Payee Payee { get; set; } = null!;
}
@@ -0,0 +1,11 @@
namespace Purrse.Core.Models;
public class PluginConfiguration
{
public Guid Id { get; set; }
public Guid PluginRegistrationId { get; set; }
public string Key { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public PluginRegistration PluginRegistration { get; set; } = null!;
}
@@ -0,0 +1,15 @@
namespace Purrse.Core.Models;
public class PluginRegistration
{
public Guid Id { get; set; }
public string PluginId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string EntryAssembly { get; set; } = string.Empty;
public string EntryType { get; set; } = string.Empty;
public bool IsEnabled { get; set; } = true;
public DateTime InstalledAt { get; set; } = DateTime.UtcNow;
public ICollection<PluginConfiguration> Configurations { get; set; } = new List<PluginConfiguration>();
}
+15
View File
@@ -0,0 +1,15 @@
namespace Purrse.Core.Models;
public class Reconciliation
{
public Guid Id { get; set; }
public Guid AccountId { get; set; }
public DateTime StatementDate { get; set; }
public decimal StatementBalance { get; set; }
public bool IsCompleted { get; set; }
public DateTime? CompletedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public Account Account { get; set; } = null!;
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
}
@@ -0,0 +1,28 @@
using Purrse.Core.Enums;
namespace Purrse.Core.Models;
public class ScheduledTransaction
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid AccountId { get; set; }
public decimal Amount { get; set; }
public string? PayeeName { get; set; }
public Guid? PayeeId { get; set; }
public Guid? CategoryId { get; set; }
public string? Memo { get; set; }
public TransactionType Type { get; set; }
public Frequency Frequency { get; set; }
public DateTime NextDueDate { get; set; }
public DateTime? EndDate { get; set; }
public bool AutoPost { get; set; }
public int ReminderDaysBefore { get; set; } = 3;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
public Account Account { get; set; } = null!;
public Payee? Payee { get; set; }
public Category? Category { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace Purrse.Core.Models;
public class Security
{
public Guid Id { get; set; }
public string Symbol { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? SecurityType { get; set; }
public ICollection<InvestmentHolding> Holdings { get; set; } = new List<InvestmentHolding>();
public ICollection<SecurityPrice> Prices { get; set; } = new List<SecurityPrice>();
}
+11
View File
@@ -0,0 +1,11 @@
namespace Purrse.Core.Models;
public class SecurityPrice
{
public Guid Id { get; set; }
public Guid SecurityId { get; set; }
public DateTime Date { get; set; }
public decimal Price { get; set; }
public Security Security { get; set; } = null!;
}
+35
View File
@@ -0,0 +1,35 @@
using Purrse.Core.Enums;
namespace Purrse.Core.Models;
public class Transaction
{
public Guid Id { get; set; }
public Guid AccountId { get; set; }
public DateTime Date { get; set; }
public decimal Amount { get; set; }
public Guid? PayeeId { get; set; }
public string? PayeeName { get; set; }
public Guid? CategoryId { get; set; }
public string? Memo { get; set; }
public string? ReferenceNumber { get; set; }
public string? CheckNumber { get; set; }
public TransactionType Type { get; set; }
public TransactionStatus Status { get; set; } = TransactionStatus.Uncleared;
public string? FitId { get; set; }
public string? Fingerprint { get; set; }
public Guid? TransferTransactionId { get; set; }
public Guid? ImportBatchId { get; set; }
public Guid? ReconciliationId { get; set; }
public bool IsVoid { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public Account Account { get; set; } = null!;
public Payee? Payee { get; set; }
public Category? Category { get; set; }
public Transaction? TransferTransaction { get; set; }
public ImportBatch? ImportBatch { get; set; }
public Reconciliation? Reconciliation { get; set; }
public ICollection<TransactionSplit> Splits { get; set; } = new List<TransactionSplit>();
}
@@ -0,0 +1,13 @@
namespace Purrse.Core.Models;
public class TransactionSplit
{
public Guid Id { get; set; }
public Guid TransactionId { get; set; }
public Guid? CategoryId { get; set; }
public decimal Amount { get; set; }
public string? Memo { get; set; }
public Transaction Transaction { get; set; } = null!;
public Category? Category { get; set; }
}
+15
View File
@@ -0,0 +1,15 @@
namespace Purrse.Core.Models;
public class User
{
public Guid Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiresAt { get; set; }
public ICollection<Account> Accounts { get; set; } = new List<Account>();
}
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="F23.StringSimilarity" Version="4.1.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class AccountConfiguration : IEntityTypeConfiguration<Account>
{
public void Configure(EntityTypeBuilder<Account> builder)
{
builder.ToTable("accounts");
builder.HasKey(a => a.Id);
builder.Property(a => a.Name).HasMaxLength(200).IsRequired();
builder.Property(a => a.Institution).HasMaxLength(200);
builder.Property(a => a.AccountNumber).HasMaxLength(50);
builder.Property(a => a.Balance).HasPrecision(18, 2);
builder.Property(a => a.CreditLimit).HasPrecision(18, 2);
builder.Property(a => a.InterestRate).HasPrecision(8, 4);
builder.Property(a => a.Notes).HasMaxLength(1000);
builder.HasOne(a => a.User)
.WithMany(u => u.Accounts)
.HasForeignKey(a => a.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(a => a.LoanDetail)
.WithOne(l => l.Account)
.HasForeignKey<LoanDetail>(l => l.AccountId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(a => a.UserId);
}
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class AmortizationEntryConfiguration : IEntityTypeConfiguration<AmortizationEntry>
{
public void Configure(EntityTypeBuilder<AmortizationEntry> builder)
{
builder.ToTable("amortization_entries");
builder.HasKey(a => a.Id);
builder.Property(a => a.PaymentAmount).HasPrecision(18, 2);
builder.Property(a => a.PrincipalAmount).HasPrecision(18, 2);
builder.Property(a => a.InterestAmount).HasPrecision(18, 2);
builder.Property(a => a.EscrowAmount).HasPrecision(18, 2);
builder.Property(a => a.RemainingBalance).HasPrecision(18, 2);
builder.HasOne(a => a.LoanDetail)
.WithMany(l => l.AmortizationEntries)
.HasForeignKey(a => a.LoanDetailId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class BudgetConfiguration : IEntityTypeConfiguration<Budget>
{
public void Configure(EntityTypeBuilder<Budget> builder)
{
builder.ToTable("budgets");
builder.HasKey(b => b.Id);
builder.Property(b => b.Notes).HasMaxLength(500);
builder.HasOne(b => b.User)
.WithMany()
.HasForeignKey(b => b.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(b => new { b.UserId, b.Year, b.Month }).IsUnique();
}
}
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class BudgetItemConfiguration : IEntityTypeConfiguration<BudgetItem>
{
public void Configure(EntityTypeBuilder<BudgetItem> builder)
{
builder.ToTable("budget_items");
builder.HasKey(bi => bi.Id);
builder.Property(bi => bi.BudgetedAmount).HasPrecision(18, 2);
builder.HasOne(bi => bi.Budget)
.WithMany(b => b.Items)
.HasForeignKey(bi => bi.BudgetId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(bi => bi.Category)
.WithMany(c => c.BudgetItems)
.HasForeignKey(bi => bi.CategoryId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
builder.ToTable("categories");
builder.HasKey(c => c.Id);
builder.Property(c => c.Name).HasMaxLength(200).IsRequired();
builder.HasOne(c => c.User)
.WithMany()
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(c => c.Parent)
.WithMany(c => c.Children)
.HasForeignKey(c => c.ParentId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => new { c.UserId, c.Name, c.ParentId }).IsUnique();
}
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class ImportBatchConfiguration : IEntityTypeConfiguration<ImportBatch>
{
public void Configure(EntityTypeBuilder<ImportBatch> builder)
{
builder.ToTable("import_batches");
builder.HasKey(b => b.Id);
builder.Property(b => b.FileName).HasMaxLength(500).IsRequired();
builder.Property(b => b.FileType).HasMaxLength(50).IsRequired();
builder.HasOne(b => b.User)
.WithMany()
.HasForeignKey(b => b.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(b => b.Account)
.WithMany()
.HasForeignKey(b => b.AccountId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class InvestmentHoldingConfiguration : IEntityTypeConfiguration<InvestmentHolding>
{
public void Configure(EntityTypeBuilder<InvestmentHolding> builder)
{
builder.ToTable("investment_holdings");
builder.HasKey(h => h.Id);
builder.Property(h => h.Shares).HasPrecision(18, 6);
builder.Property(h => h.CostBasis).HasPrecision(18, 2);
builder.HasOne(h => h.Account)
.WithMany(a => a.InvestmentHoldings)
.HasForeignKey(h => h.AccountId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(h => h.Security)
.WithMany(s => s.Holdings)
.HasForeignKey(h => h.SecurityId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public class SecurityConfiguration : IEntityTypeConfiguration<Security>
{
public void Configure(EntityTypeBuilder<Security> builder)
{
builder.ToTable("securities");
builder.HasKey(s => s.Id);
builder.Property(s => s.Symbol).HasMaxLength(20).IsRequired();
builder.Property(s => s.Name).HasMaxLength(200).IsRequired();
builder.Property(s => s.SecurityType).HasMaxLength(50);
builder.HasIndex(s => s.Symbol).IsUnique();
}
}
public class SecurityPriceConfiguration : IEntityTypeConfiguration<SecurityPrice>
{
public void Configure(EntityTypeBuilder<SecurityPrice> builder)
{
builder.ToTable("security_prices");
builder.HasKey(p => p.Id);
builder.Property(p => p.Price).HasPrecision(18, 4);
builder.HasOne(p => p.Security)
.WithMany(s => s.Prices)
.HasForeignKey(p => p.SecurityId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(p => new { p.SecurityId, p.Date }).IsUnique();
}
}
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class LoanDetailConfiguration : IEntityTypeConfiguration<LoanDetail>
{
public void Configure(EntityTypeBuilder<LoanDetail> builder)
{
builder.ToTable("loan_details");
builder.HasKey(l => l.Id);
builder.Property(l => l.OriginalBalance).HasPrecision(18, 2);
builder.Property(l => l.InterestRate).HasPrecision(8, 4);
builder.Property(l => l.MonthlyPayment).HasPrecision(18, 2);
builder.Property(l => l.EscrowAmount).HasPrecision(18, 2);
builder.Property(l => l.ExtraPayment).HasPrecision(18, 2);
}
}

Some files were not shown because too many files have changed in this diff Show More