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