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