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
+4 -1
View File
@@ -1,10 +1,13 @@
import api from './api' import api from './api'
import type { LoanDetail, PayoffScenario } from '@/types' import type { LoanDefaults, LoanDetail, PayoffScenario } from '@/types'
export const loansApi = { export const loansApi = {
getLoanDetail: (accountId: string) => getLoanDetail: (accountId: string) =>
api.get<LoanDetail>(`/loans/${accountId}/amortization`), api.get<LoanDetail>(`/loans/${accountId}/amortization`),
getLoanDefaults: (accountId: string) =>
api.get<LoanDefaults>(`/loans/${accountId}/defaults`),
createLoanDetail: (accountId: string, data: any) => createLoanDetail: (accountId: string, data: any) =>
api.post<LoanDetail>(`/loans/${accountId}`, data), api.post<LoanDetail>(`/loans/${accountId}`, data),
+7
View File
@@ -236,6 +236,13 @@ export interface PayoffScenario {
newSchedule: AmortizationEntry[] newSchedule: AmortizationEntry[]
} }
export interface LoanDefaults {
originalBalance: number
interestRate: number
termMonths: number
hasSyncData: boolean
}
export interface ImportUploadResponse { export interface ImportUploadResponse {
batchId: string batchId: string
totalTransactions: number totalTransactions: number
+25 -2
View File
@@ -215,6 +215,9 @@
<v-card> <v-card>
<v-card-title>{{ editingLoan ? 'Edit' : 'Set Up' }} Loan Details</v-card-title> <v-card-title>{{ editingLoan ? 'Edit' : 'Set Up' }} Loan Details</v-card-title>
<v-card-text> <v-card-text>
<v-alert v-if="prePopulated && !editingLoan" type="info" variant="tonal" density="compact" class="mb-4">
Some fields have been pre-populated from your bank sync data. Please review and adjust as needed.
</v-alert>
<v-text-field v-model.number="loanForm.originalBalance" label="Original Balance" type="number" prefix="$" /> <v-text-field v-model.number="loanForm.originalBalance" label="Original Balance" type="number" prefix="$" />
<v-text-field v-model.number="loanForm.interestRate" label="Interest Rate (%)" type="number" suffix="%" step="0.125" /> <v-text-field v-model.number="loanForm.interestRate" label="Interest Rate (%)" type="number" suffix="%" step="0.125" />
<v-text-field v-model.number="loanForm.termMonths" label="Term (months)" type="number" /> <v-text-field v-model.number="loanForm.termMonths" label="Term (months)" type="number" />
@@ -242,7 +245,7 @@ import { useRouter } from 'vue-router'
import { loansApi } from '@/services/loans' import { loansApi } from '@/services/loans'
import { useAccountsStore } from '@/stores/accounts' import { useAccountsStore } from '@/stores/accounts'
import { formatCurrency, formatDate, accountTypeIcon } from '@/utils/formatters' import { formatCurrency, formatDate, accountTypeIcon } from '@/utils/formatters'
import type { LoanDetail, PayoffScenario } from '@/types' import type { LoanDefaults, LoanDetail, PayoffScenario } from '@/types'
const props = defineProps<{ id?: string }>() const props = defineProps<{ id?: string }>()
const router = useRouter() const router = useRouter()
@@ -256,6 +259,7 @@ const calculatingPayoff = ref(false)
const showLoanDialog = ref(false) const showLoanDialog = ref(false)
const showSetup = ref(false) const showSetup = ref(false)
const editingLoan = ref(false) const editingLoan = ref(false)
const prePopulated = ref(false)
const snackbar = ref(false) const snackbar = ref(false)
const snackbarText = ref('') const snackbarText = ref('')
const snackbarColor = ref('success') const snackbarColor = ref('success')
@@ -316,8 +320,9 @@ async function loadLoanDetail() {
} }
} }
function openSetupDialog() { async function openSetupDialog() {
editingLoan.value = false editingLoan.value = false
prePopulated.value = false
loanForm.value = { loanForm.value = {
originalBalance: 0, originalBalance: 0,
interestRate: 0, interestRate: 0,
@@ -327,6 +332,19 @@ function openSetupDialog() {
escrowAmount: null, escrowAmount: null,
extraPayment: null, extraPayment: null,
} }
if (props.id) {
try {
const { data } = await loansApi.getLoanDefaults(props.id)
if (data.originalBalance > 0) loanForm.value.originalBalance = data.originalBalance
if (data.interestRate > 0) loanForm.value.interestRate = data.interestRate
if (data.termMonths > 0) loanForm.value.termMonths = data.termMonths
prePopulated.value = data.hasSyncData
} catch {
// Fall back to zero defaults
}
}
showLoanDialog.value = true showLoanDialog.value = true
} }
@@ -349,8 +367,13 @@ async function saveLoanDetail() {
if (!props.id) return if (!props.id) return
saving.value = true saving.value = true
try { try {
const originDate = new Date(loanForm.value.originationDate)
const maturityDate = new Date(originDate)
maturityDate.setMonth(maturityDate.getMonth() + loanForm.value.termMonths)
const payload = { const payload = {
...loanForm.value, ...loanForm.value,
maturityDate: maturityDate.toISOString(),
escrowAmount: loanForm.value.escrowAmount || undefined, escrowAmount: loanForm.value.escrowAmount || undefined,
extraPayment: loanForm.value.extraPayment || undefined, extraPayment: loanForm.value.extraPayment || undefined,
} }
@@ -23,6 +23,10 @@ public class LoansController : ControllerBase
return detail == null ? NotFound() : Ok(detail); 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}")] [HttpPost("{accountId}")]
public async Task<ActionResult<LoanDetailResponse>> CreateLoanDetail(Guid accountId, CreateLoanDetailRequest request) public async Task<ActionResult<LoanDetailResponse>> CreateLoanDetail(Guid accountId, CreateLoanDetailRequest request)
=> Ok(await _loanService.CreateLoanDetailAsync(UserId, accountId, request)); => Ok(await _loanService.CreateLoanDetailAsync(UserId, accountId, request));
@@ -21,6 +21,7 @@ public class BankSyncService : IBankSyncService
private readonly IDuplicateDetectionService _duplicateService; private readonly IDuplicateDetectionService _duplicateService;
private readonly IPayeeService _payeeService; private readonly IPayeeService _payeeService;
private readonly IAiCategorizationService _aiCategorization; private readonly IAiCategorizationService _aiCategorization;
private readonly ILoanService _loanService;
private readonly IHubContext<NotificationHub> _hub; private readonly IHubContext<NotificationHub> _hub;
private readonly ILogger<BankSyncService> _logger; private readonly ILogger<BankSyncService> _logger;
@@ -32,6 +33,7 @@ public class BankSyncService : IBankSyncService
IDuplicateDetectionService duplicateService, IDuplicateDetectionService duplicateService,
IPayeeService payeeService, IPayeeService payeeService,
IAiCategorizationService aiCategorization, IAiCategorizationService aiCategorization,
ILoanService loanService,
IHubContext<NotificationHub> hub, IHubContext<NotificationHub> hub,
ILogger<BankSyncService> logger) ILogger<BankSyncService> logger)
{ {
@@ -42,6 +44,7 @@ public class BankSyncService : IBankSyncService
_duplicateService = duplicateService; _duplicateService = duplicateService;
_payeeService = payeeService; _payeeService = payeeService;
_aiCategorization = aiCategorization; _aiCategorization = aiCategorization;
_loanService = loanService;
_hub = hub; _hub = hub;
_logger = logger; _logger = logger;
} }
@@ -413,6 +416,12 @@ public class BankSyncService : IBankSyncService
var accountResults = new List<SyncAccountResult>(); var accountResults = new List<SyncAccountResult>();
int totalFetched = 0, totalImported = 0, totalSkipped = 0; 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 // For SimpleFIN, use the earliest start date across all accounts so the
// single API call (cached) returns transactions covering every account. // single API call (cached) returns transactions covering every account.
var endDate = DateTime.UtcNow; var endDate = DateTime.UtcNow;
@@ -625,6 +634,77 @@ public class BankSyncService : IBankSyncService
accountResults); 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) private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken, PlaidConfig? plaidCredentials)
{ {
if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null) if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null)
+23
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Purrse.Core.DTOs; using Purrse.Core.DTOs;
using Purrse.Core.Enums;
using Purrse.Core.Interfaces.Services; using Purrse.Core.Interfaces.Services;
using Purrse.Core.Models; using Purrse.Core.Models;
using Purrse.Data; using Purrse.Data;
@@ -154,6 +155,28 @@ public class LoanService : ILoanService
totalInterestOriginal - totalInterestNew, monthsSaved, newEntries); 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) private static List<AmortizationEntry> GenerateAmortizationSchedule(LoanDetail loan)
{ {
var entries = new List<AmortizationEntry>(); var entries = new List<AmortizationEntry>();
+7
View File
@@ -37,6 +37,13 @@ public record AmortizationEntryResponse(
decimal RemainingBalance decimal RemainingBalance
); );
public record LoanDefaultsResponse(
decimal OriginalBalance,
decimal InterestRate,
int TermMonths,
bool HasSyncData
);
public record PayoffScenarioRequest(decimal? ExtraMonthlyPayment, decimal? LumpSumPayment, DateTime? LumpSumDate); public record PayoffScenarioRequest(decimal? ExtraMonthlyPayment, decimal? LumpSumPayment, DateTime? LumpSumDate);
public record PayoffScenarioResponse( public record PayoffScenarioResponse(
@@ -9,4 +9,5 @@ public interface ILoanService
Task<LoanDetailResponse> UpdateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request); Task<LoanDetailResponse> UpdateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request);
Task<List<AmortizationEntryResponse>> GetAmortizationScheduleAsync(Guid userId, Guid accountId); Task<List<AmortizationEntryResponse>> GetAmortizationScheduleAsync(Guid userId, Guid accountId);
Task<PayoffScenarioResponse> CalculatePayoffScenarioAsync(Guid userId, Guid accountId, PayoffScenarioRequest request); Task<PayoffScenarioResponse> CalculatePayoffScenarioAsync(Guid userId, Guid accountId, PayoffScenarioRequest request);
Task<LoanDefaultsResponse> GetLoanDefaultsAsync(Guid userId, Guid accountId);
} }
@@ -0,0 +1,14 @@
namespace Purrse.Plugins.Abstractions.Models;
public class SyncLoanData
{
public string ExternalAccountId { get; set; } = string.Empty;
public decimal? InterestRate { get; set; }
public decimal? OriginalBalance { get; set; }
public DateTime? OriginationDate { get; set; }
public DateTime? MaturityDate { get; set; }
public int? TermMonths { get; set; }
public decimal? MonthlyPayment { get; set; }
public decimal? EscrowBalance { get; set; }
public string? LoanType { get; set; }
}
@@ -1,6 +1,8 @@
using System.Text.RegularExpressions;
using Going.Plaid; using Going.Plaid;
using Going.Plaid.Entity; using Going.Plaid.Entity;
using Going.Plaid.Item; using Going.Plaid.Item;
using Going.Plaid.Liabilities;
using Going.Plaid.Link; using Going.Plaid.Link;
using Purrse.Plugins.Abstractions; using Purrse.Plugins.Abstractions;
using Purrse.Plugins.Abstractions.Models; using Purrse.Plugins.Abstractions.Models;
@@ -43,7 +45,7 @@ public class PlaidSyncProvider : IAccountSyncProvider
Secret = credentials.Secret, Secret = credentials.Secret,
User = new LinkTokenCreateRequestUser { ClientUserId = userId }, User = new LinkTokenCreateRequestUser { ClientUserId = userId },
ClientName = "Purrse", ClientName = "Purrse",
Products = new[] { Products.Transactions }, Products = new[] { Products.Transactions, Products.Liabilities },
CountryCodes = new[] { CountryCode.Us }, CountryCodes = new[] { CountryCode.Us },
Language = Language.English Language = Language.English
}); });
@@ -190,6 +192,75 @@ public class PlaidSyncProvider : IAccountSyncProvider
}; };
} }
public async Task<List<SyncLoanData>> GetLiabilitiesAsync(Dictionary<string, string> configuration)
{
var credentials = ExtractCredentials(configuration);
var client = CreateClient(credentials.Environment);
var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty;
var response = await client.LiabilitiesGetAsync(new LiabilitiesGetRequest
{
ClientId = credentials.ClientId,
Secret = credentials.Secret,
AccessToken = accessToken
});
var results = new List<SyncLoanData>();
if (response.Liabilities?.Mortgage != null)
{
foreach (var m in response.Liabilities.Mortgage)
{
results.Add(new SyncLoanData
{
ExternalAccountId = m.AccountId,
InterestRate = (decimal?)(m.InterestRate?.Percentage),
OriginalBalance = (decimal?)(m.OriginationPrincipalAmount),
OriginationDate = m.OriginationDate?.ToDateTime(TimeOnly.MinValue),
MaturityDate = m.MaturityDate?.ToDateTime(TimeOnly.MinValue),
TermMonths = ParseLoanTermToMonths(m.LoanTerm),
MonthlyPayment = (decimal?)(m.NextMonthlyPayment),
EscrowBalance = (decimal?)(m.EscrowBalance),
LoanType = "Mortgage"
});
}
}
if (response.Liabilities?.Student != null)
{
foreach (var s in response.Liabilities.Student)
{
results.Add(new SyncLoanData
{
ExternalAccountId = s.AccountId,
InterestRate = (decimal?)(s.InterestRatePercentage),
OriginalBalance = (decimal?)(s.OriginationPrincipalAmount),
OriginationDate = s.OriginationDate?.ToDateTime(TimeOnly.MinValue),
MaturityDate = s.ExpectedPayoffDate?.ToDateTime(TimeOnly.MinValue),
MonthlyPayment = (decimal?)(s.MinimumPaymentAmount),
LoanType = "StudentLoan"
});
}
}
return results;
}
internal static int? ParseLoanTermToMonths(string? loanTerm)
{
if (string.IsNullOrWhiteSpace(loanTerm)) return null;
var match = Regex.Match(loanTerm, @"(\d+)\s*year", RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out var years))
return years * 12;
match = Regex.Match(loanTerm, @"(\d+)\s*month", RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out var months))
return months;
return null;
}
private static string MapAccountType(AccountType? type, AccountSubtype? subtype) private static string MapAccountType(AccountType? type, AccountSubtype? subtype)
{ {
return type switch return type switch