Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion .github/workflows/createrelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions .github/workflows/pullrequest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ 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.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"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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<Result<Guid>> Upload(MerchantOptions merchant,
ContractOptions contract,
string accessToken,
GeneratedFile file,
CancellationToken cancellationToken);

Task<Result<FileProcessingStatusSnapshot>> GetFileStatus(string accessToken,
Guid estateId,
Guid fileId,
CancellationToken cancellationToken);
}

public sealed class FileProcessingClient(IFileProcessorClient fileProcessorClient, MerchantProcessingOptions options) : IFileProcessingClient {
public async Task<Result<Guid>> 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<Guid> { 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<Guid>? result = await fileProcessorClient.UploadFile(accessToken, file.FileName, file.Content, request, cancellationToken);

if (result.IsFailed) {
return new Result<Guid> { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to upload file '{file.FileName}'." };
}

return Result.Success(result.Data);
}

public async Task<Result<FileProcessingStatusSnapshot>> GetFileStatus(string accessToken,
Guid estateId,
Guid fileId,
CancellationToken cancellationToken) {
Result<FileDetails>? result = await fileProcessorClient.GetFile(accessToken, estateId, fileId, cancellationToken);

if (result.IsFailed || result.Data is null) {
return new Result<FileProcessingStatusSnapshot> { 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<FileProcessingLineStatusSnapshot> lines) {
bool 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Result<IReadOnlyList<ContractOptions>>> GetContracts(
MerchantOptions merchant,
string accessToken,
CancellationToken cancellationToken);
}

public sealed class MerchantContractDataClient(
ITransactionProcessorClient transactionProcessorClient) : IMerchantContractDataClient
{
public async Task<Result<IReadOnlyList<ContractOptions>>> 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<IReadOnlyList<ContractOptions>>
{
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<IReadOnlyList<ContractOptions>>
{
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<IReadOnlyList<ContractOptions>>(contracts);
}

private static List<ContractOptions> MapContracts(IReadOnlyList<ContractResponse> 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<ContractOptions> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<Result> MakeDeposit(
MerchantOptions merchant,
string accessToken,
decimal amount,
string reference,
DateTimeOffset depositTimestampUtc,
CancellationToken cancellationToken);
}

public sealed class MerchantDepositClient(
ITransactionProcessorClient transactionProcessorClient) : IMerchantDepositClient
{
public async Task<Result> 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();
}
}
Loading
Loading