This commit is contained in:
Zeeshaun
2026-04-20 14:47:24 -05:00
parent 89057eceea
commit 8b3ea92501
485 changed files with 71042 additions and 0 deletions
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LCU.API.Collections.CollectionsApiLib.Models;
using LCU.API.Collections.RepoApi.Domain.AlertChecker;
using LCU.API.Collections.RepoApi.Domain.Processing;
using LCU.Shared.Collections.RepoApiLib.Models;
using Serilog;
namespace LCU.API.Collections.RepoApi.Services.Producer
{
public class AccountAlertCheckService : IAccountAlertCheckService
{
private const string O4RStatus = "O4R";
private const string NoStatus = "";
private const string RepoStatus = "REPO";
private const string RecaStatus = "RECA";
private const string RehoStatus = "REHO";
private const string RedeemedStatus = "REDEEMED";
private readonly IEventAlertService _eventAlertService;
private readonly IEnumerable<IAlertChecker> _accountAlertPreStatusCheckers;
private readonly IEnumerable<IAlertChecker> _accountAlertPrimaryCheckers;
private readonly IEnumerable<IAlertChecker> _accountAlertPrimaryIgnoreWrittenOffCheckers;
public AccountAlertCheckService( IEventAlertService eventAlertService)
{
_eventAlertService = eventAlertService;
_accountAlertPreStatusCheckers = new List<IAlertChecker>()
{
new SkipPaidChecker()
};
_accountAlertPrimaryCheckers = new List<IAlertChecker>()
{
new RepoNotMovedChecker( 7),
new RepoNotSoldChecker( 60, 30),
new StatusChangeChecker( RecaStatus),
new StatusChangeChecker( RehoStatus),
new TimeInStatusChecker( RehoStatus, 30, 30),
new StatusChangeChecker( O4RStatus, new List<string>() { NoStatus, RedeemedStatus, RehoStatus, RecaStatus}),
new O4rNoRepoChecker(RepoType.Voluntary),
new O4rNoRepoChecker(RepoType.Involuntary),
new O4rNoRepoChecker(RepoType.PreSkip),
new SkipEndChecker( 91),
new SkipNotPaidChecker( 106, 15),
new DocumentNotFoundChecker("Notice of Plan to Sell", "LTR35", "701", RepoStatus),
new DocumentNotFoundChecker("Notice of Repossession", "LTR34", "701", RepoStatus),
new RtcLetterNotFoundChecker("RTC Letter", new List<string> { "RTC 1", "RTC 3" }),
new SoldNoProceedsChecker(),
new AuctionOnHoldChecker(),
new AuctionVinDifferentChecker(),
new RedemptionEndedNoTitleOrderedChecker()
};
_accountAlertPrimaryIgnoreWrittenOffCheckers = new List<IAlertChecker>()
{
new DebitAdjustmentChecker(),
new PaymentReceivedChecker(),
new CreditAdjustmentChecker(),
new SaleFundsReceivedChecker()
};
}
public Task AlertChecksPreStatusReject(RepoAccountProcessingSlip slip)
{
return CheckForAlerts(_accountAlertPreStatusCheckers, slip);
}
public async Task<EventAlert> ManualAlert(EventAlert alert)
{
return await _eventAlertService.SaveAsync(alert);
}
public async Task MaybeAlertOnInvalidStatus(RepoAccountProcessingSlip slip)
{
var allowedStatuses = slip.CurrentSettings?.AllowedStatusChanges.Select(x => x.CurrentStatus);
if (slip.UpdatedRepoAccount != null && allowedStatuses != null &&
!allowedStatuses.Contains(slip.UpdatedRepoAccount.Status))
{
await _eventAlertService.SaveAsync(new EventAlert()
{
RepoId = slip.UpdatedRepoAccount.RepoId,
AccountNo = slip.UpdatedRepoAccount.AccountNo,
CreatedBy = "Importer",
DateAdded = DateTime.Now,
Message = $"Collections System Status {slip.UpdatedRepoAccount.Status} Not Valid"
});
}
}
public async Task AlertChecksPrimary( RepoAccountProcessingSlip slip)
{
if (slip.TransactionHistory != null && slip.TransactionHistory.Any())
{
await CheckForAlerts(_accountAlertPrimaryIgnoreWrittenOffCheckers, slip);
}
else
{
Log.Error($"No Transactions Available, skipping Transaction Alerts for {slip.AccountNo}");
}
await CheckForAlerts(_accountAlertPrimaryCheckers, slip);
}
private async Task CheckForAlerts(IEnumerable<IAlertChecker> alertCheckers, RepoAccountProcessingSlip slip)
{
var eventAlerts = new List<EventAlert>();
foreach (var alertChecker in alertCheckers)
{
try
{
var createdAlerts = alertChecker.CheckForAlert(slip);
if (createdAlerts != null)
{
eventAlerts.AddRange(createdAlerts);
}
}
catch (Exception ex)
{
var accountNumber = slip.AccountNo ?? "Account Number Not Found";
Log.Error(ex, $"Error Checking Alert {alertChecker.GetType()} For Account: {accountNumber}");
}
}
await _eventAlertService.SaveRangeAsync(eventAlerts);
}
}
}
@@ -0,0 +1,23 @@
using System.Threading.Tasks;
using LCU.API.Collections.RepoApi.Domain.Processing;
using LCU.Shared.Collections.RepoApiLib.Models;
namespace LCU.API.Collections.RepoApi.Services.Producer
{
public interface IAccountAlertCheckService
{
/**
* These alerts run before the account is checked to see if it was moved to an un-allowed status.
* What is this moves the status to an un-allowed status?
*/
Task AlertChecksPreStatusReject(RepoAccountProcessingSlip slip);
/**
* These alerts run after the account has been updated or created and is the primary set of alert checks.
*/
Task AlertChecksPrimary(RepoAccountProcessingSlip slip);
Task<EventAlert> ManualAlert(EventAlert alert);
Task MaybeAlertOnInvalidStatus(RepoAccountProcessingSlip processingSlip);
}
}
@@ -0,0 +1,11 @@
using System.Threading.Tasks;
using LCU.API.Collections.RepoApi.Domain.Processing;
using LCU.Shared.Collections.RepoApiLib.Models.Producer;
namespace LCU.API.Collections.RepoApi.Services.Producer
{
public interface IRepoAccountProcessor
{
Task<RepoProcessingResult> HandleAccount(ProcessingItem item);
}
}
@@ -0,0 +1,23 @@
using System.Collections.Generic;
using LCU.API.Collections.CollectionsApiLib.Models;
using LCU.API.Collections.RepoApi.Domain.Processing;
using LCU.Shared.Collections.RepoApiLib.Models;
using LCU.Shared.Collections.RepoApiLib.Models.Consumer;
using LCU.Shared.Collections.RepoApiLib.Models.Settings;
namespace LCU.API.Collections.RepoApi.Services.Producer
{
public interface IRepoAccountProcessorService
{
RepoAccount CreateRepoAccount(TemenosAccount account, TemenosCase recoveryCase, LoanDetail detail);
void CheckForRejectedStatusChange(RepoAccount originalRepoAccount, RepoAccount updatedRepoAccount, Settings settings);
RepoAccount UpdateRepoAccountFromParts(RepoAccountProcessingSlip slip);
void UpdateRepoAccountDateClosed(RepoAccount repoAccount);
void TryUpdateCollateralRetitleDate(RepoAccount repo, TemenosCase temenosCase);
void AddAccountUpdateRecord(RepoAccountProcessingSlip slip);
RepoCompanySelection CreateNewRepoCompanyAssignment(Collateral collateral, TemenosCase temenosCase, int? newRepoCompanyId);
void TryUpdateOrCreateRepoCompanyAssignment(RepoAccount repoAccount,
TemenosCase temenosCase, Settings settings);
int? MapRepoAgentIdToRepoCompanyId(string repoAgentId, IReadOnlyCollection<RepoCompany> repoCompanies);
}
}
@@ -0,0 +1,331 @@
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using LCU.API.Collections.RepoApi.DataAccess;
using LCU.API.Collections.RepoApi.Domain.Extensions;
using LCU.API.Collections.RepoApi.Domain.Processing;
using LCU.API.Collections.RepoApi.Exceptions;
using LCU.API.Collections.RepoApi.Models;
using LCU.Shared.Collections.RepoApiLib.Models;
using LCU.Shared.Collections.RepoApiLib.Models.Producer;
using Serilog;
namespace LCU.API.Collections.RepoApi.Services.Producer
{
public class RepoAccountProcessor : IRepoAccountProcessor
{
private readonly IRepoAccountProcessorService _repoAccountProcessorService;
private readonly IAccountAlertCheckService _accountAlertCheckService;
private readonly IEmailer _emailer;
private readonly ISettingsService _settingsService;
private readonly ICollectionsDao _collectionsDao;
private readonly ICoreDao _coreDao;
private readonly IRepoAccountService _repoAccountService;
private readonly IRepoLetterService _repoLetterService;
private readonly IAuctionAssignmentService _auctionAssignmentService;
public const string UserStatusFieldOld = "User Status Code 1";
public const int CaseTypeRepo = 7; // Source: collections API configuration call
private const string O4RStatus = "O4R";
private const string NoStatus = "";
private const string RepoStatus = "REPO";
private const string RecaStatus = "RECA";
private const string RehoStatus = "REHO";
private const string RedeemedStatus = "REDEEMED";
private const string TemenosQueuedBy = "Temenos";
public RepoAccountProcessor(
IRepoAccountProcessorService repoAccountProcessorService,
IAccountAlertCheckService accountAlertCheckService,
ICollectionsDao collectionsDao,
IEmailer emailer,
ISettingsService settingsService,
IRepoAccountService repoAccountService,
IRepoLetterService repoLetterService, ICoreDao coreDao,
IAuctionAssignmentService auctionAssignmentService)
{
_repoAccountProcessorService = repoAccountProcessorService;
_accountAlertCheckService = accountAlertCheckService;
_collectionsDao = collectionsDao;
_emailer = emailer;
_settingsService = settingsService;
_repoAccountService = repoAccountService;
_repoLetterService = repoLetterService;
_coreDao = coreDao;
_auctionAssignmentService = auctionAssignmentService;
}
public async Task<RepoProcessingResult> HandleAccount(ProcessingItem item)
{
var processingSlip = await CreateProcessingSlip(item);
if (IsAutoCloseAccount(processingSlip))
{
Log.Debug($"Auto-Closing account {processingSlip.AccountNo} due to no Temenos Account.");
await AutoCloseAccount(processingSlip);
return RepoProcessingResult.Autoclosed;
}
processingSlip.AssertReadyForPreChecks();
// 9640 Attempt to pre-check for a matching status when case is missing.
// If no found case, and the status hasn't changed; instead of failing just skip the unconverted account.
if (IsUnconvertedIntellectAccount(processingSlip))
{
Log.Debug($"Repo Processor: {item.AccountNo} is not updated for Temenos; skipping.");
return RepoProcessingResult.Unconverted;
}
processingSlip.RecoveryCase = processingSlip.RecoveryAccount.FindRecoveryCaseOrDefault(processingSlip.CaseId);
if (!IsPossiblyRepossessionCase(processingSlip))
{
return RepoProcessingResult.Ineligible;
}
processingSlip.AssertReadyForFullProcessing();
if (ShouldCreateNewAccount(processingSlip))
{
Log.Debug($"Creating new repo account for {item.AccountNo} : {item.CaseId} ");
processingSlip.UpdatedRepoAccount = await CreateNewRepoAccount(processingSlip);
await _accountAlertCheckService.MaybeAlertOnInvalidStatus(processingSlip);
processingSlip = await AddAlertDataToProcessingSlip(processingSlip);
processingSlip.AssertReadyForAlertChecks();
await _accountAlertCheckService.AlertChecksPrimary(processingSlip);
return RepoProcessingResult.Created;
}
if (!processingSlip.ExistingRepoAccount.HasAnyCollateral())
{
await _emailer.MaybeSendO4rNoCollateralEmail(processingSlip.ExistingRepoAccount);
throw new MissingRequirementException("Existing Account Collateral",
$"{processingSlip.AccountNo} did not have collateral on existing account.");
}
Log.Debug($"Updating Repo account for {item.AccountNo} : {item.CaseId}");
processingSlip.UpdatedRepoAccount = await UpdateExistingRepoAccount(processingSlip);
//Add check for recent account repo closing when in RECA status to prevent reprocessing and new repo account creation with a DateClosed date = null
//The DateClosed date = null will ensure the account gets reprocessed, we don't want alert checks with these accounts
if (processingSlip.ExistingRepoAccount.DateClosed == null && processingSlip.UpdatedRepoAccount.DateClosed != null)
{
return RepoProcessingResult.Updated;
}
processingSlip = await AddAlertDataToProcessingSlip(processingSlip);
processingSlip.AssertReadyForAlertChecks();
await _accountAlertCheckService.AlertChecksPrimary(processingSlip);
return RepoProcessingResult.Updated;
}
// New method to give a chance to filter out non-repo cases and mark ineligible
private bool IsPossiblyRepossessionCase(RepoAccountProcessingSlip slip)
{
if (slip.RecoveryCase == null) return true;
try
{
if (slip.RecoveryCase.CaseTypeId != CaseTypeRepo)
{
return false;
}
StatusService.ConvertStatus(slip.RecoveryCase.RepoStatus);
}
catch (IneligibleAccountException)
{
return false;
}
return true;
}
private async Task AutoCloseAccount(RepoAccountProcessingSlip processingSlip)
{
AppUser appUser = new AppUser()
{
Email = processingSlip.RequestedBy.QueuedBy,
FirstName = "System",
LastName = "System"
};
processingSlip.ExistingRepoAccount.DateClosed = DateTime.Now;
processingSlip.ExistingRepoAccount.AccountLastProcessed = DateTime.Now;
await _repoAccountService.UpdateAsync(processingSlip.ExistingRepoAccount, appUser);
}
private bool IsAutoCloseAccount(RepoAccountProcessingSlip processingSlip)
{
// If recovery account couldn't be fetched, that would have been an exception.
// If recovery account is null, it means "not there", so close the RepoAccount.
// This means close the RepoAccount because the account
// was likely not brought over from Intellect. If something DOES happen, just create
// a new account later....
return (processingSlip.RecoveryAccount == null
&& processingSlip.ExistingRepoAccount != null
&& processingSlip.ExistingRepoAccount.DateClosed == null);
}
public async Task<RepoAccountProcessingSlip> CreateProcessingSlip(ProcessingItem item)
{
var accountNo = VerifyAccountNo(item);
var slip = new RepoAccountProcessingSlip
{
AccountNo = accountNo,
CurrentSettings = await _settingsService.GetCachedRepoSettingsAsync(),
RecoveryAccount = await _collectionsDao.RetrieveAccountAsync(accountNo),
ExistingRepoAccount = await _repoAccountService.GetNewestOpenAccount(accountNo),
Loan = await _coreDao.RetrieveLoanAsync(accountNo),
CaseId = VerifyCaseId(item.CaseId),
RequestedBy = item
};
if (slip.RecoveryAccount != null)
{
slip.Letters = await _repoLetterService.ListByAccountIdAsync(slip.RecoveryAccount.AccountId);
}
return slip;
}
public async Task<RepoAccountProcessingSlip> AddAlertDataToProcessingSlip(RepoAccountProcessingSlip slip)
{
slip.TransactionHistory = await _coreDao.RetrieveTransactionsAsync(slip.AccountNo);
slip.Member = await _coreDao.RetrieveMemberAsync(slip.AccountNo);
var uniqueIds = slip.UpdatedRepoAccount.GetUniqueAuctionAssignmentIds();
slip.AuctionAssignments = await _auctionAssignmentService.GetListByIdsAsync(uniqueIds);
return slip;
}
public bool IsUnconvertedIntellectAccount(RepoAccountProcessingSlip repoAccountSlip)
{
var recoveryAccount = repoAccountSlip.RecoveryAccount;
var caseId = repoAccountSlip.CaseId;
var originalRepoAccount = repoAccountSlip.ExistingRepoAccount;
if (!recoveryAccount.HasRecoveryCase(caseId) && recoveryAccount.HasOldIntellectStatus())
{
Log.Information(
$"Repo Processor: {repoAccountSlip.AccountNo} has no case, but user status code. Running pre-check.");
return (originalRepoAccount.Status == recoveryAccount.CustomFieldMap[UserStatusFieldOld]);
}
return false;
}
public int VerifyCaseId(string itemCaseId)
{
try
{
return string.IsNullOrWhiteSpace(itemCaseId) ? 0 : Int32.Parse(itemCaseId);
}
catch (Exception)
{
throw new IneligibleAccountException(
$"Bad CaseId {itemCaseId}",
$"The CaseId is present, but in a non-integer format.");
}
}
public string VerifyAccountNo(ProcessingItem item)
{
if (item == null)
{
throw new IneligibleAccountException("Processing Item", "The processing item was null.");
}
if (item.AccountNo == null)
{
throw new IneligibleAccountException(
"Null AccountNo",
"There should never be null accounts on the queue.");
}
// if (Regex.IsMatch(item.AccountNo, "^[a-zA-Z0-9]*$"))
// {
// throw new IneligibleAccountException(
// "AccountNo",
// "AccountNo contains alphabetical or symbol characters");
// }
return item.AccountNo;
}
public async Task<RepoAccount> UpdateExistingRepoAccount(RepoAccountProcessingSlip slip)
{
AppUser appUser = new AppUser()
{
Email = slip.RequestedBy.QueuedBy,
FirstName = "System",
LastName = "System"
};
if (ShouldRunCompleteUpdate(slip))
{
RepoAccount updatedRepoAccount =
_repoAccountProcessorService.UpdateRepoAccountFromParts(slip);
await _accountAlertCheckService.AlertChecksPreStatusReject(slip);
_repoAccountProcessorService.CheckForRejectedStatusChange(slip.ExistingRepoAccount, updatedRepoAccount,
slip.CurrentSettings);
_repoAccountProcessorService.TryUpdateOrCreateRepoCompanyAssignment(updatedRepoAccount,
slip.RecoveryCase, slip.CurrentSettings);
_repoAccountProcessorService.TryUpdateCollateralRetitleDate(updatedRepoAccount, slip.RecoveryCase);
await _repoAccountService.UpdateAsync(updatedRepoAccount, appUser);
return updatedRepoAccount;
}
_repoAccountProcessorService.TryUpdateOrCreateRepoCompanyAssignment(slip.ExistingRepoAccount,
slip.RecoveryCase, slip.CurrentSettings);
_repoAccountProcessorService.AddAccountUpdateRecord(slip);
_repoAccountProcessorService.TryUpdateCollateralRetitleDate(slip.ExistingRepoAccount, slip.RecoveryCase);
_repoAccountProcessorService.UpdateRepoAccountDateClosed(slip.ExistingRepoAccount);
await _repoAccountService.UpdateAsync(slip.ExistingRepoAccount, appUser);
return slip.ExistingRepoAccount;
}
public bool ShouldRunCompleteUpdate(RepoAccountProcessingSlip slip)
{
if (slip.ExistingRepoAccount == null)
{
throw new MissingRequirementException("Existing Repo Account",
$"Existing Repo Account required for update check, {slip.AccountNo}");
}
var newStatus = StatusService.ConvertStatus(slip.RecoveryCase.RepoStatus);
var dateLastPaid = slip.RecoveryAccount.DateLastPaid != null
? DateTime.Parse(slip.RecoveryAccount.DateLastPaid)
: slip.ExistingRepoAccount.LastPaymentDate;
var refStatusChanged = slip.RecoveryAccount.AccountStatus != slip.ExistingRepoAccount.ReferenceStatus;
// This is a special case, it picked up the written off flag, before there was a written off date.
if (string.Equals(slip.ExistingRepoAccount.ReferenceStatus, RepoAccountProcessorService.WrittenOff,
StringComparison.CurrentCultureIgnoreCase)
&& slip.ExistingRepoAccount.WrittenOffDate == null)
{
return true;
}
var needsRedemptionExpired = slip.ExistingRepoAccount.RedemptionExpiredDate == null
&& (slip.RecoveryCase.RedemptionExpiredDateIl != null ||
slip.RecoveryCase.RedemptionExpiredDateWi != null);
return !string.Equals(newStatus, slip.ExistingRepoAccount.Status, StringComparison.CurrentCultureIgnoreCase)
|| slip.ExistingRepoAccount.LastPaymentDate != dateLastPaid ||
slip.ExistingRepoAccount.Balance != slip.RecoveryAccount.Balance
|| needsRedemptionExpired || refStatusChanged;
}
public async Task<RepoAccount> CreateNewRepoAccount(RepoAccountProcessingSlip slip)
{
var repoAccount = _repoAccountProcessorService.CreateRepoAccount(slip.RecoveryAccount, slip.RecoveryCase, slip.Loan);
var newRepoAccount = await _repoAccountService.SaveAsync(repoAccount);
return newRepoAccount;
}
public bool ShouldCreateNewAccount(RepoAccountProcessingSlip slip)
{
return slip != null && (slip.ExistingRepoAccount == null || slip.ExistingRepoAccount.DateClosed != null);
}
}
}
@@ -0,0 +1,585 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LCU.API.Collections.CollectionsApiLib.Models;
using LCU.API.Collections.RepoApi.DataAccess;
using LCU.API.Collections.RepoApi.Domain.Processing;
using LCU.API.Collections.RepoApi.Exceptions;
using LCU.API.Collections.RepoApi.Models.Results;
using LCU.Shared.Collections.RepoApiLib.Models;
using LCU.Shared.Collections.RepoApiLib.Models.Consumer;
using LCU.Shared.Collections.RepoApiLib.Models.Settings;
using Serilog;
namespace LCU.API.Collections.RepoApi.Services.Producer
{
public class RepoAccountProcessorService : IRepoAccountProcessorService
{
private const string RedeemedStatus = "REDEEMED";
private const string PaidSkipStatus = "PSKP";
private const string RepoCancelledStatus = "RECA";
public const string WrittenOff = "Written Off";
public const string SixtyPLusUserAssignedLabel = "SixtyPlus User Assigned";
private readonly ICoreDao _coreDao;
private readonly IRedemptionExpiredDateCalculator _redemptionExpiredDateCalculator;
private readonly IAccountAlertCheckService _accountAlertCheckService;
private readonly IEventAlertService _eventAlertService;
private readonly IRepoCompanySelectionService _repoCompanySelectionService;
public RepoAccountProcessorService(ICoreDao coreDao,
IRedemptionExpiredDateCalculator redemptionExpiredDateCalculator,
IAccountAlertCheckService accountAlertCheckService,
IEventAlertService eventAlertService, IRepoCompanySelectionService repoCompanySelectionService)
{
_coreDao = coreDao;
_redemptionExpiredDateCalculator = redemptionExpiredDateCalculator;
_accountAlertCheckService = accountAlertCheckService;
_eventAlertService = eventAlertService;
_repoCompanySelectionService = repoCompanySelectionService;
}
// This process determined from the original batch code. (LCU.CollectionRepossessionTracking::IntellecRepoImport)
// -> A) Get XP/Core/Apex Account
// -> B) Get XP/Core/Apex Loan
// -> B.1) Fields needed: Branch, Next due date
// ---> If it can't find a loan, it does nothing... what do we WANT do?
// -> C) Get XP/Core/Apex Loan Collateral
// ---> If there is no collateral, it does nothing... what do we WANT do?
// -> D) Get XP/Core/Apex Borrowers (Individuals)
// ---> No borrowers throws an exception? ... what do we WANT do?
// -> E) Create Repo Account from Loan, Collateral, Borrowers and Temenos Account
public RepoAccount CreateRepoAccount(TemenosAccount account, TemenosCase recoveryCase, LoanDetail loanDetail)
{
var collateralDetailRequest = _coreDao.RetrieveCollateralAsync(account.AccountIdentifier);
var collateralDetail = collateralDetailRequest.GetAwaiter().GetResult();
if (collateralDetail == null || !collateralDetail.Any())
{
throw new AccountCreationFailed(
$"Repo-Account Not Created using Account with AccountNo: {account.AccountIdentifier}. No Collateral.");
}
var borrowers = ConstructRepoAccountBorrowers(loanDetail);
if (borrowers == null || !borrowers.Any())
{
throw new AccountCreationFailed(
$"Repo-Account Not Created using Account with AccountNo: {account.AccountIdentifier}. No Borrowers.");
}
var repoAccount = ConstructRepoAccountFromParts(loanDetail, account, recoveryCase);
repoAccount.Borrowers = borrowers;
repoAccount.Collaterals = ConstructCollateralFromDetails(collateralDetail);
TryUpdateCollateralRetitleDate(repoAccount, recoveryCase);
Log.Information($"Created Repo-Account for Not-Found Account. AccountNo: {account.AccountIdentifier}");
return repoAccount;
}
public List<Borrower> ConstructRepoAccountBorrowers(LoanDetail coreAccount)
{
List<Borrower> borrowers = new List<Borrower>();
try
{
foreach (var coreAccountBorrower in coreAccount.Borrowers)
{
var borrower = new Borrower()
{
IndividualName = coreAccountBorrower.Name,
IndividualId = coreAccountBorrower.IndividualId.ToString(),
OwnershipType = (int)ConvertBorrowerType(coreAccountBorrower.Type)
};
borrowers.Add(borrower);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error in ConstructRepoAccountBorrowers");
}
return borrowers;
}
public List<Collateral> ConstructCollateralFromDetails(IReadOnlyCollection<CollateralDetail> collateralDetail)
{
List<Collateral> collateralList = new List<Collateral>();
foreach (var detail in collateralDetail)
{
var collateral = new Collateral()
{
BodyStyle = null,
CollateralTypeId = null,
Description = detail.Description,
Inoperable = null,
RefCollateralId = detail.CollateralId.ToString()
};
if (detail.Other != null)
{
collateral.Vin = detail.Other.MiscellaneousInformation;
}
if (detail.Vehicle != null)
{
collateral.Make = detail.Vehicle.Make;
collateral.Model = detail.Vehicle.Model;
collateral.Vin = detail.Vehicle.VIN;
collateral.Year = detail.Vehicle.Year;
collateral.BodyStyle = detail.Vehicle.BodyStyle;
}
collateralList.Add(collateral);
}
return collateralList;
}
public RepoAccount ConstructRepoAccountFromParts(LoanDetail coreAccount, TemenosAccount account,
TemenosCase recoveryCase)
{
DateTime? lastPaymentDate = null;
DateTime? dateFirstDelinquent = null;
var lastPaymentResult = GetDateTimeFromString(account.DateLastPaid);
if (lastPaymentResult.IsSuccess)
{
lastPaymentDate = lastPaymentResult.Value;
}
else
{
Log.Warning(
$"Construct Repo Account from temenos accountNo: {account.AccountNumber}. Could not parse last payment result : {account.DateLastPaid}.",
lastPaymentResult, account);
}
var dateFirstDelinquentResult = GetDateTimeFromString(account.DateFirstDelinquent);
if (dateFirstDelinquentResult.IsSuccess)
{
dateFirstDelinquent = dateFirstDelinquentResult.Value;
}
else
{
Log.Warning(
$"Construct Repo Account from temenos accountNo: {account.AccountNumber}. failed parsing dateFirstDelinquent {account.DateFirstDelinquent}",
dateFirstDelinquentResult, account);
// This is no longer a "MissingRequirementException", per Bill Johns.
}
var sixtyPlusUserAssignedNumber = GetSixtyPlusUserNumber(account);
var repoAccount = new RepoAccount()
{
DueToPayDate = coreAccount.NextDueDate,
Balance = account.Balance,
AccountTypeCode = account.AccountType.Code,
OrigAccountTypeCode = account.AccountType.Code,
LoanNo = long.Parse(account.AccountIdentifier),
MemberNo = MemberIdFromAccountId(account.AccountIdentifier),
Branch = account.Branch != null ? int.Parse(account.Branch) : 0,
AccountNo = account.AccountIdentifier,
Status = StatusService.ConvertStatus(recoveryCase.RepoStatus),
O4RDate = recoveryCase.CreatedDate,
Collector = account.LoanOfficer,
DateFirstDelinquent = dateFirstDelinquent,
DelinquentAmount = account.DQAmount,
CurrentDelinquentAmount = account.DQAmount,
SubStatus = "",
DaysDelinquent = account.DaysDq,
LastPaymentDate = lastPaymentDate,
LstPmtAmt = account.AmountLastPaid,
OrigAmount = account.OriginalLoanAmount,
RequiredPaymentAmount = account.DQAmount,
PrimaryIndividualId = coreAccount.IndividualId[0].ToString(),
Bankruptcy = $"{account.BankruptcyFlag}",
DateFirstIntellect = recoveryCase.CreatedDate,
ReferenceId = account.AccountId,
ReferenceCaseId = recoveryCase.CaseId,
AccountLastProcessed = DateTime.Now,
ReferenceStatus = account.AccountStatus,
WrittenOffDate = coreAccount.Status == WrittenOff ? coreAccount.WrittenOffDate : (DateTime?)null,
SixtyPlusCollectorId = sixtyPlusUserAssignedNumber
};
return repoAccount;
}
public long MemberIdFromAccountId(string accountId)
{
return _coreDao.RetrieveMemberAsync(accountId).GetAwaiter().GetResult().MemberId;
}
public RepoAccount UpdateRepoAccountFromParts(RepoAccountProcessingSlip slip)
{
RepoAccount originalRepoAccount = slip.ExistingRepoAccount;
TemenosAccount account = slip.RecoveryAccount;
TemenosCase recoveryCase = slip.RecoveryCase;
IReadOnlyCollection<RepoLetter> letters = slip.Letters;
var redemptionExpiredDate = _redemptionExpiredDateCalculator.CalculateAsync(recoveryCase, letters);
DateTime? lastPaymentDate = null;
DateTime? dateFirstDelinquent = originalRepoAccount.DateFirstDelinquent;
var lastPaymentResult = GetDateTimeFromString(account.DateLastPaid);
if (lastPaymentResult.IsSuccess)
{
lastPaymentDate = lastPaymentResult.Value;
}
else
{
Log.Warning(
$"Repo account {originalRepoAccount.RepoId} ({originalRepoAccount.AccountNo}). Could not parse last payment result : {account.DateLastPaid}.",
lastPaymentResult);
}
var dateFirstDelinquentResult = GetDateTimeFromString(account.DateFirstDelinquent);
if (dateFirstDelinquentResult.IsSuccess)
{
dateFirstDelinquent = dateFirstDelinquentResult.Value;
}
else
{
Log.Error(
$"Repo account {originalRepoAccount.RepoId} ({originalRepoAccount.AccountNo}) failed parsing dateFirstDelinquent {account.DateFirstDelinquent}",
dateFirstDelinquentResult);
// This is no longer a "MissingRequirementException", per Bill Johns.
}
var sixtyPlusUserAssignedNumber = GetSixtyPlusUserNumber(account);
var repoAccount = new RepoAccount()
{
RepoId = originalRepoAccount.RepoId,
DueToPayDate = account.DueDate,
Balance = account.Balance,
AccountTypeCode = account.AccountType.Code,
OrigAccountTypeCode = originalRepoAccount.OrigAccountTypeCode,
LoanNo = originalRepoAccount.LoanNo,
MemberNo = originalRepoAccount.MemberNo,
Branch = originalRepoAccount.Branch,
AccountNo = originalRepoAccount.AccountNo,
Status = StatusService.ConvertStatus(recoveryCase.RepoStatus),
O4RDate = originalRepoAccount.O4RDate,
Collector = account.LoanOfficer,
DateFirstDelinquent = dateFirstDelinquent,
DelinquentAmount = account.DQAmount,
CurrentDelinquentAmount = account.DQAmount,
SubStatus = "",
DaysDelinquent = originalRepoAccount.DaysDelinquent, //leave this set to days delinquent when added.
LastPaymentDate = lastPaymentDate,
LstPmtAmt = account.AmountLastPaid ?? 0,
OrigAmount = account.OriginalLoanAmount ?? 0,
RedemptionExpiredDate = redemptionExpiredDate,
RequiredPaymentAmount = account.DQAmount,
PrimaryIndividualId = originalRepoAccount.PrimaryIndividualId,
Bankruptcy = $"{account.BankruptcyFlag}",
Collaterals = originalRepoAccount.Collaterals,
RepoStatusChanges = originalRepoAccount.RepoStatusChanges,
ManualRedemptionDate = originalRepoAccount.ManualRedemptionDate,
DateFirstIntellect = originalRepoAccount.DateFirstIntellect,
Borrowers = originalRepoAccount.Borrowers,
PaidSkipAmount = originalRepoAccount.PaidSkipAmount,
RefundStatus = originalRepoAccount.RefundStatus,
ReferenceId = account.AccountId,
ReferenceCaseId = recoveryCase.CaseId,
AccountLastProcessed = DateTime.Now,
ReferenceStatus = account.AccountStatus,
WrittenOffDate = slip.Loan.Status == WrittenOff ? slip.Loan.WrittenOffDate : (DateTime?)null,
SixtyPlusCollectorId = sixtyPlusUserAssignedNumber
};
return repoAccount;
}
/**
* High Risk function because of string comparison and unknown mapping.
* The old system got the integer from the database.
* The new system gets a string from the web service.
* This maps the new string to the old integer, if borrower types start
* showing up wrong, look here...
*/
public OwnershipType ConvertBorrowerType(string type)
{
if (type != null && string.Equals(type.Trim(), "Borrower", StringComparison.OrdinalIgnoreCase))
{
return OwnershipType.Borrower;
}
if (type != null && string.Equals(type.Trim(), "Co-Borrower", StringComparison.OrdinalIgnoreCase))
{
return OwnershipType.Coborrower;
}
return OwnershipType.Other;
}
public void CheckForRejectedStatusChange(RepoAccount originalRepoAccount, RepoAccount updatedRepoAccount,
Settings settings)
{
if (!IsStatusMatch(originalRepoAccount, updatedRepoAccount.Status) &&
!IsStatusChangeAllowed(originalRepoAccount.Status, updatedRepoAccount.Status,
settings.AllowedStatusChanges))
{
Log.Warning(
$"Status Change Reverted for Account {updatedRepoAccount.AccountNo} from {originalRepoAccount.Status} to {updatedRepoAccount.Status}");
_accountAlertCheckService.ManualAlert(new EventAlert()
{
RepoId = updatedRepoAccount.RepoId,
AccountNo = updatedRepoAccount.AccountNo,
CreatedBy = "Importer",
DateAdded = DateTime.Now,
Message = "Collections System Status Does Not Match Repo Management",
SubMessage =
$"Invalid Status Change from {originalRepoAccount.Status} to {updatedRepoAccount.Status}"
});
updatedRepoAccount.Status = originalRepoAccount.Status;
}
}
public bool IsStatusChangeAllowed(string oldStatus, string newStatus,
IEnumerable<AllowedStatusChange> settingsAllowedStatusChanges)
{
foreach (var allowedStatusChange in settingsAllowedStatusChanges)
{
if (IsStringMatch(allowedStatusChange.CurrentStatus, oldStatus) &&
IsStringMatch(allowedStatusChange.AllowedChangeStatus, newStatus))
{
return true;
}
}
return false;
}
public bool IsStatusMatch(RepoAccount repo, string status)
{
if (repo?.Status == null || status == null)
{
return false;
}
return string.Equals(repo.Status.Trim(), status.Trim(), StringComparison.OrdinalIgnoreCase);
}
public bool IsStringMatch(string x, string y)
{
if (x == null || y == null)
{
return false;
}
return string.Equals(x.Trim(), y.Trim(), StringComparison.OrdinalIgnoreCase);
}
public void UpdateRepoAccountDateClosed(RepoAccount repoAccount)
{
// Close the account after four days in certain statuses
if ((repoAccount.Status == PaidSkipStatus || repoAccount.Status == RedeemedStatus || repoAccount.Status == RepoCancelledStatus)
&& repoAccount.RepoStatusChanges.LastOrDefault(x => x.StatusTo == repoAccount.Status)?.ChangedDate <
DateTime.Now.AddDays(-3).Date)
{
repoAccount.DateClosed = DateTime.Now;
repoAccount.AccountLastProcessed = DateTime.Now;
// TODO calling this still has a side effect.
_eventAlertService.MarkAllAsRead(repoAccount.AccountNo).GetAwaiter().GetResult();
}
}
public void TryUpdateCollateralRetitleDate(RepoAccount repo, TemenosCase temenosCase)
{
DateTime? retitledDate = (temenosCase.TitleOrdered) ? temenosCase.TitleOrderedDate : null;
foreach (Collateral collateral in repo.Collaterals)
{
collateral.RetitledInLandmarkName = retitledDate;
}
}
// This exists to update accounts not originally created from Temenos
public void AddAccountUpdateRecord(RepoAccountProcessingSlip slip)
{
slip.ExistingRepoAccount.ReferenceId = slip.RecoveryAccount.AccountId;
slip.ExistingRepoAccount.ReferenceCaseId = slip.RecoveryCase.CaseId;
slip.ExistingRepoAccount.AccountLastProcessed = DateTime.Now;
}
public void TryUpdateOrCreateRepoCompanyAssignment(RepoAccount repoAccount, TemenosCase temenosCase,
Settings settings)
{
//Temenos may send blank string instead of null
var repoCompanyIdFromCase = string.IsNullOrEmpty(temenosCase.RepoAgentId)
? null
: MapRepoAgentIdToRepoCompanyId(temenosCase.RepoAgentId, settings.RepoCompanies);
foreach (var collateral in repoAccount.Collaterals)
{
var mostRecent = collateral.RepoCompanySelections.OrderByDescending(x => x.DateTimeCreated)
.FirstOrDefault();
// TODO, move to Slip, save elsewhere.
var updated =
UpdateOrCreateRepoCompanyAssignment(collateral, mostRecent, temenosCase, repoCompanyIdFromCase);
// This can no longer happen, but watch for it anyway.
if (updated.Any(repoCompanySelection =>
repoCompanySelection.DateRepoed != null &&
repoCompanySelection.RepoCompanyAssignmentDate == null))
{
Log.Error(
$"Tried to set DateRepoed before RepoCompanyAssignment for accountNo {repoAccount.AccountNo}. Please Investigate.");
throw new MissingRequirementException(
$"RepoCompanyAssignment for repo account {repoAccount.AccountNo} Missing RepoCompanyAssignmentDate",
$"Cannot assign a DateRepoed before a RepoCompanyAssignment Date.");
}
if (updated.Any())
{
_repoCompanySelectionService.SaveChangesAsync(updated).GetAwaiter().GetResult();
}
}
}
public List<RepoCompanySelection> UpdateOrCreateRepoCompanyAssignment(Collateral collateral, RepoCompanySelection mostRecentSelection,
TemenosCase temenosCase, int? repoCompanyIdFromCase)
{
var selectionUpdates = new List<RepoCompanySelection>();
//If this is the first repoCompanySelection create when temenos has data
if (mostRecentSelection == null)
{
if (temenosCase.RepoAgentId != null && temenosCase.RepoType != null &&
temenosCase.DateRepoAssigned != null)
{
var newRepoCompanyEntry = CreateNewRepoCompanyAssignment(collateral, temenosCase, repoCompanyIdFromCase);
collateral.RepoCompanySelections = collateral.RepoCompanySelections.Append(newRepoCompanyEntry).ToList();
selectionUpdates.Add(newRepoCompanyEntry);
}
return selectionUpdates;
}
//If the repoCompany has changed update with temenos data
if (mostRecentSelection.RepoCompanyId != repoCompanyIdFromCase ||
mostRecentSelection.RepoType != temenosCase.RepoType ||
mostRecentSelection.RepoCompanyAssignmentDate != temenosCase.DateRepoAssigned)
{
if (mostRecentSelection.DateRepoed == null)
{
mostRecentSelection.DateRepoed = DateTime.Now;
if (mostRecentSelection.RepoCompanyAssignmentDate == null)
{
mostRecentSelection.RepoCompanyAssignmentDate = mostRecentSelection.DateTimeCreated ?? DateTime.Now;
}
selectionUpdates.Add(mostRecentSelection);
}
var newRepoCompanyEntry = CreateNewRepoCompanyAssignment(collateral, temenosCase, repoCompanyIdFromCase);
collateral.RepoCompanySelections = collateral.RepoCompanySelections.Append(newRepoCompanyEntry).ToList();
selectionUpdates.Add(newRepoCompanyEntry);
return selectionUpdates;
}
//If no need to update or create, sync Repoed Date
mostRecentSelection.DateRepoed = temenosCase.DateRepossessed;
selectionUpdates.Add(mostRecentSelection);
return selectionUpdates;
}
public RepoCompanySelection CreateNewRepoCompanyAssignment(Collateral collateral, TemenosCase temenosCase, int? newRepoCompanyId)
{
// DateRepoAssigned is defaulted to Today. The business (Bill) doesn't want it to throw, if they start having trouble
// with the dates; change this to throw a MissingRequirementException on the field.
var newRepoCompanySelection = new RepoCompanySelection()
{
RepoCompanyId = newRepoCompanyId,
RepoType = temenosCase.RepoType ?? RepoType.Unknown,
RepoCompanyAssignmentDate = temenosCase.DateRepoAssigned ?? DateTime.Now,
DateRepoed = temenosCase.DateRepossessed,
CollateralId = collateral.CollateralId,
DateTimeCreated = DateTime.Now,
Id = null
};
return newRepoCompanySelection;
}
public int? MapRepoAgentIdToRepoCompanyId(string repoAgentId, IReadOnlyCollection<RepoCompany> repoCompanies)
{
try
{
var repoCompany =
repoCompanies.FirstOrDefault(company => company.ReferenceId == int.Parse(repoAgentId));
if (repoCompany != null)
{
return repoCompany.ID;
}
}
catch (Exception ex)
{
Log.Error(ex, $"Error when trying to map RepoAgentID: {repoAgentId} to a RepoCompany ReferenceID", repoCompanies);
}
// If you get this error, ensure there is a matching RepoCompany and that it has a RepoAgentID that matches the config call in Collections API.
throw new MissingRequirementException("RepoCompanyAssignment - RepoCompanyID", $"Ensure a RepoCompany with RepoAgentID: {repoAgentId} is present");
}
public IResult<DateTime?> GetDateTimeFromString(string dtString)
{
if (string.IsNullOrEmpty(dtString))
{
Log.Error("Cannot parse null or empty date string");
return new DateTimeResult()
{
IsSuccess = false,
ErrorMessage = "Cannot Parse Empty Date String",
};
}
try
{
return new DateTimeResult()
{
IsSuccess = true,
Value = DateTime.Parse(dtString)
};
}
catch (Exception ex)
{
Log.Error(ex, $"Cannot Parse Date String {dtString}");
return new DateTimeResult()
{
IsSuccess = false,
ErrorMessage = $"There was an Exception when processing the date string: {dtString}"
};
}
}
public int? GetSixtyPlusUserNumber(TemenosAccount account)
{
try
{
account.CustomFieldMap.TryGetValue(SixtyPLusUserAssignedLabel, out string sixtyPlusUser);
if (String.IsNullOrWhiteSpace(sixtyPlusUser))
{
return null;
}
Int32.TryParse(sixtyPlusUser, out int sixtyPlusUserNumber);
return sixtyPlusUserNumber;
}
catch(NullReferenceException nullEx)
{
Log.Warning($"60+ Collector key not found in Temenos Account for Account Number: {account.AccountNumber}. Error: {nullEx}");
return null;
}
catch(Exception ex)
{
Log.Error($"Error while attempting to Parse 60+ Collector ID for Account Number: {account.AccountNumber}. Error: {ex}");
return null;
}
}
}
}