From 3bb9c688ed219aef10ef245287e6da522fd9aa9c Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:23:49 -0500 Subject: [PATCH] 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 --- frontend/src/services/loans.ts | 5 +- frontend/src/types/index.ts | 7 ++ frontend/src/views/loans/LoansView.vue | 27 ++++++- src/Purrse.Api/Controllers/LoansController.cs | 4 + src/Purrse.Api/Services/BankSyncService.cs | 80 +++++++++++++++++++ src/Purrse.Api/Services/LoanService.cs | 23 ++++++ src/Purrse.Core/DTOs/LoanDtos.cs | 7 ++ .../Interfaces/Services/ILoanService.cs | 1 + .../Models/SyncLoanData.cs | 14 ++++ .../PlaidSyncProvider.cs | 73 ++++++++++++++++- 10 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 src/Purrse.Plugins.Abstractions/Models/SyncLoanData.cs diff --git a/frontend/src/services/loans.ts b/frontend/src/services/loans.ts index 6058adc..26da7b0 100644 --- a/frontend/src/services/loans.ts +++ b/frontend/src/services/loans.ts @@ -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(`/loans/${accountId}/amortization`), + getLoanDefaults: (accountId: string) => + api.get(`/loans/${accountId}/defaults`), + createLoanDetail: (accountId: string, data: any) => api.post(`/loans/${accountId}`, data), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 45fcbf1..bda41da 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 diff --git a/frontend/src/views/loans/LoansView.vue b/frontend/src/views/loans/LoansView.vue index f9f1fec..abc4e26 100644 --- a/frontend/src/views/loans/LoansView.vue +++ b/frontend/src/views/loans/LoansView.vue @@ -215,6 +215,9 @@ {{ editingLoan ? 'Edit' : 'Set Up' }} Loan Details + + Some fields have been pre-populated from your bank sync data. Please review and adjust as needed. + @@ -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, } diff --git a/src/Purrse.Api/Controllers/LoansController.cs b/src/Purrse.Api/Controllers/LoansController.cs index 87080f4..8fcccad 100644 --- a/src/Purrse.Api/Controllers/LoansController.cs +++ b/src/Purrse.Api/Controllers/LoansController.cs @@ -23,6 +23,10 @@ public class LoansController : ControllerBase return detail == null ? NotFound() : Ok(detail); } + [HttpGet("{accountId}/defaults")] + public async Task> GetLoanDefaults(Guid accountId) + => Ok(await _loanService.GetLoanDefaultsAsync(UserId, accountId)); + [HttpPost("{accountId}")] public async Task> CreateLoanDetail(Guid accountId, CreateLoanDetailRequest request) => Ok(await _loanService.CreateLoanDetailAsync(UserId, accountId, request)); diff --git a/src/Purrse.Api/Services/BankSyncService.cs b/src/Purrse.Api/Services/BankSyncService.cs index f4df15b..a9305ee 100644 --- a/src/Purrse.Api/Services/BankSyncService.cs +++ b/src/Purrse.Api/Services/BankSyncService.cs @@ -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 _hub; private readonly ILogger _logger; @@ -32,6 +33,7 @@ public class BankSyncService : IBankSyncService IDuplicateDetectionService duplicateService, IPayeeService payeeService, IAiCategorizationService aiCategorization, + ILoanService loanService, IHubContext hub, ILogger 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(); 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 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 BuildProviderConfig(SyncConnection connection, string accessToken, PlaidConfig? plaidCredentials) { if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null) diff --git a/src/Purrse.Api/Services/LoanService.cs b/src/Purrse.Api/Services/LoanService.cs index 8692005..4e3d4bc 100644 --- a/src/Purrse.Api/Services/LoanService.cs +++ b/src/Purrse.Api/Services/LoanService.cs @@ -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 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 GenerateAmortizationSchedule(LoanDetail loan) { var entries = new List(); diff --git a/src/Purrse.Core/DTOs/LoanDtos.cs b/src/Purrse.Core/DTOs/LoanDtos.cs index 1258b6b..bea6297 100644 --- a/src/Purrse.Core/DTOs/LoanDtos.cs +++ b/src/Purrse.Core/DTOs/LoanDtos.cs @@ -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( diff --git a/src/Purrse.Core/Interfaces/Services/ILoanService.cs b/src/Purrse.Core/Interfaces/Services/ILoanService.cs index 485b273..1cc5706 100644 --- a/src/Purrse.Core/Interfaces/Services/ILoanService.cs +++ b/src/Purrse.Core/Interfaces/Services/ILoanService.cs @@ -9,4 +9,5 @@ public interface ILoanService Task UpdateLoanDetailAsync(Guid userId, Guid accountId, CreateLoanDetailRequest request); Task> GetAmortizationScheduleAsync(Guid userId, Guid accountId); Task CalculatePayoffScenarioAsync(Guid userId, Guid accountId, PayoffScenarioRequest request); + Task GetLoanDefaultsAsync(Guid userId, Guid accountId); } diff --git a/src/Purrse.Plugins.Abstractions/Models/SyncLoanData.cs b/src/Purrse.Plugins.Abstractions/Models/SyncLoanData.cs new file mode 100644 index 0000000..cbea7f6 --- /dev/null +++ b/src/Purrse.Plugins.Abstractions/Models/SyncLoanData.cs @@ -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; } +} diff --git a/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs b/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs index c5fc046..a7e733f 100644 --- a/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs +++ b/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs @@ -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> GetLiabilitiesAsync(Dictionary 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(); + + 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