Private
Public Access
1
0

Auto-populate loan details from Plaid liabilities and pre-fill setup form

Fetch loan data (mortgage/student) from Plaid's Liabilities API during sync
and auto-create LoanDetail records. For accounts without liabilities data,
pre-populate the setup form with balance, interest rate, and smart term
defaults. Also fixes maturity date computation in the frontend form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-14 16:23:49 -05:00
parent a689681872
commit 3bb9c688ed
10 changed files with 237 additions and 4 deletions
@@ -23,6 +23,10 @@ public class LoansController : ControllerBase
return detail == null ? NotFound() : Ok(detail);
}
[HttpGet("{accountId}/defaults")]
public async Task<ActionResult<LoanDefaultsResponse>> GetLoanDefaults(Guid accountId)
=> Ok(await _loanService.GetLoanDefaultsAsync(UserId, accountId));
[HttpPost("{accountId}")]
public async Task<ActionResult<LoanDetailResponse>> CreateLoanDetail(Guid accountId, CreateLoanDetailRequest request)
=> Ok(await _loanService.CreateLoanDetailAsync(UserId, accountId, request));
@@ -21,6 +21,7 @@ public class BankSyncService : IBankSyncService
private readonly IDuplicateDetectionService _duplicateService;
private readonly IPayeeService _payeeService;
private readonly IAiCategorizationService _aiCategorization;
private readonly ILoanService _loanService;
private readonly IHubContext<NotificationHub> _hub;
private readonly ILogger<BankSyncService> _logger;
@@ -32,6 +33,7 @@ public class BankSyncService : IBankSyncService
IDuplicateDetectionService duplicateService,
IPayeeService payeeService,
IAiCategorizationService aiCategorization,
ILoanService loanService,
IHubContext<NotificationHub> hub,
ILogger<BankSyncService> logger)
{
@@ -42,6 +44,7 @@ public class BankSyncService : IBankSyncService
_duplicateService = duplicateService;
_payeeService = payeeService;
_aiCategorization = aiCategorization;
_loanService = loanService;
_hub = hub;
_logger = logger;
}
@@ -413,6 +416,12 @@ public class BankSyncService : IBankSyncService
var accountResults = new List<SyncAccountResult>();
int totalFetched = 0, totalImported = 0, totalSkipped = 0;
// Try to auto-create loan details from Plaid liabilities data
if (connection.Provider == SyncProvider.Plaid)
{
await TryCreateLoanDetailsFromPlaidAsync(userId, connection, config);
}
// For SimpleFIN, use the earliest start date across all accounts so the
// single API call (cached) returns transactions covering every account.
var endDate = DateTime.UtcNow;
@@ -625,6 +634,77 @@ public class BankSyncService : IBankSyncService
accountResults);
}
private async Task TryCreateLoanDetailsFromPlaidAsync(Guid userId, SyncConnection connection, Dictionary<string, string> config)
{
try
{
var liabilities = await _plaid.GetLiabilitiesAsync(config);
if (liabilities.Count == 0) return;
var linkedAccounts = connection.LinkedAccounts
.Where(la => la.IsEnabled && la.AccountId != null)
.ToList();
foreach (var liability in liabilities)
{
var linked = linkedAccounts.FirstOrDefault(la => la.ExternalAccountId == liability.ExternalAccountId);
if (linked?.AccountId == null) continue;
var accountId = linked.AccountId.Value;
// Skip if loan detail already exists
var existing = await _loanService.GetLoanDetailAsync(userId, accountId);
if (existing != null) continue;
// Update account interest rate from liabilities data
if (liability.InterestRate.HasValue && linked.Account != null)
{
linked.Account.InterestRate = liability.InterestRate.Value;
}
var originalBalance = liability.OriginalBalance ?? Math.Abs(linked.LastKnownBalance ?? 0);
var interestRate = liability.InterestRate ?? 0;
var termMonths = liability.TermMonths ?? (liability.LoanType == "Mortgage" ? 360 : 60);
var originationDate = liability.OriginationDate ?? DateTime.UtcNow;
var escrowBalance = liability.EscrowBalance;
// Compute monthly payment via amortization formula if not provided
var monthlyPayment = liability.MonthlyPayment ?? ComputeMonthlyPayment(originalBalance, interestRate, termMonths);
var maturityDate = liability.MaturityDate ?? originationDate.AddMonths(termMonths);
var request = new CreateLoanDetailRequest(
originalBalance,
interestRate,
termMonths,
monthlyPayment,
DateTime.SpecifyKind(originationDate, DateTimeKind.Utc),
DateTime.SpecifyKind(maturityDate, DateTimeKind.Utc),
escrowBalance,
null
);
await _loanService.CreateLoanDetailAsync(userId, accountId, request);
_logger.LogInformation("Auto-created loan details for account {AccountId} from Plaid liabilities", accountId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch Plaid liabilities for connection {ConnectionId} - sync will continue without loan details", connection.Id);
}
}
private static decimal ComputeMonthlyPayment(decimal principal, decimal annualRatePercent, int termMonths)
{
if (principal <= 0 || termMonths <= 0) return 0;
if (annualRatePercent <= 0) return principal / termMonths;
var monthlyRate = annualRatePercent / 100m / 12m;
var factor = (double)monthlyRate;
var pow = Math.Pow(1 + factor, termMonths);
return principal * (decimal)(factor * pow / (pow - 1));
}
private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken, PlaidConfig? plaidCredentials)
{
if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null)
+23
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Purrse.Core.DTOs;
using Purrse.Core.Enums;
using Purrse.Core.Interfaces.Services;
using Purrse.Core.Models;
using Purrse.Data;
@@ -154,6 +155,28 @@ public class LoanService : ILoanService
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 originalBalance = Math.Abs(account.Balance);
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(originalBalance, interestRate, termMonths, hasSyncData);
}
private static List<AmortizationEntry> GenerateAmortizationSchedule(LoanDetail loan)
{
var entries = new List<AmortizationEntry>();