Private
Public Access
1
0
Files
Purrse/src/Purrse.Api/Services/LoanService.cs
T
Catherine Renelle dd2cdbf815 Use daily interest accrual for HELOC and auto loan amortization
Mortgages and personal loans use monthly compounding (rate/12), but
HELOCs and auto loans accrue interest daily (rate/365 * actual days).
Select the calculation method based on account type and update the
disclaimer to reflect which method is being used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:41:32 -05:00

233 lines
10 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 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, account.Type);
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)
.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");
// Delete old entries directly in DB, bypassing change tracker
await _db.AmortizationEntries.Where(e => e.LoanDetailId == l.Id).ExecuteDeleteAsync();
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, account.Type);
foreach (var entry in entries)
{
entry.LoanDetailId = l.Id;
_db.AmortizationEntries.Add(entry);
}
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 useDailyAccrual = UseDailyAccrual(account.Type);
var monthlyRate = l.InterestRate / 100m / 12m;
var dailyRate = l.InterestRate / 100m / 365m;
var extraMonthly = request.ExtraMonthlyPayment ?? 0;
var basePayment = l.MonthlyPayment - (l.EscrowAmount ?? 0);
var now = DateTime.UtcNow;
var startDate = new DateTime(now.Year, now.Month, l.OriginationDate.Day, 0, 0, 0, DateTimeKind.Utc);
var previousDate = startDate;
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++;
var paymentDate = startDate.AddMonths(paymentNum);
var interest = useDailyAccrual
? Math.Round(balance * dailyRate * (paymentDate - previousDate).Days, 2)
: Math.Round(balance * monthlyRate, 2);
var principal = Math.Min(basePayment + extraMonthly - interest, balance);
if (principal < 0) principal = 0;
balance -= principal;
if (balance < 0.01m) balance = 0;
previousDate = paymentDate;
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);
}
public async Task<LoanDefaultsResponse> GetLoanDefaultsAsync(Guid userId, Guid accountId)
{
var account = await _db.Accounts
.Include(a => a.LinkedAccounts)
.FirstOrDefaultAsync(a => a.Id == accountId && a.UserId == userId)
?? throw new KeyNotFoundException("Account not found");
var interestRate = account.InterestRate ?? 0;
var hasSyncData = account.LinkedAccounts.Any() && (account.InterestRate.HasValue || account.Balance != 0);
var termMonths = account.Type switch
{
AccountType.Mortgage => 360,
AccountType.AutoLoan => 60,
AccountType.PersonalLoan => 60,
_ => 360
};
return new LoanDefaultsResponse(0, interestRate, termMonths, hasSyncData);
}
private static bool UseDailyAccrual(AccountType type) =>
type is AccountType.AutoLoan or AccountType.LineOfCredit;
private static List<AmortizationEntry> GenerateAmortizationSchedule(LoanDetail loan, AccountType accountType)
{
var entries = new List<AmortizationEntry>();
var balance = loan.OriginalBalance;
var useDailyAccrual = UseDailyAccrual(accountType);
var monthlyRate = loan.InterestRate / 100m / 12m;
var dailyRate = loan.InterestRate / 100m / 365m;
var payment = loan.MonthlyPayment - (loan.EscrowAmount ?? 0);
var extra = loan.ExtraPayment ?? 0;
var previousDate = loan.OriginationDate.AddMonths(-1);
for (int i = 1; i <= loan.TermMonths && balance > 0; i++)
{
var paymentDate = loan.OriginationDate.AddMonths(i - 1);
var interest = useDailyAccrual
? Math.Round(balance * dailyRate * (paymentDate - previousDate).Days, 2)
: Math.Round(balance * monthlyRate, 2);
var principal = Math.Min(payment + extra - interest, balance);
balance -= principal;
if (balance < 0.01m) balance = 0;
previousDate = paymentDate;
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;
}
}