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