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>
192 lines
8.4 KiB
C#
192 lines
8.4 KiB
C#
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 LoanService : ILoanService
|
|
{
|
|
private readonly PurrseDbContext _db;
|
|
|
|
public LoanService(PurrseDbContext db) => _db = db;
|
|
|
|
public async Task<LoanDetailResponse?> GetLoanDetailAsync(Guid userId, Guid accountId)
|
|
{
|
|
var account = await _db.Accounts.Include(a => a.LoanDetail).ThenInclude(l => l!.AmortizationEntries)
|
|
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId);
|
|
|
|
if (account?.LoanDetail == null) return null;
|
|
|
|
var l = account.LoanDetail;
|
|
var schedule = l.AmortizationEntries.OrderBy(e => e.PaymentNumber)
|
|
.Select(e => new AmortizationEntryResponse(e.PaymentNumber, e.PaymentDate, e.PaymentAmount, e.PrincipalAmount, e.InterestAmount, e.EscrowAmount, e.RemainingBalance))
|
|
.ToList();
|
|
|
|
return new LoanDetailResponse(l.Id, l.AccountId, account.Name, l.OriginalBalance, account.Balance,
|
|
l.InterestRate, l.TermMonths, l.MonthlyPayment, l.OriginationDate, l.MaturityDate,
|
|
l.EscrowAmount, l.ExtraPayment, schedule);
|
|
}
|
|
|
|
public async Task<LoanDetailResponse> CreateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request)
|
|
{
|
|
var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
|
?? throw new KeyNotFoundException("Account not found");
|
|
|
|
var loanDetail = new LoanDetail
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
AccountId = accountId,
|
|
OriginalBalance = request.OriginalBalance,
|
|
InterestRate = request.InterestRate,
|
|
TermMonths = request.TermMonths,
|
|
MonthlyPayment = request.MonthlyPayment,
|
|
OriginationDate = request.OriginationDate,
|
|
MaturityDate = request.MaturityDate,
|
|
EscrowAmount = request.EscrowAmount,
|
|
ExtraPayment = request.ExtraPayment
|
|
};
|
|
|
|
var entries = GenerateAmortizationSchedule(loanDetail);
|
|
loanDetail.AmortizationEntries = entries;
|
|
|
|
_db.LoanDetails.Add(loanDetail);
|
|
await _db.SaveChangesAsync();
|
|
|
|
var schedule = entries.Select(e => new AmortizationEntryResponse(e.PaymentNumber, e.PaymentDate, e.PaymentAmount, e.PrincipalAmount, e.InterestAmount, e.EscrowAmount, e.RemainingBalance)).ToList();
|
|
|
|
return new LoanDetailResponse(loanDetail.Id, accountId, account.Name, loanDetail.OriginalBalance,
|
|
account.Balance, loanDetail.InterestRate, loanDetail.TermMonths, loanDetail.MonthlyPayment,
|
|
loanDetail.OriginationDate, loanDetail.MaturityDate, loanDetail.EscrowAmount, loanDetail.ExtraPayment, schedule);
|
|
}
|
|
|
|
public async Task<LoanDetailResponse> UpdateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request)
|
|
{
|
|
var account = await _db.Accounts.Include(a => a.LoanDetail).ThenInclude(l => l!.AmortizationEntries)
|
|
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
|
?? throw new KeyNotFoundException("Account not found");
|
|
|
|
var l = account.LoanDetail ?? throw new KeyNotFoundException("No loan detail found");
|
|
|
|
_db.AmortizationEntries.RemoveRange(l.AmortizationEntries);
|
|
|
|
l.OriginalBalance = request.OriginalBalance;
|
|
l.InterestRate = request.InterestRate;
|
|
l.TermMonths = request.TermMonths;
|
|
l.MonthlyPayment = request.MonthlyPayment;
|
|
l.OriginationDate = request.OriginationDate;
|
|
l.MaturityDate = request.MaturityDate;
|
|
l.EscrowAmount = request.EscrowAmount;
|
|
l.ExtraPayment = request.ExtraPayment;
|
|
|
|
var entries = GenerateAmortizationSchedule(l);
|
|
l.AmortizationEntries = entries;
|
|
await _db.SaveChangesAsync();
|
|
|
|
var schedule = entries.Select(e => new AmortizationEntryResponse(e.PaymentNumber, e.PaymentDate, e.PaymentAmount, e.PrincipalAmount, e.InterestAmount, e.EscrowAmount, e.RemainingBalance)).ToList();
|
|
|
|
return new LoanDetailResponse(l.Id, accountId, account.Name, l.OriginalBalance, account.Balance,
|
|
l.InterestRate, l.TermMonths, l.MonthlyPayment, l.OriginationDate, l.MaturityDate,
|
|
l.EscrowAmount, l.ExtraPayment, schedule);
|
|
}
|
|
|
|
public async Task<List<AmortizationEntryResponse>> GetAmortizationScheduleAsync(Guid userId, Guid accountId)
|
|
{
|
|
var detail = await GetLoanDetailAsync(userId, accountId)
|
|
?? throw new KeyNotFoundException("Loan detail not found");
|
|
return detail.AmortizationSchedule;
|
|
}
|
|
|
|
public async Task<PayoffScenarioResponse> CalculatePayoffScenarioAsync(Guid userId, Guid accountId, PayoffScenarioRequest request)
|
|
{
|
|
var account = await _db.Accounts.Include(a => a.LoanDetail).ThenInclude(l => l!.AmortizationEntries)
|
|
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
|
|
?? throw new KeyNotFoundException("Account not found");
|
|
|
|
var l = account.LoanDetail ?? throw new KeyNotFoundException("No loan detail found");
|
|
|
|
var originalSchedule = l.AmortizationEntries.OrderBy(e => e.PaymentNumber).ToList();
|
|
var totalInterestOriginal = originalSchedule.Sum(e => e.InterestAmount);
|
|
var originalPayoffDate = originalSchedule.Last().PaymentDate;
|
|
|
|
// Build new schedule with extra payments
|
|
var balance = Math.Abs(account.Balance);
|
|
var monthlyRate = l.InterestRate / 100 / 12;
|
|
var extraMonthly = request.ExtraMonthlyPayment ?? 0;
|
|
var basePayment = l.MonthlyPayment - (l.EscrowAmount ?? 0);
|
|
var now = DateTime.UtcNow;
|
|
var paymentDate = new DateTime(now.Year, now.Month, l.OriginationDate.Day, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
var newEntries = new List<AmortizationEntryResponse>();
|
|
int paymentNum = 0;
|
|
|
|
// Apply lump sum if specified
|
|
if (request.LumpSumPayment.HasValue && request.LumpSumDate.HasValue)
|
|
{
|
|
balance -= request.LumpSumPayment.Value;
|
|
if (balance < 0) balance = 0;
|
|
}
|
|
|
|
while (balance > 0 && paymentNum < 600)
|
|
{
|
|
paymentNum++;
|
|
paymentDate = paymentDate.AddMonths(1);
|
|
|
|
var interest = balance * monthlyRate;
|
|
var principal = Math.Min(basePayment + extraMonthly - interest, balance);
|
|
if (principal < 0) principal = 0;
|
|
|
|
balance -= principal;
|
|
if (balance < 0.01m) balance = 0;
|
|
|
|
newEntries.Add(new AmortizationEntryResponse(
|
|
paymentNum, paymentDate, principal + interest + (l.EscrowAmount ?? 0),
|
|
principal, interest, l.EscrowAmount, balance));
|
|
}
|
|
|
|
var totalInterestNew = newEntries.Sum(e => e.InterestAmount);
|
|
var newPayoffDate = newEntries.LastOrDefault()?.PaymentDate ?? originalPayoffDate;
|
|
var monthsSaved = ((originalPayoffDate.Year - newPayoffDate.Year) * 12) + (originalPayoffDate.Month - newPayoffDate.Month);
|
|
|
|
return new PayoffScenarioResponse(
|
|
originalPayoffDate, newPayoffDate, totalInterestOriginal, totalInterestNew,
|
|
totalInterestOriginal - totalInterestNew, monthsSaved, newEntries);
|
|
}
|
|
|
|
private static List<AmortizationEntry> GenerateAmortizationSchedule(LoanDetail loan)
|
|
{
|
|
var entries = new List<AmortizationEntry>();
|
|
var balance = loan.OriginalBalance;
|
|
var monthlyRate = loan.InterestRate / 100 / 12;
|
|
var payment = loan.MonthlyPayment - (loan.EscrowAmount ?? 0);
|
|
var extra = loan.ExtraPayment ?? 0;
|
|
var paymentDate = loan.OriginationDate;
|
|
|
|
for (int i = 1; i <= loan.TermMonths && balance > 0; i++)
|
|
{
|
|
paymentDate = paymentDate.AddMonths(1);
|
|
var interest = balance * monthlyRate;
|
|
var principal = Math.Min(payment + extra - interest, balance);
|
|
balance -= principal;
|
|
if (balance < 0.01m) balance = 0;
|
|
|
|
entries.Add(new AmortizationEntry
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
PaymentNumber = i,
|
|
PaymentDate = paymentDate,
|
|
PaymentAmount = principal + interest + (loan.EscrowAmount ?? 0),
|
|
PrincipalAmount = principal,
|
|
InterestAmount = interest,
|
|
EscrowAmount = loan.EscrowAmount,
|
|
RemainingBalance = balance
|
|
});
|
|
|
|
if (balance == 0) break;
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
}
|