using System; using System.Text; using System.Data; using System.Linq; using System.Web.Mvc; using System.Data.Entity.Validation; using System.Collections.Generic; using Neo.Afx.Mvc; using Neo.Afx.ViewModels; using Neo.Afx.ComponentModel; using Neo.Afx.Services.Integration; using Neo.Afx.Services.Workflows; using Neo.Afx.Services.Workflows.Entities; using Neo.Afx.Services.Forms.Entities; using Neo.LegitimateLicences.Common; using Neo.LegitimateLicences.Data.Models; using Neo.LegitimateLicences.LicenceIssueRequest.Models; using Neo.LegitimateLicences.Data; using Neo.Afx.Services.Audits; using Neo.LegitimateLicences.Common.Helpers; using Neo.LegitimateLicences.LicenceIssueRequest.Models.Database; using LicenceIssueDetail = Neo.LegitimateLicences.LicenceIssueRequest.Models.Database.LicenceIssueDetail; using Vehicle = Neo.LegitimateLicences.LicenceIssueRequest.Models.Database.Vehicle; using Licence = Neo.LegitimateLicences.LicenceIssueRequest.Models.Database.Licence; using Member = Neo.LegitimateLicences.LicenceIssueRequest.Models.Database.Member; using Neo.Afx.Common; using Neo.LegitimateLicences.Logic.eNatis; using System.Configuration; using System.Net.Http; using System.Security.Cryptography.X509Certificates; using System.Net.Http.Headers; using System.Xml; using Newtonsoft.Json; using System.Net; using Neo.LegitimateLicences.ApplicationRequest.Models.Database; namespace Neo.LegitimateLicences.LicenceIssueRequest { public partial class LicenceIssueRequestController { #region Save /// /// Save LicenceIssueRequest details /// /// /// [HttpPost] public JsonNetResult Save(LicenceIssueDetail value) { var changes = new List(); var memberChanges = new List(); var licenceChanges = new List(); var vehicleChanges = new List(); var auditDb = new AuditsDatabase(); var legitimateDb = new LegitimateDatabase(); // eNatis Variables bool eNatisCheckValid = false; var loginResult = string.Empty; var eNatisCheckErrorMessage = string.Empty; var appDb = new ApplicationDatabase(); //don't use database context as we need the values refreshed every time var legitimateDatabase = new LegitimateDatabase(); if (value.LicenceIssueDetailID == Database.NewID) { throw new Exception("#EXPOSE_ERROR#New licence records can not be created manually."); } else { try { var liDb = new LicenceIssueDatabase(); //don't use database as we need the values refreshed every time var existingModel = liDb.GetLicenceIssueForm(value.LicenceIssueDetailID, Profile.UserID); //if (existingModel != null && existingModel.UpdatedDate.ToLongTimeString() != value.UpdatedDate.ToLongTimeString()) //{ // return new JsonNetResult() // { // Data = new JsonBaseResult() // { // Success = false, // ErrorCode = "CONFLICT", // Error = "#EXPOSE_ERROR#The record has been modified by someone else. Please try again." // } // }; //} value.UpdatedDate = DateTime.Now; value.UpdatedByID = Profile.UserID; #region Get active user task var itemId = WebHelper.ItemID; var taskId = WebHelper.TaskID; Vw_WorkflowInstanceTask activeUserTask = null; if (taskId != -1) { activeUserTask = WorkflowHelper.GetActiveUserUserTaskByID((long)EntityEnum.LicenceIssueDetails, itemId, taskId, Profile.UserID); } if (activeUserTask == null) { activeUserTask = WorkflowHelper.GetActiveUserTask(Profile.UserID, EntityEnum.LicenceIssueDetails, itemId); } #endregion #region Check if can save var canSubmit = (activeUserTask != null && activeUserTask.WorkflowTemplateStepTypeID == (short)WorkflowTemplateStepTypeEnum.DataCapture); if (!canSubmit) { throw new Exception("#EXPOSE_ERROR#Security error"); } #endregion //make sure the VIN number is not linked to any other licence before saving if (value.Licence != null && value.Vehicle != null && !string.IsNullOrEmpty(value.Vehicle.VIN)) { // Throws if the VIN is linked to ANOTHER licence (existing behaviour). This // does NOT catch a VIN that exists in Verified.Vehicles but is linked to no // licence (an orphan left by a previous partial save). try { legitimateDb.CheckIfvehicleIsInUse(value.Licence.OperatingLicenceNumber, value.Vehicle.VIN, Profile.UserID); } catch (Exception) { // The SP flags a VIN that's on another licence but doesn't name it. Enrich // the message with the licence number(s) when we can; otherwise rethrow as-is. var inUseLicences = Database.GetLicencesLinkedByVIN(value.Vehicle.VIN, Profile.UserID); if (!string.IsNullOrEmpty(inUseLicences)) { throw new Exception("#EXPOSE_ERROR#The vehicle (chassis " + value.Vehicle.VIN + ") is already in use on licence(s): " + inUseLicences + ". Please delink it from that licence before using it here."); } throw; } // Duplicate-VIN guard. Covers BOTH inserting a new vehicle AND editing an // existing vehicle's VIN to one already held by a DIFFERENT row. Conflict = // a row with this VIN exists and it isn't this same vehicle (NewID never matches // a real id, so new captures always qualify; an unchanged VIN matches self and is // skipped). Without this the save hits IX_Vehicles_VIN and surfaces only as a // generic "An error has occurred". var existingVehicleId = Database.GetVehicleIdByExactVIN(value.Vehicle.VIN); if (existingVehicleId != 0 && existingVehicleId != value.Vehicle.VehicleID) { // 1. Linked to a verified licence -> tell the user to delink (existing flow). var linkedLicences = Database.GetLicencesLinkedByVIN(value.Vehicle.VIN, Profile.UserID); if (!string.IsNullOrEmpty(linkedLicences)) { throw new Exception("#EXPOSE_ERROR#The chassis number (" + value.Vehicle.VIN + ") is already linked to the following licence(s): " + linkedLicences + ". Please delink the vehicle from that licence before proceeding."); } // 2. Not on a verified licence, but reserved by a running licence-issue or // application workflow -> it's spoken for, NOT an orphan. Report it as linked // to another licence rather than attempting a (futile) cleanse. var inProgress = Database.GetVehicleInProgressUsage(value.Vehicle.VIN); if (!string.IsNullOrEmpty(inProgress)) { throw new Exception("#EXPOSE_ERROR#The chassis number (" + value.Vehicle.VIN + ") is already linked to another licence that is currently being processed (" + inProgress + "). That licence must be completed or cancelled, or the vehicle delinked there, before it can be used here."); } // 3. True orphan: exists but linked to nothing. Fast targeted release, then retry. // Best-effort: a failure here must not surface as a generic error. try { Database.CleanseUnusedVehicleData(value.Vehicle.VIN); } catch { /* best-effort release */ } // Safety net: if the row somehow survived the cleanse, don't tell the user to // "Save again" (it would loop). Surface a clear message instead. if (Database.GetVehicleIdByExactVIN(value.Vehicle.VIN) != 0) { throw new Exception("#EXPOSE_ERROR#The chassis number (" + value.Vehicle.VIN + ") already exists and could not be automatically released. Please contact support."); } throw new Exception("#EXPOSE_ERROR#The chassis number (" + value.Vehicle.VIN + ") already existed in the system but was not linked to any licence. " + "An automatic cleanup has been run to release it - please click Save again to continue."); } } // Make sure the object we are saving is the same is in the current context var current = Database.Context.LicenceIssueDetails.Where(a => a.LicenceIssueDetailID == value.LicenceIssueDetailID).FirstOrDefault(); Database.Context.Entry(current).CurrentValues.SetValues(value); Database.Context.Entry(current).State = System.Data.Entity.EntityState.Modified; #region Vehicle current.Vehicle = value.Vehicle; // do eNatis check on vehicle here [DS20250117] try { // check to make sure the cookie is still valid var cookieCheck = eNatisCookieVerification(); switch (cookieCheck) { case "VALID": break; case "INVALID": try { loginResult = eNatisLogin(); } catch (Exception ex) { return new JsonNetResult() { Data = new JsonBaseResult() { Success = false, ErrorCode = "ERROR", Error = "Unable to connect to eNatis at the moment. Please check your internet connection and try again.If the issue persists, please contact support." } }; } if (loginResult == "SUCCESS") { cookieCheck = "VALID"; } else if (loginResult == "MAX_SESSIONS_IN_USE") { eNatisCheckErrorMessage += "eNatis has reached the maximum number of active sessions. Please try again later or contact support if the issue persists."; } break; } if (cookieCheck == "VALID") { try { var queryResult = eNatisVehicleQuery(current.Vehicle.RegistrationNumber, false); switch (queryResult.ErrorMessage) { case "NOT_LOGGED_IN": loginResult = eNatisLogin(); break; case "FIELD_INVALID": eNatisCheckErrorMessage += "Entered Vehicle Registration Number not found on eNatis. Please rectify."; break; } if (queryResult != null && string.IsNullOrEmpty(eNatisCheckErrorMessage)) { // get the operator details, to validate ID no against vehicle var applicationApplicant = appDb.GetApplicationApplicant(value.ApplicationDetailID, Profile.UserID); // make sure applicant details have been retrieved before doing comparison if (applicationApplicant != null) { // check the eNatis results against what user has captured // if any does not match, stop the process and do not save // ID Number Check - Use slash detection for business registrations bool idNumberMatches = false; if (applicationApplicant.IDNumberOrBusinessRegistrationNo.Contains("/")) { // If system ID has a slash, it's a business - use flexible matching idNumberMatches = DoBusinessRegNumbersMatch( applicationApplicant.IDNumberOrBusinessRegistrationNo, queryResult.OwnerIdNo ); } else if (queryResult.IdDocType == "RSA ID document") { // For RSA ID documents, do exact string comparison idNumberMatches = applicationApplicant.IDNumberOrBusinessRegistrationNo == queryResult.OwnerIdNo; } else { // For all other types, fall back to exact comparison idNumberMatches = applicationApplicant.IDNumberOrBusinessRegistrationNo == queryResult.OwnerIdNo; } if (idNumberMatches) { eNatisCheckValid = true; } else { eNatisCheckValid = false; eNatisCheckErrorMessage += $"Owner ID or Business Registration number mismatch. System: [{applicationApplicant.IDNumberOrBusinessRegistrationNo}], eNatis: [{queryResult.OwnerIdNo}]. Please rectify.
"; } // Vehicle VIN Check (case-insensitive, trimmed — eNatis returns uppercase // while capturers may type any case). string capturedVin = (current.Vehicle.VIN ?? string.Empty).Trim(); string eNatisVin = (queryResult.VinOrChassis ?? string.Empty).Trim(); if (capturedVin.Length > 0 && string.Equals(capturedVin, eNatisVin, StringComparison.OrdinalIgnoreCase)) { eNatisCheckValid = eNatisCheckValid && true; } else { eNatisCheckValid = false; eNatisCheckErrorMessage += $"VIN or Chassis Number mismatch. System: [{capturedVin}], eNatis: [{eNatisVin}]. Please rectify.
"; } // Vehicle Engine Number Check (case-insensitive, trimmed). string capturedEngine = (current.Vehicle.EngineNumber ?? string.Empty).Trim(); string eNatisEngine = (queryResult.EngineNo ?? string.Empty).Trim(); if (capturedEngine.Length > 0 && string.Equals(capturedEngine, eNatisEngine, StringComparison.OrdinalIgnoreCase)) { eNatisCheckValid = eNatisCheckValid && true; } else { eNatisCheckValid = false; eNatisCheckErrorMessage += $"Engine Number mismatch. System: [{capturedEngine}], eNatis: [{eNatisEngine}]. Please rectify.
"; } // Vehicle Make Check (string comparison against VehicleMakeDescription // captured on the form — no more FK lookup since Make is free-text now). string capturedMake = (current.Vehicle.VehicleMakeDescription ?? "").Trim(); string eNatisMake = (queryResult.VehicleMake ?? "").Trim(); if (capturedMake.Length > 0 && string.Equals(capturedMake, eNatisMake, StringComparison.OrdinalIgnoreCase)) { eNatisCheckValid = eNatisCheckValid && true; } else { eNatisCheckValid = false; eNatisCheckErrorMessage += $"Vehicle Make mismatch. System: [{capturedMake}], eNatis: [{eNatisMake}]. Please rectify.
"; } // Vehicle Model Check (string comparison against VehicleModelDescription). string capturedModel = (current.Vehicle.VehicleModelDescription ?? "").Trim(); string eNatisModel = (queryResult.VehicleModel ?? "").Trim(); if (capturedModel.Length > 0 && string.Equals(capturedModel, eNatisModel, StringComparison.OrdinalIgnoreCase)) { eNatisCheckValid = eNatisCheckValid && true; } else { eNatisCheckValid = false; eNatisCheckErrorMessage += $"Vehicle Model mismatch. System: [{capturedModel}], eNatis: [{eNatisModel}]. Please rectify.
"; } } } } catch (Exception ex) { return new JsonNetResult() { Data = new JsonBaseResult() { Success = false, ErrorCode = "ERROR", Error = "Unable to query vehicle details from eNatis at the moment. Please check your internet connection or try again later. If the issue persists, please contact support." } }; } } } finally { // Logout from eNatis after validation is complete to prevent session leakage try { eNatisLogout(); } catch (Exception logoutEx) { // Non-critical error, just log it ErrorHelper.LogError(logoutEx, new Dictionary { { "Method", "eNatisLogout" } }); } } // check if any errors has occured if (!string.IsNullOrEmpty(eNatisCheckErrorMessage)) { return new JsonNetResult() { Data = new JsonBaseResult() { Success = false, ErrorCode = "ERROR", Error = eNatisCheckErrorMessage } }; } if (eNatisCheckValid && string.IsNullOrEmpty(eNatisCheckErrorMessage)) { // Normalise the identifier fields before save so what we store matches // what eNatis returns (uppercase, no surrounding whitespace). if (current.Vehicle != null) { current.Vehicle.VIN = (current.Vehicle.VIN ?? string.Empty).Trim().ToUpperInvariant(); current.Vehicle.EngineNumber = (current.Vehicle.EngineNumber ?? string.Empty).Trim().ToUpperInvariant(); current.Vehicle.RegistrationNumber = (current.Vehicle.RegistrationNumber ?? string.Empty).Trim().ToUpperInvariant(); } if (current.Vehicle == null || current.Vehicle.VehicleID == Database.NewID) { Database.Insert(current.Vehicle); changes.Add(string.Format("{0}, Added vehicle {1}, {2}", "Vehicles", current.Vehicle.VIN, current.Vehicle.RegistrationNumber)); } else { Database.Update(current.Vehicle); vehicleChanges.AddRange(auditDb.GetEntityDifferences(existingModel.Vehicle, current.Vehicle, "VehicleID")); } // VehicleMake/Model/TypeDescription aren't EF-mapped yet (added by DBUp // script 109; .edmx still to be regenerated). Persist them via a direct // UPDATE so the form's values survive the save. Database.Save(); liDb.UpdateVehicleENatisDescriptions( current.Vehicle.VehicleID, current.Vehicle.VehicleMakeDescription, current.Vehicle.VehicleModelDescription, current.Vehicle.VehicleTypeDescription); } #endregion #region Licence if (value.Application.ApplicationRequestTypeID == (short)Neo.LegitimateLicences.Common.ApplicationRequestTypeEnum.TemporarySpecialLicence) { //this logic must be updated in the APP save as well if you adjust it if (((TimeSpan)(value.Licence.DateOfExpiry - value.Licence.DateOfIssue)).TotalDays > 14) { throw new Exception("#EXPOSE_ERROR#A special licence duration is not allowed to exceed 14 days."); } else if (value.Licence.DateOfExpiry.Value < value.Licence.DateOfIssue) { throw new Exception("#EXPOSE_ERROR#The issue date must be before or the same as the expiry date."); } Database.Update(value.Licence); licenceChanges.AddRange(auditDb.GetEntityDifferences(existingModel.Licence, value.Licence, "LicenceID")); } else { // Normal (non-temporary) licences: clerk picks DateOfExpiry on the // Licence Issuing Details screen. DateOfIssue is stamped at print, not here. // DateOfExpiry stays editable for the life of the licence, so the clerk // can correct mistakes or extend validity even after first print. if (value.Licence != null && value.Licence.DateOfExpiry.HasValue) { if (value.Licence.DateOfExpiry.Value.Date < DateTime.Today) { throw new Exception("#EXPOSE_ERROR#Date of Expiry cannot be in the past."); } if (value.Licence.DateOfExpiry != existingModel.Licence.DateOfExpiry) { liDb.UpdateLicenceDateOfExpiry(value.Licence.LicenceID, value.Licence.DateOfExpiry); licenceChanges.Add(string.Format("DateOfExpiry changed from {0} to {1}", existingModel.Licence.DateOfExpiry, value.Licence.DateOfExpiry)); } } } #endregion #region Member Database.Update(value.Licence.Member); memberChanges.AddRange(auditDb.GetEntityDifferences(existingModel.Licence.Member, value.Licence.Member, "MemberID")); #endregion #region Licence Issue Detail Database.Update(current); changes.AddRange(auditDb.GetEntityDifferences(existingModel, value, "LicenceIssueDetailID")); #endregion #region Do save try { Database.Save(); Database.LicenceIssuePostSave(value.LicenceIssueDetailID, Profile.UserID); if (changes.Count > 0) { auditDb.WriteEntries((short)AuditSourceEnum.Application, (short)AuditActionEnum.Updated, changes, (short)EntityEnum.LicenceIssueDetails, value.LicenceIssueDetailID, Profile.UserID); } if (memberChanges.Count > 0) { auditDb.WriteEntries((short)AuditSourceEnum.Application, (short)AuditActionEnum.Updated, memberChanges, (short)EntityEnum.LicenceIssueDetails, value.LicenceIssueDetailID, Profile.UserID); auditDb.WriteEntries((short)AuditSourceEnum.Application, (short)AuditActionEnum.Updated, memberChanges, (short)EntityEnum.Members, value.Licence.MemberID, Profile.UserID); } if (licenceChanges.Count > 0) { auditDb.WriteEntries((short)AuditSourceEnum.Application, (short)AuditActionEnum.Updated, licenceChanges, (short)EntityEnum.LicenceIssueDetails, value.LicenceIssueDetailID, Profile.UserID); auditDb.WriteEntries((short)AuditSourceEnum.Application, (short)AuditActionEnum.Updated, licenceChanges, (short)EntityEnum.OperatingLicences, value.Licence.LicenceID, Profile.UserID); } if (vehicleChanges.Count > 0) { auditDb.WriteEntries((short)AuditSourceEnum.Application, (short)AuditActionEnum.Updated, vehicleChanges, (short)EntityEnum.LicenceIssueDetails, value.LicenceIssueDetailID, Profile.UserID); if (value.Licence != null && value.Licence.VehicleID.HasValue) { auditDb.WriteEntries((short)AuditSourceEnum.Application, (short)AuditActionEnum.Updated, vehicleChanges, (short)EntityEnum.Vehicles, value.Licence.VehicleID.Value, Profile.UserID); } } } catch (DbEntityValidationException dbEx) { foreach (var validationError in dbEx.EntityValidationErrors.SelectMany(validationErrors => validationErrors.ValidationErrors)) { return new JsonNetResult() { Data = new JsonBaseResult() { Success = false, ErrorCode = "ERROR", Error = string.Format("Property: {0} Error: {1}", validationError.PropertyName, validationError.ErrorMessage) } }; } } catch (Exception ex) { #region Error Handling #region Error Additional Data Dictionary additionalData = new Dictionary(); additionalData.Add("value", value); #endregion var errorMessage = ErrorHelper.ProcessError(ex, "LicenceIssueRequest", "Save", additionalData); #endregion var message = (ex.InnerException != null && ex.InnerException.InnerException != null) ? ex.InnerException.InnerException.Message : ex.Message; if (!string.IsNullOrEmpty(message) && message.ToLower().Contains("ix_vehicles")) { var chassis = (value.Vehicle != null && !string.IsNullOrEmpty(value.Vehicle.VIN)) ? value.Vehicle.VIN : "blank"; var linkedLicences = Database.GetLicencesLinkedByVIN(value.Vehicle.VIN, Profile.UserID); if (string.IsNullOrEmpty(linkedLicences)) { // Defensive: a VIN orphaned between the pre-insert guard and this // insert. Release just this VIN now (fast) so the retry succeeds. try { Database.CleanseUnusedVehicleData(value.Vehicle.VIN); } catch { /* best-effort release */ } message = "The specified chassis number (" + chassis + ") could not be saved because a conflicting unlinked vehicle record was present. An automatic cleanup has been run - please try saving again."; } else { message = "The specified chassis number (" + chassis + ") is already linked to the following application(s): " + Database.GetLicencesLinkedByVIN(value.Vehicle.VIN, Profile.UserID); } } return new JsonNetResult() { Data = new JsonBaseResult() { Success = false, ErrorCode = "ERROR", Error = string.Format("Error: {0}", message) } }; } #endregion } catch (Exception ex) { #region Error Handling #region Error Additional Data Dictionary additionalData = new Dictionary(); additionalData.Add("value", value); #endregion var errorMessage = ErrorHelper.ProcessError(ex, "LicenceIssueRequest", "Save", additionalData); #endregion return new JsonNetResult() { Data = new JsonBaseResult() { Success = false, ErrorCode = "ERROR", Error = errorMessage } }; } return new JsonNetResult() { Data = new JsonBaseResult() { Success = true, Error = "", ErrorCode = "" } }; } } #endregion #region eNatis Check private static string loginURL = ConfigurationManager.AppSettings["eNatisLoginURL"]; private static string eNatisQueryURL = ConfigurationManager.AppSettings["eNatisQueryURL"]; /// /// Cookie proxy that reads from the shared . Kept /// for legacy call sites that referenced the instance field directly. /// private string _cookie { get { return ENatisSession.Cookie; } } public string eNatisLogin() { try { ENatisSession.EnsureSession(); return "SUCCESS"; } catch (MaxSessionsInUseException) { return "MAX_SESSIONS_IN_USE"; } catch (Exception ex) { ErrorHelper.LogError(ex, new Dictionary { { "Method", "eNatisLogin" } }); throw; } } #if FALSE private string eNatisLoginLegacy() { // Set up HttpClientHandler with the certificate var handler = new WebRequestHandler { ClientCertificateOptions = ClientCertificateOption.Manual }; // Load the certificate with proper key storage flags for server environments string certificatePath = ConfigurationManager.AppSettings["eNatisCertificatePath"]; string certificatePassword = ConfigurationManager.AppSettings["eNatisCertificatePassword"]; // Validate certificate path exists if (string.IsNullOrEmpty(certificatePath)) { throw new Exception("eNatisCertificatePath is not configured in web.config"); } if (!System.IO.File.Exists(certificatePath)) { throw new Exception($"Certificate file not found at path: {certificatePath}. Please verify the certificate path is correct and the file is accessible on the server."); } // Load certificate with MachineKeySet and PersistKeySet flags for IIS/server compatibility X509Certificate2 certificate; try { certificate = new X509Certificate2(certificatePath, certificatePassword, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet); } catch (Exception certEx) { throw new Exception($"Failed to load certificate from {certificatePath}. Error: {certEx.Message}. Please verify the certificate file is valid and the password is correct.", certEx); } handler.ClientCertificates.Add(certificate); // Support both TLS 1.1 and TLS 1.2 for eNatis connectivity System.Net.ServicePointManager.SecurityProtocol = System.Net.SecurityProtocolType.Tls12 | System.Net.SecurityProtocolType.Tls11; System.Net.ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, sslPolicyErrors) => true; // The XML content to send in the POST request var xmlContent = $@" {ConfigurationManager.AppSettings["eNatisOpModInd"]} {ConfigurationManager.AppSettings["eNatisESTxanID"]} {ConfigurationManager.AppSettings["eNatisESUserN"]} {ConfigurationManager.AppSettings["eNatisESUID"]} {ConfigurationManager.AppSettings["eNatisEStermID"]} {ConfigurationManager.AppSettings["eNatisUsername"]} {ConfigurationManager.AppSettings["eNatisPassword"]} {ConfigurationManager.AppSettings["eNatisTxanType"]} "; var content = new StringContent(xmlContent, Encoding.UTF8, "application/xml"); using (var client = new HttpClient(handler)) { // Example: Add Authorization token if needed var username = ConfigurationManager.AppSettings["eNatisUsername"]; var password = ConfigurationManager.AppSettings["eNatisPassword"]; var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); HttpStatusCode? httpStatusCode = null; string responseContent = string.Empty; try { // Perform the POST request var response = client.PostAsync(loginURL, content).Result; httpStatusCode = response.StatusCode; // Read the response content early for logging if needed responseContent = response.Content.ReadAsStringAsync().Result; // Check if the response contains the "Set-Cookie" header if (response.Headers.Contains("Set-Cookie")) { // Retrieve the cookies from the "Set-Cookie" header var cookies = response.Headers.GetValues("Set-Cookie"); foreach (var c in cookies) { _cookie = c; } } // Load the XML into an XmlDocument XmlDocument xmlDoc = new XmlDocument(); xmlDoc.LoadXml(responseContent); // Convert the XmlDocument to a JSON string string json = JsonConvert.SerializeXmlNode(xmlDoc); // Parse the JSON into a dynamic object dynamic jsonObj = JsonConvert.DeserializeObject(json); //Get the status of the request var requestState = jsonObj.X4000Resp.TxState.Value; return requestState; } catch (Exception ex) { // Log detailed error for troubleshooting var additionalData = new Dictionary(); additionalData.Add("loginURL", loginURL); additionalData.Add("certificatePath", certificatePath); additionalData.Add("HttpStatusCode", httpStatusCode?.ToString() ?? "N/A"); additionalData.Add("ResponseContent", responseContent ?? "N/A"); ErrorHelper.LogError(ex, new Dictionary { { "Method", "eNatisLogin" } }, additionalData); // Re-throw with a more descriptive message if it's a connection issue if (ex.Message.Contains("SSL/TLS") || ex.Message.Contains("secure channel") || ex.InnerException?.Message.Contains("SSL/TLS") == true) { throw new Exception("#EXPOSE_ERROR#Failed to establish a secure connection to eNatis (TLS error). Please ensure the server supports TLS 1.2 and the certificate is valid.", ex); } throw; } } } #endif public string eNatisLogout() { ENatisSession.ForceLogout(); return "SUCCESS"; } #if FALSE private string eNatisLogoutLegacy() { if (string.IsNullOrEmpty(_cookie)) { return "NO_SESSION"; } // Create the cookie container and add the session cookie CookieContainer cookieContainer = new CookieContainer(); try { int startIndex = _cookie.IndexOf("="); int endIndex = _cookie.IndexOf(";", startIndex + 1); if (endIndex == -1) endIndex = _cookie.Length; string cookieValue = _cookie.Substring(startIndex + 1, endIndex - startIndex - 1); cookieContainer.Add(new Uri(loginURL), new Cookie("JSESSIONID", cookieValue)); } catch { /* Ignore cookie parsing errors */ } // Set up HttpClientHandler with the certificate and cookie container var handler = new WebRequestHandler { ClientCertificateOptions = ClientCertificateOption.Manual, CookieContainer = cookieContainer }; // Load the certificate string certificatePath = ConfigurationManager.AppSettings["eNatisCertificatePath"]; string certificatePassword = ConfigurationManager.AppSettings["eNatisCertificatePassword"]; X509Certificate2 certificate = new X509Certificate2(certificatePath, certificatePassword, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet); handler.ClientCertificates.Add(certificate); // Support both TLS 1.1 and TLS 1.2 System.Net.ServicePointManager.SecurityProtocol = System.Net.SecurityProtocolType.Tls12 | System.Net.SecurityProtocolType.Tls11; System.Net.ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, sslPolicyErrors) => true; // The XML content to send in the POST request (CLOSE transaction) var xmlContent = $@" {ConfigurationManager.AppSettings["eNatisOpModInd"]} {ConfigurationManager.AppSettings["eNatisESTxanID"]} {ConfigurationManager.AppSettings["eNatisESUserN"]} {ConfigurationManager.AppSettings["eNatisESUID"]} {ConfigurationManager.AppSettings["eNatisEStermID"]} {ConfigurationManager.AppSettings["eNatisUsername"]} {ConfigurationManager.AppSettings["eNatisPassword"]} CLOSE "; var content = new StringContent(xmlContent, Encoding.UTF8, "application/xml"); using (var client = new HttpClient(handler)) { var username = ConfigurationManager.AppSettings["eNatisUsername"]; var password = ConfigurationManager.AppSettings["eNatisPassword"]; var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); try { var response = client.PostAsync(loginURL, content).Result; _cookie = string.Empty; // Clear the cookie after logout return "SUCCESS"; } catch (Exception ex) { ErrorHelper.LogError(ex, new Dictionary { { "Method", "eNatisLogout" } }); return "ERROR"; } } } #endif public LicenceIssueRequesteNatisModel eNatisVehicleQuery(string vehicleRegistrationNumber, bool cookieCheck, string searchType = "registration") { // eNatis is case-sensitive on registration / VIN / engine numbers and rejects // whitespace. Normalise once at the entry point so the rest of the code (and // the tag-variant retry loop) only deals with canonical values. var normalised = (vehicleRegistrationNumber ?? string.Empty).Trim().ToUpperInvariant(); // Run the eNatis vehicle query under the shared session gate. ENatisSession // owns login/logout lifecycle and serialises traffic so we never exceed // one concurrent eNatis session. return ENatisSession.WithSession(cookie => { return eNatisVehicleQueryInternal(normalised, cookieCheck, searchType, cookie); }); } private LicenceIssueRequesteNatisModel eNatisVehicleQueryInternal(string vehicleRegistrationNumber, bool cookieCheck, string searchType, string cookie) { var model = new LicenceIssueRequesteNatisModel(); // Create the cookie container and add a cookie CookieContainer cookieContainer = new CookieContainer(); int startIndex = cookie.IndexOf("="); int endIndex = cookie.IndexOf(";", startIndex + 1); if (endIndex == -1) endIndex = cookie.Length; cookieContainer.Add(new Uri(eNatisQueryURL), new Cookie("JSESSIONID", cookie.Substring(startIndex + 1, endIndex - startIndex - 1))); // Set up HttpClientHandler with the certificate var handler = new WebRequestHandler { ClientCertificateOptions = ClientCertificateOption.Manual }; // Load the certificate with proper key storage flags for server environments string certificatePath = ConfigurationManager.AppSettings["eNatisCertificatePath"]; string certificatePassword = ConfigurationManager.AppSettings["eNatisCertificatePassword"]; // Validate certificate path exists if (string.IsNullOrEmpty(certificatePath)) { throw new Exception("eNatisCertificatePath is not configured in web.config"); } if (!System.IO.File.Exists(certificatePath)) { throw new Exception($"Certificate file not found at path: {certificatePath}. Please verify the certificate path is correct and the file is accessible on the server."); } // Load certificate with MachineKeySet and PersistKeySet flags for IIS/server compatibility X509Certificate2 certificate; try { certificate = new X509Certificate2(certificatePath, certificatePassword, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet); } catch (Exception certEx) { throw new Exception($"Failed to load certificate from {certificatePath}. Error: {certEx.Message}. Please verify the certificate file is valid and the password is correct.", certEx); } handler.ClientCertificates.Add(certificate); handler.CookieContainer = cookieContainer; // Support both TLS 1.1 and TLS 1.2 for eNatis connectivity System.Net.ServicePointManager.SecurityProtocol = System.Net.SecurityProtocolType.Tls12 | System.Net.SecurityProtocolType.Tls11; System.Net.ServicePointManager.ServerCertificateValidationCallback = (sender, cert, chain, sslPolicyErrors) => true; using (var client = new HttpClient(handler)) { // Phase 2k: try each tag variant for the chosen search type until eNatis returns // vehicle data. Captures the case where a number plate the user typed is stored // under MVRegN (or MRegN) rather than LicN, etc. var variants = TagVariantsFor(searchType); dynamic jsonObj = null; foreach (var tag in variants) { string xmlContent = BuildLookupXml(tag, vehicleRegistrationNumber); jsonObj = SendXmlRequest(xmlContent, client); // Cookie check just needs any response back — don't retry across tags. if (cookieCheck) break; // Did this variant find a vehicle? If so, stop. if (HasVehicleData(jsonObj)) break; } // Drive the populate branch off actual vehicle-data presence, not a // TxState blocklist — eNatis can return TxStates we don't know about // and used to NRE on jsonObj.X1001Resp.Vehicle.VehicleDet (null) when // we incorrectly assumed it was populated. if (HasVehicleData(jsonObj)) { // valid query, proceed with comparison //Set model values with returned data model.MVRegN = jsonObj.X1001Resp.Vehicle.VehicleDet.MVRegN; model.VinOrChassis = jsonObj.X1001Resp.Vehicle.VehicleDet.VinOrChassis; model.EngineNo = jsonObj.X1001Resp.Vehicle.VehicleDet.EngineN; model.VehicleModel = jsonObj.X1001Resp.Vehicle.VehicleDet.ModelName.Desc; model.VehicleMake = jsonObj.X1001Resp.Vehicle.VehicleDet.Make.Desc; model.OwnerIdNo = jsonObj.X1001Resp.Vehicle.Owner.PerDet.PerId.IdDocN; // Capture the ID document type from the eNatis response model.IdDocType = jsonObj.X1001Resp.Vehicle.Owner.PerDet.PerId.IdDocType.Desc; // Owner name (BusOrSurname). Reference-only on the capture form so // the capturer can spot a mismatch before clicking Save. model.OwnerName = GetJsonString(jsonObj?.X1001Resp?.Vehicle?.Owner?.PerDet?.PerId, "BusOrSurname"); // Additional fields used by the auto-populate (Lookup-from-eNatis) flow. // Field paths confirmed against a real X1001 response (HINO Bus 2008). var det = jsonObj?.X1001Resp?.Vehicle?.VehicleDet; model.LicN = GetJsonString(det, "LicN", "MVRegN", "MRegN"); // Vehicle Description = " - " so an inspector // sees both the regulatory category AND the body type // (e.g. "Light passenger mv (less than 12 persons) - Hatch back"). // Falls back gracefully if either side is missing. var mvCat = GetJsonString(det, "MVCat.Desc", "VehCat.Desc"); var mvDesc = GetJsonString(det, "MVDesc.Desc", "BodyType"); if (!string.IsNullOrWhiteSpace(mvCat) && !string.IsNullOrWhiteSpace(mvDesc)) model.VehicleType = mvCat.Trim() + " - " + mvDesc.Trim(); else model.VehicleType = mvCat ?? mvDesc; // YearOfManufacture: prefer ManufYear; fall back to year of MVLicFirstD (first-licensed date). model.YearOfManufacture = GetJsonString(det, "ManufYear", "ManufYr", "YearOfBuild") ?? ExtractYearFromDate(GetJsonString(det, "MVLicFirstD")); model.GrossMass = GetJsonString(det, "GVM", "GVWR", "PermGVWR", "GrossMass", "GrossWeight"); model.Tare = GetJsonString(det, "Tare", "TareWt", "UnladenMass"); model.SeatedCapacity = GetJsonString(det, "CapSit", "SeatCap", "StCap"); model.StandingCapacity = GetJsonString(det, "CapStand", "StandCap"); model.CarryingCapacity = GetJsonString(det, "CarrCap", "CapCarry", "LoadCap"); } else { // Null-safe TxState read. eNatis returns `FIELD_INVALID` — // Json.NET converts this to a JValue string ("FIELD_INVALID"), not an object // with a Value child. Some responses do wrap with attributes giving // { "Value": "..." }, so we try both shapes. string txState = null; try { var tsNode = jsonObj?.X4000Resp?.TxState; if (tsNode != null) { try { txState = (string)tsNode.Value; } catch { } if (string.IsNullOrWhiteSpace(txState)) try { txState = (string)tsNode; } catch { } } } catch { } if (txState == "FIELD_INVALID" && cookieCheck) { model.CookieCheckPassed = true; } else { model.ErrorMessage = string.IsNullOrEmpty(txState) ? "EMPTY_RESPONSE" : txState; model.CookieCheckPassed = false; } } return model; } } public string eNatisCookieVerification() { // ENatisSession owns the cookie lifecycle (login on first use, refresh // on idle timeout). If a cookie is cached, treat it as valid — the next // eNatisVehicleQuery call will re-login automatically if it has expired. return string.IsNullOrEmpty(ENatisSession.Cookie) ? "INVALID" : "VALID"; } // Phase 2k: candidate tag names per search type. eNatis stores a vehicle's // "registration-like" identifier across LicN / MVRegN / MRegN inconsistently — // a value the capturer types might match any of them. We try each in turn. private static readonly Dictionary _eNatisTagVariants = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "registration", new[] { "LicN", "MVRegN", "MRegN" } }, { "vin", new[] { "VinOrChassis", "VinN" } }, { "engine", new[] { "EngineN", "EngN" } } }; private static string[] TagVariantsFor(string searchType) { string[] result; return _eNatisTagVariants.TryGetValue(searchType ?? "registration", out result) ? result : _eNatisTagVariants["registration"]; } private static bool HasVehicleData(dynamic jsonObj) { try { var det = jsonObj?.X1001Resp?.Vehicle?.VehicleDet; if (det == null) return false; var lic = det.LicN?.ToString(); var reg = det.MVRegN?.ToString(); return !string.IsNullOrWhiteSpace(lic) || !string.IsNullOrWhiteSpace(reg); } catch { return false; } } private string BuildLookupXml(string tagName, string value) { return $@" {ConfigurationManager.AppSettings["eNatisOpModInd"]} {ConfigurationManager.AppSettings["eNatisESTxanID"]} {ConfigurationManager.AppSettings["eNatisESUserN"]} {ConfigurationManager.AppSettings["eNatisESUID"]} {ConfigurationManager.AppSettings["eNatisEStermID"]} <{tagName}>{value} "; } private static string GetJsonString(dynamic obj, params string[] propertyChains) { if (obj == null) return null; foreach (var chain in propertyChains) { try { dynamic node = obj; foreach (var part in chain.Split('.')) { if (node == null) break; node = node[part]; } var s = node?.ToString(); if (!string.IsNullOrWhiteSpace(s)) return s; } catch { } } return null; } private static string ExtractYearFromDate(string dateString) { if (string.IsNullOrWhiteSpace(dateString)) return null; DateTime d; if (DateTime.TryParse(dateString, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out d)) return d.Year.ToString(); return null; } private void ResolveLookupIDs(LicenceIssueRequesteNatisModel m) { var legDb = new LegitimateDatabase(); if (!string.IsNullOrWhiteSpace(m.VehicleMake)) { var make = legDb.GetVehicleMakes().FirstOrDefault(x => x.Description != null && x.Description.Trim().Equals(m.VehicleMake.Trim(), StringComparison.OrdinalIgnoreCase)); m.VehicleMakeID = make?.VehicleMakeID; if (m.VehicleMakeID.HasValue && !string.IsNullOrWhiteSpace(m.VehicleModel)) { var model = legDb.GetVehicleModels().FirstOrDefault(x => x.VehicleMakeID == m.VehicleMakeID && x.Description != null && x.Description.Trim().Equals(m.VehicleModel.Trim(), StringComparison.OrdinalIgnoreCase)); m.VehicleModelID = model?.VehicleModelID; } } if (!string.IsNullOrWhiteSpace(m.VehicleType)) { var vt = legDb.GetVehicleTypes().FirstOrDefault(x => x.Description != null && x.Description.Trim().Equals(m.VehicleType.Trim(), StringComparison.OrdinalIgnoreCase)); m.VehicleTypeID = vt?.VehicleTypeID; } if (!string.IsNullOrWhiteSpace(m.CarryingCapacity)) { var cc = legDb.GetVehicleCarryingCapacities().FirstOrDefault(x => x.Description != null && x.Description.Trim().Equals(m.CarryingCapacity.Trim(), StringComparison.OrdinalIgnoreCase)); m.CarryingCapacityID = cc?.CarryingCapacityID; } } /// /// Reads vehicle details from eNatis without performing the mismatch validation, so the /// data capturer can auto-populate the Vehicle Details form. Lookup key is one of /// "registration" / "vin" / "engine". /// [HttpPost] public JsonNetResult LookupVehicleFromENatis(string searchValue, string searchType) { if (string.IsNullOrWhiteSpace(searchValue)) { return new JsonNetResult { Data = new JsonBaseResult { Success = false, ErrorCode = "ERROR", Error = "Please enter a value to lookup." } }; } var normalisedType = (searchType ?? "registration").ToLower(); if (normalisedType != "registration" && normalisedType != "vin" && normalisedType != "engine") { return new JsonNetResult { Data = new JsonBaseResult { Success = false, ErrorCode = "ERROR", Error = "Invalid lookup type." } }; } try { // Reuse the current session if still valid; otherwise login. var cookieState = eNatisCookieVerification(); if (cookieState == "INVALID") { var loginResult = eNatisLogin(); if (loginResult != "SUCCESS") { var userMsg = loginResult == "MAX_SESSIONS_IN_USE" ? "eNatis has reached the maximum number of active sessions. Please try again later." : "Unable to authenticate with eNatis. Please try again or contact support."; return new JsonNetResult { Data = new JsonBaseResult { Success = false, ErrorCode = "ERROR", Error = userMsg } }; } } var result = eNatisVehicleQuery(searchValue, cookieCheck: false, searchType: normalisedType); if (!string.IsNullOrEmpty(result.ErrorMessage)) { var userMsg = result.ErrorMessage == "FIELD_INVALID" ? "Vehicle not found on eNatis for the supplied value." : $"eNatis lookup failed: {result.ErrorMessage}"; return new JsonNetResult { Data = new JsonBaseResult { Success = false, ErrorCode = "ERROR", Error = userMsg } }; } ResolveLookupIDs(result); return new JsonNetResult { Data = new { Success = true, Vehicle = result } }; } catch (Exception ex) { // Temporary diagnostic log to App_Data/eNatis-lookup.log so we can read the // actual exception without Sentry access. Safe to remove once the intermittent // failure is diagnosed. try { var logPath = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "App_Data", "eNatis-lookup.log"); System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(logPath)); System.IO.File.AppendAllText(logPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} LookupVehicleFromENatis (LicenceIssue) searchType={searchType} searchValue={searchValue}{Environment.NewLine}" + $" {ex.GetType().FullName}: {ex.Message}{Environment.NewLine}" + $"{ex.StackTrace}{Environment.NewLine}{Environment.NewLine}"); } catch { /* never let the diagnostic log break the response */ } ErrorHelper.LogError(ex, new Dictionary { { "Method", "LookupVehicleFromENatis" } }); return new JsonNetResult { Data = new JsonBaseResult { Success = false, ErrorCode = "ERROR", Error = "Unable to query vehicle details from eNatis at the moment. Please try again or contact support." } }; } finally { try { eNatisLogout(); } catch (Exception logoutEx) { ErrorHelper.LogError(logoutEx, new Dictionary { { "Method", "LookupVehicleFromENatis.Logout" } }); } } } private dynamic SendXmlRequest(string xmlContent, HttpClient client) { var content = new StringContent(xmlContent, Encoding.UTF8, "application/xml"); var username = ConfigurationManager.AppSettings["eNatisUsername"]; var password = ConfigurationManager.AppSettings["eNatisPassword"]; var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); var response = client.PostAsync(eNatisQueryURL, content).Result; var responseContent = response.Content.ReadAsStringAsync().Result; XmlDocument xmlDoc = new XmlDocument(); xmlDoc.LoadXml(responseContent); string json = JsonConvert.SerializeXmlNode(xmlDoc); return JsonConvert.DeserializeObject(json); } #endregion eNatis Check #region ID Validation Helpers /// /// Standardizes a business registration number by removing non-alphanumeric characters /// /// The registration number to standardize /// Standardized registration number private string StandardizeBusinessRegNumber(string registrationNumber) { if (string.IsNullOrEmpty(registrationNumber)) return string.Empty; // Remove all non-alphanumeric characters return System.Text.RegularExpressions.Regex.Replace(registrationNumber, "[^a-zA-Z0-9]", ""); } /// /// Extracts the year from a business registration number /// /// The registration number to extract from /// Whether the number is from eNatis format /// The extracted year private string ExtractYear(string registrationNumber, bool isENatis) { if (string.IsNullOrEmpty(registrationNumber)) return string.Empty; try { if (isENatis) { // For letter-prefixed format (e.g. F17...) if (registrationNumber.Length >= 3 && char.IsLetter(registrationNumber[0])) return registrationNumber.Substring(1, 2); // For numeric-only format (e.g. 98...) if (registrationNumber.Length >= 2) return registrationNumber.Substring(0, 2); } else { // For standard format (e.g. 2017/390392/07), extract year before first slash int slashIndex = registrationNumber.IndexOf('/'); if (slashIndex > 0) { string fullYear = registrationNumber.Substring(0, slashIndex); if (fullYear.Length >= 2) return fullYear.Substring(fullYear.Length - 2); } } } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Error extracting year: {ex.Message}"); } return string.Empty; } /// /// Extracts the core registration number from a business registration number /// /// The registration number to extract from /// Whether the number is from eNatis format /// The extracted core registration number private string ExtractCoreRegistrationNumber(string registrationNumber, bool isENatis) { if (string.IsNullOrEmpty(registrationNumber)) return string.Empty; try { if (isENatis) { // 1. Try letter-prefixed format (e.g. F173903920050) var match = System.Text.RegularExpressions.Regex.Match(registrationNumber, @"[A-Z]\d{2}(\d{6})"); if (match.Success && match.Groups.Count > 1) return match.Groups[1].Value; // 2. Try numeric-only format (e.g. 9858431230011) var numericMatch = System.Text.RegularExpressions.Regex.Match(registrationNumber, @"^\d{2}(\d+)"); if (numericMatch.Success && numericMatch.Groups.Count > 1) { return numericMatch.Groups[1].Value; } } else { // For standard format (e.g. 2017/390392/07), extract between slashes var match = System.Text.RegularExpressions.Regex.Match(registrationNumber, @"/(\d+)/"); if (match.Success && match.Groups.Count > 1) { // Standardize to 6 digits by adding leading zeros if needed, // to match how eNatis usually pads the sequence return match.Groups[1].Value.PadLeft(6, '0'); } } } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Error extracting core number: {ex.Message}"); } return string.Empty; } /// /// Checks if two business registration numbers match /// /// The number from our system /// The number from eNatis /// True if the numbers match based on year and core number private bool DoBusinessRegNumbersMatch(string systemNumber, string eNatisNumber) { if (string.IsNullOrEmpty(systemNumber) || string.IsNullOrEmpty(eNatisNumber)) return false; // Equality short-circuit if (systemNumber == eNatisNumber) return true; try { string systemYear = ExtractYear(systemNumber, false); string eNatisYear = ExtractYear(eNatisNumber, true); bool yearMatches = !string.IsNullOrEmpty(systemYear) && !string.IsNullOrEmpty(eNatisYear) && systemYear == eNatisYear; string systemCoreNumber = ExtractCoreRegistrationNumber(systemNumber, false).TrimStart('0'); string eNatisCoreNumber = ExtractCoreRegistrationNumber(eNatisNumber, true).TrimStart('0'); bool coreNumberMatches = !string.IsNullOrEmpty(systemCoreNumber) && !string.IsNullOrEmpty(eNatisCoreNumber) && eNatisCoreNumber.StartsWith(systemCoreNumber); return yearMatches && coreNumberMatches; } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Error in business reg comparison: {ex.Message}"); return false; } } #endregion ID Validation Helpers } }