From 02d20f3d540fa5aadd938ba05b5db50a2bd89235 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 16 Sep 2026 13:06:37 +0100 Subject: [PATCH 1/3] Keep failed transaction dispatches eligible for retry --- .../FileProcessorDomainServiceTests.cs | 89 +++++++++++++++++++ .../Services/FileProcessorDomainService.cs | 22 +++-- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs index 1105517..b577272 100644 --- a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs @@ -959,6 +959,95 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_Transa message.Contains(TestData.FileId.ToString())); } + [Fact] + public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_SendFailure_LeavesLineEligibleForRetry() + { + this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.GetFileAggregateWithLines())); + this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Failure()); + this.TransactionProcessorClient.GetMerchant(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(TestData.GetMerchantResponseWithOperator); + this.TransactionProcessorClient.GetMerchantContracts(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(TestData.GetMerchantContractsResponse())); + this.SecurityServiceClient.GetToken(Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.TokenResponse())); + this.FileFormatHandler.FileLineCanBeIgnored(Arg.Any()).Returns(false); + this.FileFormatHandler.ParseFileLine(Arg.Any()).Returns(TestData.TransactionMetadata); + + Result result = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).Called(Count.Never()); + } + + [Fact] + public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_RetryCarriesSameFileLineIdentity() + { + Int32 aggregateReadCount = 0; + Int32 transactionAttemptCount = 0; + List transactionNumbers = new(); + List retryKeys = new(); + this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()) + .Returns((Guid fileId, CancellationToken cancellationToken) => + { + aggregateReadCount++; + return Task.FromResult(Result.Success(TestData.GetFileAggregateWithLines())); + }); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success()); + this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(async (String accessToken, SaleTransactionRequest request, CancellationToken cancellationToken) => + { + transactionAttemptCount++; + transactionNumbers.Add(request.TransactionNumber); + retryKeys.Add($"{request.AdditionalTransactionMetadata["FileId"]}:{request.AdditionalTransactionMetadata["FileLineNumber"]}"); + if (transactionAttemptCount == 1) + return Result.Failure(); + + return TestData.ClientSaleTransactionResponse; + }); + this.TransactionProcessorClient.GetMerchant(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(TestData.GetMerchantResponseWithOperator); + this.TransactionProcessorClient.GetMerchantContracts(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(TestData.GetMerchantContractsResponse())); + this.SecurityServiceClient.GetToken(Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.TokenResponse())); + this.FileFormatHandler.FileLineCanBeIgnored(Arg.Any()).Returns(false); + this.FileFormatHandler.ParseFileLine(Arg.Any()).Returns((String line) => new Dictionary(TestData.TransactionMetadata)); + + Result firstAttempt = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); + Result retry = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); + + firstAttempt.IsFailed.ShouldBeTrue(); + retry.IsSuccess.ShouldBeTrue(); + aggregateReadCount.ShouldBe(2); + transactionAttemptCount.ShouldBe(2); + transactionNumbers.Count.ShouldBe(2); + Int32.TryParse(transactionNumbers[0], out _).ShouldBeTrue(); + Int32.TryParse(transactionNumbers[1], out _).ShouldBeTrue(); + retryKeys[0].ShouldBe(retryKeys[1]); + } + + [Fact] + public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_TransactionDispatchTimeout_LeavesLineEligibleForRetry() + { + this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.GetFileAggregateWithLines())); + this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new TimeoutException("Transaction processor request timed out")); + this.TransactionProcessorClient.GetMerchant(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(TestData.GetMerchantResponseWithOperator); + this.TransactionProcessorClient.GetMerchantContracts(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ReturnsAsync(Result.Success(TestData.GetMerchantContractsResponse())); + this.SecurityServiceClient.GetToken(Arg.Any(), Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.TokenResponse())); + this.FileFormatHandler.FileLineCanBeIgnored(Arg.Any()).Returns(false); + this.FileFormatHandler.ParseFileLine(Arg.Any()).Returns(TestData.TransactionMetadata); + + Result result = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).Called(Count.Never()); + } + [Fact] public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_TransactionNotSuccessful_RequestIsHandled() { diff --git a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs index 255a4fc..4c3a903 100644 --- a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs @@ -11,6 +11,7 @@ namespace FileProcessor.BusinessLogic.Services; using TransactionProcessor.DataTransferObjects.Responses.Operator; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.IO.Abstractions; using System.Linq; @@ -366,7 +367,7 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran return stateResult; } - Interlocked.Increment(ref TransactionNumber); + Int32 transactionNumber = Interlocked.Increment(ref TransactionNumber); Result<(Guid ContractId, Guid OperatorId, Guid ProductId, String MerchantDevice)> detailsForTransaction = await this.GetDetailsForTransaction(cancellationToken, fileDetails, @@ -380,18 +381,22 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran Result saleResult = await this.SendSaleTransaction(fileDetails, detailsForTransaction, metadataResult.Data, + transactionNumber, processingContext, cancellationToken); processingContext.TransactionDispatchSucceeded = saleResult.IsSuccess; if (saleResult.IsSuccess) processingContext.ResponseCode = saleResult.Data.ResponseCode; + + // A failed dispatch has no definitive processor response. Leave the line unmodified so + // the file processing workflow can retry it with the same transaction number. + if (saleResult.IsFailed) + return ResultHelpers.CreateFailure(saleResult); 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"), - }; + stateResult = saleResult.Data.ResponseCode == "0000" + ? fileAggregate.RecordFileLineAsSuccessful(command.LineNumber, saleResult.Data.TransactionId) + : fileAggregate.RecordFileLineAsFailed(command.LineNumber, saleResult.Data.TransactionId, saleResult.Data.ResponseCode, saleResult.Data.ResponseMessage); ProcessingResult processingResult = saleResult.IsSuccess && saleResult.Data.ResponseCode == "0000" ? ProcessingResult.Successful @@ -446,6 +451,7 @@ private Result> BuildTransactionMetadata(FileCommands private async Task> SendSaleTransaction(FileDetails fileDetails, Result<(Guid ContractId, Guid OperatorId, Guid ProductId, String MerchantDevice)> detailsForTransaction, Dictionary transactionMetadata, + Int32 transactionNumber, FileLineProcessingContext processingContext, CancellationToken cancellationToken) { processingContext.Stage = FileLineProcessingStage.TokenAcquisition; @@ -461,7 +467,7 @@ private async Task> SendSaleTransaction(FileDeta EstateId = fileDetails.EstateId, MerchantId = fileDetails.MerchantId, TransactionDateTime = fileDetails.FileReceivedDateTime, - TransactionNumber = TransactionNumber.ToString(), + TransactionNumber = transactionNumber.ToString(CultureInfo.InvariantCulture), TransactionType = "Sale", ContractId = detailsForTransaction.Data.ContractId, DeviceIdentifier = detailsForTransaction.Data.MerchantDevice, @@ -641,7 +647,7 @@ private async Task ProcessFile(Guid fileId, } private static Int32 TransactionNumber = 0; - + private Boolean FileLineCanBeIgnored(String domainEventFileLine, String fileProfileFileFormatHandler) { From 1361e08d91f4587c839499378946b6938d086c6f Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 16 Sep 2026 14:12:54 +0100 Subject: [PATCH 2/3] Record failed transaction dispatch attempts for audit --- FIleProcessor.Models/FileLine.cs | 7 +++- .../TransactionDispatchAttempt.cs | 13 ++++++ .../FileProcessorDomainServiceTests.cs | 30 ++++++++++---- .../Common/FileLineProcessingDiagnostics.cs | 4 ++ .../EventHandling/FileDomainEventHandler.cs | 7 ++++ .../Services/FileProcessorDomainService.cs | 38 +++++++++++++++-- .../FileAggregateDomainEvents.cs.cs | 4 +- FileProcessor.FileAggregate/FileAggregate.cs | 41 ++++++++++++++++++- .../FileProcessorSubscriptionStreamBuilder.js | 3 +- 9 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 FIleProcessor.Models/TransactionDispatchAttempt.cs diff --git a/FIleProcessor.Models/FileLine.cs b/FIleProcessor.Models/FileLine.cs index 29a0d81..2e5a4b3 100644 --- a/FIleProcessor.Models/FileLine.cs +++ b/FIleProcessor.Models/FileLine.cs @@ -1,6 +1,7 @@ namespace FileProcessor.Models { using System; + using System.Collections.Generic; /// /// @@ -50,6 +51,10 @@ public class FileLine /// public String RejectedReason { get; set; } + public Int32 DispatchAttemptCount { get; set; } + + public List FailedDispatchAttempts { get; set; } = new(); + #endregion } -} \ No newline at end of file +} diff --git a/FIleProcessor.Models/TransactionDispatchAttempt.cs b/FIleProcessor.Models/TransactionDispatchAttempt.cs new file mode 100644 index 0000000..8ea4541 --- /dev/null +++ b/FIleProcessor.Models/TransactionDispatchAttempt.cs @@ -0,0 +1,13 @@ +namespace FileProcessor.Models +{ + using System; + + public class TransactionDispatchAttempt + { + public Int32 TransactionNumber { get; set; } + + public String FailureType { get; set; } + + public DateTime AttemptedAt { get; set; } + } +} diff --git a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs index b577272..7227dfb 100644 --- a/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs +++ b/FileProcessor.BusinessLogic.Tests/FileProcessorDomainServiceTests.cs @@ -10,11 +10,13 @@ namespace FileProcessor.BusinessLogic.Tests; using System.IO; using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; +using System.Linq; using System.Threading; using System.Threading.Tasks; using FileAggregate; using FileFormatHandlers; using FileImportLogAggregate; +using Models; using Managers; using Microsoft.Extensions.Configuration; using Imposter.Abstractions; @@ -963,7 +965,9 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_Transa public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_SendFailure_LeavesLineEligibleForRetry() { this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); - this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.GetFileAggregateWithLines())); + FileAggregate fileAggregate = TestData.GetFileAggregateWithLines(); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(fileAggregate)); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success()); this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsAsync(Result.Failure()); this.TransactionProcessorClient.GetMerchant(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -977,7 +981,10 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_SendFa Result result = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); result.IsFailed.ShouldBeTrue(); - this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).Called(Count.Never()); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).Called(Count.Once()); + fileAggregate.GetFile().FileLines.Single().ProcessingResult.ShouldBe(ProcessingResult.NotProcessed); + fileAggregate.GetFile().FileLines.Single().FailedDispatchAttempts.ShouldHaveSingleItem().FailureType.ShouldBe("SendFailure"); + fileAggregate.GetFile().FileLines.Single().DispatchAttemptCount.ShouldBe(1); } [Fact] @@ -985,6 +992,7 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_RetryC { Int32 aggregateReadCount = 0; Int32 transactionAttemptCount = 0; + FileAggregate fileAggregate = TestData.GetFileAggregateWithLines(); List transactionNumbers = new(); List retryKeys = new(); this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); @@ -992,7 +1000,7 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_RetryC .Returns((Guid fileId, CancellationToken cancellationToken) => { aggregateReadCount++; - return Task.FromResult(Result.Success(TestData.GetFileAggregateWithLines())); + return Task.FromResult(Result.Success(fileAggregate)); }); this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success()); this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()) @@ -1025,13 +1033,16 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_RetryC Int32.TryParse(transactionNumbers[0], out _).ShouldBeTrue(); Int32.TryParse(transactionNumbers[1], out _).ShouldBeTrue(); retryKeys[0].ShouldBe(retryKeys[1]); + fileAggregate.GetFile().FileLines.Single().DispatchAttemptCount.ShouldBe(2); } [Fact] - public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_TransactionDispatchTimeout_LeavesLineEligibleForRetry() + public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_TransactionDispatchTimeout_RecordsAttemptAndLeavesLineEligibleForRetry() { this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); - this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.GetFileAggregateWithLines())); + FileAggregate fileAggregate = TestData.GetFileAggregateWithLines(); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(fileAggregate)); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success()); this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()) .ThrowsAsync(new TimeoutException("Transaction processor request timed out")); this.TransactionProcessorClient.GetMerchant(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -1045,7 +1056,10 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_Transa Result result = await this.FileProcessorDomainService.ProcessTransactionForFileLine(TestData.ProcessTransactionForFileLineCommand, CancellationToken.None); result.IsFailed.ShouldBeTrue(); - this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).Called(Count.Never()); + this.FileAggregateRepository.SaveChanges(Arg.Any(), Arg.Any()).Called(Count.Once()); + fileAggregate.GetFile().FileLines.Single().ProcessingResult.ShouldBe(ProcessingResult.NotProcessed); + fileAggregate.GetFile().FileLines.Single().FailedDispatchAttempts.ShouldHaveSingleItem().FailureType.ShouldBe("Timeout"); + fileAggregate.GetFile().FileLines.Single().DispatchAttemptCount.ShouldBe(1); } [Fact] @@ -1053,7 +1067,8 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_Transa { this.FileProcessorManager.GetFileProfile(Arg.Any(), Arg.Any()).ReturnsAsync(TestData.FileProfileSafaricom); - this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(TestData.GetFileAggregateWithLines())); + FileAggregate fileAggregate = TestData.GetFileAggregateWithLines(); + this.FileAggregateRepository.GetLatestVersion(Arg.Any(), Arg.Any()).ReturnsAsync(Result.Success(fileAggregate)); this.TransactionProcessorClient.PerformTransaction(Arg.Any(), Arg.Any(), Arg.Any()) .ReturnsAsync(TestData.ClientSaleTransactionFailedResponse); @@ -1075,6 +1090,7 @@ public async Task FileRequestHandler_ProcessTransactionForFileLineRequest_Transa this.DiagnosticLogger.Messages.ShouldContain(message => message.Contains("transaction-dispatch-completed") && message.Contains(TestData.ResponseCodeFailed) && message.Contains(TestData.FileId.ToString())); + fileAggregate.GetFile().FileLines.Single().DispatchAttemptCount.ShouldBe(1); } diff --git a/FileProcessor.BusinessLogic/Common/FileLineProcessingDiagnostics.cs b/FileProcessor.BusinessLogic/Common/FileLineProcessingDiagnostics.cs index 3f1b10b..b350caa 100644 --- a/FileProcessor.BusinessLogic/Common/FileLineProcessingDiagnostics.cs +++ b/FileProcessor.BusinessLogic/Common/FileLineProcessingDiagnostics.cs @@ -25,6 +25,7 @@ internal enum FileLineProcessingStage TransactionDetailsResolved, TransactionDispatchStarted, TransactionDispatchCompleted, + TransactionDispatchAttemptPersisted, FileLineStateUpdate, FileLineStatePersisted } @@ -44,6 +45,8 @@ public FileLineProcessingContext(FileCommands.ProcessTransactionForFileLineComma public Boolean TransactionDispatchSucceeded { get; set; } + public String TransactionDispatchFailureType { get; set; } + public Guid EstateId { get; set; } public Guid MerchantId { get; set; } @@ -141,6 +144,7 @@ public static void EventHandlerDispatchThrew(FileLineAddedEvent domainEvent, Exc FileLineProcessingStage.TransactionDetailsResolved => "transaction-details-resolved", FileLineProcessingStage.TransactionDispatchStarted => "transaction-dispatch-started", FileLineProcessingStage.TransactionDispatchCompleted => "transaction-dispatch-completed", + FileLineProcessingStage.TransactionDispatchAttemptPersisted => "transaction-dispatch-attempt-persisted", 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 b78b223..930bd23 100644 --- a/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs +++ b/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs @@ -84,6 +84,13 @@ private async Task HandleSpecificDomainEvent(FileLineAddedEvent domainEv } } + private Task HandleSpecificDomainEvent(FileLineTransactionDispatchFailedEvent domainEvent, + CancellationToken cancellationToken) + { + // The event records an attempt for audit purposes; it must not enqueue the line as completed. + return Task.FromResult(Result.Success()); + } + /// /// Handles the specific domain event. /// diff --git a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs index 4c3a903..a6f85ee 100644 --- a/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs +++ b/FileProcessor.BusinessLogic/Services/FileProcessorDomainService.cs @@ -301,6 +301,7 @@ private async Task> GetOperatorIdForFileProfile(Guid estateId, Guid public async Task ProcessTransactionForFileLine(FileCommands.ProcessTransactionForFileLineCommand command, CancellationToken cancellationToken) { FileLineProcessingContext processingContext = new(command); + Result dispatchFailureResult = null; Result result = await ApplyFileUpdates(async (FileAggregate fileAggregate) => { FileDetails fileDetails = fileAggregate.GetFile(); processingContext.EstateId = fileDetails.EstateId; @@ -389,9 +390,20 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran processingContext.ResponseCode = saleResult.Data.ResponseCode; // A failed dispatch has no definitive processor response. Leave the line unmodified so - // the file processing workflow can retry it with the same transaction number. + // the file processing workflow can retry it with the same file and line identity. if (saleResult.IsFailed) - return ResultHelpers.CreateFailure(saleResult); + { + stateResult = fileAggregate.RecordTransactionDispatchFailure(command.LineNumber, + transactionNumber, + processingContext.TransactionDispatchFailureType, + DateTime.UtcNow); + if (stateResult.IsFailed) + return stateResult; + + dispatchFailureResult = ResultHelpers.CreateFailure(saleResult); + processingContext.Stage = FileLineProcessingStage.TransactionDispatchAttemptPersisted; + return Result.Success(); + } processingContext.Stage = FileLineProcessingStage.FileLineStateUpdate; stateResult = saleResult.Data.ResponseCode == "0000" @@ -408,6 +420,9 @@ public async Task ProcessTransactionForFileLine(FileCommands.ProcessTran return stateResult; }, command.FileId, cancellationToken); + if (result.IsSuccess && dispatchFailureResult != null) + result = dispatchFailureResult; + if (result.IsFailed) { FileLineProcessingDiagnostics.ProcessingFailed(processingContext, result); @@ -482,9 +497,26 @@ private async Task> SendSaleTransaction(FileDeta processingContext.Stage = FileLineProcessingStage.TransactionDispatchStarted; processingContext.TransactionDispatchAttempted = true; FileLineProcessingDiagnostics.TransactionDispatchStarted(processingContext); - Result result = await this.TransactionProcessorClient.PerformTransaction(this.TokenResponse.AccessToken, saleTransactionRequest, cancellationToken); + Result result; + try + { + result = await this.TransactionProcessorClient.PerformTransaction(this.TokenResponse.AccessToken, saleTransactionRequest, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + processingContext.TransactionDispatchFailureType = exception is TimeoutException or TaskCanceledException + ? "Timeout" + : "SendFailure"; + return Result.Failure(exception.GetExceptionMessages()); + } + if (result.IsFailed) { + processingContext.TransactionDispatchFailureType = "SendFailure"; FileLineProcessingDiagnostics.TransactionDispatchFailed(processingContext, result); return ResultHelpers.CreateFailure(result); } diff --git a/FileProcessor.File.DomainEvents/FileAggregateDomainEvents.cs.cs b/FileProcessor.File.DomainEvents/FileAggregateDomainEvents.cs.cs index 4abd173..53fa44a 100644 --- a/FileProcessor.File.DomainEvents/FileAggregateDomainEvents.cs.cs +++ b/FileProcessor.File.DomainEvents/FileAggregateDomainEvents.cs.cs @@ -9,6 +9,8 @@ public record FileProcessingCompletedEvent(Guid FileId, Guid EstateId, Guid Merc public record FileLineProcessingFailedEvent(Guid FileId, Guid EstateId, Guid MerchantId, Int32 LineNumber, Guid TransactionId, String ResponseCode, String ResponseMessage) : DomainEvent(FileId, Guid.NewGuid()); + public record FileLineTransactionDispatchFailedEvent(Guid FileId, Guid EstateId, Guid MerchantId, Int32 LineNumber, Int32 TransactionNumber, String FailureType, DateTime AttemptedAt) : DomainEvent(FileId, Guid.NewGuid()); + public record FileLineProcessingRejectedEvent(Guid FileId, Guid EstateId, Guid MerchantId, Int32 LineNumber, String Reason) : DomainEvent(FileId, Guid.NewGuid()); public record FileLineProcessingIgnoredEvent(Guid FileId, Guid EstateId, Guid MerchantId, Int32 LineNumber) : DomainEvent(FileId, Guid.NewGuid()); @@ -24,4 +26,4 @@ public record FileCreatedEvent(Guid FileId, String FileLocation, DateTime FileReceivedDateTime, Guid OperatorId) : DomainEvent(FileId, Guid.NewGuid()); -} \ No newline at end of file +} diff --git a/FileProcessor.FileAggregate/FileAggregate.cs b/FileProcessor.FileAggregate/FileAggregate.cs index 8c51313..8373a5f 100644 --- a/FileProcessor.FileAggregate/FileAggregate.cs +++ b/FileProcessor.FileAggregate/FileAggregate.cs @@ -59,7 +59,8 @@ public static void PlayEvent(this FileAggregate aggregate, FileLineAddedEvent do aggregate.FileLines.Add(new FileLine { LineData = domainEvent.FileLine, - LineNumber = domainEvent.LineNumber + LineNumber = domainEvent.LineNumber, + FailedDispatchAttempts = new List() }); } @@ -84,6 +85,7 @@ public static void PlayEvent(this FileAggregate aggregate, FileLineProcessingSuc FileLine fileLine = aggregate.FileLines.Single(f => f.LineNumber == domainEvent.LineNumber); fileLine.TransactionId = domainEvent.TransactionId; fileLine.ProcessingResult = ProcessingResult.Successful; + fileLine.DispatchAttemptCount++; } public static void PlayEvent(this FileAggregate aggregate, FileLineProcessingFailedEvent domainEvent) @@ -92,6 +94,19 @@ public static void PlayEvent(this FileAggregate aggregate, FileLineProcessingFai FileLine fileLine = aggregate.FileLines.Single(f => f.LineNumber == domainEvent.LineNumber); fileLine.TransactionId = domainEvent.TransactionId; fileLine.ProcessingResult = ProcessingResult.Failed; + fileLine.DispatchAttemptCount++; + } + + public static void PlayEvent(this FileAggregate aggregate, FileLineTransactionDispatchFailedEvent domainEvent) + { + FileLine fileLine = aggregate.FileLines.Single(line => line.LineNumber == domainEvent.LineNumber); + fileLine.DispatchAttemptCount++; + fileLine.FailedDispatchAttempts.Add(new TransactionDispatchAttempt + { + TransactionNumber = domainEvent.TransactionNumber, + FailureType = domainEvent.FailureType, + AttemptedAt = domainEvent.AttemptedAt + }); } public static void PlayEvent(this FileAggregate aggregate, FileProcessingCompletedEvent domainEvent) @@ -216,6 +231,30 @@ public static Result RecordFileLineAsFailed(this FileAggregate aggregate,Int32 l return Result.Success(); } + public static Result RecordTransactionDispatchFailure(this FileAggregate aggregate, + Int32 lineNumber, + Int32 transactionNumber, + String failureType, + DateTime attemptedAt) + { + if (aggregate.FileLines.Any() == false) + return Result.Invalid("File has no lines to record a dispatch failure for"); + + if (aggregate.FileLines.SingleOrDefault(line => line.LineNumber == lineNumber) == null) + return Result.NotFound($"File line with number {lineNumber} not found to record a dispatch failure for"); + + FileLineTransactionDispatchFailedEvent dispatchFailedEvent = new(aggregate.AggregateId, + aggregate.EstateId, + aggregate.MerchantId, + lineNumber, + transactionNumber, + failureType, + attemptedAt); + aggregate.ApplyAndAppend(dispatchFailedEvent); + + return Result.Success(); + } + /// /// Completeds the checks. /// diff --git a/FileProcessor.IntegrationTests/projections/continuous/FileProcessorSubscriptionStreamBuilder.js b/FileProcessor.IntegrationTests/projections/continuous/FileProcessorSubscriptionStreamBuilder.js index ff3eb8f..4bdefd9 100644 --- a/FileProcessor.IntegrationTests/projections/continuous/FileProcessorSubscriptionStreamBuilder.js +++ b/FileProcessor.IntegrationTests/projections/continuous/FileProcessorSubscriptionStreamBuilder.js @@ -13,6 +13,7 @@ getSupportedEventTypes = function () { eventTypes.push('FileLineProcessingSuccessfulEvent'); eventTypes.push('FileLineProcessingIgnoredEvent'); eventTypes.push('FileLineProcessingFailedEvent'); + eventTypes.push('FileLineTransactionDispatchFailedEvent'); eventTypes.push('FileProcessingCompletedEvent'); return eventTypes; @@ -64,4 +65,4 @@ fromAll() } } } -); \ No newline at end of file +); From 9cfee514cd8db5e19fb57dbfcbfbb0fa852eb293 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 16 Sep 2026 14:40:54 +0100 Subject: [PATCH 3/3] discards --- .../EventHandling/FileDomainEventHandler.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs b/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs index 930bd23..85595fe 100644 --- a/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs +++ b/FileProcessor.BusinessLogic/EventHandling/FileDomainEventHandler.cs @@ -87,6 +87,8 @@ private async Task HandleSpecificDomainEvent(FileLineAddedEvent domainEv private Task HandleSpecificDomainEvent(FileLineTransactionDispatchFailedEvent domainEvent, CancellationToken cancellationToken) { + _ = domainEvent; + _ = cancellationToken; // The event records an attempt for audit purposes; it must not enqueue the line as completed. return Task.FromResult(Result.Success()); }