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 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 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 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> GetAmortizationScheduleAsync(Guid userId, Guid accountId) { var detail = await GetLoanDetailAsync(userId, accountId) ?? throw new KeyNotFoundException("Loan detail not found"); return detail.AmortizationSchedule; } public async Task 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(); 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 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 GenerateAmortizationSchedule(LoanDetail loan, AccountType accountType) { var entries = new List(); 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; } }