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,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);
}