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 ConstructRepoAccountBorrowers(LoanDetail coreAccount) { List borrowers = new List(); 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 ConstructCollateralFromDetails(IReadOnlyCollection collateralDetail) { List collateralList = new List(); 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 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 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 UpdateOrCreateRepoCompanyAssignment(Collateral collateral, RepoCompanySelection mostRecentSelection, TemenosCase temenosCase, int? repoCompanyIdFromCase) { var selectionUpdates = new List(); //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 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 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; } } } }