Private
Public Access
1
0

Initial commit: Purrse personal finance app

Self-hosted, plugin-extensible personal finance manager built with
ASP.NET Core 9.0, Vue 3 + Vuetify 3, and PostgreSQL 17.

Backend (8 .NET projects):
- Core: 19 domain models, 6 enums, 14 DTOs, 12 service interfaces
- Data: EF Core DbContext, 16 entity configurations, category seeder
- API: 14 controllers, 15 services, JWT auth, SignalR, middleware
- Plugins: Abstractions + OFX/CSV/QIF file parsers
- Tests: 28 xUnit tests (fingerprinting, duplicate detection, parsers)

Frontend (Vue 3 + Vuetify 3 + TypeScript):
- 13 views, Pinia stores, Axios API services with JWT interceptors
- Dashboard, accounts, transactions, categories, imports, and more

Deployment:
- Docker Compose (PostgreSQL 17 + .NET API + nginx frontend)
- Auto-migration on startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-08 08:56:46 -05:00
commit 6520ebf221
169 changed files with 7180 additions and 0 deletions
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class AccountsController : ControllerBase
{
private readonly IAccountService _accountService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public AccountsController(IAccountService accountService) => _accountService = accountService;
[HttpGet]
public async Task<ActionResult<List<AccountResponse>>> GetAll()
=> Ok(await _accountService.GetAllAsync(UserId));
[HttpGet("{id}")]
public async Task<ActionResult<AccountResponse>> GetById(Guid id)
{
var account = await _accountService.GetByIdAsync(UserId, id);
return account == null ? NotFound() : Ok(account);
}
[HttpPost]
public async Task<ActionResult<AccountResponse>> Create(CreateAccountRequest request)
{
var account = await _accountService.CreateAsync(UserId, request);
return CreatedAtAction(nameof(GetById), new { id = account.Id }, account);
}
[HttpPut("{id}")]
public async Task<ActionResult<AccountResponse>> Update(Guid id, UpdateAccountRequest request)
=> Ok(await _accountService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _accountService.DeleteAsync(UserId, id);
return NoContent();
}
[HttpGet("{id}/balance-history")]
public async Task<ActionResult<List<BalanceHistoryEntry>>> GetBalanceHistory(Guid id, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
=> Ok(await _accountService.GetBalanceHistoryAsync(UserId, id, startDate, endDate));
}
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService) => _authService = authService;
[HttpPost("register")]
public async Task<ActionResult<AuthResponse>> Register(RegisterRequest request)
{
var result = await _authService.RegisterAsync(request);
return Ok(result);
}
[HttpPost("login")]
public async Task<ActionResult<AuthResponse>> Login(LoginRequest request)
{
var result = await _authService.LoginAsync(request);
return Ok(result);
}
[HttpPost("refresh")]
public async Task<ActionResult<AuthResponse>> Refresh(RefreshRequest request)
{
var result = await _authService.RefreshTokenAsync(request.RefreshToken);
return Ok(result);
}
[Authorize]
[HttpPost("revoke")]
public async Task<ActionResult> Revoke()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
await _authService.RevokeRefreshTokenAsync(userId);
return NoContent();
}
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class BudgetsController : ControllerBase
{
private readonly IBudgetService _budgetService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public BudgetsController(IBudgetService budgetService) => _budgetService = budgetService;
[HttpGet("{year}/{month}")]
public async Task<ActionResult<BudgetResponse>> GetByMonth(int year, int month)
{
var budget = await _budgetService.GetByMonthAsync(UserId, year, month);
return budget == null ? NotFound() : Ok(budget);
}
[HttpPost]
public async Task<ActionResult<BudgetResponse>> Create(CreateBudgetRequest request)
=> Ok(await _budgetService.CreateAsync(UserId, request));
[HttpPut("{id}")]
public async Task<ActionResult<BudgetResponse>> Update(Guid id, UpdateBudgetRequest request)
=> Ok(await _budgetService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _budgetService.DeleteAsync(UserId, id);
return NoContent();
}
[HttpGet("{id}/actual")]
public async Task<ActionResult<List<BudgetItemResponse>>> GetActuals(Guid id)
=> Ok(await _budgetService.GetActualsAsync(UserId, id));
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class CategoriesController : ControllerBase
{
private readonly ICategoryService _categoryService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public CategoriesController(ICategoryService categoryService) => _categoryService = categoryService;
[HttpGet]
public async Task<ActionResult<List<CategoryResponse>>> GetAll()
=> Ok(await _categoryService.GetAllAsync(UserId));
[HttpGet("tree")]
public async Task<ActionResult<List<CategoryTreeResponse>>> GetTree()
=> Ok(await _categoryService.GetTreeAsync(UserId));
[HttpGet("{id}")]
public async Task<ActionResult<CategoryResponse>> GetById(Guid id)
{
var cat = await _categoryService.GetByIdAsync(UserId, id);
return cat == null ? NotFound() : Ok(cat);
}
[HttpPost]
public async Task<ActionResult<CategoryResponse>> Create(CreateCategoryRequest request)
{
var cat = await _categoryService.CreateAsync(UserId, request);
return CreatedAtAction(nameof(GetById), new { id = cat.Id }, cat);
}
[HttpPut("{id}")]
public async Task<ActionResult<CategoryResponse>> Update(Guid id, UpdateCategoryRequest request)
=> Ok(await _categoryService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _categoryService.DeleteAsync(UserId, id);
return NoContent();
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DashboardController : ControllerBase
{
private readonly IDashboardService _dashboardService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public DashboardController(IDashboardService dashboardService) => _dashboardService = dashboardService;
[HttpGet]
public async Task<ActionResult<DashboardResponse>> Get()
=> Ok(await _dashboardService.GetDashboardAsync(UserId));
}
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ImportsController : ControllerBase
{
private readonly IImportService _importService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public ImportsController(IImportService importService) => _importService = importService;
[HttpPost("upload")]
public async Task<ActionResult<ImportUploadResponse>> Upload([FromQuery] Guid accountId, IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new { error = "No file uploaded" });
using var stream = file.OpenReadStream();
var result = await _importService.UploadAsync(UserId, accountId, file.FileName, stream);
return Ok(result);
}
[HttpPost("batches/{batchId}/confirm")]
public async Task<ActionResult<ImportBatchResponse>> ConfirmBatch(Guid batchId)
=> Ok(await _importService.ConfirmBatchAsync(UserId, batchId));
[HttpPost("batches/{batchId}/resolve-duplicates")]
public async Task<ActionResult> ResolveDuplicates(Guid batchId, ResolveDuplicatesRequest request)
{
await _importService.ResolveDuplicatesAsync(UserId, batchId, request);
return NoContent();
}
}
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Purrse.Core.DTOs;
using Purrse.Data;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class InvestmentsController : ControllerBase
{
private readonly PurrseDbContext _db;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public InvestmentsController(PurrseDbContext db) => _db = db;
[HttpGet("{accountId}/holdings")]
public async Task<ActionResult<List<InvestmentHoldingResponse>>> GetHoldings(Guid accountId)
{
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId)
?? throw new KeyNotFoundException("Account not found");
var holdings = await _db.InvestmentHoldings
.Include(h => h.Security).ThenInclude(s => s.Prices)
.Where(h => h.AccountId == accountId)
.ToListAsync();
var responses = holdings.Select(h =>
{
var latestPrice = h.Security.Prices.OrderByDescending(p => p.Date).FirstOrDefault()?.Price ?? 0;
var marketValue = h.Shares * latestPrice;
var gainLoss = marketValue - h.CostBasis;
var gainLossPct = h.CostBasis != 0 ? (gainLoss / h.CostBasis) * 100 : 0;
return new InvestmentHoldingResponse(h.Id, h.Security.Symbol, h.Security.Name,
h.Shares, h.CostBasis, latestPrice, marketValue, gainLoss, gainLossPct, h.AsOfDate);
}).ToList();
return Ok(responses);
}
[HttpGet("{accountId}/performance")]
public async Task<ActionResult<InvestmentPerformanceResponse>> GetPerformance(Guid accountId)
{
var holdingsResult = await GetHoldings(accountId);
var holdings = (holdingsResult.Result as OkObjectResult)?.Value as List<InvestmentHoldingResponse> ?? new();
var totalMarket = holdings.Sum(h => h.MarketValue);
var totalCost = holdings.Sum(h => h.CostBasis);
var totalGL = totalMarket - totalCost;
var totalPct = totalCost != 0 ? (totalGL / totalCost) * 100 : 0;
return Ok(new InvestmentPerformanceResponse(totalMarket, totalCost, totalGL, totalPct, holdings));
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class LoansController : ControllerBase
{
private readonly ILoanService _loanService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public LoansController(ILoanService loanService) => _loanService = loanService;
[HttpGet("{accountId}/amortization")]
public async Task<ActionResult<LoanDetailResponse>> GetLoanDetail(Guid accountId)
{
var detail = await _loanService.GetLoanDetailAsync(UserId, accountId);
return detail == null ? NotFound() : Ok(detail);
}
[HttpPost("{accountId}")]
public async Task<ActionResult<LoanDetailResponse>> CreateLoanDetail(Guid accountId, CreateLoanDetailRequest request)
=> Ok(await _loanService.CreateLoanDetailAsync(UserId, accountId, request));
[HttpPut("{accountId}")]
public async Task<ActionResult<LoanDetailResponse>> UpdateLoanDetail(Guid accountId, CreateLoanDetailRequest request)
=> Ok(await _loanService.UpdateLoanDetailAsync(UserId, accountId, request));
[HttpPost("{accountId}/payoff-scenario")]
public async Task<ActionResult<PayoffScenarioResponse>> PayoffScenario(Guid accountId, PayoffScenarioRequest request)
=> Ok(await _loanService.CalculatePayoffScenarioAsync(UserId, accountId, request));
}
@@ -0,0 +1,61 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class PayeesController : ControllerBase
{
private readonly IPayeeService _payeeService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public PayeesController(IPayeeService payeeService) => _payeeService = payeeService;
[HttpGet]
public async Task<ActionResult<List<PayeeResponse>>> GetAll()
=> Ok(await _payeeService.GetAllAsync(UserId));
[HttpGet("{id}")]
public async Task<ActionResult<PayeeResponse>> GetById(Guid id)
{
var payee = await _payeeService.GetByIdAsync(UserId, id);
return payee == null ? NotFound() : Ok(payee);
}
[HttpPost]
public async Task<ActionResult<PayeeResponse>> Create(CreatePayeeRequest request)
{
var payee = await _payeeService.CreateAsync(UserId, request);
return CreatedAtAction(nameof(GetById), new { id = payee.Id }, payee);
}
[HttpPut("{id}")]
public async Task<ActionResult<PayeeResponse>> Update(Guid id, UpdatePayeeRequest request)
=> Ok(await _payeeService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _payeeService.DeleteAsync(UserId, id);
return NoContent();
}
[HttpPost("{id}/aliases")]
public async Task<ActionResult> AddAlias(Guid id, CreatePayeeAliasRequest request)
{
await _payeeService.AddAliasAsync(UserId, id, request.Alias);
return NoContent();
}
[HttpDelete("{id}/aliases/{aliasId}")]
public async Task<ActionResult> RemoveAlias(Guid id, Guid aliasId)
{
await _payeeService.RemoveAliasAsync(UserId, id, aliasId);
return NoContent();
}
}
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Api.Services;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class PluginsController : ControllerBase
{
private readonly PluginService _pluginService;
public PluginsController(PluginService pluginService) => _pluginService = pluginService;
[HttpGet]
public ActionResult GetAll()
{
var plugins = _pluginService.GetPlugins().Select(p => new
{
p.Id, p.Name, p.Version, p.Description
});
return Ok(plugins);
}
}
@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Purrse.Core.DTOs;
using Purrse.Core.Enums;
using Purrse.Core.Models;
using Purrse.Data;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ReconciliationController : ControllerBase
{
private readonly PurrseDbContext _db;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public ReconciliationController(PurrseDbContext db) => _db = db;
[HttpPost("{accountId}/start")]
public async Task<ActionResult<ReconciliationResponse>> Start(Guid accountId, StartReconciliationRequest request)
{
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == UserId)
?? throw new KeyNotFoundException("Account not found");
var reconciliation = new Reconciliation
{
Id = Guid.NewGuid(),
AccountId = accountId,
StatementDate = request.StatementDate,
StatementBalance = request.StatementBalance
};
_db.Reconciliations.Add(reconciliation);
await _db.SaveChangesAsync();
var uncleared = await _db.Transactions
.Include(t => t.Category)
.Where(t => t.AccountId == accountId && t.Status != TransactionStatus.Reconciled && !t.IsVoid)
.OrderByDescending(t => t.Date)
.Select(t => new TransactionResponse(t.Id, t.AccountId, null, t.Date, t.Amount,
t.PayeeId, t.PayeeName, t.CategoryId, t.Category != null ? t.Category.Name : null,
t.Memo, t.ReferenceNumber, t.CheckNumber, t.Type, t.Status, t.IsVoid,
t.TransferTransactionId, t.ImportBatchId, null, t.CreatedAt))
.ToListAsync();
var clearedBalance = await _db.Transactions
.Where(t => t.AccountId == accountId && t.Status == TransactionStatus.Cleared && !t.IsVoid)
.SumAsync(t => t.Amount);
return Ok(new ReconciliationResponse(reconciliation.Id, accountId, request.StatementDate,
request.StatementBalance, clearedBalance, request.StatementBalance - clearedBalance, false, uncleared));
}
[HttpPost("{accountId}/complete")]
public async Task<ActionResult> Complete(Guid accountId, [FromQuery] Guid reconciliationId)
{
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId)
?? throw new KeyNotFoundException("Reconciliation not found");
var clearedTxns = await _db.Transactions
.Where(t => t.AccountId == accountId && t.Status == TransactionStatus.Cleared && !t.IsVoid)
.ToListAsync();
foreach (var txn in clearedTxns)
{
txn.Status = TransactionStatus.Reconciled;
txn.ReconciliationId = reconciliationId;
}
rec.IsCompleted = true;
rec.CompletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return NoContent();
}
[HttpPost("{accountId}/cancel")]
public async Task<ActionResult> Cancel(Guid accountId, [FromQuery] Guid reconciliationId)
{
var rec = await _db.Reconciliations.FirstOrDefaultAsync(r => r.Id == reconciliationId && r.AccountId == accountId && !r.IsCompleted)
?? throw new KeyNotFoundException("Reconciliation not found");
_db.Reconciliations.Remove(rec);
await _db.SaveChangesAsync();
return NoContent();
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ReportsController : ControllerBase
{
private readonly IReportService _reportService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public ReportsController(IReportService reportService) => _reportService = reportService;
[HttpGet("spending-by-category")]
public async Task<ActionResult<SpendingByCategoryReport>> SpendingByCategory([FromQuery] DateTime startDate, [FromQuery] DateTime endDate, [FromQuery] Guid? accountId)
=> Ok(await _reportService.GetSpendingByCategoryAsync(UserId, startDate, endDate, accountId));
[HttpGet("income-vs-expense")]
public async Task<ActionResult<IncomeVsExpenseReport>> IncomeVsExpense([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
=> Ok(await _reportService.GetIncomeVsExpenseAsync(UserId, startDate, endDate));
[HttpGet("net-worth")]
public async Task<ActionResult<NetWorthReport>> NetWorth([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
=> Ok(await _reportService.GetNetWorthAsync(UserId, startDate, endDate));
[HttpGet("cash-flow")]
public async Task<ActionResult<CashFlowReport>> CashFlow([FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
=> Ok(await _reportService.GetCashFlowAsync(UserId, startDate, endDate));
}
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/scheduled-transactions")]
public class ScheduledTransactionsController : ControllerBase
{
private readonly IScheduledTransactionService _service;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public ScheduledTransactionsController(IScheduledTransactionService service) => _service = service;
[HttpGet]
public async Task<ActionResult<List<ScheduledTransactionResponse>>> GetAll()
=> Ok(await _service.GetAllAsync(UserId));
[HttpGet("{id}")]
public async Task<ActionResult<ScheduledTransactionResponse>> GetById(Guid id)
{
var s = await _service.GetByIdAsync(UserId, id);
return s == null ? NotFound() : Ok(s);
}
[HttpPost]
public async Task<ActionResult<ScheduledTransactionResponse>> Create(CreateScheduledTransactionRequest request)
=> Ok(await _service.CreateAsync(UserId, request));
[HttpPut("{id}")]
public async Task<ActionResult<ScheduledTransactionResponse>> Update(Guid id, UpdateScheduledTransactionRequest request)
=> Ok(await _service.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _service.DeleteAsync(UserId, id);
return NoContent();
}
[HttpPost("{id}/skip")]
public async Task<ActionResult> Skip(Guid id)
{
await _service.SkipAsync(UserId, id);
return NoContent();
}
[HttpPost("{id}/post-now")]
public async Task<ActionResult<TransactionResponse>> PostNow(Guid id)
=> Ok(await _service.PostNowAsync(UserId, id));
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class TransactionsController : ControllerBase
{
private readonly ITransactionService _transactionService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public TransactionsController(ITransactionService transactionService) => _transactionService = transactionService;
[HttpGet("account/{accountId}")]
public async Task<ActionResult<PagedResult<TransactionResponse>>> GetByAccount(Guid accountId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
=> Ok(await _transactionService.GetByAccountAsync(UserId, accountId, page, pageSize));
[HttpGet("{id}")]
public async Task<ActionResult<TransactionResponse>> GetById(Guid id)
{
var txn = await _transactionService.GetByIdAsync(UserId, id);
return txn == null ? NotFound() : Ok(txn);
}
[HttpPost]
public async Task<ActionResult<TransactionResponse>> Create(CreateTransactionRequest request)
{
var txn = await _transactionService.CreateAsync(UserId, request);
return CreatedAtAction(nameof(GetById), new { id = txn.Id }, txn);
}
[HttpPut("{id}")]
public async Task<ActionResult<TransactionResponse>> Update(Guid id, UpdateTransactionRequest request)
=> Ok(await _transactionService.UpdateAsync(UserId, id, request));
[HttpDelete("{id}")]
public async Task<ActionResult> Delete(Guid id)
{
await _transactionService.DeleteAsync(UserId, id);
return NoContent();
}
[HttpPost("search")]
public async Task<ActionResult<PagedResult<TransactionResponse>>> Search(TransactionSearchRequest request)
=> Ok(await _transactionService.SearchAsync(UserId, request));
[HttpPost("transfer")]
public async Task<ActionResult> CreateTransfer(CreateTransferRequest request)
{
var (from, to) = await _transactionService.CreateTransferAsync(UserId, request);
return Ok(new { from, to });
}
[HttpPost("{id}/void")]
public async Task<ActionResult> Void(Guid id)
{
await _transactionService.VoidAsync(UserId, id);
return NoContent();
}
}