Files
2026-03-11 11:18:25 -05:00

441 lines
21 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Options;
using Serilog;
using CRIF.ACTion.Services.Crif;
using Landmark.OLA.Application.Configuration;
using Landmark.OLA.Application.DataAccess;
using Landmark.OLA.Application.DataAccessLayer;
using Landmark.OLA.Application.Services.MicroApiServices;
using Landmark.OLA.Models.Loan;
using Landmark.OLA.Models.Loan.Enums;
using Landmark.OLA.Models.Loan.Validators;
using OnlineBankingAuth.Tokens;
using LCU.Library.Internal.NotificationsModels.Models;
using System.Net;
namespace Landmark.OLA.Application.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class LoanApplicationController : ControllerBase
{
private readonly ICrifVendorClient VendorClient;
private readonly ILoanApplicationDataAccess loanApplicationStorage;
private readonly IBranchDataAccess branchStorage;
private readonly IBlobStorageAccess _blobStorageAccess;
private readonly ITokenService _tokenService;
readonly IOptions<DealershipEmailSettings> _dealershipEmailSettings;
private readonly INotificationsApiService _notificationsApiService;
private readonly Services.Email.IEmailTemplateService _emailTemplateService;
public LoanApplicationController(
ICrifVendorClient vendorClient,
ILoanApplicationDataAccess loanApplicationDataAccess,
IBranchDataAccess branchDataAccess,
IBlobStorageAccess blobStorageAccess,
ITokenService tokenService,
IOptions<DealershipEmailSettings> dealershipEmailSettings,
INotificationsApiService notificationsApiService,
IWebHostEnvironment webHostEnvironment,
Services.Email.IEmailTemplateService emailTemplateService)
{
VendorClient = vendorClient;
loanApplicationStorage = loanApplicationDataAccess;
branchStorage = branchDataAccess;
_blobStorageAccess = blobStorageAccess;
_tokenService = tokenService;
_dealershipEmailSettings = dealershipEmailSettings;
_notificationsApiService = notificationsApiService;
_emailTemplateService = emailTemplateService;
}
[HttpPost("AuthenticateDealership")]
[ProducesResponseType(200)]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
[ProducesResponseType(500)]
public async Task<IActionResult> AuthenticateDealership([FromBody] DealershipAuthRequest authRequest)
{
try
{
if (string.IsNullOrEmpty(authRequest.ApplicationId))
{
return BadRequest("ApplicationId is required.");
}
authRequest.DealershipName = "";
var authResponse = await VendorClient.GetDealershipAuthDetails(authRequest.ApplicationId);
if (authRequest.Equals(authResponse))
{
// This portion of the try can be uncommented so that the "session" of an upload won't always be re-created.
// There may be a small bug that might only affect app# 10056 if this is re-enabled. Basically the blob file was deleted, but the cosmos record has a reference.
// deleting the cosmos record should resolve the issue
/*try
{
var loanApplicationId = loanApplicationStorage.GetContainerIdByLoanId(authRequest.ApplicationId);
return Ok(new { cosmosId = loanApplicationId });
}
catch (KeyNotFoundException exception)
{*/
var token = _tokenService.GetAuthenticatedDataFromCookie(HttpContext);
var loanContainer = new LoanContainer(new LoanApplication())
{
Id = (Guid.NewGuid()).ToString(),
LoanId = authRequest.ApplicationId
};
loanApplicationStorage.CreateContainer(loanContainer);
HttpContext.Response.StatusCode = 201;
return Created($"/api/LoanApplication/AuthenticateDealer/{loanContainer.Id}", new { cosmosId = loanContainer.Id });
/*}*/
}
return Unauthorized();
}
catch (Exception e)
{
Log.Error(e.Message);
return StatusCode((int)HttpStatusCode.InternalServerError);
}
}
[HttpPost("SubmitDealershipFiles/{cosmosId}")]
[ProducesResponseType(200)]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
[ProducesResponseType(500)]
public async Task<IActionResult> SubmitDealershipFiles(string cosmosId, [FromBody] DealershipAuthRequest authRequest)
{
try
{
if (string.IsNullOrEmpty(cosmosId))
{
return BadRequest("Cosmos Id is required.");
}
if (string.IsNullOrEmpty(authRequest.ApplicationId))
{
return BadRequest("ApplicationId is required.");
}
authRequest.DealershipName = "";
var authResponse = await VendorClient.GetDealershipAuthDetails(authRequest.ApplicationId);
if (authRequest.Equals(authResponse))
{
LoanContainer loanContainer = loanApplicationStorage.GetContainerById(cosmosId);
LoanApplication loanApplication = loanContainer.LoanApplication;
loanApplication.Id = authResponse.ApplicationId;
// Submit files to ACTion, 1-by-1. Each time the POST request is called, it will upload 1 file. Once the last file has been sent, an email will be sent
if (loanApplication.Files.Count > 0)
{
if (!(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.Equals("Production", StringComparison.OrdinalIgnoreCase) ?? false) &&
loanApplication.Files[0].Name.Equals("do-not-allow-transmission.jpg", StringComparison.OrdinalIgnoreCase))
{
throw new Exception();
}
var fileBase64Value = _blobStorageAccess.GetBlobAsBase64(loanApplication.Files[0]);
var fileSubmitted = await VendorClient.AddFile(loanApplication.Id, loanApplication.Files[0], fileBase64Value);
if (fileSubmitted)
{
_blobStorageAccess.DeleteBlob(loanApplication.Files[0]);
loanApplication.Files.RemoveAt(0);
loanApplicationStorage.UpdateContainer(loanContainer);
// Return the files if there are still some left to upload. If not, send the email and return
if (loanApplication.Files.Count > 0)
{
return StatusCode(201, new { files = loanApplication.Files });
}
}
else
{
Log.Error($"File not sent to ACTion, not deleting file: {loanApplication.Files[0]}");
}
}
if (_dealershipEmailSettings.Value.Recipients != null && _dealershipEmailSettings.Value.Recipients.Count > 0 && !_dealershipEmailSettings.Value.Recipients.Contains("PERENVIRONMENT") && !string.IsNullOrEmpty(_dealershipEmailSettings.Value.ActionLink) && !string.IsNullOrEmpty(_dealershipEmailSettings.Value.EmailAPIUrl))
{
try
{
var redBannerMessage = _dealershipEmailSettings.Value.RedBannerMessage ?? string.Empty;
var contentHtml =
"This is an auto generated message. <br><br>" +
"A new deal has been submitted and is ready to be viewed and exported <br><br>" +
$"Dealership Name: <strong>{authResponse.DealershipName}</strong><br>" +
$"Application ID: <strong>{authResponse.ApplicationId}</strong><br>" +
$"Primary Applicant's Last Name or Business Full Name: <strong>{authResponse.ApplicantLastName}</strong><br><br>" +
$"Click on the following link to open the application record within ACTion: <a href=\"{_dealershipEmailSettings.Value.ActionLink}{authResponse.ApplicationId}\">{authResponse.ApplicationId}</a><br>";
var requestBody = _emailTemplateService.BuildBrandedBody(contentHtml, redBannerMessage);
var request = new EmailRequest()
{
Subject = $"Dealership has uploaded documents to application {loanApplication.Id}",
Recipient = _dealershipEmailSettings.Value.Recipients,
Body = requestBody
};
Log.Information($"Sending email to {_dealershipEmailSettings.Value.Recipients} for application ID: {loanApplication.Id}");
await _notificationsApiService.SendEmailAsync(request);
}
catch (Exception ex)
{
Log.Error($"Error when sending email to loan team for application ID {loanApplication.Id}");
Log.Error(ex.Message);
}
}
loanApplicationStorage.DeleteApplication(cosmosId);
return StatusCode(200, new { files = loanApplication.Files });
}
return Unauthorized();
}
catch (Exception e)
{
Log.Error(e.Message);
return StatusCode((int)System.Net.HttpStatusCode.InternalServerError);
}
}
/// <summary>
/// Use to transmit an existing loan application to CRIF Lending
/// </summary>
/// <remarks>
/// Transmits a loan application to CRIF lending given a loan storage id (not a loan id).
/// Cookies: None required or set.
/// Security: None required or provided.
/// </remarks>
// Post: api/LoanApplication/Submit/:id
[HttpPost("Submit/{cosmosId}")]
[ProducesResponseType(typeof(LoanApplication), 201)]
[ProducesResponseType(typeof(ModelStateDictionary), 400)]
[ProducesResponseType(404)]
[ProducesResponseType(520)]
[ProducesResponseType(500)]
public async Task<IActionResult> Post(string cosmosId)
{
try
{
if (string.IsNullOrEmpty(cosmosId))
{
return BadRequest("Id is required.");
}
LoanContainer loanContainer = loanApplicationStorage.GetContainerById(cosmosId);
if ( (loanContainer.Status != LoanStatus.In_Progress && loanContainer.Status != LoanStatus.Vendor_Error
&& loanContainer.Status != LoanStatus.Application_Error) || loanContainer.LoanApplication == null)
{
return BadRequest("Loan is not in progress. Unable to submit.");
}
LoanApplication loanApplication = loanContainer.LoanApplication;
var memberInfo = _tokenService.GetAuthenticatedDataFromCookie(HttpContext);
if (memberInfo != null)
{
loanApplication.PrimaryBorrower.CustomerNumber = memberInfo.MemberID;
}
var validator = new LoanApplicationValidator();
var validatorResult = validator.Validate(loanApplication);
var catchError = "";
if (!validatorResult.IsValid)
{
foreach (var error in validatorResult.Errors)
{
catchError += error;
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
loanApplicationStorage.UpdateError(loanContainer.Id, new LoanError(LoanErrorCode.Error_Submitting, catchError));
loanApplicationStorage.UpdateStatus(loanContainer.Id, LoanStatus.Application_Error);
Log.Error($"Could not submit application, errors follow: {@ModelState}");
return BadRequest(ModelState);
}
// CRIF id will be assigned
loanApplication.Id = await VendorClient.CreateApplicationAndGetId(loanApplication, branchStorage);
// Submit files to ACTion
foreach(var file in loanApplication.Files)
{
var fileBase64Value = _blobStorageAccess.GetBlobAsBase64(file);
var fileSubmitted = true;
bool shouldUploadFileToAction = false;
var vehicleTypes = new List<LoanType> { LoanType.V_Vehicle, LoanType.V_VehiclePreapproval, LoanType.P_Powersport, LoanType.T_TrailerMotorhome, LoanType.M_Boat };
if (vehicleTypes.Contains((LoanType)loanApplication.LoanType))
{
if (file.Description.Equals("DNR Registration Card", StringComparison.OrdinalIgnoreCase))
{
shouldUploadFileToAction = true;
}
else
{
shouldUploadFileToAction = loanApplication.Purpose == LoanPurpose.Purpose.Purchase;
}
} else if (loanApplication.LoanType != LoanType.EQ_HELOC_Flex)
{
shouldUploadFileToAction = true;
} else if (loanApplication.LoanType == LoanType.EQ_HELOC_Flex && loanApplication.RequestedAmount > 100000)
{
shouldUploadFileToAction = true;
} else if (!file.Description.Equals("Proof of Income", StringComparison.OrdinalIgnoreCase))
{
shouldUploadFileToAction = true;
}
if (shouldUploadFileToAction)
{
fileSubmitted = await VendorClient.AddFile(loanApplication.Id, file, fileBase64Value);
}
if(fileSubmitted)
{
_blobStorageAccess.DeleteBlob(file);
} else
{
Log.Error($"File not sent to ACTion, not deleting file: {file}");
}
}
loanApplicationStorage.UpdateStatus(cosmosId, LoanStatus.Submitted);
loanApplicationStorage.UpdateError(loanContainer.Id, new LoanError(LoanErrorCode.No_Error, "No_Error"));
loanApplicationStorage.UpdateLoanId(loanContainer.Id, loanApplication.Id);
loanApplicationStorage.DeleteApplication(cosmosId);
return Ok(new { loanApplicationId = loanApplication.Id });
}
catch (KeyNotFoundException ex)
{
Log.Error(ex.Message);
return NotFound("An application with that Id could not be found");
}
catch (InvalidDataException ie)
{
loanApplicationStorage.UpdateError(cosmosId, new LoanError(LoanErrorCode.Error_Submitting, ie.Message));
loanApplicationStorage.UpdateStatus(cosmosId, LoanStatus.Application_Error);
return StatusCode((int)System.Net.HttpStatusCode.InternalServerError);
}
catch (TimeoutException te)
{
loanApplicationStorage.UpdateError(cosmosId, new LoanError(LoanErrorCode.Error_Submitting, "The time taken to submit the loan application to the vendor has exceded the configured timeout length."));
Log.Error(te.Message);
loanApplicationStorage.UpdateStatus(cosmosId, LoanStatus.Vendor_Error);
return BadRequest("The time taken to submit the loan application to the vendor has exceeded the configured timeout length.");
}
catch (ArgumentException ae)
{
loanApplicationStorage.UpdateError(cosmosId, new LoanError(LoanErrorCode.Error_Submitting, "Invalid argument."));
loanApplicationStorage.UpdateStatus(cosmosId, LoanStatus.Vendor_Error);
Log.Error(ae.Message);
return BadRequest(ae.Message);
}
catch (FormatException fe)
{
loanApplicationStorage.UpdateError(cosmosId, new LoanError(LoanErrorCode.Error_Submitting, "Vendor returned unexpected response."));
loanApplicationStorage.UpdateStatus(cosmosId, LoanStatus.Vendor_Error);
Log.Error(fe.Message);
return StatusCode(520, fe.Message);
//Cloudflare uses an HTTP 520 when the external "server returns something unexpected"[1]
//We return this when we don't understand CRIF's response
//[1] https://support.cloudflare.com/hc/en-us/articles/206973867-Error-analytics-by-colo
}
catch (Exception e)
{
loanApplicationStorage.UpdateError(cosmosId, new LoanError(LoanErrorCode.Error_Submitting, "Unexpected exception when submitting application."));
loanApplicationStorage.UpdateStatus(cosmosId, LoanStatus.Vendor_Error);
Log.Error(e.Message);
return StatusCode((int)System.Net.HttpStatusCode.InternalServerError);
}
}
/// <summary>
/// Use to transmit a new loan application directly to CRIF Lending
/// </summary>
/// <remarks>
/// Transmits a loan application directly to CRIF Lending given a valid loan application.
/// Cookies: None required or set.
/// Security: None required or provided.
/// </remarks>
// Post: api/LoanApplication/
[HttpPost]
[ProducesResponseType(typeof(LoanApplication), 201)]
[ProducesResponseType(typeof(Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary), 400)]
[ProducesResponseType(520)]
[ProducesResponseType(500)]
public async Task<IActionResult> Post([FromBody] LoanApplication loanApplication)
{
try
{
var validator = new LoanApplicationValidator();
var validatorResult = validator.Validate(loanApplication);
if (!validatorResult.IsValid)
{
foreach (var error in validatorResult.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
Log.Error($"Could not submit application, [Possibly third party], errors follow: {@ModelState}");
return BadRequest(ModelState);
}
var application = await VendorClient.CreateApplication(loanApplication, branchStorage);
return CreatedAtRoute("GetApplication", new { loanApplicationId = application.Id }, application);
}
catch (TimeoutException te)
{
Log.Error(te.Message);
return BadRequest("The time taken to submit the loan application to the vendor has exceeded the configured timeout length.");
}
catch (ArgumentException ae)
{
Log.Error(ae.Message);
return BadRequest(ae.Message);
}
catch (FormatException fe)
{
Log.Error(fe.Message);
return StatusCode(520, fe.Message);
//Cloudflare uses an HTTP 520 when the external "server returns something unexpected"[1]
//We return this when we don't understand CRIF's response
//[1] https://support.cloudflare.com/hc/en-us/articles/206973867-Error-analytics-by-colo
}
catch (Exception e)
{
Log.Error(e.Message);
return StatusCode((int)System.Net.HttpStatusCode.InternalServerError);
}
}
}
}