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:
@@ -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