From 9e089c0c441ded75043a9cb16d6b687de926501d Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 16 Sep 2026 09:17:46 +0100 Subject: [PATCH 1/3] Add diagnostic logging for file line processing and event dispatch --- .../CapturingLogger.cs | 32 +++ .../DomainEventHandlerTests.cs | 40 ++++ .../FileProcessorDomainServiceTests.cs | 20 +- .../EventHandling/FileDomainEventHandler.cs | 46 +++- .../Services/FileProcessorDomainService.cs | 196 +++++++++++++++++- 5 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 FileProcessor.BusinessLogic.Tests/CapturingLogger.cs diff --git a/FileProcessor.BusinessLogic.Tests/CapturingLogger.cs b/FileProcessor.BusinessLogic.Tests/CapturingLogger.cs new file mode 100644 index 0000000..4d6ac68 --- /dev/null +++ b/FileProcessor.BusinessLogic.Tests/CapturingLogger.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace FileProcessor.BusinessLogic.Tests; + +internal sealed class CapturingLogger : ILogger +{ + public List Messages { get; } = new(); + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, + EventId eventId, + TState state, + Exception exception, + Func formatter) + { + this.Messages.Add(formatter(state, exception)); + } + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() + { + } + } +} diff --git a/FileProcessor.BusinessLogic.Tests/DomainEventHandlerTests.cs b/FileProcessor.BusinessLogic.Tests/DomainEventHandlerTests.cs index 55f3e47..eb7ceee 100644 --- a/FileProcessor.BusinessLogic.Tests/DomainEventHandlerTests.cs +++ b/FileProcessor.BusinessLogic.Tests/DomainEventHandlerTests.cs @@ -12,6 +12,9 @@ namespace FileProcessor.BusinessLogic.Tests using FileImportLog.DomainEvents; using MediatR; using Imposter.Abstractions; + using Microsoft.Extensions.Logging; + using Requests; + using SimpleResults; using Shouldly; using Shouldly.Configuration; using Testing; @@ -19,10 +22,46 @@ namespace FileProcessor.BusinessLogic.Tests public class DomainEventHandlerTests { + [Fact] + public async Task FileDomainEventHandler_FileLineAddedEvent_MediatorFailureIsLoggedWithFileAndLine() + { + IMediatorImposter mediator = new IMediatorImposter(); + CapturingLogger logger = new(); + mediator.Send(Arg>.Any(), Arg.Any()) + .ReturnsAsync(Result.Failure("Processing failed")); + FileDomainEventHandler eventHandler = new(mediator.Instance(), logger); + + Result result = await eventHandler.Handle(TestData.FileLineAddedEvent, CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + logger.Messages.ShouldContain(message => message.Contains("event-handler-dispatch-failed") && + message.Contains(TestData.FileLineAddedEvent.FileId.ToString()) && + message.Contains(TestData.FileLineAddedEvent.LineNumber.ToString())); + } + + [Fact] + public async Task FileDomainEventHandler_FileLineAddedEvent_MediatorExceptionIsLoggedAndPropagated() + { + IMediatorImposter mediator = new IMediatorImposter(); + CapturingLogger logger = new(); + mediator.Send(Arg>.Any(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("Mediator failed")); + FileDomainEventHandler eventHandler = new(mediator.Instance(), logger); + + InvalidOperationException exception = await Should.ThrowAsync( + () => eventHandler.Handle(TestData.FileLineAddedEvent, CancellationToken.None)); + + exception.Message.ShouldBe("Mediator failed"); + logger.Messages.ShouldContain(message => message.Contains("event-handler-dispatch-threw") && + message.Contains(TestData.FileLineAddedEvent.FileId.ToString()) && + message.Contains(TestData.FileLineAddedEvent.LineNumber.ToString())); + } + [Fact] public void FileDomainEventHandler_FileLineAddedEvent_EventIsHandled() { IMediatorImposter mediator = new IMediatorImposter(); + mediator.Send(Arg>.Any(), Arg.Any()).ReturnsAsync(Result.Success()); FileDomainEventHandler eventHandler = new FileDomainEventHandler(mediator.Instance()); FileLineAddedEvent fileLineAddedEvent = TestData.FileLineAddedEvent; Should.NotThrow(async () => @@ -35,6 +74,7 @@ public void FileDomainEventHandler_FileLineAddedEvent_EventIsHandled() public void FileDomainEventHandler_FileAddedToImportLogEvent_EventIsHandled() { IMediatorImposter mediator = new IMediatorImposter(); + mediator.Send(Arg>.Any(), Arg.Any()).ReturnsAsync(Result.Success()); FileDomainEventHandler eventHandler = new FileDomainEventHandler(mediator.Instance()); FileAddedToImportLogEvent fileAddedToImportLogEvent = TestData.FileAddedToImportLogEvent; Should.NotThrow(async () => diff --git a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs index 4631c4f..c7df557 100644 --- a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs @@ -17,6 +17,7 @@ namespace FileProcessor.BusinessLogic.Tests; using FileImportLogAggregate; using Managers; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Imposter.Abstractions; using Requests; using SecurityService.Client; @@ -49,6 +50,7 @@ public class FileProcessorDomainServiceTests private FileProcessorDomainService FileProcessorDomainService; private MockFileSystem FileSystem; + private CapturingLogger DiagnosticLogger; public FileProcessorDomainServiceTests() { IConfigurationRoot configurationRoot = new ConfigurationBuilder().AddInMemoryCollection(TestData.DefaultAppSettings).Build(); @@ -65,6 +67,7 @@ public FileProcessorDomainServiceTests() this.SecurityServiceClient = new ISecurityServiceClientImposter(); this.FileFormatHandler = new IFileFormatHandlerImposter(); this.FileSystem = new MockFileSystem(); + this.DiagnosticLogger = new CapturingLogger(); Func fileFormatHandlerResolver = (format) => { @@ -77,7 +80,8 @@ public FileProcessorDomainServiceTests() this.TransactionProcessorClient.Instance(), this.SecurityServiceClient.Instance(), fileFormatHandlerResolver, - this.FileSystem); + this.FileSystem, + this.DiagnosticLogger); Logger.Initialise(NullLogger.Instance); } @@ -552,6 +556,9 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_SaveFa Result result = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); result.IsFailed.ShouldBeTrue(); + this.DiagnosticLogger.Messages.ShouldContain(message => message.Contains("file-line-state-persisted/failed") && + message.Contains(TestData.FileId.ToString()) && + message.Contains(TestData.ProcessTransactionForFileLineCommand.LineNumber.ToString())); } [Theory] @@ -810,6 +817,11 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_NoMerc var result = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); result.IsFailed.ShouldBeTrue(); result.Status.ShouldBe(ResultStatus.NotFound); + this.DiagnosticLogger.Messages.ShouldContain(message => message.Contains("contract-lookup") && + message.Contains(TestData.FileId.ToString()) && + message.Contains(TestData.ProcessTransactionForFileLineCommand.LineNumber.ToString())); + this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()) + .Called(Count.Never()); } [Fact] @@ -944,6 +956,9 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_Transa var result = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); result.IsFailed.ShouldBeTrue(); result.Status.ShouldBe(ResultStatus.Failure); + this.DiagnosticLogger.Messages.ShouldContain(message => message.Contains("transaction-dispatch-failed") && + message.Contains("PerformTransaction") && + message.Contains(TestData.FileId.ToString())); } [Fact] @@ -970,6 +985,9 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_Transa var result = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); result.IsFailed.ShouldBeTrue(); result.Status.ShouldBe(ResultStatus.Failure); + this.DiagnosticLogger.Messages.ShouldContain(message => message.Contains("transaction-dispatch-completed") && + message.Contains(TestData.ResponseCodeFailed) && + message.Contains(TestData.FileId.ToString())); } diff --git a/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs b/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs index 6e68c92..2d73682 100644 --- a/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs +++ b/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs @@ -1,6 +1,8 @@ using System.Threading.Tasks; +using System; using Shared.Logger; using SimpleResults; +using Microsoft.Extensions.Logging; namespace FileProcessor.BusinessLogic.EventHandling { @@ -22,6 +24,7 @@ public class FileDomainEventHandler : IDomainEventHandler /// The mediator /// private readonly IMediator Mediator; + private readonly ILogger DiagnosticLogger; #region Fields @@ -34,8 +37,14 @@ public class FileDomainEventHandler : IDomainEventHandler /// /// The mediator. public FileDomainEventHandler(IMediator mediator) + : this(mediator, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) + { + } + + public FileDomainEventHandler(IMediator mediator, ILogger diagnosticLogger) { this.Mediator = mediator; + this.DiagnosticLogger = diagnosticLogger; } #endregion @@ -62,8 +71,43 @@ private async Task HandleSpecificDomainEvent(FileLineAddedEvent domainEv CancellationToken cancellationToken) { FileCommands.ProcessTransactionForFileLineCommand command = new (domainEvent.FileId, domainEvent.LineNumber, domainEvent.FileLine); + try + { + Result result = await this.Mediator.Send(command, cancellationToken); + if (result.IsFailed) + { + this.DiagnosticLogger.LogError("event-handler-dispatch-failed FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} EventType {EventType} ResultStatus {ResultStatus} Error {Error}", + domainEvent.FileId, + domainEvent.LineNumber, + domainEvent.EstateId, + domainEvent.MerchantId, + nameof(FileLineAddedEvent), + result.Status, + result.Message); + } + else + { + this.DiagnosticLogger.LogDebug("event-handler-dispatch-completed FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} EventType {EventType}", + domainEvent.FileId, + domainEvent.LineNumber, + domainEvent.EstateId, + domainEvent.MerchantId, + nameof(FileLineAddedEvent)); + } - return await this.Mediator.Send(command, cancellationToken); + return result; + } + catch (Exception ex) + { + this.DiagnosticLogger.LogError(ex, + "event-handler-dispatch-threw FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} EventType {EventType}", + domainEvent.FileId, + domainEvent.LineNumber, + domainEvent.EstateId, + domainEvent.MerchantId, + nameof(FileLineAddedEvent)); + throw; + } } /// diff --git a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs index a0d9b86..a38c2b5 100644 --- a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs @@ -31,6 +31,7 @@ namespace FileProcessor.BusinessLogic.Services; using Shared.Exceptions; using Shared.General; using Shared.Logger; +using Microsoft.Extensions.Logging; using TransactionProcessor.Client; using TransactionProcessor.DataTransferObjects; using FileDetails = Models.FileDetails; @@ -50,6 +51,7 @@ Task ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFil public class FileProcessorDomainService : IFileProcessorDomainService { + private readonly ILogger DiagnosticLogger; private readonly IFileProcessorManager FileProcessorManager; private readonly IAggregateRepository FileImportLogAggregateRepository; @@ -78,6 +80,25 @@ public FileProcessorDomainService(IFileProcessorManager fileProcessorManager, this.SecurityServiceClient = securityServiceClient; this.FileFormatHandlerResolver = fileFormatHandlerResolver; this.FileSystem = fileSystem; + this.DiagnosticLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + } + + public FileProcessorDomainService(IFileProcessorManager fileProcessorManager, + IAggregateRepository fileImportLogAggregateRepository, + IAggregateRepository fileAggregateRepository, + ITransactionProcessorClient transactionProcessorClient, + ISecurityServiceClient securityServiceClient, + Func fileFormatHandlerResolver, + IFileSystem fileSystem, + ILogger diagnosticLogger) { + this.FileProcessorManager = fileProcessorManager; + this.FileImportLogAggregateRepository = fileImportLogAggregateRepository; + this.FileAggregateRepository = fileAggregateRepository; + this.TransactionProcessorClient = transactionProcessorClient; + this.SecurityServiceClient = securityServiceClient; + this.FileFormatHandlerResolver = fileFormatHandlerResolver; + this.FileSystem = fileSystem; + this.DiagnosticLogger = diagnosticLogger; } private async Task ApplyFileUpdates(Func> action, @@ -299,9 +320,28 @@ private async Task> GetOperatorIdForFileProfile(Guid estateId, Guid public async Task ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFileLineCommand command, CancellationToken cancellationToken) { + String processingStage = "file-aggregate-load"; + Boolean transactionDispatchAttempted = false; + Boolean transactionDispatchSucceeded = false; + Guid estateId = Guid.Empty; + Guid merchantId = Guid.Empty; + Guid fileProfileId = Guid.Empty; + String operatorName = null; + String responseCode = null; Result result = await ApplyFileUpdates(async (FileAggregate fileAggregate) => { FileDetails fileDetails = fileAggregate.GetFile(); - + estateId = fileDetails.EstateId; + merchantId = fileDetails.MerchantId; + fileProfileId = fileDetails.FileProfileId; + this.DiagnosticLogger.LogInformation("line-processing-started FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} FileProfileId {FileProfileId} Operator {Operator}", + command.FileId, + command.LineNumber, + estateId, + merchantId, + fileDetails.FileProfileId, + (String)null); + + processingStage = "file-line-lookup"; if (fileDetails.FileLines.Any() == false) { return Result.Invalid($"File Id [{command.FileId}] has no lines added"); } @@ -314,56 +354,143 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran if (fileLine.ProcessingResult != ProcessingResult.NotProcessed) { // Line already processed + this.DiagnosticLogger.LogInformation("file-line-processing-skipped FileId {FileId} LineNumber {LineNumber} ProcessingResult {ProcessingResult}", + command.FileId, + command.LineNumber, + fileLine.ProcessingResult); return Result.Success(); } + processingStage = "file-profile-lookup"; Result fileProfileResult = await this.FileProcessorManager.GetFileProfile(fileDetails.FileProfileId, cancellationToken); if (fileProfileResult.IsFailed) return ResultHelpers.CreateFailure(fileProfileResult); FileProfileModel fileProfile = fileProfileResult.Data; + operatorName = fileProfile.OperatorName; Result stateResult; // Determine if we need to actually process this file line + processingStage = "line-ignore-evaluation"; if (this.FileLineCanBeIgnored(fileLine.LineData, fileProfile.FileFormatHandler)) { + processingStage = "file-line-state-update"; // Write something to aggregate to say line was explicity ignored stateResult = fileAggregate.RecordFileLineAsIgnored(fileLine.LineNumber); if (stateResult.IsFailed) return stateResult; + this.DiagnosticLogger.LogInformation("file-line-ignored FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} Reason {Reason}", + command.FileId, + command.LineNumber, + estateId, + merchantId, + operatorName, + "Ignored by file format handler"); + processingStage = "file-line-state-persisted"; return Result.Success(); } - Result> metadataResult = this.BuildTransactionMetadata(command, fileLine, fileProfile, out String operatorName); + processingStage = "transaction-details-parsing"; + Result> metadataResult = this.BuildTransactionMetadata(command, fileLine, fileProfile, out String parsedOperatorName); + operatorName = parsedOperatorName; if (metadataResult.IsFailed) { + processingStage = "file-line-state-update"; stateResult = fileAggregate.RecordFileLineAsRejected(fileLine.LineNumber, "Invalid Format"); if (stateResult.IsFailed) return ResultHelpers.CreateFailure(stateResult); + if (stateResult.IsSuccess) + { + this.DiagnosticLogger.LogWarning("file-line-rejected FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} Reason {Reason}", + command.FileId, + command.LineNumber, + estateId, + merchantId, + operatorName, + "Invalid Format"); + processingStage = "file-line-state-persisted"; + } return stateResult; } Interlocked.Increment(ref TransactionNumber); - Result<(Guid ContractId, Guid OperatorId, Guid ProductId, String MerchantDevice)> detailsForTransaction = await this.GetDetailsForTransaction(cancellationToken, fileDetails, fileProfile, operatorName); + Result<(Guid ContractId, Guid OperatorId, Guid ProductId, String MerchantDevice)> detailsForTransaction = await this.GetDetailsForTransaction(cancellationToken, + fileDetails, + fileProfile, + operatorName, + stage => processingStage = stage); if (detailsForTransaction.IsFailed) return ResultHelpers.CreateFailure(detailsForTransaction); - Result saleResult = await this.SendSaleTransaction(fileDetails, detailsForTransaction, metadataResult.Data, cancellationToken); + processingStage = "transaction-details-resolved"; + Result saleResult = await this.SendSaleTransaction(fileDetails, + detailsForTransaction, + metadataResult.Data, + command, + operatorName, + stage => processingStage = stage, + () => transactionDispatchAttempted = true, + cancellationToken); + transactionDispatchSucceeded = saleResult.IsSuccess; + if (saleResult.IsSuccess) + responseCode = saleResult.Data.ResponseCode; + processingStage = "file-line-state-update"; stateResult = saleResult switch { _ when saleResult.IsSuccess && saleResult.Data.ResponseCode == "0000" => fileAggregate.RecordFileLineAsSuccessful(command.LineNumber, saleResult.Data.TransactionId), _ when saleResult.IsSuccess => fileAggregate.RecordFileLineAsFailed(command.LineNumber, saleResult.Data.TransactionId, saleResult.Data.ResponseCode, saleResult.Data.ResponseMessage), _ when saleResult.IsFailed => fileAggregate.RecordFileLineAsFailed(command.LineNumber, Guid.Empty, "9999", "Failed to Send to Transaction Processor"), }; + this.DiagnosticLogger.LogInformation("file-line-state-determined FileId {FileId} LineNumber {LineNumber} ProcessingResult {ProcessingResult} TransactionDispatchAttempted {TransactionDispatchAttempted} TransactionDispatchSucceeded {TransactionDispatchSucceeded} ResponseCode {ResponseCode}", + command.FileId, + command.LineNumber, + saleResult.IsSuccess && saleResult.Data.ResponseCode == "0000" ? "Successful" : "Failed", + transactionDispatchAttempted, + transactionDispatchSucceeded, + responseCode); + if (stateResult.IsSuccess) + processingStage = "file-line-state-persisted"; + return stateResult; }, command.FileId, cancellationToken); if (result.IsFailed) + { + String failureStage = processingStage == "file-line-state-persisted" + ? "file-line-state-persisted/failed" + : processingStage; + this.DiagnosticLogger.LogError("{ProcessingStage} failed for FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} FileProfileId {FileProfileId} Operator {Operator} TransactionDispatchAttempted {TransactionDispatchAttempted} TransactionDispatchSucceeded {TransactionDispatchSucceeded} ResponseCode {ResponseCode} ResultStatus {ResultStatus} Error {Error}", + failureStage, + command.FileId, + command.LineNumber, + estateId, + merchantId, + fileProfileId, + operatorName, + transactionDispatchAttempted, + transactionDispatchSucceeded, + responseCode, + result.Status, + result.Message); Logger.LogWarning($"{command.LineNumber} Status {result.Status} Message {result.Message}"); + } + else if (processingStage == "file-line-state-persisted") + { + this.DiagnosticLogger.LogInformation("file-line-state-persisted FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} FileProfileId {FileProfileId} Operator {Operator} TransactionDispatchAttempted {TransactionDispatchAttempted} TransactionDispatchSucceeded {TransactionDispatchSucceeded} ResponseCode {ResponseCode}", + command.FileId, + command.LineNumber, + estateId, + merchantId, + fileProfileId, + operatorName, + transactionDispatchAttempted, + transactionDispatchSucceeded, + responseCode); + } return result; } @@ -398,7 +525,12 @@ private Result> BuildTransactionMetadata(FileCommands private async Task> SendSaleTransaction(FileDetails fileDetails, Result<(Guid ContractId, Guid OperatorId, Guid ProductId, String MerchantDevice)> detailsForTransaction, Dictionary transactionMetadata, + FileCommands.ProcessTransactionForFileLineCommand command, + String operatorName, + Action setProcessingStage, + Action markDispatchAttempted, CancellationToken cancellationToken) { + setProcessingStage("token-acquisition"); Result getTokenResult = await this.GetToken(cancellationToken); if (getTokenResult.IsFailed) { return ResultHelpers.CreateFailure(getTokenResult); @@ -423,18 +555,52 @@ private async Task> SendSaleTransaction(FileDeta // Send request to transaction processor - Result result= await this.TransactionProcessorClient.PerformTransaction(this.TokenResponse.AccessToken, saleTransactionRequest, cancellationToken); + setProcessingStage("transaction-dispatch-started"); + markDispatchAttempted(); + this.DiagnosticLogger.LogInformation("transaction-dispatch-started FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} ClientOperation {ClientOperation}", + command.FileId, + command.LineNumber, + fileDetails.EstateId, + fileDetails.MerchantId, + operatorName, + "PerformTransaction"); + Result result = await this.TransactionProcessorClient.PerformTransaction(this.TokenResponse.AccessToken, saleTransactionRequest, cancellationToken); if (result.IsFailed) + { + this.DiagnosticLogger.LogError("transaction-dispatch-failed FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} ClientOperation {ClientOperation} ResultStatus {ResultStatus} Error {Error}", + command.FileId, + command.LineNumber, + fileDetails.EstateId, + fileDetails.MerchantId, + operatorName, + "PerformTransaction", + result.Status, + result.Message); return ResultHelpers.CreateFailure(result); + } + + this.DiagnosticLogger.LogInformation("transaction-dispatch-completed FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} ClientOperation {ClientOperation} TransactionId {TransactionId} ResponseCode {ResponseCode} ResponseMessage {ResponseMessage}", + command.FileId, + command.LineNumber, + fileDetails.EstateId, + fileDetails.MerchantId, + operatorName, + "PerformTransaction", + result.Data.TransactionId, + result.Data.ResponseCode, + result.Data.ResponseMessage); + setProcessingStage("transaction-dispatch-completed"); return result; } private async Task> GetDetailsForTransaction(CancellationToken cancellationToken, - FileDetails fileDetails, - FileProfileModel fileProfile, - String operatorName) { + FileDetails fileDetails, + FileProfileModel fileProfile, + String operatorName, + Action setProcessingStage) { + setProcessingStage("token-acquisition"); Result getTokenResult = await this.GetToken(cancellationToken); if (getTokenResult.IsFailed) { @@ -444,13 +610,17 @@ private async Task> SendSaleTransaction(FileDeta this.TokenResponse = getTokenResult.Data; // Get the merchant details + setProcessingStage("merchant-lookup"); Result getMerchantResult = await this.TransactionProcessorClient.GetMerchant(this.TokenResponse.AccessToken, fileDetails.EstateId, fileDetails.MerchantId, cancellationToken); if (getMerchantResult.IsFailed) { return ResultHelpers.CreateFailure(getMerchantResult); } MerchantResponse merchant = getMerchantResult.Data; + if (merchant == null) + return Result.NotFound($"Merchant Id {fileDetails.MerchantId} not found on estate Id {fileDetails.EstateId}"); + setProcessingStage("contract-lookup"); Result> getContractsResult = await this.TransactionProcessorClient.GetMerchantContracts(this.TokenResponse.AccessToken, fileDetails.EstateId, fileDetails.MerchantId, cancellationToken); if (getContractsResult.IsFailed) { return ResultHelpers.CreateFailure(getContractsResult); @@ -458,10 +628,11 @@ private async Task> SendSaleTransaction(FileDeta List contracts = getContractsResult.Data; - if (contracts.Any() == false) { + if (contracts == null || contracts.Any() == false) { return Result.NotFound($"No contracts found for Merchant Id {fileDetails.MerchantId} on estate Id {fileDetails.EstateId}"); } + setProcessingStage("contract-operator-match"); ContractResponse contract = fileProfile.OperatorName switch { "Voucher" => contracts.SingleOrDefault(c => c.Description.Contains(operatorName)), _ => contracts.SingleOrDefault(c => c.OperatorName == operatorName) @@ -471,12 +642,17 @@ private async Task> SendSaleTransaction(FileDeta return Result.NotFound($"No merchant contract for operator Id {operatorName} found for Merchant Id {merchant.MerchantId}"); } - ContractProduct product = contract.Products.SingleOrDefault(p => p.Value == null); + setProcessingStage("variable-value-product-lookup"); + ContractProduct product = contract.Products?.SingleOrDefault(p => p.Value == null); if (product == null) { return Result.NotFound($"No variable value product found on the merchant contract for operator Id {fileProfile.OperatorName} and Merchant Id {merchant.MerchantId}"); } + setProcessingStage("merchant-device-resolution"); + if (merchant == null || merchant.Devices == null || merchant.Devices.Any() == false || String.IsNullOrWhiteSpace(merchant.Devices.First().Value)) + return Result.NotFound($"No valid merchant device found for Merchant Id {merchant.MerchantId} on estate Id {fileDetails.EstateId}"); + return Result.Success((contract.ContractId, contract.OperatorId, product.ProductId, merchant.Devices.First().Value)); } From 91afc7b2cd7971771d58f70723be72c873c034af Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 16 Sep 2026 10:05:44 +0100 Subject: [PATCH 2/3] Use shared logger for file processing diagnostics --- .../DomainEventHandlerTests.cs | 17 +-- .../FileProcessorDomainServiceTests.cs | 6 +- .../EventHandling/FileDomainEventHandler.cs | 32 +---- .../Services/FileProcessorDomainService.cs | 112 ++---------------- 4 files changed, 19 insertions(+), 148 deletions(-) diff --git a/FileProcessor.BusinessLogic.Tests/DomainEventHandlerTests.cs b/FileProcessor.BusinessLogic.Tests/DomainEventHandlerTests.cs index eb7ceee..d4ade47 100644 --- a/FileProcessor.BusinessLogic.Tests/DomainEventHandlerTests.cs +++ b/FileProcessor.BusinessLogic.Tests/DomainEventHandlerTests.cs @@ -12,7 +12,6 @@ namespace FileProcessor.BusinessLogic.Tests using FileImportLog.DomainEvents; using MediatR; using Imposter.Abstractions; - using Microsoft.Extensions.Logging; using Requests; using SimpleResults; using Shouldly; @@ -23,38 +22,30 @@ namespace FileProcessor.BusinessLogic.Tests public class DomainEventHandlerTests { [Fact] - public async Task FileDomainEventHandler_FileLineAddedEvent_MediatorFailureIsLoggedWithFileAndLine() + public async Task FileDomainEventHandler_FileLineAddedEvent_MediatorFailureIsReturned() { IMediatorImposter mediator = new IMediatorImposter(); - CapturingLogger logger = new(); mediator.Send(Arg>.Any(), Arg.Any()) .ReturnsAsync(Result.Failure("Processing failed")); - FileDomainEventHandler eventHandler = new(mediator.Instance(), logger); + FileDomainEventHandler eventHandler = new(mediator.Instance()); Result result = await eventHandler.Handle(TestData.FileLineAddedEvent, CancellationToken.None); result.IsFailed.ShouldBeTrue(); - logger.Messages.ShouldContain(message => message.Contains("event-handler-dispatch-failed") && - message.Contains(TestData.FileLineAddedEvent.FileId.ToString()) && - message.Contains(TestData.FileLineAddedEvent.LineNumber.ToString())); } [Fact] - public async Task FileDomainEventHandler_FileLineAddedEvent_MediatorExceptionIsLoggedAndPropagated() + public async Task FileDomainEventHandler_FileLineAddedEvent_MediatorExceptionIsPropagated() { IMediatorImposter mediator = new IMediatorImposter(); - CapturingLogger logger = new(); mediator.Send(Arg>.Any(), Arg.Any()) .ThrowsAsync(new InvalidOperationException("Mediator failed")); - FileDomainEventHandler eventHandler = new(mediator.Instance(), logger); + FileDomainEventHandler eventHandler = new(mediator.Instance()); InvalidOperationException exception = await Should.ThrowAsync( () => eventHandler.Handle(TestData.FileLineAddedEvent, CancellationToken.None)); exception.Message.ShouldBe("Mediator failed"); - logger.Messages.ShouldContain(message => message.Contains("event-handler-dispatch-threw") && - message.Contains(TestData.FileLineAddedEvent.FileId.ToString()) && - message.Contains(TestData.FileLineAddedEvent.LineNumber.ToString())); } [Fact] diff --git a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs index c7df557..1105517 100644 --- a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs @@ -17,7 +17,6 @@ namespace FileProcessor.BusinessLogic.Tests; using FileImportLogAggregate; using Managers; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; using Imposter.Abstractions; using Requests; using SecurityService.Client; @@ -80,9 +79,8 @@ public FileProcessorDomainServiceTests() this.TransactionProcessorClient.Instance(), this.SecurityServiceClient.Instance(), fileFormatHandlerResolver, - this.FileSystem, - this.DiagnosticLogger); - Logger.Initialise(NullLogger.Instance); + this.FileSystem); + Logger.Initialise(this.DiagnosticLogger); } [Fact] diff --git a/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs b/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs index 2d73682..12ba92f 100644 --- a/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs +++ b/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs @@ -2,7 +2,6 @@ using System; using Shared.Logger; using SimpleResults; -using Microsoft.Extensions.Logging; namespace FileProcessor.BusinessLogic.EventHandling { @@ -24,7 +23,6 @@ public class FileDomainEventHandler : IDomainEventHandler /// The mediator /// private readonly IMediator Mediator; - private readonly ILogger DiagnosticLogger; #region Fields @@ -37,14 +35,8 @@ public class FileDomainEventHandler : IDomainEventHandler /// /// The mediator. public FileDomainEventHandler(IMediator mediator) - : this(mediator, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance) - { - } - - public FileDomainEventHandler(IMediator mediator, ILogger diagnosticLogger) { this.Mediator = mediator; - this.DiagnosticLogger = diagnosticLogger; } #endregion @@ -76,36 +68,18 @@ private async Task HandleSpecificDomainEvent(FileLineAddedEvent domainEv Result result = await this.Mediator.Send(command, cancellationToken); if (result.IsFailed) { - this.DiagnosticLogger.LogError("event-handler-dispatch-failed FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} EventType {EventType} ResultStatus {ResultStatus} Error {Error}", - domainEvent.FileId, - domainEvent.LineNumber, - domainEvent.EstateId, - domainEvent.MerchantId, - nameof(FileLineAddedEvent), - result.Status, - result.Message); + Logger.LogError($"event-handler-dispatch-failed FileId={domainEvent.FileId} LineNumber={domainEvent.LineNumber} EstateId={domainEvent.EstateId} MerchantId={domainEvent.MerchantId} EventType={nameof(FileLineAddedEvent)} ResultStatus={result.Status} Error={result.Message}"); } else { - this.DiagnosticLogger.LogDebug("event-handler-dispatch-completed FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} EventType {EventType}", - domainEvent.FileId, - domainEvent.LineNumber, - domainEvent.EstateId, - domainEvent.MerchantId, - nameof(FileLineAddedEvent)); + Logger.LogDebug($"event-handler-dispatch-completed FileId={domainEvent.FileId} LineNumber={domainEvent.LineNumber} EstateId={domainEvent.EstateId} MerchantId={domainEvent.MerchantId} EventType={nameof(FileLineAddedEvent)}"); } return result; } catch (Exception ex) { - this.DiagnosticLogger.LogError(ex, - "event-handler-dispatch-threw FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} EventType {EventType}", - domainEvent.FileId, - domainEvent.LineNumber, - domainEvent.EstateId, - domainEvent.MerchantId, - nameof(FileLineAddedEvent)); + Logger.LogError($"event-handler-dispatch-threw FileId={domainEvent.FileId} LineNumber={domainEvent.LineNumber} EstateId={domainEvent.EstateId} MerchantId={domainEvent.MerchantId} EventType={nameof(FileLineAddedEvent)} Exception={ex}"); throw; } } diff --git a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs index a38c2b5..2e75ffa 100644 --- a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs @@ -31,7 +31,6 @@ namespace FileProcessor.BusinessLogic.Services; using Shared.Exceptions; using Shared.General; using Shared.Logger; -using Microsoft.Extensions.Logging; using TransactionProcessor.Client; using TransactionProcessor.DataTransferObjects; using FileDetails = Models.FileDetails; @@ -51,7 +50,6 @@ Task ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFil public class FileProcessorDomainService : IFileProcessorDomainService { - private readonly ILogger DiagnosticLogger; private readonly IFileProcessorManager FileProcessorManager; private readonly IAggregateRepository FileImportLogAggregateRepository; @@ -80,25 +78,6 @@ public FileProcessorDomainService(IFileProcessorManager fileProcessorManager, this.SecurityServiceClient = securityServiceClient; this.FileFormatHandlerResolver = fileFormatHandlerResolver; this.FileSystem = fileSystem; - this.DiagnosticLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; - } - - public FileProcessorDomainService(IFileProcessorManager fileProcessorManager, - IAggregateRepository fileImportLogAggregateRepository, - IAggregateRepository fileAggregateRepository, - ITransactionProcessorClient transactionProcessorClient, - ISecurityServiceClient securityServiceClient, - Func fileFormatHandlerResolver, - IFileSystem fileSystem, - ILogger diagnosticLogger) { - this.FileProcessorManager = fileProcessorManager; - this.FileImportLogAggregateRepository = fileImportLogAggregateRepository; - this.FileAggregateRepository = fileAggregateRepository; - this.TransactionProcessorClient = transactionProcessorClient; - this.SecurityServiceClient = securityServiceClient; - this.FileFormatHandlerResolver = fileFormatHandlerResolver; - this.FileSystem = fileSystem; - this.DiagnosticLogger = diagnosticLogger; } private async Task ApplyFileUpdates(Func> action, @@ -333,13 +312,7 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran estateId = fileDetails.EstateId; merchantId = fileDetails.MerchantId; fileProfileId = fileDetails.FileProfileId; - this.DiagnosticLogger.LogInformation("line-processing-started FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} FileProfileId {FileProfileId} Operator {Operator}", - command.FileId, - command.LineNumber, - estateId, - merchantId, - fileDetails.FileProfileId, - (String)null); + Logger.LogInformation($"line-processing-started FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} FileProfileId={fileDetails.FileProfileId}"); processingStage = "file-line-lookup"; if (fileDetails.FileLines.Any() == false) { @@ -354,10 +327,7 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran if (fileLine.ProcessingResult != ProcessingResult.NotProcessed) { // Line already processed - this.DiagnosticLogger.LogInformation("file-line-processing-skipped FileId {FileId} LineNumber {LineNumber} ProcessingResult {ProcessingResult}", - command.FileId, - command.LineNumber, - fileLine.ProcessingResult); + Logger.LogInformation($"file-line-processing-skipped FileId={command.FileId} LineNumber={command.LineNumber} ProcessingResult={fileLine.ProcessingResult}"); return Result.Success(); } @@ -381,13 +351,7 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran stateResult = fileAggregate.RecordFileLineAsIgnored(fileLine.LineNumber); if (stateResult.IsFailed) return stateResult; - this.DiagnosticLogger.LogInformation("file-line-ignored FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} Reason {Reason}", - command.FileId, - command.LineNumber, - estateId, - merchantId, - operatorName, - "Ignored by file format handler"); + Logger.LogInformation($"file-line-ignored FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} Operator={operatorName} Reason=Ignored by file format handler"); processingStage = "file-line-state-persisted"; return Result.Success(); } @@ -403,13 +367,7 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran if (stateResult.IsSuccess) { - this.DiagnosticLogger.LogWarning("file-line-rejected FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} Reason {Reason}", - command.FileId, - command.LineNumber, - estateId, - merchantId, - operatorName, - "Invalid Format"); + Logger.LogWarning($"file-line-rejected FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} Operator={operatorName} Reason=Invalid Format"); processingStage = "file-line-state-persisted"; } return stateResult; @@ -445,13 +403,7 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran _ when saleResult.IsFailed => fileAggregate.RecordFileLineAsFailed(command.LineNumber, Guid.Empty, "9999", "Failed to Send to Transaction Processor"), }; - this.DiagnosticLogger.LogInformation("file-line-state-determined FileId {FileId} LineNumber {LineNumber} ProcessingResult {ProcessingResult} TransactionDispatchAttempted {TransactionDispatchAttempted} TransactionDispatchSucceeded {TransactionDispatchSucceeded} ResponseCode {ResponseCode}", - command.FileId, - command.LineNumber, - saleResult.IsSuccess && saleResult.Data.ResponseCode == "0000" ? "Successful" : "Failed", - transactionDispatchAttempted, - transactionDispatchSucceeded, - responseCode); + Logger.LogInformation($"file-line-state-determined FileId={command.FileId} LineNumber={command.LineNumber} ProcessingResult={(saleResult.IsSuccess && saleResult.Data.ResponseCode == "0000" ? "Successful" : "Failed")} TransactionDispatchAttempted={transactionDispatchAttempted} TransactionDispatchSucceeded={transactionDispatchSucceeded} ResponseCode={responseCode}"); if (stateResult.IsSuccess) processingStage = "file-line-state-persisted"; @@ -463,33 +415,12 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran String failureStage = processingStage == "file-line-state-persisted" ? "file-line-state-persisted/failed" : processingStage; - this.DiagnosticLogger.LogError("{ProcessingStage} failed for FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} FileProfileId {FileProfileId} Operator {Operator} TransactionDispatchAttempted {TransactionDispatchAttempted} TransactionDispatchSucceeded {TransactionDispatchSucceeded} ResponseCode {ResponseCode} ResultStatus {ResultStatus} Error {Error}", - failureStage, - command.FileId, - command.LineNumber, - estateId, - merchantId, - fileProfileId, - operatorName, - transactionDispatchAttempted, - transactionDispatchSucceeded, - responseCode, - result.Status, - result.Message); + Logger.LogError($"{failureStage} failed FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} FileProfileId={fileProfileId} Operator={operatorName} TransactionDispatchAttempted={transactionDispatchAttempted} TransactionDispatchSucceeded={transactionDispatchSucceeded} ResponseCode={responseCode} ResultStatus={result.Status} Error={result.Message}"); Logger.LogWarning($"{command.LineNumber} Status {result.Status} Message {result.Message}"); } else if (processingStage == "file-line-state-persisted") { - this.DiagnosticLogger.LogInformation("file-line-state-persisted FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} FileProfileId {FileProfileId} Operator {Operator} TransactionDispatchAttempted {TransactionDispatchAttempted} TransactionDispatchSucceeded {TransactionDispatchSucceeded} ResponseCode {ResponseCode}", - command.FileId, - command.LineNumber, - estateId, - merchantId, - fileProfileId, - operatorName, - transactionDispatchAttempted, - transactionDispatchSucceeded, - responseCode); + Logger.LogInformation($"file-line-state-persisted FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} FileProfileId={fileProfileId} Operator={operatorName} TransactionDispatchAttempted={transactionDispatchAttempted} TransactionDispatchSucceeded={transactionDispatchSucceeded} ResponseCode={responseCode}"); } return result; @@ -557,38 +488,15 @@ private async Task> SendSaleTransaction(FileDeta // Send request to transaction processor setProcessingStage("transaction-dispatch-started"); markDispatchAttempted(); - this.DiagnosticLogger.LogInformation("transaction-dispatch-started FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} ClientOperation {ClientOperation}", - command.FileId, - command.LineNumber, - fileDetails.EstateId, - fileDetails.MerchantId, - operatorName, - "PerformTransaction"); + Logger.LogInformation($"transaction-dispatch-started FileId={command.FileId} LineNumber={command.LineNumber} EstateId={fileDetails.EstateId} MerchantId={fileDetails.MerchantId} Operator={operatorName} ClientOperation=PerformTransaction"); Result result = await this.TransactionProcessorClient.PerformTransaction(this.TokenResponse.AccessToken, saleTransactionRequest, cancellationToken); if (result.IsFailed) { - this.DiagnosticLogger.LogError("transaction-dispatch-failed FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} ClientOperation {ClientOperation} ResultStatus {ResultStatus} Error {Error}", - command.FileId, - command.LineNumber, - fileDetails.EstateId, - fileDetails.MerchantId, - operatorName, - "PerformTransaction", - result.Status, - result.Message); + Logger.LogError($"transaction-dispatch-failed FileId={command.FileId} LineNumber={command.LineNumber} EstateId={fileDetails.EstateId} MerchantId={fileDetails.MerchantId} Operator={operatorName} ClientOperation=PerformTransaction ResultStatus={result.Status} Error={result.Message}"); return ResultHelpers.CreateFailure(result); } - this.DiagnosticLogger.LogInformation("transaction-dispatch-completed FileId {FileId} LineNumber {LineNumber} EstateId {EstateId} MerchantId {MerchantId} Operator {Operator} ClientOperation {ClientOperation} TransactionId {TransactionId} ResponseCode {ResponseCode} ResponseMessage {ResponseMessage}", - command.FileId, - command.LineNumber, - fileDetails.EstateId, - fileDetails.MerchantId, - operatorName, - "PerformTransaction", - result.Data.TransactionId, - result.Data.ResponseCode, - result.Data.ResponseMessage); + Logger.LogInformation($"transaction-dispatch-completed FileId={command.FileId} LineNumber={command.LineNumber} EstateId={fileDetails.EstateId} MerchantId={fileDetails.MerchantId} Operator={operatorName} ClientOperation=PerformTransaction TransactionId={result.Data.TransactionId} ResponseCode={result.Data.ResponseCode} ResponseMessage={result.Data.ResponseMessage}"); setProcessingStage("transaction-dispatch-completed"); return result; From fc048030b53ff950cb37de49531036278036ba1e Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 16 Sep 2026 10:18:10 +0100 Subject: [PATCH 3/3] Centralize file line processing diagnostics --- .../Common/FileLineProcessingDiagnostics.cs | 148 ++++++++++++++++++ .../EventHandling/FileDomainEventHandler.cs | 8 +- .../Services/FileProcessorDomainService.cs | 115 ++++++-------- 3 files changed, 203 insertions(+), 68 deletions(-) create mode 100644 FileProcessor.BusinessLogic/Common/FileLineProcessingDiagnostics.cs diff --git a/FileProcessor.BusinessLogic/Common/FileLineProcessingDiagnostics.cs b/FileProcessor.BusinessLogic/Common/FileLineProcessingDiagnostics.cs new file mode 100644 index 0000000..3f1b10b --- /dev/null +++ b/FileProcessor.BusinessLogic/Common/FileLineProcessingDiagnostics.cs @@ -0,0 +1,148 @@ +using System; +using FileProcessor.BusinessLogic.Requests; +using FileProcessor.File.DomainEvents; +using FileProcessor.FileAggregate; +using FileProcessor.Models; +using Shared.Logger; +using SimpleResults; +using TransactionProcessor.DataTransferObjects; + +namespace FileProcessor.BusinessLogic.Common; + +internal enum FileLineProcessingStage +{ + FileAggregateLoad, + FileLineLookup, + FileProfileLookup, + LineIgnoreEvaluation, + TransactionDetailsParsing, + TokenAcquisition, + MerchantLookup, + ContractLookup, + ContractOperatorMatch, + VariableValueProductLookup, + MerchantDeviceResolution, + TransactionDetailsResolved, + TransactionDispatchStarted, + TransactionDispatchCompleted, + FileLineStateUpdate, + FileLineStatePersisted +} + +internal sealed class FileLineProcessingContext +{ + public FileLineProcessingContext(FileCommands.ProcessTransactionForFileLineCommand command) + { + this.Command = command; + } + + public FileCommands.ProcessTransactionForFileLineCommand Command { get; } + + public FileLineProcessingStage Stage { get; set; } = FileLineProcessingStage.FileAggregateLoad; + + public Boolean TransactionDispatchAttempted { get; set; } + + public Boolean TransactionDispatchSucceeded { get; set; } + + public Guid EstateId { get; set; } + + public Guid MerchantId { get; set; } + + public Guid FileProfileId { get; set; } + + public String OperatorName { get; set; } + + public String ResponseCode { get; set; } +} + +internal static class FileLineProcessingDiagnostics +{ + public static void LineProcessingStarted(FileLineProcessingContext context) + { + Logger.LogInformation($"line-processing-started FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} EstateId={context.EstateId} MerchantId={context.MerchantId} FileProfileId={context.FileProfileId}"); + } + + public static void LineProcessingSkipped(FileLineProcessingContext context, ProcessingResult processingResult) + { + Logger.LogInformation($"file-line-processing-skipped FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} ProcessingResult={processingResult}"); + } + + public static void LineIgnored(FileLineProcessingContext context) + { + Logger.LogInformation($"file-line-ignored FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} EstateId={context.EstateId} MerchantId={context.MerchantId} Operator={context.OperatorName} Reason=Ignored by file format handler"); + } + + public static void LineRejected(FileLineProcessingContext context, String reason) + { + Logger.LogWarning($"file-line-rejected FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} EstateId={context.EstateId} MerchantId={context.MerchantId} Operator={context.OperatorName} Reason={reason}"); + } + + public static void LineStateDetermined(FileLineProcessingContext context, ProcessingResult processingResult) + { + Logger.LogInformation($"file-line-state-determined FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} ProcessingResult={processingResult} TransactionDispatchAttempted={context.TransactionDispatchAttempted} TransactionDispatchSucceeded={context.TransactionDispatchSucceeded} ResponseCode={context.ResponseCode}"); + } + + public static void ProcessingFailed(FileLineProcessingContext context, Result result) + { + String stage = context.Stage == FileLineProcessingStage.FileLineStatePersisted + ? "file-line-state-persisted/failed" + : GetStageName(context.Stage); + Logger.LogError($"{stage} failed FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} EstateId={context.EstateId} MerchantId={context.MerchantId} FileProfileId={context.FileProfileId} Operator={context.OperatorName} TransactionDispatchAttempted={context.TransactionDispatchAttempted} TransactionDispatchSucceeded={context.TransactionDispatchSucceeded} ResponseCode={context.ResponseCode} ResultStatus={result.Status} Error={result.Message}"); + } + + public static void LineStatePersisted(FileLineProcessingContext context) + { + Logger.LogInformation($"file-line-state-persisted FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} EstateId={context.EstateId} MerchantId={context.MerchantId} FileProfileId={context.FileProfileId} Operator={context.OperatorName} TransactionDispatchAttempted={context.TransactionDispatchAttempted} TransactionDispatchSucceeded={context.TransactionDispatchSucceeded} ResponseCode={context.ResponseCode}"); + } + + public static void TransactionDispatchStarted(FileLineProcessingContext context) + { + Logger.LogInformation($"transaction-dispatch-started FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} EstateId={context.EstateId} MerchantId={context.MerchantId} Operator={context.OperatorName} ClientOperation=PerformTransaction"); + } + + public static void TransactionDispatchFailed(FileLineProcessingContext context, Result result) + { + Logger.LogError($"transaction-dispatch-failed FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} EstateId={context.EstateId} MerchantId={context.MerchantId} Operator={context.OperatorName} ClientOperation=PerformTransaction ResultStatus={result.Status} Error={result.Message}"); + } + + public static void TransactionDispatchCompleted(FileLineProcessingContext context, SaleTransactionResponse response) + { + Logger.LogInformation($"transaction-dispatch-completed FileId={context.Command.FileId} LineNumber={context.Command.LineNumber} EstateId={context.EstateId} MerchantId={context.MerchantId} Operator={context.OperatorName} ClientOperation=PerformTransaction TransactionId={response.TransactionId} ResponseCode={response.ResponseCode} ResponseMessage={response.ResponseMessage}"); + } + + public static void EventHandlerDispatchFailed(FileLineAddedEvent domainEvent, Result result) + { + Logger.LogError($"event-handler-dispatch-failed FileId={domainEvent.FileId} LineNumber={domainEvent.LineNumber} EstateId={domainEvent.EstateId} MerchantId={domainEvent.MerchantId} EventType={nameof(FileLineAddedEvent)} ResultStatus={result.Status} Error={result.Message}"); + } + + public static void EventHandlerDispatchCompleted(FileLineAddedEvent domainEvent) + { + Logger.LogDebug($"event-handler-dispatch-completed FileId={domainEvent.FileId} LineNumber={domainEvent.LineNumber} EstateId={domainEvent.EstateId} MerchantId={domainEvent.MerchantId} EventType={nameof(FileLineAddedEvent)}"); + } + + public static void EventHandlerDispatchThrew(FileLineAddedEvent domainEvent, Exception exception) + { + Logger.LogError($"event-handler-dispatch-threw FileId={domainEvent.FileId} LineNumber={domainEvent.LineNumber} EstateId={domainEvent.EstateId} MerchantId={domainEvent.MerchantId} EventType={nameof(FileLineAddedEvent)} Exception={exception}"); + } + + private static String GetStageName(FileLineProcessingStage stage) => stage switch + { + FileLineProcessingStage.FileAggregateLoad => "file-aggregate-load", + FileLineProcessingStage.FileLineLookup => "file-line-lookup", + FileLineProcessingStage.FileProfileLookup => "file-profile-lookup", + FileLineProcessingStage.LineIgnoreEvaluation => "line-ignore-evaluation", + FileLineProcessingStage.TransactionDetailsParsing => "transaction-details-parsing", + FileLineProcessingStage.TokenAcquisition => "token-acquisition", + FileLineProcessingStage.MerchantLookup => "merchant-lookup", + FileLineProcessingStage.ContractLookup => "contract-lookup", + FileLineProcessingStage.ContractOperatorMatch => "contract-operator-match", + FileLineProcessingStage.VariableValueProductLookup => "variable-value-product-lookup", + FileLineProcessingStage.MerchantDeviceResolution => "merchant-device-resolution", + FileLineProcessingStage.TransactionDetailsResolved => "transaction-details-resolved", + FileLineProcessingStage.TransactionDispatchStarted => "transaction-dispatch-started", + FileLineProcessingStage.TransactionDispatchCompleted => "transaction-dispatch-completed", + FileLineProcessingStage.FileLineStateUpdate => "file-line-state-update", + FileLineProcessingStage.FileLineStatePersisted => "file-line-state-persisted", + _ => throw new ArgumentOutOfRangeException(nameof(stage), stage, "Unknown file-line processing stage") + }; +} diff --git a/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs b/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs index 12ba92f..b78b223 100644 --- a/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs +++ b/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs @@ -1,7 +1,7 @@ using System.Threading.Tasks; using System; -using Shared.Logger; using SimpleResults; +using FileProcessor.BusinessLogic.Common; namespace FileProcessor.BusinessLogic.EventHandling { @@ -68,18 +68,18 @@ private async Task HandleSpecificDomainEvent(FileLineAddedEvent domainEv Result result = await this.Mediator.Send(command, cancellationToken); if (result.IsFailed) { - Logger.LogError($"event-handler-dispatch-failed FileId={domainEvent.FileId} LineNumber={domainEvent.LineNumber} EstateId={domainEvent.EstateId} MerchantId={domainEvent.MerchantId} EventType={nameof(FileLineAddedEvent)} ResultStatus={result.Status} Error={result.Message}"); + FileLineProcessingDiagnostics.EventHandlerDispatchFailed(domainEvent, result); } else { - Logger.LogDebug($"event-handler-dispatch-completed FileId={domainEvent.FileId} LineNumber={domainEvent.LineNumber} EstateId={domainEvent.EstateId} MerchantId={domainEvent.MerchantId} EventType={nameof(FileLineAddedEvent)}"); + FileLineProcessingDiagnostics.EventHandlerDispatchCompleted(domainEvent); } return result; } catch (Exception ex) { - Logger.LogError($"event-handler-dispatch-threw FileId={domainEvent.FileId} LineNumber={domainEvent.LineNumber} EstateId={domainEvent.EstateId} MerchantId={domainEvent.MerchantId} EventType={nameof(FileLineAddedEvent)} Exception={ex}"); + FileLineProcessingDiagnostics.EventHandlerDispatchThrew(domainEvent, ex); throw; } } diff --git a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs index 2e75ffa..255a4fc 100644 --- a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs @@ -299,22 +299,15 @@ private async Task> GetOperatorIdForFileProfile(Guid estateId, Guid public async Task ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFileLineCommand command, CancellationToken cancellationToken) { - String processingStage = "file-aggregate-load"; - Boolean transactionDispatchAttempted = false; - Boolean transactionDispatchSucceeded = false; - Guid estateId = Guid.Empty; - Guid merchantId = Guid.Empty; - Guid fileProfileId = Guid.Empty; - String operatorName = null; - String responseCode = null; + FileLineProcessingContext processingContext = new(command); Result result = await ApplyFileUpdates(async (FileAggregate fileAggregate) => { FileDetails fileDetails = fileAggregate.GetFile(); - estateId = fileDetails.EstateId; - merchantId = fileDetails.MerchantId; - fileProfileId = fileDetails.FileProfileId; - Logger.LogInformation($"line-processing-started FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} FileProfileId={fileDetails.FileProfileId}"); + processingContext.EstateId = fileDetails.EstateId; + processingContext.MerchantId = fileDetails.MerchantId; + processingContext.FileProfileId = fileDetails.FileProfileId; + FileLineProcessingDiagnostics.LineProcessingStarted(processingContext); - processingStage = "file-line-lookup"; + processingContext.Stage = FileLineProcessingStage.FileLineLookup; if (fileDetails.FileLines.Any() == false) { return Result.Invalid($"File Id [{command.FileId}] has no lines added"); } @@ -327,48 +320,48 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran if (fileLine.ProcessingResult != ProcessingResult.NotProcessed) { // Line already processed - Logger.LogInformation($"file-line-processing-skipped FileId={command.FileId} LineNumber={command.LineNumber} ProcessingResult={fileLine.ProcessingResult}"); + FileLineProcessingDiagnostics.LineProcessingSkipped(processingContext, fileLine.ProcessingResult); return Result.Success(); } - processingStage = "file-profile-lookup"; + processingContext.Stage = FileLineProcessingStage.FileProfileLookup; Result fileProfileResult = await this.FileProcessorManager.GetFileProfile(fileDetails.FileProfileId, cancellationToken); if (fileProfileResult.IsFailed) return ResultHelpers.CreateFailure(fileProfileResult); FileProfileModel fileProfile = fileProfileResult.Data; - operatorName = fileProfile.OperatorName; + processingContext.OperatorName = fileProfile.OperatorName; Result stateResult; // Determine if we need to actually process this file line - processingStage = "line-ignore-evaluation"; + processingContext.Stage = FileLineProcessingStage.LineIgnoreEvaluation; if (this.FileLineCanBeIgnored(fileLine.LineData, fileProfile.FileFormatHandler)) { - processingStage = "file-line-state-update"; + processingContext.Stage = FileLineProcessingStage.FileLineStateUpdate; // Write something to aggregate to say line was explicity ignored stateResult = fileAggregate.RecordFileLineAsIgnored(fileLine.LineNumber); if (stateResult.IsFailed) return stateResult; - Logger.LogInformation($"file-line-ignored FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} Operator={operatorName} Reason=Ignored by file format handler"); - processingStage = "file-line-state-persisted"; + FileLineProcessingDiagnostics.LineIgnored(processingContext); + processingContext.Stage = FileLineProcessingStage.FileLineStatePersisted; return Result.Success(); } - processingStage = "transaction-details-parsing"; + processingContext.Stage = FileLineProcessingStage.TransactionDetailsParsing; Result> metadataResult = this.BuildTransactionMetadata(command, fileLine, fileProfile, out String parsedOperatorName); - operatorName = parsedOperatorName; + processingContext.OperatorName = parsedOperatorName; if (metadataResult.IsFailed) { - processingStage = "file-line-state-update"; + processingContext.Stage = FileLineProcessingStage.FileLineStateUpdate; stateResult = fileAggregate.RecordFileLineAsRejected(fileLine.LineNumber, "Invalid Format"); if (stateResult.IsFailed) return ResultHelpers.CreateFailure(stateResult); if (stateResult.IsSuccess) { - Logger.LogWarning($"file-line-rejected FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} Operator={operatorName} Reason=Invalid Format"); - processingStage = "file-line-state-persisted"; + FileLineProcessingDiagnostics.LineRejected(processingContext, "Invalid Format"); + processingContext.Stage = FileLineProcessingStage.FileLineStatePersisted; } return stateResult; } @@ -378,49 +371,46 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran Result<(Guid ContractId, Guid OperatorId, Guid ProductId, String MerchantDevice)> detailsForTransaction = await this.GetDetailsForTransaction(cancellationToken, fileDetails, fileProfile, - operatorName, - stage => processingStage = stage); + processingContext.OperatorName, + processingContext); if (detailsForTransaction.IsFailed) return ResultHelpers.CreateFailure(detailsForTransaction); - processingStage = "transaction-details-resolved"; + processingContext.Stage = FileLineProcessingStage.TransactionDetailsResolved; Result saleResult = await this.SendSaleTransaction(fileDetails, detailsForTransaction, metadataResult.Data, - command, - operatorName, - stage => processingStage = stage, - () => transactionDispatchAttempted = true, + processingContext, cancellationToken); - transactionDispatchSucceeded = saleResult.IsSuccess; + processingContext.TransactionDispatchSucceeded = saleResult.IsSuccess; if (saleResult.IsSuccess) - responseCode = saleResult.Data.ResponseCode; + processingContext.ResponseCode = saleResult.Data.ResponseCode; - processingStage = "file-line-state-update"; + processingContext.Stage = FileLineProcessingStage.FileLineStateUpdate; stateResult = saleResult switch { _ when saleResult.IsSuccess && saleResult.Data.ResponseCode == "0000" => fileAggregate.RecordFileLineAsSuccessful(command.LineNumber, saleResult.Data.TransactionId), _ when saleResult.IsSuccess => fileAggregate.RecordFileLineAsFailed(command.LineNumber, saleResult.Data.TransactionId, saleResult.Data.ResponseCode, saleResult.Data.ResponseMessage), _ when saleResult.IsFailed => fileAggregate.RecordFileLineAsFailed(command.LineNumber, Guid.Empty, "9999", "Failed to Send to Transaction Processor"), }; - Logger.LogInformation($"file-line-state-determined FileId={command.FileId} LineNumber={command.LineNumber} ProcessingResult={(saleResult.IsSuccess && saleResult.Data.ResponseCode == "0000" ? "Successful" : "Failed")} TransactionDispatchAttempted={transactionDispatchAttempted} TransactionDispatchSucceeded={transactionDispatchSucceeded} ResponseCode={responseCode}"); + ProcessingResult processingResult = saleResult.IsSuccess && saleResult.Data.ResponseCode == "0000" + ? ProcessingResult.Successful + : ProcessingResult.Failed; + FileLineProcessingDiagnostics.LineStateDetermined(processingContext, processingResult); if (stateResult.IsSuccess) - processingStage = "file-line-state-persisted"; + processingContext.Stage = FileLineProcessingStage.FileLineStatePersisted; return stateResult; }, command.FileId, cancellationToken); if (result.IsFailed) { - String failureStage = processingStage == "file-line-state-persisted" - ? "file-line-state-persisted/failed" - : processingStage; - Logger.LogError($"{failureStage} failed FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} FileProfileId={fileProfileId} Operator={operatorName} TransactionDispatchAttempted={transactionDispatchAttempted} TransactionDispatchSucceeded={transactionDispatchSucceeded} ResponseCode={responseCode} ResultStatus={result.Status} Error={result.Message}"); + FileLineProcessingDiagnostics.ProcessingFailed(processingContext, result); Logger.LogWarning($"{command.LineNumber} Status {result.Status} Message {result.Message}"); } - else if (processingStage == "file-line-state-persisted") + else if (processingContext.Stage == FileLineProcessingStage.FileLineStatePersisted) { - Logger.LogInformation($"file-line-state-persisted FileId={command.FileId} LineNumber={command.LineNumber} EstateId={estateId} MerchantId={merchantId} FileProfileId={fileProfileId} Operator={operatorName} TransactionDispatchAttempted={transactionDispatchAttempted} TransactionDispatchSucceeded={transactionDispatchSucceeded} ResponseCode={responseCode}"); + FileLineProcessingDiagnostics.LineStatePersisted(processingContext); } return result; @@ -456,12 +446,9 @@ private Result> BuildTransactionMetadata(FileCommands private async Task> SendSaleTransaction(FileDetails fileDetails, Result<(Guid ContractId, Guid OperatorId, Guid ProductId, String MerchantDevice)> detailsForTransaction, Dictionary transactionMetadata, - FileCommands.ProcessTransactionForFileLineCommand command, - String operatorName, - Action setProcessingStage, - Action markDispatchAttempted, + FileLineProcessingContext processingContext, CancellationToken cancellationToken) { - setProcessingStage("token-acquisition"); + processingContext.Stage = FileLineProcessingStage.TokenAcquisition; Result getTokenResult = await this.GetToken(cancellationToken); if (getTokenResult.IsFailed) { return ResultHelpers.CreateFailure(getTokenResult); @@ -486,29 +473,29 @@ private async Task> SendSaleTransaction(FileDeta // Send request to transaction processor - setProcessingStage("transaction-dispatch-started"); - markDispatchAttempted(); - Logger.LogInformation($"transaction-dispatch-started FileId={command.FileId} LineNumber={command.LineNumber} EstateId={fileDetails.EstateId} MerchantId={fileDetails.MerchantId} Operator={operatorName} ClientOperation=PerformTransaction"); + processingContext.Stage = FileLineProcessingStage.TransactionDispatchStarted; + processingContext.TransactionDispatchAttempted = true; + FileLineProcessingDiagnostics.TransactionDispatchStarted(processingContext); Result result = await this.TransactionProcessorClient.PerformTransaction(this.TokenResponse.AccessToken, saleTransactionRequest, cancellationToken); if (result.IsFailed) { - Logger.LogError($"transaction-dispatch-failed FileId={command.FileId} LineNumber={command.LineNumber} EstateId={fileDetails.EstateId} MerchantId={fileDetails.MerchantId} Operator={operatorName} ClientOperation=PerformTransaction ResultStatus={result.Status} Error={result.Message}"); + FileLineProcessingDiagnostics.TransactionDispatchFailed(processingContext, result); return ResultHelpers.CreateFailure(result); } - Logger.LogInformation($"transaction-dispatch-completed FileId={command.FileId} LineNumber={command.LineNumber} EstateId={fileDetails.EstateId} MerchantId={fileDetails.MerchantId} Operator={operatorName} ClientOperation=PerformTransaction TransactionId={result.Data.TransactionId} ResponseCode={result.Data.ResponseCode} ResponseMessage={result.Data.ResponseMessage}"); - setProcessingStage("transaction-dispatch-completed"); + FileLineProcessingDiagnostics.TransactionDispatchCompleted(processingContext, result.Data); + processingContext.Stage = FileLineProcessingStage.TransactionDispatchCompleted; return result; } private async Task> GetDetailsForTransaction(CancellationToken cancellationToken, - FileDetails fileDetails, - FileProfileModel fileProfile, - String operatorName, - Action setProcessingStage) { + FileDetails fileDetails, + FileProfileModel fileProfile, + String operatorName, + FileLineProcessingContext processingContext) { - setProcessingStage("token-acquisition"); + processingContext.Stage = FileLineProcessingStage.TokenAcquisition; Result getTokenResult = await this.GetToken(cancellationToken); if (getTokenResult.IsFailed) { @@ -518,7 +505,7 @@ private async Task> SendSaleTransaction(FileDeta this.TokenResponse = getTokenResult.Data; // Get the merchant details - setProcessingStage("merchant-lookup"); + processingContext.Stage = FileLineProcessingStage.MerchantLookup; Result getMerchantResult = await this.TransactionProcessorClient.GetMerchant(this.TokenResponse.AccessToken, fileDetails.EstateId, fileDetails.MerchantId, cancellationToken); if (getMerchantResult.IsFailed) { return ResultHelpers.CreateFailure(getMerchantResult); @@ -528,7 +515,7 @@ private async Task> SendSaleTransaction(FileDeta if (merchant == null) return Result.NotFound($"Merchant Id {fileDetails.MerchantId} not found on estate Id {fileDetails.EstateId}"); - setProcessingStage("contract-lookup"); + processingContext.Stage = FileLineProcessingStage.ContractLookup; Result> getContractsResult = await this.TransactionProcessorClient.GetMerchantContracts(this.TokenResponse.AccessToken, fileDetails.EstateId, fileDetails.MerchantId, cancellationToken); if (getContractsResult.IsFailed) { return ResultHelpers.CreateFailure(getContractsResult); @@ -540,7 +527,7 @@ private async Task> SendSaleTransaction(FileDeta return Result.NotFound($"No contracts found for Merchant Id {fileDetails.MerchantId} on estate Id {fileDetails.EstateId}"); } - setProcessingStage("contract-operator-match"); + processingContext.Stage = FileLineProcessingStage.ContractOperatorMatch; ContractResponse contract = fileProfile.OperatorName switch { "Voucher" => contracts.SingleOrDefault(c => c.Description.Contains(operatorName)), _ => contracts.SingleOrDefault(c => c.OperatorName == operatorName) @@ -550,14 +537,14 @@ private async Task> SendSaleTransaction(FileDeta return Result.NotFound($"No merchant contract for operator Id {operatorName} found for Merchant Id {merchant.MerchantId}"); } - setProcessingStage("variable-value-product-lookup"); + processingContext.Stage = FileLineProcessingStage.VariableValueProductLookup; ContractProduct product = contract.Products?.SingleOrDefault(p => p.Value == null); if (product == null) { return Result.NotFound($"No variable value product found on the merchant contract for operator Id {fileProfile.OperatorName} and Merchant Id {merchant.MerchantId}"); } - setProcessingStage("merchant-device-resolution"); + processingContext.Stage = FileLineProcessingStage.MerchantDeviceResolution; if (merchant == null || merchant.Devices == null || merchant.Devices.Any() == false || String.IsNullOrWhiteSpace(merchant.Devices.First().Value)) return Result.NotFound($"No valid merchant device found for Merchant Id {merchant.MerchantId} on estate Id {fileDetails.EstateId}");