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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user