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:
@@ -1,10 +1,13 @@
|
||||
import api from './api'
|
||||
import type { LoanDetail, PayoffScenario } from '@/types'
|
||||
import type { LoanDefaults, LoanDetail, PayoffScenario } from '@/types'
|
||||
|
||||
export const loansApi = {
|
||||
getLoanDetail: (accountId: string) =>
|
||||
api.get<LoanDetail>(`/loans/${accountId}/amortization`),
|
||||
|
||||
getLoanDefaults: (accountId: string) =>
|
||||
api.get<LoanDefaults>(`/loans/${accountId}/defaults`),
|
||||
|
||||
createLoanDetail: (accountId: string, data: any) =>
|
||||
api.post<LoanDetail>(`/loans/${accountId}`, data),
|
||||
|
||||
|
||||
@@ -236,6 +236,13 @@ export interface PayoffScenario {
|
||||
newSchedule: AmortizationEntry[]
|
||||
}
|
||||
|
||||
export interface LoanDefaults {
|
||||
originalBalance: number
|
||||
interestRate: number
|
||||
termMonths: number
|
||||
hasSyncData: boolean
|
||||
}
|
||||
|
||||
export interface ImportUploadResponse {
|
||||
batchId: string
|
||||
totalTransactions: number
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
<v-card>
|
||||
<v-card-title>{{ editingLoan ? 'Edit' : 'Set Up' }} Loan Details</v-card-title>
|
||||
<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.interestRate" label="Interest Rate (%)" type="number" suffix="%" step="0.125" />
|
||||
<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 { useAccountsStore } from '@/stores/accounts'
|
||||
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 router = useRouter()
|
||||
@@ -256,6 +259,7 @@ const calculatingPayoff = ref(false)
|
||||
const showLoanDialog = ref(false)
|
||||
const showSetup = ref(false)
|
||||
const editingLoan = ref(false)
|
||||
const prePopulated = ref(false)
|
||||
const snackbar = ref(false)
|
||||
const snackbarText = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
@@ -316,8 +320,9 @@ async function loadLoanDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function openSetupDialog() {
|
||||
async function openSetupDialog() {
|
||||
editingLoan.value = false
|
||||
prePopulated.value = false
|
||||
loanForm.value = {
|
||||
originalBalance: 0,
|
||||
interestRate: 0,
|
||||
@@ -327,6 +332,19 @@ function openSetupDialog() {
|
||||
escrowAmount: 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
|
||||
}
|
||||
|
||||
@@ -349,8 +367,13 @@ async function saveLoanDetail() {
|
||||
if (!props.id) return
|
||||
saving.value = true
|
||||
try {
|
||||
const originDate = new Date(loanForm.value.originationDate)
|
||||
const maturityDate = new Date(originDate)
|
||||
maturityDate.setMonth(maturityDate.getMonth() + loanForm.value.termMonths)
|
||||
|
||||
const payload = {
|
||||
...loanForm.value,
|
||||
maturityDate: maturityDate.toISOString(),
|
||||
escrowAmount: loanForm.value.escrowAmount || undefined,
|
||||
extraPayment: loanForm.value.extraPayment || undefined,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -37,6 +37,13 @@ public record AmortizationEntryResponse(
|
||||
decimal RemainingBalance
|
||||
);
|
||||
|
||||
public record LoanDefaultsResponse(
|
||||
decimal OriginalBalance,
|
||||
decimal InterestRate,
|
||||
int TermMonths,
|
||||
bool HasSyncData
|
||||
);
|
||||
|
||||
public record PayoffScenarioRequest(decimal? ExtraMonthlyPayment, decimal? LumpSumPayment, DateTime? LumpSumDate);
|
||||
|
||||
public record PayoffScenarioResponse(
|
||||
|
||||
@@ -9,4 +9,5 @@ public interface ILoanService
|
||||
Task<LoanDetailResponse> UpdateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request);
|
||||
Task<List<AmortizationEntryResponse>> GetAmortizationScheduleAsync(Guid userId, Guid accountId);
|
||||
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.Entity;
|
||||
using Going.Plaid.Item;
|
||||
using Going.Plaid.Liabilities;
|
||||
using Going.Plaid.Link;
|
||||
using Purrse.Plugins.Abstractions;
|
||||
using Purrse.Plugins.Abstractions.Models;
|
||||
@@ -43,7 +45,7 @@ public class PlaidSyncProvider : IAccountSyncProvider
|
||||
Secret = credentials.Secret,
|
||||
User = new LinkTokenCreateRequestUser { ClientUserId = userId },
|
||||
ClientName = "Purrse",
|
||||
Products = new[] { Products.Transactions },
|
||||
Products = new[] { Products.Transactions, Products.Liabilities },
|
||||
CountryCodes = new[] { CountryCode.Us },
|
||||
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)
|
||||
{
|
||||
return type switch
|
||||
|
||||
Reference in New Issue
Block a user