From bcad00618c8f4810aad04dbfafc002763540c4c9 Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Thu, 23 Apr 2026 16:23:35 +0100 Subject: [PATCH 1/2] initial bulk file generator version --- .github/workflows/createrelease.yml | 59 ++- .github/workflows/pullrequest.yml | 2 + .../Clients/FileProcessingClient.cs | 86 ++++ .../Clients/MerchantContractDataClient.cs | 132 ++++++ .../Clients/MerchantDepositClient.cs | 56 +++ .../Clients/SharedAccessTokenProvider.cs | 91 ++++ .../Configuration/AuthenticationOptions.cs | 11 + .../ContractDefinitionOptions.cs | 7 + .../Configuration/ContractOptions.cs | 11 + .../DelimitedFileProfileOptions.cs | 11 + .../Configuration/FileFieldOptions.cs | 11 + .../Configuration/FileProcessingOptions.cs | 7 + .../Configuration/FileProfileFormats.cs | 14 + .../Configuration/FileProfileOptions.cs | 23 + .../Configuration/FileStatusPollingOptions.cs | 7 + .../Configuration/FrameworkLoggingOptions.cs | 9 + .../Configuration/JsonFileProfileOptions.cs | 7 + .../Configuration/MerchantOptions.cs | 27 ++ .../MerchantProcessingOptions.cs | 19 + .../MerchantProcessingOptionsValidator.cs | 85 ++++ .../Configuration/ProductOptions.cs | 15 + .../TransactionFileFieldSources.cs | 37 ++ .../TransactionGenerationOptions.cs | 7 + .../DelimitedTransactionFileBuilder.cs | 51 ++ .../JsonTransactionFileBuilder.cs | 58 +++ .../TransactionFileGenerationService.cs | 83 ++++ .../FileBuilding/TransactionFileModels.cs | 185 ++++++++ .../FileStatusPollingWorker.cs | 96 ++++ .../NLog.config | 38 ++ .../Persistence/FileSendRecord.cs | 46 ++ .../Persistence/FileSendRecordLineStatus.cs | 20 + .../MerchantFileProcessorDbContext.cs | 61 +++ .../Persistence/MerchantRunRecord.cs | 20 + .../Program.cs | 309 +++++++++++++ .../Properties/launchSettings.json | 13 + .../Reporting/FileStatusReportService.cs | 434 ++++++++++++++++++ ...ReportingEndpointRouteBuilderExtensions.cs | 33 ++ .../Services/FileProcessingStatusModels.cs | 10 + .../Services/FileStatusStore.cs | 358 +++++++++++++++ .../Services/LocalLogger.cs | 36 ++ .../Services/MerchantProcessingService.cs | 213 +++++++++ ...ionProcessing.MerchantFileProcessor.csproj | 30 ++ ...actionProcessing.MerchantFileProcessor.sln | 25 + .../Worker.cs | 107 +++++ .../appsettings.json | 140 ++++++ .../appsettings.staging.json | 151 ++++++ .../hosting.json | 4 + 47 files changed, 3254 insertions(+), 1 deletion(-) create mode 100644 TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Clients/MerchantContractDataClient.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Clients/MerchantDepositClient.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/AuthenticationOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/ContractDefinitionOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/ContractOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/DelimitedFileProfileOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/FileFieldOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/FileProcessingOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/FileProfileFormats.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/FileProfileOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/FileStatusPollingOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/FrameworkLoggingOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/JsonFileProfileOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/MerchantOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/ProductOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/TransactionFileFieldSources.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Configuration/TransactionGenerationOptions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/FileBuilding/DelimitedTransactionFileBuilder.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/FileBuilding/JsonTransactionFileBuilder.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/NLog.config create mode 100644 TransactionProcessing.MerchantFileProcessor/Persistence/FileSendRecord.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Persistence/FileSendRecordLineStatus.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Persistence/MerchantRunRecord.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Program.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Properties/launchSettings.json create mode 100644 TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Services/FileProcessingStatusModels.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Services/FileStatusStore.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Services/LocalLogger.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/Services/MerchantProcessingService.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj create mode 100644 TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.sln create mode 100644 TransactionProcessing.MerchantFileProcessor/Worker.cs create mode 100644 TransactionProcessing.MerchantFileProcessor/appsettings.json create mode 100644 TransactionProcessing.MerchantFileProcessor/appsettings.staging.json create mode 100644 TransactionProcessing.MerchantFileProcessor/hosting.json diff --git a/.github/workflows/createrelease.yml b/.github/workflows/createrelease.yml index e308647..edcc029 100644 --- a/.github/workflows/createrelease.yml +++ b/.github/workflows/createrelease.yml @@ -29,16 +29,19 @@ jobs: dotnet restore TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} dotnet restore TransactionProcessing.SchedulerService/TransactionProcessing.SchedulerService.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} dotnet restore TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} + dotnet restore TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} - name: Build Code run: | dotnet build TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.sln --configuration Release dotnet build TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln --configuration Release + dotnet build TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.sln --configuration Release - name: Publish API run: | dotnet publish "TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.csproj" --configuration Release --output TransactionProcessor.HealthChecksUI/publishOutput -r win-x64 --self-contained dotnet publish "TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.csproj" --configuration Release --output TransactionProcessing.MerchantPos/publishOutput -r win-x64 --self-contained + dotnet publish "TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj" --configuration Release --output TransactionProcessing.MerchantFileProcessor/publishOutput -r win-x64 --self-contained - name: Build Release Package (Health Check UI) run: | @@ -63,6 +66,18 @@ jobs: with: name: merchantpos path: /home/runner/work/SupportTools/SupportTools/TransactionProcessing.MerchantPos/merchantpos.zip + + - name: Build Release Package (Merchant File Processor) + run: | + cd /home/runner/work/SupportTools/SupportTools/TransactionProcessing.MerchantFileProcessor/publishOutput + zip -r ../merchantfileprocessor.zip ./* + echo "Zip file created at: $(realpath ../merchantfileprocessor.zip)" + + - name: Upload the artifact (Merchant File Processor) + uses: actions/upload-artifact@v4.4.0 + with: + name: merchantfileprocessor + path: /home/runner/work/SupportTools/SupportTools/TransactionProcessing.MerchantFileProcessor/merchantfileprocessor.zip deploystaging: runs-on: [stagingserver, windows] @@ -123,6 +138,27 @@ jobs: New-Service -Name $serviceName -BinaryPathName $servicePath -Description $serviceName -DisplayName $serviceName -StartupType Automatic Start-Service -Name $serviceName + - name: Remove existing Windows service (Merchant File Processor) + run: | + $serviceName = "Transaction Processing - Merchant File Processor" + # Check if the service exists + if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { + Stop-Service -Name $serviceName + sc.exe delete $serviceName + } + + - name: Unzip the files (Merchant File Processor) + run: | + Expand-Archive -Path merchantfileprocessor.zip -DestinationPath "C:\txnproc\transactionprocessing\merchantfileprocessor" -Force + + - name: Install as a Windows service (Merchant File Processor) + run: | + $serviceName = "Transaction Processing - Merchant File Processor" + $servicePath = "C:\txnproc\transactionprocessing\merchantfileprocessor\TransactionProcessing.MerchantFileProcessor.exe" + + New-Service -Name $serviceName -BinaryPathName $servicePath -Description $serviceName -DisplayName $serviceName -StartupType Automatic + Start-Service -Name $serviceName + deployproduction: runs-on: [productionserver, windows] needs: [build, deploystaging] @@ -175,4 +211,25 @@ jobs: $servicePath = "C:\txnproc\transactionprocessing\merchantpos\TransactionProcessor.MerchantPos.exe" New-Service -Name $serviceName -BinaryPathName $servicePath -Description $serviceName -DisplayName $serviceName -StartupType Automatic - Start-Service -Name $serviceName + Start-Service -Name $serviceName + + - name: Remove existing Windows service (Merchant File Processor) + run: | + $serviceName = "Transaction Processing - Merchant File Processor" + # Check if the service exists + if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { + Stop-Service -Name $serviceName + sc.exe delete $serviceName + } + + - name: Unzip the files (Merchant File Processor) + run: | + Expand-Archive -Path merchantfileprocessor.zip -DestinationPath "C:\txnproc\transactionprocessing\merchantfileprocessor" -Force + + - name: Install as a Windows service (Merchant File Processor) + run: | + $serviceName = "Transaction Processing - Merchant File Processor" + $servicePath = "C:\txnproc\transactionprocessing\merchantfileprocessor\TransactionProcessing.MerchantFileProcessor.exe" + + New-Service -Name $serviceName -BinaryPathName $servicePath -Description $serviceName -DisplayName $serviceName -StartupType Automatic + Start-Service -Name $serviceName diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 0c82218..27cbd4f 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -26,6 +26,7 @@ jobs: dotnet restore TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} dotnet restore TransactionProcessing.SchedulerService/TransactionProcessing.SchedulerService.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} dotnet restore TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} + dotnet restore TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} - name: Build Code @@ -33,6 +34,7 @@ jobs: dotnet build TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.sln --configuration Release dotnet build TransactionProcessing.SchedulerService/TransactionProcessing.SchedulerService.sln --configuration Release dotnet build TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln --configuration Release + dotnet build TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.sln --configuration Release pester-tests: name: "Run PowerShell Tests" diff --git a/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs b/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs new file mode 100644 index 0000000..efd1faa --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs @@ -0,0 +1,86 @@ +using FileProcessor.Client; +using FileProcessor.DataTransferObjects; +using FileProcessor.DataTransferObjects.Responses; +using SimpleResults; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessing.MerchantFileProcessor.FileBuilding; +using TransactionProcessing.MerchantFileProcessor.Services; + +namespace TransactionProcessing.MerchantFileProcessor.Clients; + +public interface IFileProcessingClient +{ + Task> Upload(MerchantOptions merchant, + ContractOptions contract, + string accessToken, + GeneratedFile file, + CancellationToken cancellationToken); + + Task> GetFileStatus(string accessToken, + Guid estateId, + Guid fileId, + CancellationToken cancellationToken); +} + +public sealed class FileProcessingClient(IFileProcessorClient fileProcessorClient, MerchantProcessingOptions options) : IFileProcessingClient { + public async Task> Upload(MerchantOptions merchant, + ContractOptions contract, + string accessToken, + GeneratedFile file, + CancellationToken cancellationToken) { + FileProfileOptions? fileProfile = options.FileProfiles.FirstOrDefault(profile => profile.FileProfileId.Equals(file.FileProfileId, StringComparison.OrdinalIgnoreCase)); + + if (fileProfile is null) { + return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"Generated file references unknown file profile '{file.FileProfileId}'." }; + } + + UploadFileRequest request = new UploadFileRequest { + EstateId = merchant.GetEstateGuid(), + MerchantId = merchant.GetMerchantGuid(), + UserId = options.FileProcessing.GetUserGuid(), + FileProfileId = fileProfile.GetFileProcessorFileProfileGuid(), + UploadDateTime = DateTime.UtcNow + }; + + Result? result = await fileProcessorClient.UploadFile(accessToken, file.FileName, file.Content, request, cancellationToken); + //var result = Result.Success(Guid.NewGuid()); // TODO: Replace with actual file upload call + + if (result.IsFailed) { + return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to upload file '{file.FileName}'." }; + } + + return Result.Success(result.Data); + } + + public async Task> GetFileStatus(string accessToken, + Guid estateId, + Guid fileId, + CancellationToken cancellationToken) { + Result? result = await fileProcessorClient.GetFile(accessToken, estateId, fileId, cancellationToken); + + if (result.IsFailed || result.Data is null) { + return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to retrieve status for file '{fileId}'." }; + } + + FileDetails? fileDetails = result.Data; + FileProcessingLineStatusSnapshot[] lineStatuses = fileDetails.FileLines?.OrderBy(line => line.LineNumber).Select(MapLineStatus).ToArray() ?? []; + + return Result.Success(new FileProcessingStatusSnapshot(fileDetails.ProcessingCompleted || AreAllLinesResolved(lineStatuses), lineStatuses)); + } + + private static FileProcessingLineStatusSnapshot MapLineStatus(FileLine line) => new(line.LineNumber, line.LineData, line.ProcessingResult.ToString(), string.IsNullOrWhiteSpace(line.RejectionReason) ? null : line.RejectionReason, line.TransactionId == Guid.Empty ? null : line.TransactionId); + + private static bool AreAllLinesResolved(IEnumerable lines) { + Boolean hasLines = false; + + foreach (FileProcessingLineStatusSnapshot line in lines) { + hasLines = true; + + if (line.ProcessingStatus.Equals(FileLineStatuses.Unknown, StringComparison.OrdinalIgnoreCase) || line.ProcessingStatus.Equals(FileLineStatuses.NotProcessed, StringComparison.OrdinalIgnoreCase)) { + return false; + } + } + + return hasLines; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Clients/MerchantContractDataClient.cs b/TransactionProcessing.MerchantFileProcessor/Clients/MerchantContractDataClient.cs new file mode 100644 index 0000000..94da107 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Clients/MerchantContractDataClient.cs @@ -0,0 +1,132 @@ +using Shared.Logger; +using SimpleResults; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessor.Client; +using TransactionProcessor.DataTransferObjects.Responses.Contract; + +namespace TransactionProcessing.MerchantFileProcessor.Clients; + +public interface IMerchantContractDataClient +{ + Task>> GetContracts( + MerchantOptions merchant, + string accessToken, + CancellationToken cancellationToken); +} + +public sealed class MerchantContractDataClient( + ITransactionProcessorClient transactionProcessorClient) : IMerchantContractDataClient +{ + public async Task>> GetContracts( + MerchantOptions merchant, + string accessToken, + CancellationToken cancellationToken) + { + Logger.LogInformation($"Requesting merchant contracts from TransactionProcessor for merchant {merchant.MerchantId} in estate {merchant.EstateId}"); + + var result = await transactionProcessorClient.GetMerchantContracts( + accessToken, + merchant.GetEstateGuid(), + merchant.GetMerchantGuid(), + cancellationToken); + + if (result.IsFailed || result.Data is null || result.Data.Count == 0) + { + return new Result> + { + IsSuccess = false, + Status = ResultStatus.Failure, + Message = $"Transaction processor client did not return any contracts for merchant '{merchant.MerchantId}'." + }; + } + + var contracts = MapContracts(result.Data); + var validationResult = ValidateContracts(merchant.MerchantId, contracts); + + if (validationResult.IsFailed) + { + return new Result> + { + IsSuccess = false, + Status = validationResult.Status, + Message = validationResult.Message, + Errors = validationResult.Errors.ToList() + }; + } + + Logger.LogInformation($"Retrieved {contracts.Count} contracts from TransactionProcessor for merchant {merchant.MerchantId}"); + + return Result.Success>(contracts); + } + + private static List MapContracts(IReadOnlyList sourceContracts) + { + return sourceContracts + .Select(contract => new ContractOptions + { + ContractId = contract.ContractId.ToString(), + ContractName = ResolveContractName(contract), + Issuer = ResolveContractIssuer(contract), + Products = contract.Products? + .Select(product => new ProductOptions + { + ProductCode = product.ProductId.ToString(), + Description = string.IsNullOrWhiteSpace(product.DisplayText) ? product.Name : product.DisplayText, + IsFixedValue = product.Value.HasValue, + Quantity = 1, + UnitAmount = product.Value ?? 0m, + Currency = "GBP" + }) + .ToList() ?? [] + }) + .ToList(); + } + + private static string ResolveContractName(ContractResponse contract) + { + if (!string.IsNullOrWhiteSpace(contract.Description)) + { + return contract.Description.Trim(); + } + + return contract.ContractId.ToString(); + } + + private static string ResolveContractIssuer(ContractResponse contract) + { + if (string.IsNullOrWhiteSpace(contract.Description)) + { + return string.Empty; + } + + const string contractSuffix = " Contract"; + + return contract.Description.EndsWith(contractSuffix, StringComparison.OrdinalIgnoreCase) + ? contract.Description[..^contractSuffix.Length].TrimEnd() + : contract.Description.Trim(); + } + + private static Result ValidateContracts(string merchantId, IReadOnlyList contracts) + { + foreach (var contract in contracts) + { + if (string.IsNullOrWhiteSpace(contract.ContractId) || contract.Products.Count == 0) + { + return Result.Failure($"Contract data for merchant '{merchantId}' is missing a contract identifier or products."); + } + + foreach (var product in contract.Products) + { + if (string.IsNullOrWhiteSpace(product.ProductCode) || + product.Quantity <= 0 || + product.UnitAmount < 0 || + string.IsNullOrWhiteSpace(product.Currency)) + { + return Result.Failure($"Contract data for merchant '{merchantId}' contains an invalid product definition."); + } + } + } + + return Result.Success(); + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Clients/MerchantDepositClient.cs b/TransactionProcessing.MerchantFileProcessor/Clients/MerchantDepositClient.cs new file mode 100644 index 0000000..1074384 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Clients/MerchantDepositClient.cs @@ -0,0 +1,56 @@ +using Shared.Logger; +using SimpleResults; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessor.Client; +using TransactionProcessor.DataTransferObjects.Requests.Merchant; + +namespace TransactionProcessing.MerchantFileProcessor.Clients; + +public interface IMerchantDepositClient +{ + Task MakeDeposit( + MerchantOptions merchant, + string accessToken, + decimal amount, + string reference, + DateTimeOffset depositTimestampUtc, + CancellationToken cancellationToken); +} + +public sealed class MerchantDepositClient( + ITransactionProcessorClient transactionProcessorClient) : IMerchantDepositClient +{ + public async Task MakeDeposit( + MerchantOptions merchant, + string accessToken, + decimal amount, + string reference, + DateTimeOffset depositTimestampUtc, + CancellationToken cancellationToken) + { + if (amount <= 0) + { + return Result.Failure($"Deposit amount for merchant '{merchant.MerchantId}' must be greater than zero."); + } + + var request = new MakeMerchantDepositRequest + { + Amount = amount, + DepositDateTime = depositTimestampUtc.UtcDateTime, + Reference = reference + }; + + Logger.LogInformation($"Making merchant deposit of {amount:0.00} for merchant {merchant.MerchantId} using reference {reference}"); + + var result = await transactionProcessorClient.MakeMerchantDeposit( + accessToken, + merchant.GetEstateGuid(), + merchant.GetMerchantGuid(), + request, + cancellationToken); + + return result.IsFailed + ? Result.Failure($"Transaction processor client failed to make a deposit for merchant '{merchant.MerchantId}'.") + : Result.Success(); + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs b/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs new file mode 100644 index 0000000..58f70ce --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs @@ -0,0 +1,91 @@ +using SecurityService.Client; +using Shared.Logger; +using SimpleResults; +using TransactionProcessing.MerchantFileProcessor.Configuration; + +namespace TransactionProcessing.MerchantFileProcessor.Clients; + +public interface IAccessTokenProvider +{ + Task> GetAccessToken(CancellationToken cancellationToken); +} + +public sealed class SharedAccessTokenProvider( + ISecurityServiceClient securityServiceClient, + MerchantProcessingOptions options) : IAccessTokenProvider +{ + private readonly SemaphoreSlim refreshLock = new(1, 1); + private CachedToken? currentToken; + + public async Task> GetAccessToken(CancellationToken cancellationToken) + { + if (IsTokenValid(this.currentToken)) + { + return Result.Success(this.currentToken); + } + + await this.refreshLock.WaitAsync(cancellationToken); + + try + { + if (IsTokenValid(this.currentToken)) + { + return Result.Success(this.currentToken); + } + + var tokenResult = await this.RequestToken(cancellationToken); + + if (tokenResult.IsFailed || tokenResult.Data is null) { + return Result.Failure(string.Join("; ", tokenResult.Errors)); + } + + this.currentToken = tokenResult.Data; + Logger.LogInformation( + $"Retrieved shared access token that expires at {this.currentToken.ExpiresUtc:O}"); + + return Result.Success(this.currentToken); + } + finally + { + this.refreshLock.Release(); + } + } + + private async Task> RequestToken(CancellationToken cancellationToken) + { + var authentication = options.Authentication; + var tokenResult = await securityServiceClient.GetToken( + authentication.ClientId, + authentication.ClientSecret, + cancellationToken); + + if (tokenResult.IsFailed) + { + return new Result + { + IsSuccess = false, + Status = ResultStatus.Failure, + Message = "Security service client failed to retrieve an access token." + }; + } + + var token = tokenResult.Data; + + if (string.IsNullOrWhiteSpace(token.AccessToken)) + { + return new Result + { + IsSuccess = false, + Status = ResultStatus.Failure, + Message = "Security service client returned an empty access token." + }; + } + + return Result.Success(new CachedToken(token.AccessToken, DateTimeOffset.UtcNow.AddSeconds(Convert.ToInt32(token.ExpiresIn)))); + } + + private static bool IsTokenValid(CachedToken? token) => + token is not null && token.ExpiresUtc > DateTimeOffset.UtcNow.AddMinutes(2); + + public sealed record CachedToken(string AccessToken, DateTimeOffset ExpiresUtc); +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/AuthenticationOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/AuthenticationOptions.cs new file mode 100644 index 0000000..0fe5522 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/AuthenticationOptions.cs @@ -0,0 +1,11 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class AuthenticationOptions { + public string ClientId { get; init; } = string.Empty; + + public string ClientSecret { get; init; } = string.Empty; + + public string? Scope { get; init; } + + public string? Audience { get; init; } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/ContractDefinitionOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/ContractDefinitionOptions.cs new file mode 100644 index 0000000..4b18060 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/ContractDefinitionOptions.cs @@ -0,0 +1,7 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class ContractDefinitionOptions { + public string ContractId { get; init; } = string.Empty; + + public string FileProfileId { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/ContractOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/ContractOptions.cs new file mode 100644 index 0000000..378a60d --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/ContractOptions.cs @@ -0,0 +1,11 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class ContractOptions { + public string ContractId { get; init; } = string.Empty; + + public string ContractName { get; init; } = string.Empty; + + public string Issuer { get; init; } = string.Empty; + + public List Products { get; init; } = []; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/DelimitedFileProfileOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/DelimitedFileProfileOptions.cs new file mode 100644 index 0000000..243958d --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/DelimitedFileProfileOptions.cs @@ -0,0 +1,11 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class DelimitedFileProfileOptions { + public string Delimiter { get; init; } = ","; + + public bool IncludeHeader { get; init; } = true; + + public List HeaderFields { get; init; } = []; + + public List TrailerFields { get; init; } = []; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/FileFieldOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/FileFieldOptions.cs new file mode 100644 index 0000000..bf3d0fe --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/FileFieldOptions.cs @@ -0,0 +1,11 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class FileFieldOptions { + public string Name { get; init; } = string.Empty; + + public string Source { get; init; } = string.Empty; + + public string? Format { get; init; } + + public string? Value { get; init; } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/FileProcessingOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/FileProcessingOptions.cs new file mode 100644 index 0000000..6604944 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/FileProcessingOptions.cs @@ -0,0 +1,7 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class FileProcessingOptions { + public string UserId { get; init; } = string.Empty; + + public Guid GetUserGuid() => Guid.Parse(this.UserId); +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/FileProfileFormats.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/FileProfileFormats.cs new file mode 100644 index 0000000..7f5132e --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/FileProfileFormats.cs @@ -0,0 +1,14 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public static class FileProfileFormats +{ + public const string Delimited = "delimited"; + + public const string Json = "json"; + + public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase) + { + Delimited, + Json + }; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/FileProfileOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/FileProfileOptions.cs new file mode 100644 index 0000000..5080f4a --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/FileProfileOptions.cs @@ -0,0 +1,23 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class FileProfileOptions { + public string FileProfileId { get; init; } = string.Empty; + + public string FileProcessorFileProfileId { get; init; } = string.Empty; + + public Guid GetFileProcessorFileProfileGuid() => Guid.Parse(this.FileProcessorFileProfileId); + + public string Format { get; init; } = FileProfileFormats.Delimited; + + public string FileExtension { get; init; } = "csv"; + + public string? FileNamePattern { get; init; } + + public string? ContentType { get; init; } + + public DelimitedFileProfileOptions Delimited { get; init; } = new(); + + public JsonFileProfileOptions Json { get; init; } = new(); + + public List Fields { get; init; } = []; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/FileStatusPollingOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/FileStatusPollingOptions.cs new file mode 100644 index 0000000..b66f377 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/FileStatusPollingOptions.cs @@ -0,0 +1,7 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class FileStatusPollingOptions { + public int PollIntervalSeconds { get; init; } = 30; + + public TimeSpan GetPollInterval() => TimeSpan.FromSeconds(this.PollIntervalSeconds); +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/FrameworkLoggingOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/FrameworkLoggingOptions.cs new file mode 100644 index 0000000..65c5eaa --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/FrameworkLoggingOptions.cs @@ -0,0 +1,9 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class FrameworkLoggingOptions { + public const string SectionName = "FrameworkLogging"; + + public bool EnableEfCoreCommandTrace { get; init; } + + public bool EnableHttpClientTrace { get; init; } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/JsonFileProfileOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/JsonFileProfileOptions.cs new file mode 100644 index 0000000..49f62f0 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/JsonFileProfileOptions.cs @@ -0,0 +1,7 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class JsonFileProfileOptions { + public bool WriteIndented { get; init; } + + public string? RootPropertyName { get; init; } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantOptions.cs new file mode 100644 index 0000000..ecc9e47 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantOptions.cs @@ -0,0 +1,27 @@ +using System.Globalization; + +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class MerchantOptions { + public string Name { get; init; } = string.Empty; + + public bool Enabled { get; init; } = true; + + public string EstateId { get; init; } = string.Empty; + + public string MerchantId { get; init; } = string.Empty; + + public string RunAtUtc { get; init; } = "02:00:00"; + + public List RunTimesUtc { get; init; } = []; + + public IReadOnlyList GetDailyRunTimesUtc() { + var configuredTimes = this.RunTimesUtc.Count > 0 ? this.RunTimesUtc : [this.RunAtUtc]; + + return configuredTimes.Select(runTime => TimeOnly.ParseExact(runTime, "HH:mm:ss", CultureInfo.InvariantCulture)).Distinct().OrderBy(runTime => runTime).ToArray(); + } + + public Guid GetEstateGuid() => Guid.Parse(this.EstateId); + + public Guid GetMerchantGuid() => Guid.Parse(this.MerchantId); +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptions.cs new file mode 100644 index 0000000..ec5b917 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptions.cs @@ -0,0 +1,19 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class MerchantProcessingOptions { + public const string SectionName = "MerchantProcessing"; + + public AuthenticationOptions Authentication { get; init; } = new(); + + public FileProcessingOptions FileProcessing { get; init; } = new(); + + public TransactionGenerationOptions TransactionGeneration { get; init; } = new(); + + public FileStatusPollingOptions FileStatusPolling { get; init; } = new(); + + public List ContractDefinitions { get; init; } = []; + + public List FileProfiles { get; init; } = []; + + public List Merchants { get; init; } = []; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs new file mode 100644 index 0000000..1e888a4 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs @@ -0,0 +1,85 @@ +using System.Globalization; + +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public static class MerchantProcessingOptionsValidator { + public static bool Validate(MerchantProcessingOptions options) { + if (options.Merchants.Count == 0) { + return false; + } + + if (string.IsNullOrWhiteSpace(options.Authentication.ClientId) || string.IsNullOrWhiteSpace(options.Authentication.ClientSecret)) { + return false; + } + + if (string.IsNullOrWhiteSpace(options.FileProcessing.UserId) || !Guid.TryParse(options.FileProcessing.UserId, out _)) { + return false; + } + + if (options.FileProfiles.Count == 0) { + return false; + } + + if (options.TransactionGeneration.MinimumTransactionsPerContract <= 1 || options.TransactionGeneration.MaximumTransactionsPerContract < options.TransactionGeneration.MinimumTransactionsPerContract) { + return false; + } + + if (options.FileStatusPolling.PollIntervalSeconds <= 0) { + return false; + } + + if (options.ContractDefinitions.Count == 0) { + return false; + } + + var fileProfileIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var contractIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var fileProfile in options.FileProfiles) { + if (string.IsNullOrWhiteSpace(fileProfile.FileProfileId) || string.IsNullOrWhiteSpace(fileProfile.FileProcessorFileProfileId) || !fileProfileIds.Add(fileProfile.FileProfileId) || !Guid.TryParse(fileProfile.FileProcessorFileProfileId, out _) || !FileProfileFormats.All.Contains(fileProfile.Format) || string.IsNullOrWhiteSpace(fileProfile.FileExtension) || fileProfile.Fields.Count == 0) { + return false; + } + + if (fileProfile.Format.Equals(FileProfileFormats.Delimited, StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(fileProfile.Delimited.Delimiter)) { + return false; + } + + if (!AreFieldsValid(fileProfile.Fields)) { + return false; + } + + if (!AreFieldsValid(fileProfile.Delimited.HeaderFields) || !AreFieldsValid(fileProfile.Delimited.TrailerFields)) { + return false; + } + } + + foreach (var contractDefinition in options.ContractDefinitions) { + if (string.IsNullOrWhiteSpace(contractDefinition.ContractId) || string.IsNullOrWhiteSpace(contractDefinition.FileProfileId) || !contractIds.Add(contractDefinition.ContractId) || !fileProfileIds.Contains(contractDefinition.FileProfileId)) { + return false; + } + } + + foreach (var merchant in options.Merchants) { + var configuredTimes = merchant.RunTimesUtc.Count > 0 ? merchant.RunTimesUtc : [merchant.RunAtUtc]; + + if (string.IsNullOrWhiteSpace(merchant.EstateId) || string.IsNullOrWhiteSpace(merchant.MerchantId) || !Guid.TryParse(merchant.EstateId, out _) || !Guid.TryParse(merchant.MerchantId, out _) || configuredTimes.Count == 0 || configuredTimes.Any(runTime => !TimeOnly.TryParseExact(runTime, "HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))) { + return false; + } + } + + return true; + } + + private static bool AreFieldsValid(IEnumerable fields) { + foreach (var field in fields) { + var hasLiteralValue = !string.IsNullOrWhiteSpace(field.Value); + var hasSource = !string.IsNullOrWhiteSpace(field.Source); + + if (string.IsNullOrWhiteSpace(field.Name) || (!hasLiteralValue && !hasSource) || (hasSource && !TransactionFileFieldSources.All.Contains(field.Source))) { + return false; + } + } + + return true; + } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/ProductOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/ProductOptions.cs new file mode 100644 index 0000000..1f9c32d --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/ProductOptions.cs @@ -0,0 +1,15 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class ProductOptions { + public string ProductCode { get; init; } = string.Empty; + + public string Description { get; init; } = string.Empty; + + public bool IsFixedValue { get; init; } + + public int Quantity { get; init; } = 1; + + public decimal UnitAmount { get; init; } + + public string Currency { get; init; } = "GBP"; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/TransactionFileFieldSources.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/TransactionFileFieldSources.cs new file mode 100644 index 0000000..c7e7919 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/TransactionFileFieldSources.cs @@ -0,0 +1,37 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public static class TransactionFileFieldSources +{ + public const string MerchantId = "merchantid"; + public const string ContractId = "contractid"; + public const string ProductCode = "productcode"; + public const string Description = "description"; + public const string Quantity = "quantity"; + public const string UnitAmount = "unitamount"; + public const string TotalAmount = "totalamount"; + public const string Currency = "currency"; + public const string TransactionDateUtc = "transactiondateutc"; + public const string RecipientMobileNumber = "recipientmobilenumber"; + public const string ContractIssuer = "contractissuer"; + public const string ProcessingDateUtc = "processingdateutc"; + public const string RecordCount = "recordcount"; + public const string FileTotalAmount = "filetotalamount"; + + public static readonly HashSet All = new(StringComparer.OrdinalIgnoreCase) + { + MerchantId, + ContractId, + ProductCode, + Description, + Quantity, + UnitAmount, + TotalAmount, + Currency, + TransactionDateUtc, + RecipientMobileNumber, + ContractIssuer, + ProcessingDateUtc, + RecordCount, + FileTotalAmount + }; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/TransactionGenerationOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/TransactionGenerationOptions.cs new file mode 100644 index 0000000..3755a0f --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/TransactionGenerationOptions.cs @@ -0,0 +1,7 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed class TransactionGenerationOptions { + public int MinimumTransactionsPerContract { get; init; } = 5; + + public int MaximumTransactionsPerContract { get; init; } = 25; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/DelimitedTransactionFileBuilder.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/DelimitedTransactionFileBuilder.cs new file mode 100644 index 0000000..f41f143 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/DelimitedTransactionFileBuilder.cs @@ -0,0 +1,51 @@ +using System.Text; +using TransactionProcessing.MerchantFileProcessor.Configuration; + +namespace TransactionProcessing.MerchantFileProcessor.FileBuilding; + +public sealed class DelimitedTransactionFileBuilder : ITransactionFileBuilder { + public string Format => FileProfileFormats.Delimited; + + public GeneratedFile Build(MerchantOptions merchant, + ContractOptions contract, + FileProfileOptions fileProfile, + IReadOnlyList transactions, + DateTimeOffset processingTimestampUtc) { + String delimiter = NormalizeDelimiter(fileProfile.Delimited.Delimiter); + List lines = new List(); + Decimal fileTotalAmount = transactions.Sum(transaction => transaction.TotalAmount); + + if (fileProfile.Delimited.HeaderFields.Count > 0) { + TransactionFileContext headerContext = new TransactionFileContext(merchant, contract, null, processingTimestampUtc, transactions.Count, fileTotalAmount); + + lines.Add(string.Join(delimiter, fileProfile.Delimited.HeaderFields.Select(field => Escape(TransactionFileFieldResolver.GetTextValue(field, headerContext), delimiter)))); + } + else if (fileProfile.Delimited.IncludeHeader) { + lines.Add(string.Join(delimiter, fileProfile.Fields.Select(field => Escape(field.Name, delimiter)))); + } + + foreach (GeneratedTransaction transaction in transactions) { + TransactionFileContext detailContext = new TransactionFileContext(merchant, contract, transaction, processingTimestampUtc, transactions.Count, fileTotalAmount); + + lines.Add(string.Join(delimiter, fileProfile.Fields.Select(field => Escape(TransactionFileFieldResolver.GetTextValue(field, detailContext), delimiter)))); + } + + if (fileProfile.Delimited.TrailerFields.Count > 0) { + TransactionFileContext trailerContext = new TransactionFileContext(merchant, contract, null, processingTimestampUtc, transactions.Count, fileTotalAmount); + + lines.Add(string.Join(delimiter, fileProfile.Delimited.TrailerFields.Select(field => Escape(TransactionFileFieldResolver.GetTextValue(field, trailerContext), delimiter)))); + } + + return new GeneratedFile(GeneratedFileNameFactory.BuildFileName(fileProfile, merchant, contract, processingTimestampUtc), Encoding.UTF8.GetBytes(string.Join(Environment.NewLine, lines)), string.IsNullOrWhiteSpace(fileProfile.ContentType) ? "text/plain" : fileProfile.ContentType, transactions.Count, fileTotalAmount, fileProfile.FileProfileId, fileProfile.Format, transactions); + } + + private static string NormalizeDelimiter(string delimiter) => delimiter.Replace("\\t", "\t", StringComparison.Ordinal).Replace("\\r", "\r", StringComparison.Ordinal).Replace("\\n", "\n", StringComparison.Ordinal); + + private static string Escape(string value, + string delimiter) { + Boolean shouldQuote = value.Contains('"') || value.Contains('\r') || value.Contains('\n') || value.Contains(delimiter, StringComparison.Ordinal); + String escapedValue = value.Replace("\"", "\"\"", StringComparison.Ordinal); + + return shouldQuote ? $"\"{escapedValue}\"" : escapedValue; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/JsonTransactionFileBuilder.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/JsonTransactionFileBuilder.cs new file mode 100644 index 0000000..1750807 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/JsonTransactionFileBuilder.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using TransactionProcessing.MerchantFileProcessor.Configuration; + +namespace TransactionProcessing.MerchantFileProcessor.FileBuilding; + +public sealed class JsonTransactionFileBuilder : ITransactionFileBuilder +{ + public string Format => FileProfileFormats.Json; + + public GeneratedFile Build( + MerchantOptions merchant, + ContractOptions contract, + FileProfileOptions fileProfile, + IReadOnlyList transactions, + DateTimeOffset processingTimestampUtc) + { + var mappedRecords = transactions + .Select(transaction => + { + var context = new TransactionFileContext( + merchant, + contract, + transaction, + processingTimestampUtc, + transactions.Count, + transactions.Sum(candidate => candidate.TotalAmount)); + + return fileProfile.Fields.ToDictionary( + field => field.Name, + field => TransactionFileFieldResolver.GetValue(field, context)); + }) + .ToArray(); + + object payload = string.IsNullOrWhiteSpace(fileProfile.Json.RootPropertyName) + ? mappedRecords + : new Dictionary + { + [fileProfile.Json.RootPropertyName] = mappedRecords + }; + + var content = JsonSerializer.SerializeToUtf8Bytes( + payload, + new JsonSerializerOptions + { + WriteIndented = fileProfile.Json.WriteIndented + }); + + return new GeneratedFile( + GeneratedFileNameFactory.BuildFileName(fileProfile, merchant, contract, processingTimestampUtc), + content, + string.IsNullOrWhiteSpace(fileProfile.ContentType) ? "application/json" : fileProfile.ContentType, + transactions.Count, + transactions.Sum(transaction => transaction.TotalAmount), + fileProfile.FileProfileId, + fileProfile.Format, + transactions); + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs new file mode 100644 index 0000000..2d5ed5f --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs @@ -0,0 +1,83 @@ +using TransactionProcessing.MerchantFileProcessor.Configuration; + +namespace TransactionProcessing.MerchantFileProcessor.FileBuilding; + +public interface ITransactionFileGenerationService +{ + IReadOnlyList GetConfiguredContracts(IReadOnlyList contracts); + + GeneratedFile BuildFile(MerchantOptions merchant, ContractOptions contract, DateTimeOffset processingTimestampUtc); +} + +public interface ITransactionFileBuilder +{ + string Format { get; } + + GeneratedFile Build( + MerchantOptions merchant, + ContractOptions contract, + FileProfileOptions fileProfile, + IReadOnlyList transactions, + DateTimeOffset processingTimestampUtc); +} + +public sealed class TransactionFileGenerationService( + MerchantProcessingOptions options, + ITransactionGenerator transactionGenerator, + IEnumerable builders) : ITransactionFileGenerationService +{ + public IReadOnlyList GetConfiguredContracts(IReadOnlyList contracts) + { + return contracts + .Where(contract => this.TryResolveFileProfile(contract.ContractId, out _)) + .ToArray(); + } + + public GeneratedFile BuildFile(MerchantOptions merchant, ContractOptions contract, DateTimeOffset processingTimestampUtc) + { + if (!this.TryResolveFileProfile(contract.ContractId, out var fileProfile)) + { + throw new InvalidOperationException( + $"Contract '{contract.ContractId}' for merchant '{merchant.MerchantId}' does not have a shared contract definition."); + } + + var resolvedFileProfile = fileProfile ?? throw new InvalidOperationException( + $"Contract '{contract.ContractId}' for merchant '{merchant.MerchantId}' could not resolve a file profile."); + + var builder = builders.FirstOrDefault(candidate => + candidate.Format.Equals(resolvedFileProfile.Format, StringComparison.OrdinalIgnoreCase)); + + if (builder is null) + { + throw new InvalidOperationException( + $"No transaction file builder is registered for format '{resolvedFileProfile.Format}'."); + } + + var transactions = transactionGenerator.GenerateTransactions(merchant, contract, processingTimestampUtc); + + return builder.Build(merchant, contract, resolvedFileProfile, transactions, processingTimestampUtc); + } + + private bool TryResolveFileProfile(string contractId, out FileProfileOptions? fileProfile) + { + var contractDefinition = options.ContractDefinitions.FirstOrDefault(definition => + definition.ContractId.Equals(contractId, StringComparison.OrdinalIgnoreCase)); + + if (contractDefinition is null) + { + fileProfile = null; + return false; + } + + fileProfile = options.FileProfiles.FirstOrDefault(profile => + profile.FileProfileId.Equals(contractDefinition.FileProfileId, StringComparison.OrdinalIgnoreCase)); + + if (fileProfile is null) + { + throw new InvalidOperationException( + $"Contract '{contractId}' references unknown file profile '{contractDefinition.FileProfileId}'."); + } + + return true; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs new file mode 100644 index 0000000..a07968d --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using TransactionProcessing.MerchantFileProcessor.Configuration; + +namespace TransactionProcessing.MerchantFileProcessor.FileBuilding; + +public sealed record GeneratedFile( + string FileName, + byte[] Content, + string ContentType, + int RecordCount, + decimal TotalAmount, + string FileProfileId, + string Format, + IReadOnlyList Transactions); + +public sealed record GeneratedTransaction( + string MerchantId, + string ContractId, + string ProductCode, + string Description, + string RecipientMobileNumber, + int Quantity, + decimal UnitAmount, + decimal TotalAmount, + string Currency, + DateTimeOffset TransactionDateUtc); + +public sealed record TransactionFileContext( + MerchantOptions Merchant, + ContractOptions Contract, + GeneratedTransaction? Transaction, + DateTimeOffset ProcessingTimestampUtc, + int RecordCount, + decimal FileTotalAmount); + +public interface ITransactionGenerator +{ + IReadOnlyList GenerateTransactions( + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset processingTimestampUtc); +} + +public sealed class RandomTransactionGenerator( + MerchantProcessingOptions options) : ITransactionGenerator +{ + public IReadOnlyList GenerateTransactions( + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset processingTimestampUtc) + { + var fixedValueProducts = contract.Products + .Where(product => product.IsFixedValue) + .ToArray(); + + if (fixedValueProducts.Length == 0) + { + throw new InvalidOperationException( + $"Contract '{contract.ContractId}' for merchant '{merchant.MerchantId}' does not contain any fixed-value products."); + } + + var transactionCount = Random.Shared.Next( + options.TransactionGeneration.MinimumTransactionsPerContract, + options.TransactionGeneration.MaximumTransactionsPerContract + 1); + + return Enumerable.Range(0, transactionCount) + .Select(_ => + { + var product = fixedValueProducts[Random.Shared.Next(fixedValueProducts.Length)]; + var totalAmount = product.Quantity * product.UnitAmount; + + return new GeneratedTransaction( + merchant.MerchantId, + contract.ContractId, + product.ProductCode, + product.Description, + BuildMobileNumber(), + product.Quantity, + product.UnitAmount, + totalAmount, + product.Currency, + processingTimestampUtc); + }) + .ToArray(); + } + + private static string BuildMobileNumber() => + $"07{Random.Shared.NextInt64(0, 1_000_000_000):D9}"; +} + +public static class TransactionFileFieldResolver +{ + public static string GetTextValue(FileFieldOptions field, TransactionFileContext context) + { + return GetValue(field, context)?.ToString() ?? string.Empty; + } + + public static object GetValue(FileFieldOptions field, TransactionFileContext context) + { + if (!string.IsNullOrWhiteSpace(field.Value)) + { + return field.Value; + } + + if (string.IsNullOrWhiteSpace(field.Source)) + { + throw new InvalidOperationException($"Field '{field.Name}' must define either a source or a literal value."); + } + + var transaction = context.Transaction; + + return field.Source.ToLowerInvariant() switch + { + TransactionFileFieldSources.MerchantId => context.Merchant.MerchantId, + TransactionFileFieldSources.ContractId => context.Contract.ContractId, + TransactionFileFieldSources.ContractIssuer => context.Contract.Issuer, + TransactionFileFieldSources.ProductCode => GetTransaction(transaction).ProductCode, + TransactionFileFieldSources.Description => GetTransaction(transaction).Description, + TransactionFileFieldSources.RecipientMobileNumber => GetTransaction(transaction).RecipientMobileNumber, + TransactionFileFieldSources.Quantity => ApplyFormat(GetTransaction(transaction).Quantity, field.Format), + TransactionFileFieldSources.UnitAmount => ApplyFormat(GetTransaction(transaction).UnitAmount, field.Format), + TransactionFileFieldSources.TotalAmount => ApplyFormat(GetTransaction(transaction).TotalAmount, field.Format), + TransactionFileFieldSources.Currency => GetTransaction(transaction).Currency, + TransactionFileFieldSources.TransactionDateUtc => ApplyDateFormat(GetTransaction(transaction).TransactionDateUtc, field.Format), + TransactionFileFieldSources.ProcessingDateUtc => ApplyDateFormat(context.ProcessingTimestampUtc, field.Format), + TransactionFileFieldSources.RecordCount => ApplyFormat(context.RecordCount, field.Format), + TransactionFileFieldSources.FileTotalAmount => ApplyFormat(context.FileTotalAmount, field.Format ?? "0.00"), + _ => throw new InvalidOperationException($"Unsupported field source '{field.Source}'.") + }; + } + + private static GeneratedTransaction GetTransaction(GeneratedTransaction? transaction) => + transaction ?? throw new InvalidOperationException("The requested field requires a transaction record, but no transaction context was supplied."); + + private static object ApplyFormat(T value, string? format) + where T : IFormattable + { + return string.IsNullOrWhiteSpace(format) + ? value + : value.ToString(format, CultureInfo.InvariantCulture); + } + + private static object ApplyDateFormat(DateTimeOffset value, string? format) => + value.ToString(string.IsNullOrWhiteSpace(format) ? "yyyy-MM-dd" : format, CultureInfo.InvariantCulture); +} + +public static class GeneratedFileNameFactory +{ + public static string BuildFileName( + FileProfileOptions fileProfile, + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset processingTimestampUtc) + { + var extension = fileProfile.FileExtension.TrimStart('.'); + + if (string.IsNullOrWhiteSpace(fileProfile.FileNamePattern)) + { + return $"{Sanitize(merchant.MerchantId)}_{Sanitize(contract.ContractId)}_{processingTimestampUtc:yyyyMMddTHHmmssZ}.{extension}"; + } + + return fileProfile.FileNamePattern + .Replace("{merchantId}", Sanitize(merchant.MerchantId), StringComparison.OrdinalIgnoreCase) + .Replace("{contractId}", Sanitize(contract.ContractId), StringComparison.OrdinalIgnoreCase) + .Replace("{fileProfileId}", Sanitize(fileProfile.FileProfileId), StringComparison.OrdinalIgnoreCase) + .Replace("{format}", Sanitize(fileProfile.Format), StringComparison.OrdinalIgnoreCase) + .Replace("{timestampUtc}", processingTimestampUtc.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase); + } + + private static string Sanitize(string value) + { + var invalidCharacters = Path.GetInvalidFileNameChars(); + var buffer = value.ToCharArray(); + + for (var index = 0; index < buffer.Length; index++) + { + if (invalidCharacters.Contains(buffer[index])) + { + buffer[index] = '_'; + } + } + + return new string(buffer); + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs b/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs new file mode 100644 index 0000000..3976fa8 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs @@ -0,0 +1,96 @@ +using TransactionProcessing.MerchantFileProcessor.Clients; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessing.MerchantFileProcessor.Services; + +namespace TransactionProcessing.MerchantFileProcessor; + +public sealed class FileStatusPollingWorker( + MerchantProcessingOptions options, + IAccessTokenProvider accessTokenProvider, + IFileProcessingClient fileProcessingClient, + IFileStatusStore fileStatusStore) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var logger = LocalLogger.For(); + var pollInterval = options.FileStatusPolling.GetPollInterval(); + logger.LogInformation($"File status polling worker started with interval of {pollInterval.TotalSeconds:0} seconds"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await this.PollPendingFilesAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError("File status polling iteration failed", ex); + } + + try + { + await Task.Delay(pollInterval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + } + + private async Task PollPendingFilesAsync(CancellationToken cancellationToken) + { + var pendingFiles = await fileStatusStore.GetPendingStatusPollTargetsAsync(cancellationToken); + if (pendingFiles.Count == 0) + { + return; + } + + var accessTokenResult = await accessTokenProvider.GetAccessToken(cancellationToken); + if (accessTokenResult.IsFailed) + { + throw new InvalidOperationException(string.Join("; ", accessTokenResult.Errors)); + } + + var accessToken = accessTokenResult.Data.AccessToken; + + foreach (var pendingFile in pendingFiles) + { + try + { + var statusResult = await fileProcessingClient.GetFileStatus( + accessToken, + Guid.Parse(pendingFile.EstateId), + pendingFile.FileProcessorFileId, + cancellationToken); + + if (statusResult.IsFailed || statusResult.Data is null) + { + LocalLogger.For(pendingFile.MerchantId) + .LogWarning($"Unable to retrieve status for file {pendingFile.FileProcessorFileId} ({pendingFile.FileName})."); + continue; + } + + await fileStatusStore.UpdateFileStatusAsync( + pendingFile.FileSendRecordId, + statusResult.Data, + cancellationToken); + + if (statusResult.Data.ProcessingCompleted) + { + LocalLogger.For(pendingFile.MerchantId) + .LogInformation($"Completed line status tracking for file {pendingFile.FileProcessorFileId} ({pendingFile.FileName})."); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LocalLogger.For(pendingFile.MerchantId) + .LogError($"File status polling failed for file {pendingFile.FileProcessorFileId} ({pendingFile.FileName})", ex); + } + } + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/NLog.config b/TransactionProcessing.MerchantFileProcessor/NLog.config new file mode 100644 index 0000000..b035b34 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/NLog.config @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/FileSendRecord.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/FileSendRecord.cs new file mode 100644 index 0000000..4570e8c --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/FileSendRecord.cs @@ -0,0 +1,46 @@ +namespace TransactionProcessing.MerchantFileProcessor.Persistence; + +public sealed class FileSendRecord +{ + public long Id { get; set; } + + public Guid RunId { get; set; } + + public string MerchantId { get; set; } = string.Empty; + + public string? EstateId { get; set; } + + public string? MerchantName { get; set; } + + public string ContractId { get; set; } = string.Empty; + + public string? ContractName { get; set; } + + public string? FileName { get; set; } + + public string? FileProfileId { get; set; } + + public string? Format { get; set; } + + public string? FileProcessorFileId { get; set; } + + public DateTimeOffset ScheduledRunUtc { get; set; } + + public string? FileContent { get; set; } + + public int? RecordCount { get; set; } + + public decimal? TotalAmount { get; set; } + + public string Status { get; set; } = string.Empty; + + public string? ErrorMessage { get; set; } + + public bool ProcessingCompleted { get; set; } + + public DateTimeOffset? LastStatusCheckUtc { get; set; } + + public DateTimeOffset ProcessedUtc { get; set; } + + public List LineStatuses { get; set; } = []; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/FileSendRecordLineStatus.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/FileSendRecordLineStatus.cs new file mode 100644 index 0000000..3d75efe --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/FileSendRecordLineStatus.cs @@ -0,0 +1,20 @@ +namespace TransactionProcessing.MerchantFileProcessor.Persistence; + +public sealed class FileSendRecordLineStatus +{ + public long Id { get; set; } + + public long FileSendRecordId { get; set; } + + public int LineNumber { get; set; } + + public string? LineData { get; set; } + + public string ProcessingStatus { get; set; } = string.Empty; + + public string? RejectionReason { get; set; } + + public string? TransactionId { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs new file mode 100644 index 0000000..296c54f --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; + +namespace TransactionProcessing.MerchantFileProcessor.Persistence; + +public sealed class MerchantFileProcessorDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet FileSendRecords => this.Set(); + + public DbSet FileSendRecordLineStatuses => this.Set(); + + public DbSet MerchantRunRecords => this.Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("FileSendRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.MerchantId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.EstateId).HasMaxLength(64); + entity.Property(record => record.MerchantName).HasMaxLength(256); + entity.Property(record => record.ContractId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.ContractName).HasMaxLength(256); + entity.Property(record => record.FileName).HasMaxLength(260); + entity.Property(record => record.FileProfileId).HasMaxLength(128); + entity.Property(record => record.Format).HasMaxLength(32); + entity.Property(record => record.FileProcessorFileId).HasMaxLength(64); + entity.Property(record => record.ScheduledRunUtc); + entity.Property(record => record.Status).HasMaxLength(32).IsRequired(); + entity.Property(record => record.ErrorMessage).HasMaxLength(2048); + entity.HasIndex(record => new { record.MerchantId, record.ProcessedUtc }); + entity.HasIndex(record => new { record.MerchantId, record.ContractId, record.ScheduledRunUtc }); + entity.HasIndex(record => record.ProcessedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("FileSendRecordLineStatuses"); + entity.HasKey(record => record.Id); + entity.Property(record => record.LineData).HasMaxLength(4096); + entity.Property(record => record.ProcessingStatus).HasMaxLength(32).IsRequired(); + entity.Property(record => record.RejectionReason).HasMaxLength(2048); + entity.HasIndex(record => new { record.FileSendRecordId, record.LineNumber }).IsUnique(); + entity.HasOne() + .WithMany(record => record.LineStatuses) + .HasForeignKey(record => record.FileSendRecordId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantRunRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.MerchantId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.MerchantName).HasMaxLength(256); + entity.Property(record => record.Status).HasMaxLength(32).IsRequired(); + entity.Property(record => record.ErrorMessage).HasMaxLength(2048); + entity.HasIndex(record => new { record.MerchantId, record.ScheduledRunUtc, record.CompletedUtc }); + }); + } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantRunRecord.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantRunRecord.cs new file mode 100644 index 0000000..23260b5 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantRunRecord.cs @@ -0,0 +1,20 @@ +namespace TransactionProcessing.MerchantFileProcessor.Persistence; + +public sealed class MerchantRunRecord +{ + public long Id { get; set; } + + public Guid RunId { get; set; } + + public string MerchantId { get; set; } = string.Empty; + + public string? MerchantName { get; set; } + + public DateTimeOffset ScheduledRunUtc { get; set; } + + public string Status { get; set; } = string.Empty; + + public string? ErrorMessage { get; set; } + + public DateTimeOffset CompletedUtc { get; set; } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantFileProcessor/Program.cs b/TransactionProcessing.MerchantFileProcessor/Program.cs new file mode 100644 index 0000000..f11d9ad --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Program.cs @@ -0,0 +1,309 @@ +using ClientProxyBase; +using FileProcessor.Client; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NLog; +using NLog.Extensions.Logging; +using SecurityService.Client; +using TransactionProcessing.MerchantFileProcessor; +using TransactionProcessing.MerchantFileProcessor.Clients; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessing.MerchantFileProcessor.FileBuilding; +using TransactionProcessing.MerchantFileProcessor.Persistence; +using TransactionProcessing.MerchantFileProcessor.Reporting; +using TransactionProcessing.MerchantFileProcessor.Services; +using SharedLogger = Shared.Logger.Logger; +using TransactionProcessor.Client; + +var environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") + ?? Environments.Production; +var contentRoot = AppContext.BaseDirectory; +var nlogConfigPath = Path.Combine(contentRoot, "NLog.config"); + +var bootstrapLogger = LogManager.Setup() + .LoadConfigurationFromFile(nlogConfigPath) + .GetCurrentClassLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + Args = args, + ContentRootPath = contentRoot, + EnvironmentName = environmentName + }); + + builder.Configuration.Sources.Clear(); + builder.Configuration + .SetBasePath(contentRoot) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) + .AddJsonFile("hosting.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddCommandLine(args); + + builder.WebHost.UseConfiguration(builder.Configuration); + + var frameworkLoggingOptions = builder.Configuration + .GetSection(FrameworkLoggingOptions.SectionName) + .Get() ?? new FrameworkLoggingOptions(); + + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Information); + builder.Logging.AddFilter((category, level) => ShouldLogCategory(category, level, frameworkLoggingOptions)); + builder.Logging.AddNLog(new NLogProviderOptions + { + RemoveLoggerFactoryFilter = false + }); + + builder.Services.AddWindowsService(options => + { + options.ServiceName = "Merchant File Processor"; + }); + + var merchantProcessingOptions = builder.Configuration + .GetSection(MerchantProcessingOptions.SectionName) + .Get() ?? new MerchantProcessingOptions(); + + if (!MerchantProcessingOptionsValidator.Validate(merchantProcessingOptions)) + { + throw new InvalidOperationException("MerchantProcessing configuration is invalid."); + } + + builder.Services.AddSingleton(merchantProcessingOptions); + builder.Services.AddSingleton(frameworkLoggingOptions); + + builder.Services.AddSingleton>(sp => + { + var apiConfiguration = sp.GetRequiredService().GetSection("ApiConfiguration"); + + return configSetting => + { + if (string.IsNullOrWhiteSpace(configSetting)) + { + return string.Empty; + } + + var child = apiConfiguration.GetChildren() + .FirstOrDefault(c => string.Equals(c.Key, configSetting, StringComparison.OrdinalIgnoreCase)); + + return child?.Value ?? string.Empty; + }; + }); + + var connectionString = BuildConnectionString( + builder.Configuration.GetConnectionString("MerchantFileProcessor"), + contentRoot); + + builder.Services.AddHttpClient(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddDbContextFactory(options => + options.UseSqlite(connectionString)); + builder.Services.RegisterHttpClient(); + builder.Services.RegisterHttpClient(); + builder.Services.RegisterHttpClient(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + + var app = builder.Build(); + + using (var scope = app.Services.CreateScope()) + { + var startupLogger = scope.ServiceProvider.GetRequiredService>(); + SharedLogger.Initialise(startupLogger); + + var dbContextFactory = scope.ServiceProvider.GetRequiredService>(); + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + await dbContext.Database.EnsureCreatedAsync(); + await EnsurePersistenceSchemaAsync(dbContext, CancellationToken.None); + } + + var appSettingsPath = Path.Combine(contentRoot, "appsettings.json"); + if (!File.Exists(appSettingsPath)) + { + SharedLogger.LogWarning($"appsettings.json was not found at {appSettingsPath}. Using environment-specific configuration and environment variables."); + } + + app.MapReportingEndpoints(); + + await app.RunAsync(); +} +catch (Exception ex) +{ + bootstrapLogger.Error(ex, "Merchant file processor terminated unexpectedly."); + throw; +} +finally +{ + LogManager.Shutdown(); +} + +static string BuildConnectionString(string? configuredConnectionString, string contentRoot) +{ + if (string.IsNullOrWhiteSpace(configuredConnectionString)) + { + return $"Data Source={Path.Combine(contentRoot, "merchant-file-processor.db")}"; + } + + const string dataSourcePrefix = "Data Source="; + + if (!configuredConnectionString.StartsWith(dataSourcePrefix, StringComparison.OrdinalIgnoreCase)) + { + return configuredConnectionString; + } + + var filePath = configuredConnectionString[dataSourcePrefix.Length..].Trim(); + + if (Path.IsPathRooted(filePath)) + { + return configuredConnectionString; + } + + return $"{dataSourcePrefix}{Path.Combine(contentRoot, filePath)}"; +} + +static bool ShouldLogCategory( + string? category, + Microsoft.Extensions.Logging.LogLevel level, + FrameworkLoggingOptions frameworkLoggingOptions) +{ + if (level < Microsoft.Extensions.Logging.LogLevel.Information) + { + return false; + } + + if (string.IsNullOrWhiteSpace(category)) + { + return true; + } + + if (category.StartsWith("Microsoft.EntityFrameworkCore.Database.Command", StringComparison.OrdinalIgnoreCase)) + { + return frameworkLoggingOptions.EnableEfCoreCommandTrace || + level >= Microsoft.Extensions.Logging.LogLevel.Warning; + } + + if (category.StartsWith("System.Net.Http.HttpClient", StringComparison.OrdinalIgnoreCase)) + { + return frameworkLoggingOptions.EnableHttpClientTrace || + level >= Microsoft.Extensions.Logging.LogLevel.Warning; + } + + return true; +} + +static async Task EnsurePersistenceSchemaAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) +{ + var existingColumns = await dbContext.Database + .SqlQueryRaw("SELECT name AS Value FROM pragma_table_info('FileSendRecords');") + .ToListAsync(cancellationToken); + + if (!existingColumns.Contains("MerchantName", StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE FileSendRecords ADD COLUMN MerchantName TEXT NULL;", + cancellationToken); + } + + if (!existingColumns.Contains("ContractName", StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE FileSendRecords ADD COLUMN ContractName TEXT NULL;", + cancellationToken); + } + + if (!existingColumns.Contains("FileContent", StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE FileSendRecords ADD COLUMN FileContent TEXT NULL;", + cancellationToken); + } + + if (!existingColumns.Contains("EstateId", StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE FileSendRecords ADD COLUMN EstateId TEXT NULL;", + cancellationToken); + } + + if (!existingColumns.Contains("FileProcessorFileId", StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE FileSendRecords ADD COLUMN FileProcessorFileId TEXT NULL;", + cancellationToken); + } + + if (!existingColumns.Contains("ProcessingCompleted", StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE FileSendRecords ADD COLUMN ProcessingCompleted INTEGER NOT NULL DEFAULT 0;", + cancellationToken); + } + + if (!existingColumns.Contains("LastStatusCheckUtc", StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE FileSendRecords ADD COLUMN LastStatusCheckUtc TEXT NULL;", + cancellationToken); + } + + if (!existingColumns.Contains("ScheduledRunUtc", StringComparer.OrdinalIgnoreCase)) + { + await dbContext.Database.ExecuteSqlRawAsync( + "ALTER TABLE FileSendRecords ADD COLUMN ScheduledRunUtc TEXT NOT NULL DEFAULT '0001-01-01T00:00:00+00:00';", + cancellationToken); + } + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS FileSendRecordLineStatuses ( + Id INTEGER NOT NULL CONSTRAINT PK_FileSendRecordLineStatuses PRIMARY KEY AUTOINCREMENT, + FileSendRecordId INTEGER NOT NULL, + LineNumber INTEGER NOT NULL, + LineData TEXT NULL, + ProcessingStatus TEXT NOT NULL, + RejectionReason TEXT NULL, + TransactionId TEXT NULL, + UpdatedUtc TEXT NOT NULL, + CONSTRAINT FK_FileSendRecordLineStatuses_FileSendRecords_FileSendRecordId + FOREIGN KEY (FileSendRecordId) REFERENCES FileSendRecords (Id) ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE UNIQUE INDEX IF NOT EXISTS IX_FileSendRecordLineStatuses_FileSendRecordId_LineNumber ON FileSendRecordLineStatuses (FileSendRecordId, LineNumber);", + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantRunRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantRunRecords PRIMARY KEY AUTOINCREMENT, + RunId TEXT NOT NULL, + MerchantId TEXT NOT NULL, + MerchantName TEXT NULL, + ScheduledRunUtc TEXT NOT NULL, + Status TEXT NOT NULL, + ErrorMessage TEXT NULL, + CompletedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_MerchantRunRecords_MerchantId_ScheduledRunUtc_CompletedUtc ON MerchantRunRecords (MerchantId, ScheduledRunUtc, CompletedUtc);", + cancellationToken); +} diff --git a/TransactionProcessing.MerchantFileProcessor/Properties/launchSettings.json b/TransactionProcessing.MerchantFileProcessor/Properties/launchSettings.json new file mode 100644 index 0000000..a8272f4 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "MerchantFileProcessor": { + "commandName": "Project", + "applicationUrl": "http://localhost:5099", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs new file mode 100644 index 0000000..82c2b79 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs @@ -0,0 +1,434 @@ +using System.Net; +using System.Text; +using Microsoft.EntityFrameworkCore; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessing.MerchantFileProcessor.Persistence; +using TransactionProcessing.MerchantFileProcessor.Services; + +namespace TransactionProcessing.MerchantFileProcessor.Reporting; + +public interface IFileStatusReportService +{ + Task GetReportAsync(CancellationToken cancellationToken); + + Task RenderHtmlAsync(CancellationToken cancellationToken); + + Task RenderMerchantHtmlAsync(string merchantId, CancellationToken cancellationToken); + + Task RenderFileHtmlAsync(string merchantId, long fileId, CancellationToken cancellationToken); +} + +public sealed record MerchantFileSummary(string MerchantId, + string MerchantName, + bool Enabled, + int SuccessfulFilesSent, + int FailedFiles, + DateTimeOffset? LastProcessedUtc, + DateTimeOffset? NextScheduledUtc); + +public sealed record FileStatusRow(long Id, + DateTimeOffset ProcessedUtc, + string MerchantId, + string MerchantName, + string ContractId, + string ContractName, + string Status, + string FileName, + string FileProfileId, + string Format, + string? FileContent, + int RecordCount, + decimal TotalAmount, + string? ErrorMessage, + bool ProcessingCompleted, + DateTimeOffset? LastStatusCheckUtc); + +public sealed record FileStatusReport(DateTimeOffset GeneratedUtc, + IReadOnlyList MerchantSummaries, + IReadOnlyList RecentFiles); + +public sealed record MerchantDetailReport(DateTimeOffset GeneratedUtc, + MerchantFileSummary Merchant, + IReadOnlyList RecentFiles); + +public sealed record FileDetailReport(DateTimeOffset GeneratedUtc, + MerchantFileSummary Merchant, + FileStatusRow File, + IReadOnlyList FileLines); + +public sealed record FileLineStatusRow(int LineNumber, + string Content, + string ProcessingStatus, + string? RejectionReason, + string? TransactionId); + +public sealed class FileStatusReportService( + IDbContextFactory dbContextFactory, + MerchantProcessingOptions options) : IFileStatusReportService +{ + public async Task GetReportAsync(CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + var aggregateSource = await dbContext.FileSendRecords + .Select(record => new + { + record.MerchantId, + record.Status, + record.ProcessedUtc + }) + .ToListAsync(cancellationToken); + + var aggregates = aggregateSource + .GroupBy(record => record.MerchantId, StringComparer.OrdinalIgnoreCase) + .Select(group => new + { + MerchantId = group.Key, + SuccessfulFilesSent = group.Count(record => record.Status == FileSendStatuses.Succeeded), + FailedFiles = group.Count(record => record.Status == FileSendStatuses.Failed), + LastProcessedUtc = group.Max(record => (DateTimeOffset?)record.ProcessedUtc) + }) + .ToList(); + + var aggregateLookup = aggregates.ToDictionary(item => item.MerchantId, StringComparer.OrdinalIgnoreCase); + + var merchantSummaries = options.Merchants + .OrderBy(merchant => merchant.MerchantId, StringComparer.OrdinalIgnoreCase) + .Select(merchant => + { + aggregateLookup.TryGetValue(merchant.MerchantId, out var aggregate); + + return new MerchantFileSummary( + merchant.MerchantId, + string.IsNullOrWhiteSpace(merchant.Name) ? merchant.MerchantId : merchant.Name, + merchant.Enabled, + aggregate?.SuccessfulFilesSent ?? 0, + aggregate?.FailedFiles ?? 0, + aggregate?.LastProcessedUtc, + GetNextScheduledUtc(merchant, DateTimeOffset.UtcNow)); + }) + .ToArray(); + + var recentFiles = (await dbContext.FileSendRecords + .Select(record => new FileStatusRow( + record.Id, + record.ProcessedUtc, + record.MerchantId, + record.MerchantName ?? record.MerchantId, + record.ContractId, + record.ContractName ?? record.ContractId, + record.Status, + record.FileName ?? string.Empty, + record.FileProfileId ?? string.Empty, + record.Format ?? string.Empty, + record.FileContent, + record.RecordCount ?? 0, + record.TotalAmount ?? 0m, + record.ErrorMessage, + record.ProcessingCompleted, + record.LastStatusCheckUtc)) + .ToListAsync(cancellationToken)) + .OrderByDescending(record => record.ProcessedUtc) + .Take(100) + .ToList(); + + return new FileStatusReport(DateTimeOffset.UtcNow, merchantSummaries, recentFiles); + } + + public async Task RenderHtmlAsync(CancellationToken cancellationToken) + { + var report = await this.GetReportAsync(cancellationToken); + var html = new StringBuilder(); + + AppendDocumentStart(html, "Merchant File Status"); + html.AppendLine($"

Merchant File Status

"); + html.AppendLine($"

Generated at {Encode(report.GeneratedUtc.ToString("u"))}. Auto-refreshes every 30 seconds.

"); + html.AppendLine("

Merchants

"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var merchant in report.MerchantSummaries) + { + html.AppendLine( + $" "); + } + + html.AppendLine(" "); + html.AppendLine("
MerchantEnabledSuccessful FilesFailed FilesLast Processed (UTC)Next Scheduled Send (UTC)Details
{Encode(merchant.MerchantName)}
{Encode(merchant.MerchantId)}
{(merchant.Enabled ? "Yes" : "No")}{merchant.SuccessfulFilesSent}{merchant.FailedFiles}{Encode(merchant.LastProcessedUtc?.ToString("u") ?? "Never")}{Encode(merchant.NextScheduledUtc?.ToString("u") ?? "Disabled")}View details
"); + AppendDocumentEnd(html); + + return html.ToString(); + } + + public async Task RenderMerchantHtmlAsync(string merchantId, CancellationToken cancellationToken) + { + var report = await this.GetMerchantReportAsync(merchantId, cancellationToken); + if (report is null) + { + return null; + } + + var html = new StringBuilder(); + AppendDocumentStart(html, $"Merchant {report.Merchant.MerchantName} Details"); + html.AppendLine($"

← Back to merchants

"); + html.AppendLine($"

{Encode(report.Merchant.MerchantName)}

"); + html.AppendLine($"

{Encode(report.Merchant.MerchantId)}

"); + html.AppendLine($"

Generated at {Encode(report.GeneratedUtc.ToString("u"))}. Auto-refreshes every 30 seconds.

"); + html.AppendLine("

Summary

"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine( + $" "); + html.AppendLine(" "); + html.AppendLine("
EnabledSuccessful FilesFailed FilesLast Processed (UTC)Next Scheduled Send (UTC)
{(report.Merchant.Enabled ? "Yes" : "No")}{report.Merchant.SuccessfulFilesSent}{report.Merchant.FailedFiles}{Encode(report.Merchant.LastProcessedUtc?.ToString("u") ?? "Never")}{Encode(report.Merchant.NextScheduledUtc?.ToString("u") ?? "Disabled")}
"); + + html.AppendLine("

Recent File Activity

"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var file in report.RecentFiles) + { + html.AppendLine( + $" "); + } + + if (report.RecentFiles.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
Processed (UTC)ContractStatusProfileDetails
{Encode(file.ProcessedUtc.ToString("u"))}{Encode(file.ContractName)}
{Encode(file.ContractId)}
{Encode(RenderStatus(file))}{Encode(file.FileProfileId)}View file
No file activity recorded yet.
"); + AppendDocumentEnd(html); + + return html.ToString(); + } + + public async Task RenderFileHtmlAsync(string merchantId, long fileId, CancellationToken cancellationToken) + { + var report = await this.GetFileReportAsync(merchantId, fileId, cancellationToken); + if (report is null) + { + return null; + } + + var html = new StringBuilder(); + AppendDocumentStart(html, $"Merchant {report.Merchant.MerchantName} File {report.File.Id}"); + html.AppendLine($"

← Back to merchant

"); + html.AppendLine($"

File Details

"); + html.AppendLine($"

Merchant {Encode(report.Merchant.MerchantName)}
{Encode(report.Merchant.MerchantId)}

"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine( + $" "); + html.AppendLine(" "); + html.AppendLine("
Processed (UTC)ContractUpload StatusProcessingProfileFormat
{Encode(report.File.ProcessedUtc.ToString("u"))}{Encode(report.File.ContractName)}
{Encode(report.File.ContractId)}
{Encode(report.File.Status)}{Encode(report.File.ProcessingCompleted ? "Complete" : "Pending")}{(report.File.LastStatusCheckUtc.HasValue ? $"
Last checked {Encode(report.File.LastStatusCheckUtc.Value.ToString("u"))}" : string.Empty)}
{Encode(report.File.FileProfileId)}{Encode(report.File.Format)}
"); + + html.AppendLine("

File Data

"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine( + $" "); + html.AppendLine(" "); + html.AppendLine("
File NameRecordsTotal Amount
{Encode(string.IsNullOrWhiteSpace(report.File.FileName) ? "(not generated)" : report.File.FileName)}{report.File.RecordCount}{report.File.TotalAmount:0.00}
"); + + html.AppendLine("

File Lines

"); + if (report.FileLines.Count == 0) + { + html.AppendLine("

No file content recorded.

"); + } + else + { + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var line in report.FileLines) + { + html.AppendLine($" "); + } + + html.AppendLine(" "); + html.AppendLine("
LineContentStatusTransactionRejection
{line.LineNumber}{Encode(line.Content)}{Encode(line.ProcessingStatus)}{Encode(line.TransactionId ?? string.Empty)}{Encode(line.RejectionReason ?? string.Empty)}
"); + } + + html.AppendLine("

Error Details

"); + html.AppendLine($"
{Encode(string.IsNullOrWhiteSpace(report.File.ErrorMessage) ? "No error recorded." : report.File.ErrorMessage)}
"); + AppendDocumentEnd(html); + + return html.ToString(); + } + + private async Task GetMerchantReportAsync(string merchantId, CancellationToken cancellationToken) + { + var summary = (await this.GetReportAsync(cancellationToken)) + .MerchantSummaries + .FirstOrDefault(merchant => merchant.MerchantId.Equals(merchantId, StringComparison.OrdinalIgnoreCase)); + + if (summary is null) + { + return null; + } + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var recentFiles = (await dbContext.FileSendRecords + .Where(record => record.MerchantId == summary.MerchantId) + .Select(record => new FileStatusRow( + record.Id, + record.ProcessedUtc, + record.MerchantId, + record.MerchantName ?? record.MerchantId, + record.ContractId, + record.ContractName ?? record.ContractId, + record.Status, + record.FileName ?? string.Empty, + record.FileProfileId ?? string.Empty, + record.Format ?? string.Empty, + record.FileContent, + record.RecordCount ?? 0, + record.TotalAmount ?? 0m, + record.ErrorMessage, + record.ProcessingCompleted, + record.LastStatusCheckUtc)) + .ToListAsync(cancellationToken)) + .OrderByDescending(record => record.ProcessedUtc) + .Take(100) + .ToList(); + + return new MerchantDetailReport(DateTimeOffset.UtcNow, summary, recentFiles); + } + + private async Task GetFileReportAsync(string merchantId, long fileId, CancellationToken cancellationToken) + { + var merchantReport = await this.GetMerchantReportAsync(merchantId, cancellationToken); + if (merchantReport is null) + { + return null; + } + + var file = merchantReport.RecentFiles.FirstOrDefault(row => row.Id == fileId); + if (file is null) + { + return null; + } + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var storedLineStatuses = await dbContext.FileSendRecordLineStatuses + .Where(line => line.FileSendRecordId == file.Id) + .OrderBy(line => line.LineNumber) + .Select(line => new FileLineStatusRow( + line.LineNumber, + line.LineData ?? string.Empty, + line.ProcessingStatus, + line.RejectionReason, + line.TransactionId)) + .ToListAsync(cancellationToken); + + var fileLines = storedLineStatuses.Count > 0 + ? storedLineStatuses + : BuildFileLineRows(file.FileContent); + + return new FileDetailReport(DateTimeOffset.UtcNow, merchantReport.Merchant, file, fileLines); + } + + private static DateTimeOffset? GetNextScheduledUtc(MerchantOptions merchant, DateTimeOffset nowUtc) + { + if (!merchant.Enabled) + { + return null; + } + + var runTimes = merchant.GetDailyRunTimesUtc(); + var nextRunDate = DateOnly.FromDateTime(nowUtc.UtcDateTime); + + foreach (var runTime in runTimes) + { + var nextRun = new DateTimeOffset( + nextRunDate.Year, + nextRunDate.Month, + nextRunDate.Day, + runTime.Hour, + runTime.Minute, + runTime.Second, + TimeSpan.Zero); + + if (nextRun > nowUtc) + { + return nextRun; + } + } + + var firstRunTime = runTimes[0]; + var tomorrow = nextRunDate.AddDays(1); + return new DateTimeOffset( + tomorrow.Year, + tomorrow.Month, + tomorrow.Day, + firstRunTime.Hour, + firstRunTime.Minute, + firstRunTime.Second, + TimeSpan.Zero); + } + + private static void AppendDocumentStart(StringBuilder html, string title) + { + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine($" {WebUtility.HtmlEncode(title)}"); + html.AppendLine(" "); + html.AppendLine(""); + html.AppendLine(""); + } + + private static void AppendDocumentEnd(StringBuilder html) + { + html.AppendLine(""); + html.AppendLine(""); + } + + private static string Encode(string value) => WebUtility.HtmlEncode(value); + + private static IReadOnlyList BuildFileLineRows(string? fileContent) + { + if (string.IsNullOrWhiteSpace(fileContent)) + { + return Array.Empty(); + } + + return fileContent + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n') + .Select((line, index) => new FileLineStatusRow( + index + 1, + line, + FileLineStatuses.Unknown, + null, + null)) + .ToArray(); + } + + private static string RenderStatus(FileStatusRow file) => + file.Status == FileSendStatuses.Succeeded + ? $"{file.Status} / {(file.ProcessingCompleted ? "Complete" : "Pending")}" + : file.Status; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..2b264a7 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs @@ -0,0 +1,33 @@ +namespace TransactionProcessing.MerchantFileProcessor.Reporting; + +public static class ReportingEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapReportingEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/", () => Results.Redirect("/status")); + + endpoints.MapGet("/api/status", async (IFileStatusReportService reportService, CancellationToken cancellationToken) => + Results.Json(await reportService.GetReportAsync(cancellationToken))); + + endpoints.MapGet("/status", async (IFileStatusReportService reportService, CancellationToken cancellationToken) => + Results.Content(await reportService.RenderHtmlAsync(cancellationToken), "text/html")); + + endpoints.MapGet("/status/{merchantId}", async (string merchantId, IFileStatusReportService reportService, CancellationToken cancellationToken) => + { + var html = await reportService.RenderMerchantHtmlAsync(merchantId, cancellationToken); + return html is null + ? Results.NotFound($"Merchant '{merchantId}' was not found.") + : Results.Content(html, "text/html"); + }); + + endpoints.MapGet("/status/{merchantId}/files/{fileId:long}", async (string merchantId, long fileId, IFileStatusReportService reportService, CancellationToken cancellationToken) => + { + var html = await reportService.RenderFileHtmlAsync(merchantId, fileId, cancellationToken); + return html is null + ? Results.NotFound($"File '{fileId}' was not found for merchant '{merchantId}'.") + : Results.Content(html, "text/html"); + }); + + return endpoints; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Services/FileProcessingStatusModels.cs b/TransactionProcessing.MerchantFileProcessor/Services/FileProcessingStatusModels.cs new file mode 100644 index 0000000..4bed122 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Services/FileProcessingStatusModels.cs @@ -0,0 +1,10 @@ +namespace TransactionProcessing.MerchantFileProcessor.Services; + +public sealed record FileProcessingStatusSnapshot(bool ProcessingCompleted, + IReadOnlyList Lines); + +public sealed record FileProcessingLineStatusSnapshot(int LineNumber, + string? LineData, + string ProcessingStatus, + string? RejectionReason, + Guid? TransactionId); diff --git a/TransactionProcessing.MerchantFileProcessor/Services/FileStatusStore.cs b/TransactionProcessing.MerchantFileProcessor/Services/FileStatusStore.cs new file mode 100644 index 0000000..f7d6e26 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Services/FileStatusStore.cs @@ -0,0 +1,358 @@ +using System.Text; +using Microsoft.EntityFrameworkCore; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessing.MerchantFileProcessor.FileBuilding; +using TransactionProcessing.MerchantFileProcessor.Persistence; + +namespace TransactionProcessing.MerchantFileProcessor.Services; + +public interface IFileStatusStore +{ + Task HasSuccessfulUploadAsync( + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset scheduledRunUtc, + CancellationToken cancellationToken); + + Task IsMerchantRunCompleteAsync( + MerchantOptions merchant, + DateTimeOffset scheduledRunUtc, + CancellationToken cancellationToken); + + Task RecordSuccessAsync( + Guid runId, + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset scheduledRunUtc, + Guid fileProcessorFileId, + GeneratedFile file, + CancellationToken cancellationToken); + + Task RecordFailureAsync( + Guid runId, + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset scheduledRunUtc, + GeneratedFile? file, + string errorMessage, + CancellationToken cancellationToken); + + Task RecordMerchantRunResultAsync( + Guid runId, + MerchantOptions merchant, + DateTimeOffset scheduledRunUtc, + string status, + string? errorMessage, + CancellationToken cancellationToken); + + Task> GetPendingStatusPollTargetsAsync(CancellationToken cancellationToken); + + Task UpdateFileStatusAsync( + long fileSendRecordId, + FileProcessingStatusSnapshot snapshot, + CancellationToken cancellationToken); +} + +public static class FileSendStatuses +{ + public const string Succeeded = "Succeeded"; + + public const string Failed = "Failed"; +} + +public static class MerchantRunStatuses +{ + public const string Succeeded = "Succeeded"; + + public const string Failed = "Failed"; +} + +public static class FileLineStatuses +{ + public const string Unknown = "Unknown"; + + public const string NotProcessed = "NotProcessed"; + + public const string Successful = "Successful"; + + public const string Failed = "Failed"; + + public const string Ignored = "Ignored"; + + public const string Rejected = "Rejected"; +} + +public sealed record FileStatusPollTarget( + long FileSendRecordId, + string MerchantId, + string EstateId, + Guid FileProcessorFileId, + string FileName); + +public sealed class FileStatusStore( + IDbContextFactory dbContextFactory) : IFileStatusStore +{ + public async Task HasSuccessfulUploadAsync( + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset scheduledRunUtc, + CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + return await dbContext.FileSendRecords.AnyAsync(record => + record.MerchantId == merchant.MerchantId && + record.ContractId == contract.ContractId && + record.ScheduledRunUtc == scheduledRunUtc && + record.Status == FileSendStatuses.Succeeded, + cancellationToken); + } + + public async Task IsMerchantRunCompleteAsync( + MerchantOptions merchant, + DateTimeOffset scheduledRunUtc, + CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + var latestRunResult = await dbContext.MerchantRunRecords + .Where(record => + record.MerchantId == merchant.MerchantId && + record.ScheduledRunUtc == scheduledRunUtc) + .Select(record => new + { + record.Status, + record.CompletedUtc + }) + .ToListAsync(cancellationToken); + + return string.Equals( + latestRunResult + .OrderByDescending(record => record.CompletedUtc) + .Select(record => record.Status) + .FirstOrDefault(), + MerchantRunStatuses.Succeeded, + StringComparison.OrdinalIgnoreCase); + } + + public Task RecordSuccessAsync( + Guid runId, + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset scheduledRunUtc, + Guid fileProcessorFileId, + GeneratedFile file, + CancellationToken cancellationToken) => + this.RecordAsync(runId, merchant, contract, scheduledRunUtc, file, FileSendStatuses.Succeeded, null, fileProcessorFileId, cancellationToken); + + public Task RecordFailureAsync( + Guid runId, + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset scheduledRunUtc, + GeneratedFile? file, + string errorMessage, + CancellationToken cancellationToken) => + this.RecordAsync(runId, merchant, contract, scheduledRunUtc, file, FileSendStatuses.Failed, errorMessage, null, cancellationToken); + + public async Task RecordMerchantRunResultAsync( + Guid runId, + MerchantOptions merchant, + DateTimeOffset scheduledRunUtc, + string status, + string? errorMessage, + CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + dbContext.MerchantRunRecords.Add(new MerchantRunRecord + { + RunId = runId, + MerchantId = merchant.MerchantId, + MerchantName = ResolveMerchantName(merchant), + ScheduledRunUtc = scheduledRunUtc, + Status = status, + ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? null : Truncate(errorMessage, 2048), + CompletedUtc = DateTimeOffset.UtcNow + }); + + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task> GetPendingStatusPollTargetsAsync(CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + var candidates = await dbContext.FileSendRecords + .Where(record => + record.Status == FileSendStatuses.Succeeded && + !record.ProcessingCompleted && + !string.IsNullOrWhiteSpace(record.EstateId) && + !string.IsNullOrWhiteSpace(record.FileProcessorFileId)) + .ToListAsync(cancellationToken); + + return candidates + .Where(candidate => + Guid.TryParse(candidate.FileProcessorFileId, out _) && + !string.IsNullOrWhiteSpace(candidate.EstateId)) + .OrderBy(candidate => candidate.ProcessedUtc) + .Select(candidate => new FileStatusPollTarget( + candidate.Id, + candidate.MerchantId, + candidate.EstateId!, + Guid.Parse(candidate.FileProcessorFileId!), + candidate.FileName ?? string.Empty)) + .ToArray(); + } + + public async Task UpdateFileStatusAsync( + long fileSendRecordId, + FileProcessingStatusSnapshot snapshot, + CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var fileRecord = await dbContext.FileSendRecords + .Include(record => record.LineStatuses) + .FirstOrDefaultAsync(record => record.Id == fileSendRecordId, cancellationToken); + + if (fileRecord is null) + { + return; + } + + var updateUtc = DateTimeOffset.UtcNow; + var existingLineLookup = fileRecord.LineStatuses.ToDictionary(line => line.LineNumber); + + foreach (var line in snapshot.Lines) + { + if (!existingLineLookup.TryGetValue(line.LineNumber, out var entity)) + { + entity = new FileSendRecordLineStatus + { + FileSendRecordId = fileRecord.Id, + LineNumber = line.LineNumber + }; + + dbContext.FileSendRecordLineStatuses.Add(entity); + existingLineLookup.Add(line.LineNumber, entity); + } + + entity.LineData = string.IsNullOrWhiteSpace(line.LineData) ? entity.LineData : Truncate(line.LineData, 4096); + entity.ProcessingStatus = ResolveLineStatus(line.ProcessingStatus); + entity.RejectionReason = string.IsNullOrWhiteSpace(line.RejectionReason) ? null : Truncate(line.RejectionReason, 2048); + entity.TransactionId = line.TransactionId?.ToString(); + entity.UpdatedUtc = updateUtc; + } + + fileRecord.LastStatusCheckUtc = updateUtc; + fileRecord.ProcessingCompleted = snapshot.ProcessingCompleted || AreAllLinesResolved(existingLineLookup.Values); + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task RecordAsync( + Guid runId, + MerchantOptions merchant, + ContractOptions contract, + DateTimeOffset scheduledRunUtc, + GeneratedFile? file, + string status, + string? errorMessage, + Guid? fileProcessorFileId, + CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + var fileSendRecord = new FileSendRecord + { + RunId = runId, + MerchantId = merchant.MerchantId, + EstateId = merchant.EstateId, + MerchantName = ResolveMerchantName(merchant), + ContractId = contract.ContractId, + ContractName = ResolveContractName(contract), + FileName = file?.FileName, + FileProfileId = file?.FileProfileId, + Format = file?.Format, + FileProcessorFileId = fileProcessorFileId?.ToString(), + ScheduledRunUtc = scheduledRunUtc, + FileContent = ResolveFileContent(file), + RecordCount = file?.RecordCount, + TotalAmount = file?.TotalAmount, + Status = status, + ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? null : Truncate(errorMessage, 2048), + ProcessingCompleted = status == FileSendStatuses.Failed, + ProcessedUtc = DateTimeOffset.UtcNow + }; + + dbContext.FileSendRecords.Add(fileSendRecord); + + await dbContext.SaveChangesAsync(cancellationToken); + + if (file is null) + { + return; + } + + var fileLines = GetFileLines(file); + if (fileLines.Count == 0) + { + return; + } + + if (status == FileSendStatuses.Succeeded) + { + dbContext.FileSendRecordLineStatuses.AddRange( + fileLines.Select((line, index) => new FileSendRecordLineStatus + { + FileSendRecordId = fileSendRecord.Id, + LineNumber = index + 1, + LineData = Truncate(line, 4096), + ProcessingStatus = FileLineStatuses.Unknown, + UpdatedUtc = fileSendRecord.ProcessedUtc + })); + + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength]; + + private static string ResolveMerchantName(MerchantOptions merchant) => + string.IsNullOrWhiteSpace(merchant.Name) ? merchant.MerchantId : Truncate(merchant.Name.Trim(), 256); + + private static string ResolveContractName(ContractOptions contract) => + string.IsNullOrWhiteSpace(contract.ContractName) ? contract.ContractId : Truncate(contract.ContractName.Trim(), 256); + + private static string? ResolveFileContent(GeneratedFile? file) => + file is null ? null : Encoding.UTF8.GetString(file.Content); + + private static IReadOnlyList GetFileLines(GeneratedFile file) => + ResolveFileContent(file)? + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n') + .ToArray() ?? []; + + private static string ResolveLineStatus(string? value) => + string.IsNullOrWhiteSpace(value) ? FileLineStatuses.Unknown : Truncate(value.Trim(), 32); + + private static bool AreAllLinesResolved(IEnumerable lines) + { + var hasLines = false; + + foreach (var line in lines) + { + hasLines = true; + + if (line.ProcessingStatus.Equals(FileLineStatuses.Unknown, StringComparison.OrdinalIgnoreCase) || + line.ProcessingStatus.Equals(FileLineStatuses.NotProcessed, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return hasLines; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Services/LocalLogger.cs b/TransactionProcessing.MerchantFileProcessor/Services/LocalLogger.cs new file mode 100644 index 0000000..4f2f248 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Services/LocalLogger.cs @@ -0,0 +1,36 @@ +using Shared.Logger; + +namespace TransactionProcessing.MerchantFileProcessor.Services; + +public static class LocalLogger +{ + public static Context For(string? merchantId = null, Guid? runId = null) => new(merchantId, runId); + + public static void LogInformation(string? merchantId, Guid? runId, string message) => + Logger.LogInformation(FormatMessage(merchantId, runId, message)); + + public static void LogWarning(string? merchantId, Guid? runId, string message) => + Logger.LogWarning(FormatMessage(merchantId, runId, message)); + + public static void LogError(string? merchantId, Guid? runId, string message, Exception exception) => + Logger.LogError(FormatMessage(merchantId, runId, message), exception); + + private static string FormatMessage(string? merchantId, Guid? runId, string message) + { + var merchantColumn = merchantId ?? string.Empty; + var runColumn = runId?.ToString() ?? string.Empty; + + return $"|{merchantColumn}|{runColumn}|{message}"; + } + + public sealed class Context(string? merchantId, Guid? runId) + { + public Context WithRun(Guid? value) => new(merchantId, value); + + public void LogInformation(string message) => LocalLogger.LogInformation(merchantId, runId, message); + + public void LogWarning(string message) => LocalLogger.LogWarning(merchantId, runId, message); + + public void LogError(string message, Exception exception) => LocalLogger.LogError(merchantId, runId, message, exception); + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Services/MerchantProcessingService.cs b/TransactionProcessing.MerchantFileProcessor/Services/MerchantProcessingService.cs new file mode 100644 index 0000000..e965743 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Services/MerchantProcessingService.cs @@ -0,0 +1,213 @@ +using TransactionProcessing.MerchantFileProcessor.Clients; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessing.MerchantFileProcessor.FileBuilding; + +namespace TransactionProcessing.MerchantFileProcessor.Services; + +public interface IMerchantProcessingService +{ + Task ProcessAsync(MerchantOptions merchant, DateTimeOffset scheduledRunUtc, Guid runId, CancellationToken cancellationToken); +} + +public sealed class MerchantProcessingService( + IAccessTokenProvider accessTokenProvider, + IMerchantContractDataClient merchantContractDataClient, + ITransactionFileGenerationService transactionFileGenerationService, + IMerchantDepositClient merchantDepositClient, + IFileProcessingClient fileProcessingClient, + IFileStatusStore fileStatusStore) : IMerchantProcessingService +{ + public async Task ProcessAsync(MerchantOptions merchant, DateTimeOffset scheduledRunUtc, Guid runId, CancellationToken cancellationToken) + { + var processingTimestampUtc = DateTimeOffset.UtcNow; + var logger = LocalLogger.For(merchant.MerchantId, runId); + var runResultRecorded = false; + + logger.LogInformation($"Processing merchant for scheduled run {scheduledRunUtc:O}"); + + try + { + logger.LogInformation("Requesting access token"); + var accessTokenResult = await accessTokenProvider.GetAccessToken(cancellationToken); + if (accessTokenResult.IsFailed) + { + throw new InvalidOperationException(string.Join("; ", accessTokenResult.Errors)); + } + + var accessToken = accessTokenResult.Data.AccessToken; + logger.LogInformation("Access token retrieved successfully"); + + logger.LogInformation("Requesting contract data"); + var contractsResult = await merchantContractDataClient.GetContracts(merchant, accessToken, cancellationToken); + if (contractsResult.IsFailed || contractsResult.Data is null) + { + throw new InvalidOperationException(string.Join("; ", contractsResult.Errors)); + } + + var contracts = contractsResult.Data; + logger.LogInformation($"Retrieved {contracts.Count} contracts"); + + var configuredContracts = transactionFileGenerationService.GetConfiguredContracts(contracts); + var skippedUnconfiguredContracts = contracts.Count - configuredContracts.Count; + + if (skippedUnconfiguredContracts > 0) + { + logger.LogInformation($"Skipping {skippedUnconfiguredContracts} contracts because they do not have a configured file profile."); + } + + var contractFailures = new List(); + + foreach (var contract in configuredContracts) + { + if (!contract.Products.Any(product => product.IsFixedValue)) + { + logger.LogWarning($"Skipping contract {contract.ContractId} because it does not contain any fixed-value products."); + continue; + } + + var hasSuccessfulUpload = await fileStatusStore.HasSuccessfulUploadAsync( + merchant, + contract, + scheduledRunUtc, + cancellationToken); + + if (hasSuccessfulUpload) + { + logger.LogInformation($"Skipping contract {contract.ContractId} because it was already uploaded for scheduled run {scheduledRunUtc:O}."); + continue; + } + + GeneratedFile? generatedFile = null; + var fileUploaded = false; + + try + { + generatedFile = transactionFileGenerationService.BuildFile(merchant, contract, processingTimestampUtc); + + logger.LogInformation($"Generated {generatedFile.Format} file {generatedFile.FileName} using profile {generatedFile.FileProfileId} for contract {contract.ContractId} with {generatedFile.RecordCount} records totaling {generatedFile.TotalAmount}"); + + var depositAmount = CalculateDepositAmount(generatedFile); + var depositReference = BuildDepositReference(runId, contract, generatedFile); + var depositResult = await merchantDepositClient.MakeDeposit( + merchant, + accessToken, + depositAmount, + depositReference, + processingTimestampUtc, + cancellationToken); + + if (depositResult.IsFailed) + { + throw new InvalidOperationException(string.Join("; ", depositResult.Errors)); + } + + logger.LogInformation($"Made merchant deposit of {depositAmount:0.00} for contract {contract.ContractId}"); + + var uploadResult = await fileProcessingClient.Upload( + merchant, + contract, + accessToken, + generatedFile, + cancellationToken); + if (uploadResult.IsFailed || uploadResult.Data == Guid.Empty) + { + throw new InvalidOperationException(string.Join("; ", uploadResult.Errors)); + } + + var fileId = uploadResult.Data; + fileUploaded = true; + + await fileStatusStore.RecordSuccessAsync( + runId, + merchant, + contract, + scheduledRunUtc, + fileId, + generatedFile, + cancellationToken); + + logger.LogInformation($"Uploaded {generatedFile.Format} file {generatedFile.FileName} for contract {contract.ContractId} with file id {fileId}"); + } + catch (Exception ex) + { + if (!fileUploaded) + { + await fileStatusStore.RecordFailureAsync( + runId, + merchant, + contract, + scheduledRunUtc, + generatedFile, + ex.ToString(), + cancellationToken); + } + + logger.LogError($"Contract processing failed for contract {contract.ContractId}", ex); + contractFailures.Add(ex); + } + } + + if (contractFailures.Count > 0) + { + var errorMessage = string.Join(Environment.NewLine + Environment.NewLine, contractFailures.Select(ex => ex.ToString())); + await fileStatusStore.RecordMerchantRunResultAsync( + runId, + merchant, + scheduledRunUtc, + MerchantRunStatuses.Failed, + errorMessage, + cancellationToken); + runResultRecorded = true; + + throw new AggregateException($"Scheduled run {scheduledRunUtc:O} failed for {contractFailures.Count} contract(s).", contractFailures); + } + + await fileStatusStore.RecordMerchantRunResultAsync( + runId, + merchant, + scheduledRunUtc, + MerchantRunStatuses.Succeeded, + null, + cancellationToken); + runResultRecorded = true; + + logger.LogInformation($"Completed merchant for scheduled run {scheduledRunUtc:O}"); + } + catch (Exception ex) + { + if (!runResultRecorded && ex is not OperationCanceledException) + { + await fileStatusStore.RecordMerchantRunResultAsync( + runId, + merchant, + scheduledRunUtc, + MerchantRunStatuses.Failed, + ex.ToString(), + cancellationToken); + } + + logger.LogError("Merchant processing failed", ex); + + throw; + } + } + + private static decimal CalculateDepositAmount(GeneratedFile generatedFile) + { + if (generatedFile.Transactions.Count < 2) + { + throw new InvalidOperationException( + $"Generated file '{generatedFile.FileName}' must contain at least two transactions to support the deposit adjustment."); + } + + return generatedFile.Transactions + .Take(generatedFile.Transactions.Count - 1) + .Sum(transaction => transaction.TotalAmount); + } + + private static string BuildDepositReference(Guid runId, ContractOptions contract, GeneratedFile generatedFile) + { + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(generatedFile.FileName); + return $"{contract.ContractId}-{runId:N}-{fileNameWithoutExtension}"; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj b/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj new file mode 100644 index 0000000..f32a5ce --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + dotnet-MerchantFileProcessor-315512c1-9124-49b4-8877-aa1cbc82076c + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + diff --git a/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.sln b/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.sln new file mode 100644 index 0000000..54c9fad --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.4.11626.88 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessing.MerchantFileProcessor", "TransactionProcessing.MerchantFileProcessor.csproj", "{3C40A33C-4C7C-11CF-7C7F-C13F3FE06DD5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3C40A33C-4C7C-11CF-7C7F-C13F3FE06DD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C40A33C-4C7C-11CF-7C7F-C13F3FE06DD5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C40A33C-4C7C-11CF-7C7F-C13F3FE06DD5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C40A33C-4C7C-11CF-7C7F-C13F3FE06DD5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {66A99464-A7F0-40F3-81C3-0C81C1439B16} + EndGlobalSection +EndGlobal diff --git a/TransactionProcessing.MerchantFileProcessor/Worker.cs b/TransactionProcessing.MerchantFileProcessor/Worker.cs new file mode 100644 index 0000000..668dcb0 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Worker.cs @@ -0,0 +1,107 @@ +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessing.MerchantFileProcessor.Services; + +namespace TransactionProcessing.MerchantFileProcessor; + +public sealed class Worker(MerchantProcessingOptions options, + IMerchantProcessingService merchantProcessingService, + IFileStatusStore fileStatusStore) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + LocalLogger.Context logger = LocalLogger.For(); + logger.LogInformation($"Merchant processor started with {options.Merchants.Count} merchant schedules"); + + while (!stoppingToken.IsCancellationRequested) { + MerchantOptions[] enabledMerchants = options.Merchants.Where(merchant => merchant.Enabled).ToArray(); + + if (enabledMerchants.Length == 0) { + logger.LogWarning("No enabled merchants are configured for processing. Waiting before checking again."); + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + continue; + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + ScheduledMerchant scheduledMerchant = await this.GetNextScheduledMerchantAsync(now, enabledMerchants, stoppingToken); + DateTimeOffset nextRun = scheduledMerchant.TriggerUtc; + TimeSpan delay = nextRun - now; + + LocalLogger.Context merchantLogger = LocalLogger.For(scheduledMerchant.Merchant.MerchantId); + merchantLogger.LogInformation($"Next merchant processing run scheduled at {nextRun:O} for slot {scheduledMerchant.ScheduledRunUtc:O}"); + + if (delay > TimeSpan.Zero) { + await Task.Delay(delay, stoppingToken); + } + + if (stoppingToken.IsCancellationRequested) { + break; + } + + Guid? runId = null; + + try { + runId = Guid.NewGuid(); + Guid currentRunId = runId.Value; + + merchantLogger.WithRun(currentRunId).LogInformation("Starting merchant processing run"); + + await merchantProcessingService.ProcessAsync(scheduledMerchant.Merchant, scheduledMerchant.ScheduledRunUtc, currentRunId, stoppingToken); + + merchantLogger.WithRun(currentRunId).LogInformation("Merchant processing run completed successfully"); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { + break; + } + catch (Exception ex) { + merchantLogger.WithRun(runId).LogError(runId is null ? "Merchant processing run failed before a run identifier was assigned." : "Merchant processing run failed", ex); + } + } + } + + private async Task GetNextScheduledMerchantAsync(DateTimeOffset now, + IReadOnlyList merchants, + CancellationToken cancellationToken) { + List scheduledMerchants = new List(merchants.Count); + + foreach (MerchantOptions merchant in merchants) { + ScheduledMerchant nextRun = await this.GetNextRunUtcAsync(now, merchant, cancellationToken); + scheduledMerchants.Add(nextRun); + } + + return scheduledMerchants.OrderBy(candidate => candidate.TriggerUtc).ThenBy(candidate => candidate.Merchant.MerchantId, StringComparer.OrdinalIgnoreCase).First(); + } + + private async Task GetNextRunUtcAsync(DateTimeOffset now, + MerchantOptions merchant, + CancellationToken cancellationToken) { + IReadOnlyList runTimes = merchant.GetDailyRunTimesUtc(); + DateOnly currentDate = DateOnly.FromDateTime(now.UtcDateTime); + + for (Int32 dayOffset = 0; dayOffset <= 1; dayOffset++) { + DateOnly candidateDate = currentDate.AddDays(dayOffset); + + foreach (TimeOnly runTime in runTimes) { + DateTimeOffset scheduledRunUtc = new DateTimeOffset(candidateDate.Year, candidateDate.Month, candidateDate.Day, runTime.Hour, runTime.Minute, runTime.Second, TimeSpan.Zero); + + if (scheduledRunUtc <= now) { + Boolean isComplete = await fileStatusStore.IsMerchantRunCompleteAsync(merchant, scheduledRunUtc, cancellationToken); + if (!isComplete) { + LocalLogger.For(merchant.MerchantId).LogInformation($"Missed scheduled run at {scheduledRunUtc:O}. Scheduling an immediate catch-up run."); + return new ScheduledMerchant(merchant, scheduledRunUtc, now); + } + + continue; + } + + return new ScheduledMerchant(merchant, scheduledRunUtc, scheduledRunUtc); + } + } + + TimeOnly firstRunTime = runTimes[0]; + DateOnly nextDate = currentDate.AddDays(1); + DateTimeOffset nextScheduledRunUtc = new DateTimeOffset(nextDate.Year, nextDate.Month, nextDate.Day, firstRunTime.Hour, firstRunTime.Minute, firstRunTime.Second, TimeSpan.Zero); + + return new ScheduledMerchant(merchant, nextScheduledRunUtc, nextScheduledRunUtc); + } + + private sealed record ScheduledMerchant(MerchantOptions Merchant, DateTimeOffset ScheduledRunUtc, DateTimeOffset TriggerUtc); +} diff --git a/TransactionProcessing.MerchantFileProcessor/appsettings.json b/TransactionProcessing.MerchantFileProcessor/appsettings.json new file mode 100644 index 0000000..72f946e --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/appsettings.json @@ -0,0 +1,140 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "FrameworkLogging": { + "EnableEfCoreCommandTrace": false, + "EnableHttpClientTrace": false + }, + "ConnectionStrings": { + "MerchantFileProcessor": "Data Source=merchant-file-processor.db" + }, + "ApiConfiguration": { + + }, + "MerchantProcessing": { + "Authentication": { + "ClientId": "", + "ClientSecret": "" + }, + "FileProcessing": { + "UserId": "" + }, + "TransactionGeneration": { + "MinimumTransactionsPerContract": 5, + "MaximumTransactionsPerContract": 25 + }, + "FileStatusPolling": { + "PollIntervalSeconds": 30 + }, + "ContractDefinitions": [ + + ], + "FileProfiles": [ + { + "FileProfileId": "safaricom", + "FileProcessorFileProfileId": "", + "Format": "delimited", + "FileExtension": "csv", + "ContentType": "text/csv", + "Delimited": { + "Delimiter": ",", + "IncludeHeader": false, + "HeaderFields": [ + { + "Name": "RecordType", + "Value": "H" + }, + { + "Name": "ProcessingDate", + "Source": "processingDateUtc", + "Format": "yyyy-MM-dd" + } + ], + "TrailerFields": [ + { + "Name": "RecordType", + "Value": "T" + }, + { + "Name": "RecordCount", + "Source": "recordCount" + } + ] + }, + "Fields": [ + { + "Name": "RecordType", + "Value": "D" + }, + { + "Name": "RecipientMobileNumber", + "Source": "recipientMobileNumber" + }, + { + "Name": "TotalAmount", + "Source": "totalAmount", + "Format": "0.00" + } + ] + }, + { + "FileProfileId": "voucher", + "FileProcessorFileProfileId": "", + "Format": "delimited", + "FileExtension": "csv", + "ContentType": "text/csv", + "Delimited": { + "Delimiter": ",", + "IncludeHeader": false, + "HeaderFields": [ + { + "Name": "RecordType", + "Value": "H" + }, + { + "Name": "ProcessingDate", + "Source": "processingDateUtc", + "Format": "yyyy-MM-dd" + } + ], + "TrailerFields": [ + { + "Name": "RecordType", + "Value": "T" + }, + { + "Name": "RecordCount", + "Source": "recordCount" + } + ] + }, + "Fields": [ + { + "Name": "RecordType", + "Value": "D" + }, + { + "Name": "Issuer", + "Source": "contractIssuer" + }, + { + "Name": "RecipientMobileNumber", + "Source": "recipientMobileNumber" + }, + { + "Name": "TotalAmount", + "Source": "totalAmount", + "Format": "0.00" + } + ] + } + ], + "Merchants": [ + + ] + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json b/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json new file mode 100644 index 0000000..50c514c --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json @@ -0,0 +1,151 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ApiConfiguration": { + "SecurityService": "https://127.0.0.1:5001", + "TransactionProcessorApi": "http://127.0.0.1:5002", + "FileProcessorApi": "http://127.0.0.1:5009" + }, + "MerchantProcessing": { + "Authentication": { + "ClientId": "serviceClient", + "ClientSecret": "d192cbc46d834d0da90e8a9d50ded543" + }, + "FileProcessing": { + "UserId": "a51791a8-2731-490c-a8ac-639461f2a41f" + }, + "TransactionGeneration": { + "MinimumTransactionsPerContract": 5, + "MaximumTransactionsPerContract": 25 + }, + "FileStatusPolling": { + "PollIntervalSeconds": 30 + }, + "ContractDefinitions": [ + { + "ContractId": "052A6CA5-47FC-4844-ADCA-7D44EFD8F21C", + "FileProfileId": "safaricom" + }, + { + "ContractId": "9114770B-D188-4F32-9259-B9BD29472370", + "FileProfileId": "voucher" + } + ], + "FileProfiles": [ + { + "FileProfileId": "safaricom", + "FileProcessorFileProfileId": "B2A59ABF-293D-4A6B-B81B-7007503C3476", + "Format": "delimited", + "FileExtension": "csv", + "ContentType": "text/csv", + "Delimited": { + "Delimiter": ",", + "IncludeHeader": false, + "HeaderFields": [ + { + "Name": "RecordType", + "Value": "H" + }, + { + "Name": "ProcessingDate", + "Source": "processingDateUtc", + "Format": "yyyy-MM-dd" + } + ], + "TrailerFields": [ + { + "Name": "RecordType", + "Value": "T" + }, + { + "Name": "RecordCount", + "Source": "recordCount" + } + ] + }, + "Fields": [ + { + "Name": "RecordType", + "Value": "D" + }, + { + "Name": "RecipientMobileNumber", + "Source": "recipientMobileNumber" + }, + { + "Name": "TotalAmount", + "Source": "totalAmount", + "Format": "0.00" + } + ] + }, + { + "FileProfileId": "voucher", + "FileProcessorFileProfileId": "8806EDBC-3ED6-406B-9E5F-A9078356BE99", + "Format": "delimited", + "FileExtension": "csv", + "ContentType": "text/csv", + "Delimited": { + "Delimiter": ",", + "IncludeHeader": false, + "HeaderFields": [ + { + "Name": "RecordType", + "Value": "H" + }, + { + "Name": "ProcessingDate", + "Source": "processingDateUtc", + "Format": "yyyy-MM-dd" + } + ], + "TrailerFields": [ + { + "Name": "RecordType", + "Value": "T" + }, + { + "Name": "RecordCount", + "Source": "recordCount" + } + ] + }, + "Fields": [ + { + "Name": "RecordType", + "Value": "D" + }, + { + "Name": "Issuer", + "Source": "contractIssuer" + }, + { + "Name": "RecipientMobileNumber", + "Source": "recipientMobileNumber" + }, + { + "Name": "TotalAmount", + "Source": "totalAmount", + "Format": "0.00" + } + ] + } + ], + "Merchants": [ + { + "Name": "Test Merchant 1", + "EstateId": "435613ac-a468-47a3-ac4f-649d89764c22", + "MerchantId": "ab1c99fb-1c6c-4694-9a32-b71be5d1da33", + "RunTimesUtc": [ + "09:02:00", + "15:02:00" + ], + "Enabled": true + } + ] + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/hosting.json b/TransactionProcessing.MerchantFileProcessor/hosting.json new file mode 100644 index 0000000..6e36358 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/hosting.json @@ -0,0 +1,4 @@ +{ + "Urls": "http://*:9601", + "AllowedHosts": "*" +} From 2198b2e6e3e2131dae676663f7187b89749ba533 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:05:33 +0000 Subject: [PATCH 2/2] Fix quick Codacy issues: performance improvement and best practice violations Agent-Logs-Url: https://github.com/TransactionProcessing/SupportTools/sessions/7373b758-d29d-43de-a82d-3bc060d3ecb2 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Clients/FileProcessingClient.cs | 3 +-- .../FileBuilding/DelimitedTransactionFileBuilder.cs | 10 +++++----- .../FileBuilding/JsonTransactionFileBuilder.cs | 3 ++- .../Reporting/FileStatusReportService.cs | 6 +++--- TransactionProcessing.MerchantFileProcessor/Worker.cs | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs b/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs index efd1faa..1ac585f 100644 --- a/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs +++ b/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs @@ -43,7 +43,6 @@ public async Task> Upload(MerchantOptions merchant, }; Result? result = await fileProcessorClient.UploadFile(accessToken, file.FileName, file.Content, request, cancellationToken); - //var result = Result.Success(Guid.NewGuid()); // TODO: Replace with actual file upload call if (result.IsFailed) { return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to upload file '{file.FileName}'." }; @@ -71,7 +70,7 @@ public async Task> GetFileStatus(string acc private static FileProcessingLineStatusSnapshot MapLineStatus(FileLine line) => new(line.LineNumber, line.LineData, line.ProcessingResult.ToString(), string.IsNullOrWhiteSpace(line.RejectionReason) ? null : line.RejectionReason, line.TransactionId == Guid.Empty ? null : line.TransactionId); private static bool AreAllLinesResolved(IEnumerable lines) { - Boolean hasLines = false; + bool hasLines = false; foreach (FileProcessingLineStatusSnapshot line in lines) { hasLines = true; diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/DelimitedTransactionFileBuilder.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/DelimitedTransactionFileBuilder.cs index f41f143..197c954 100644 --- a/TransactionProcessing.MerchantFileProcessor/FileBuilding/DelimitedTransactionFileBuilder.cs +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/DelimitedTransactionFileBuilder.cs @@ -11,9 +11,9 @@ public GeneratedFile Build(MerchantOptions merchant, FileProfileOptions fileProfile, IReadOnlyList transactions, DateTimeOffset processingTimestampUtc) { - String delimiter = NormalizeDelimiter(fileProfile.Delimited.Delimiter); - List lines = new List(); - Decimal fileTotalAmount = transactions.Sum(transaction => transaction.TotalAmount); + string delimiter = NormalizeDelimiter(fileProfile.Delimited.Delimiter); + List lines = new List(); + decimal fileTotalAmount = transactions.Sum(transaction => transaction.TotalAmount); if (fileProfile.Delimited.HeaderFields.Count > 0) { TransactionFileContext headerContext = new TransactionFileContext(merchant, contract, null, processingTimestampUtc, transactions.Count, fileTotalAmount); @@ -43,8 +43,8 @@ public GeneratedFile Build(MerchantOptions merchant, private static string Escape(string value, string delimiter) { - Boolean shouldQuote = value.Contains('"') || value.Contains('\r') || value.Contains('\n') || value.Contains(delimiter, StringComparison.Ordinal); - String escapedValue = value.Replace("\"", "\"\"", StringComparison.Ordinal); + bool shouldQuote = value.Contains('"') || value.Contains('\r') || value.Contains('\n') || value.Contains(delimiter, StringComparison.Ordinal); + string escapedValue = value.Replace("\"", "\"\"", StringComparison.Ordinal); return shouldQuote ? $"\"{escapedValue}\"" : escapedValue; } diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/JsonTransactionFileBuilder.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/JsonTransactionFileBuilder.cs index 1750807..89e0b1e 100644 --- a/TransactionProcessing.MerchantFileProcessor/FileBuilding/JsonTransactionFileBuilder.cs +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/JsonTransactionFileBuilder.cs @@ -14,6 +14,7 @@ public GeneratedFile Build( IReadOnlyList transactions, DateTimeOffset processingTimestampUtc) { + var fileTotalAmount = transactions.Sum(transaction => transaction.TotalAmount); var mappedRecords = transactions .Select(transaction => { @@ -23,7 +24,7 @@ public GeneratedFile Build( transaction, processingTimestampUtc, transactions.Count, - transactions.Sum(candidate => candidate.TotalAmount)); + fileTotalAmount); return fileProfile.Fields.ToDictionary( field => field.Name, diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs index 82c2b79..8f064b5 100644 --- a/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs @@ -141,7 +141,7 @@ public async Task RenderHtmlAsync(CancellationToken cancellationToken) var html = new StringBuilder(); AppendDocumentStart(html, "Merchant File Status"); - html.AppendLine($"

Merchant File Status

"); + html.AppendLine("

Merchant File Status

"); html.AppendLine($"

Generated at {Encode(report.GeneratedUtc.ToString("u"))}. Auto-refreshes every 30 seconds.

"); html.AppendLine("

Merchants

"); html.AppendLine(" "); @@ -171,7 +171,7 @@ public async Task RenderHtmlAsync(CancellationToken cancellationToken) var html = new StringBuilder(); AppendDocumentStart(html, $"Merchant {report.Merchant.MerchantName} Details"); - html.AppendLine($"

← Back to merchants

"); + html.AppendLine("

← Back to merchants

"); html.AppendLine($"

{Encode(report.Merchant.MerchantName)}

"); html.AppendLine($"

{Encode(report.Merchant.MerchantId)}

"); html.AppendLine($"

Generated at {Encode(report.GeneratedUtc.ToString("u"))}. Auto-refreshes every 30 seconds.

"); @@ -218,7 +218,7 @@ public async Task RenderHtmlAsync(CancellationToken cancellationToken) var html = new StringBuilder(); AppendDocumentStart(html, $"Merchant {report.Merchant.MerchantName} File {report.File.Id}"); html.AppendLine($"

← Back to merchant

"); - html.AppendLine($"

File Details

"); + html.AppendLine("

File Details

"); html.AppendLine($"

Merchant {Encode(report.Merchant.MerchantName)}
{Encode(report.Merchant.MerchantId)}

"); html.AppendLine("
"); html.AppendLine(" "); diff --git a/TransactionProcessing.MerchantFileProcessor/Worker.cs b/TransactionProcessing.MerchantFileProcessor/Worker.cs index 668dcb0..ec273a6 100644 --- a/TransactionProcessing.MerchantFileProcessor/Worker.cs +++ b/TransactionProcessing.MerchantFileProcessor/Worker.cs @@ -76,14 +76,14 @@ private async Task GetNextRunUtcAsync(DateTimeOffset now, IReadOnlyList runTimes = merchant.GetDailyRunTimesUtc(); DateOnly currentDate = DateOnly.FromDateTime(now.UtcDateTime); - for (Int32 dayOffset = 0; dayOffset <= 1; dayOffset++) { + for (int dayOffset = 0; dayOffset <= 1; dayOffset++) { DateOnly candidateDate = currentDate.AddDays(dayOffset); foreach (TimeOnly runTime in runTimes) { DateTimeOffset scheduledRunUtc = new DateTimeOffset(candidateDate.Year, candidateDate.Month, candidateDate.Day, runTime.Hour, runTime.Minute, runTime.Second, TimeSpan.Zero); if (scheduledRunUtc <= now) { - Boolean isComplete = await fileStatusStore.IsMerchantRunCompleteAsync(merchant, scheduledRunUtc, cancellationToken); + bool isComplete = await fileStatusStore.IsMerchantRunCompleteAsync(merchant, scheduledRunUtc, cancellationToken); if (!isComplete) { LocalLogger.For(merchant.MerchantId).LogInformation($"Missed scheduled run at {scheduledRunUtc:O}. Scheduling an immediate catch-up run."); return new ScheduledMerchant(merchant, scheduledRunUtc, now);
Processed (UTC)ContractUpload StatusProcessingProfileFormat