8b2f5aad56
Npgsql requires DateTimeKind.Utc for timestamp with time zone columns. This fixes errors in transaction create/edit, budgets, reports, loans, scheduled transactions, and file imports. Changes: - TransactionService: SpecifyKind on request.Date for create, update, and transfer - BudgetService: UTC kind on all new DateTime(year, month, 1) - ReportService: UTC kind on endOfMonth; rewrite spending-by- category GroupBy to avoid untranslatable navigation properties - LoanService: UTC kind on payoff scenario date construction - ScheduledTransactionService: UTC kind on SemiMonthly date - ImportService: SpecifyKind on parsed.Date when persisting - OFX/CSV/QIF parsers: AssumeUniversal + AdjustToUniversal on all date parsing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
157 lines
6.1 KiB
C#
157 lines
6.1 KiB
C#
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)), 0, 0, 0, DateTimeKind.Utc) : 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);
|
|
}
|