diff --git a/.github/workflows/powershell_nightly.yml b/.github/workflows/powershell_nightly.yml new file mode 100644 index 0000000..8a56f9b --- /dev/null +++ b/.github/workflows/powershell_nightly.yml @@ -0,0 +1,27 @@ +name: Nightly PowerShell Tests + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + +jobs: + pester-tests: + name: Run PowerShell Tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Ensure Pester is Available + shell: pwsh + run: | + if (-not (Get-Module -ListAvailable -Name Pester)) { + Install-Module -Name Pester -Force -SkipPublisherCheck -Scope CurrentUser + } + + - name: Run Pester Tests + shell: pwsh + run: | + Invoke-Pester -Path ./Scripts/Tests -CI diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 0934dde..0c82218 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -33,3 +33,24 @@ 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 + + pester-tests: + name: "Run PowerShell Tests" + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Ensure Pester is Available + shell: pwsh + run: | + if (-not (Get-Module -ListAvailable -Name Pester)) { + Install-Module -Name Pester -Force -SkipPublisherCheck -Scope CurrentUser + } + + - name: Run Pester Tests + shell: pwsh + run: | + Invoke-Pester -Path ./Scripts/Tests -CI diff --git a/.gitignore b/.gitignore index 7125ff7..5bf2a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ Generated\ Files/ # NUnit *.VisualState.xml TestResult.xml +testResults.xml nunit-*.xml # Build Results of an ATL Project diff --git a/Scripts/DailySettlementProcessingTask.xml b/Scripts/DailySettlementProcessingTask.xml new file mode 100644 index 0000000..b293b87 --- /dev/null +++ b/Scripts/DailySettlementProcessingTask.xml @@ -0,0 +1,52 @@ + + + + 2026-03-12T00:00:00 + TransactionProcessing + Runs the daily settlement processing PowerShell script once per day. + + + + 2026-03-13T01:00:00 + true + + 1 + + + + + + S-1-5-18 + ServiceAccount + HighestAvailable + + + + IgnoreNew + false + false + true + true + true + + false + false + + true + true + false + false + false + true + false + PT1H + 7 + + + + powershell.exe + -NoProfile -ExecutionPolicy Bypass -File "C:\SupportTools\Scripts\Invoke-DailySettlementProcessing.ps1" -EstateId "__ESTATE_ID__" -BaseUrl "__BASE_URL__" -SecurityServiceUrl "__SECURITY_SERVICE_URL__" -ClientId "__CLIENT_ID__" -ClientSecret "__CLIENT_SECRET__" + C:\SupportTools\Scripts + + + diff --git a/Scripts/Invoke-DailySettlementProcessing.ps1 b/Scripts/Invoke-DailySettlementProcessing.ps1 new file mode 100644 index 0000000..7a85871 --- /dev/null +++ b/Scripts/Invoke-DailySettlementProcessing.ps1 @@ -0,0 +1,196 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$EstateId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$BaseUrl, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$SecurityServiceUrl, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ClientSecret +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-AccessToken { + param( + [Parameter(Mandatory = $true)] + [string]$SecurityServiceUrl, + + [Parameter(Mandatory = $true)] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [string]$ClientSecret + ) + + $tokenEndpoint = '{0}/connect/token' -f $SecurityServiceUrl.TrimEnd('/') + Write-Verbose "Requesting access token from [$tokenEndpoint]" + + $tokenResponse = Invoke-RestMethod -Method Post ` + -Uri $tokenEndpoint ` + -ContentType 'application/x-www-form-urlencoded' ` + -Body @{ + grant_type = 'client_credentials' + client_id = $ClientId + client_secret = $ClientSecret + } + + if ($tokenResponse -is [hashtable]) { + foreach ($propertyName in 'access_token', 'AccessToken', 'token', 'Token') { + if ($tokenResponse.ContainsKey($propertyName)) { + $accessToken = [string]$tokenResponse[$propertyName] + if (-not [string]::IsNullOrWhiteSpace($accessToken)) { + return $accessToken + } + } + } + } + else { + foreach ($propertyName in 'access_token', 'AccessToken', 'token', 'Token') { + $property = $tokenResponse.PSObject.Properties[$propertyName] + if ($null -ne $property) { + $accessToken = [string]$property.Value + if (-not [string]::IsNullOrWhiteSpace($accessToken)) { + return $accessToken + } + } + } + } + + throw 'Token response did not contain an access token.' +} + +function Get-MerchantItems { + param( + [Parameter(Mandatory = $false)] + $MerchantResponse + ) + + if ($null -eq $MerchantResponse) { + return @() + } + + if ($MerchantResponse -is [string]) { + return @($MerchantResponse) + } + + if ($MerchantResponse -is [hashtable]) { + foreach ($propertyName in 'merchants', 'Merchants', 'data', 'Data', 'items', 'Items', 'value', 'Value') { + if ($MerchantResponse.ContainsKey($propertyName)) { + return @(Get-MerchantItems -MerchantResponse $MerchantResponse[$propertyName]) + } + } + + return @($MerchantResponse) + } + + if ($MerchantResponse -is [System.Collections.IEnumerable] -and $MerchantResponse -isnot [psobject]) { + return @($MerchantResponse) + } + + foreach ($propertyName in 'merchants', 'Merchants', 'data', 'Data', 'items', 'Items', 'value', 'Value') { + $property = $MerchantResponse.PSObject.Properties[$propertyName] + if ($null -ne $property) { + return @(Get-MerchantItems -MerchantResponse $property.Value) + } + } + + if ($MerchantResponse -is [System.Collections.IEnumerable] -and $MerchantResponse -isnot [string]) { + return @($MerchantResponse) + } + + return @($MerchantResponse) +} + +function Get-MerchantId { + param( + [Parameter(Mandatory = $true)] + $Merchant + ) + + if ($Merchant -is [string]) { + return $Merchant + } + + if ($Merchant -is [hashtable]) { + foreach ($propertyName in 'merchantId', 'MerchantId', 'id', 'Id') { + if ($Merchant.ContainsKey($propertyName)) { + $merchantId = [string]$Merchant[$propertyName] + if (-not [string]::IsNullOrWhiteSpace($merchantId)) { + return $merchantId + } + } + } + } + else { + foreach ($propertyName in 'merchantId', 'MerchantId', 'id', 'Id') { + $property = $Merchant.PSObject.Properties[$propertyName] + if ($null -ne $property) { + $merchantId = [string]$property.Value + if (-not [string]::IsNullOrWhiteSpace($merchantId)) { + return $merchantId + } + } + } + } + + throw 'Unable to determine merchant id from merchant response.' +} + +function Invoke-DailySettlementProcessing { + param( + [Parameter(Mandatory = $true)] + [string]$EstateId, + + [Parameter(Mandatory = $true)] + [string]$BaseUrl, + + [Parameter(Mandatory = $true)] + [string]$SecurityServiceUrl, + + [Parameter(Mandatory = $true)] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [string]$ClientSecret + ) + + $settlementDate = (Get-Date).ToString('yyyy-MM-dd') + $accessToken = Get-AccessToken -SecurityServiceUrl $SecurityServiceUrl -ClientId $ClientId -ClientSecret $ClientSecret + $headers = @{ + Authorization = "Bearer $accessToken" + } + + $normalizedBaseUrl = $BaseUrl.TrimEnd('/') + $merchantsEndpoint = '{0}/api/v2/estates/{1}/merchants' -f $normalizedBaseUrl, $EstateId + Write-Verbose "Requesting merchants from [$merchantsEndpoint]" + + $merchantResponse = Invoke-RestMethod -Method Get -Uri $merchantsEndpoint -Headers $headers + $merchantIds = @(Get-MerchantItems -MerchantResponse $merchantResponse | ForEach-Object { Get-MerchantId -Merchant $_ }) + + if ($merchantIds.Count -eq 0) { + Write-Verbose "No merchants were returned for estate [$EstateId]" + return + } + + foreach ($merchantId in $merchantIds) { + $settlementEndpoint = '{0}/api/estates/{1}/settlements/{2}/merchants/{3}' -f $normalizedBaseUrl, $EstateId, $settlementDate, $merchantId + Write-Verbose "Submitting settlement for merchant [$merchantId] using [$settlementEndpoint]" + Invoke-RestMethod -Method Post -Uri $settlementEndpoint -Headers $headers + } +} + +Invoke-DailySettlementProcessing @PSBoundParameters diff --git a/Scripts/Tests/Invoke-DailySettlementProcessing.Tests.ps1 b/Scripts/Tests/Invoke-DailySettlementProcessing.Tests.ps1 new file mode 100644 index 0000000..dd6ec1f --- /dev/null +++ b/Scripts/Tests/Invoke-DailySettlementProcessing.Tests.ps1 @@ -0,0 +1,61 @@ +Describe 'Invoke-DailySettlementProcessing.ps1' { + It 'gets merchants and posts a settlement for each merchant returned' { + $postedUris = [System.Collections.Generic.List[string]]::new() + $settlementDate = (Get-Date).ToString('yyyy-MM-dd') + $scriptUnderTest = (Resolve-Path (Join-Path (Join-Path $PSScriptRoot '..') 'Invoke-DailySettlementProcessing.ps1')).Path + + Mock Invoke-RestMethod { + @{ + access_token = 'access-token' + } + } -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq 'https://security.example/connect/token' -and + $ContentType -eq 'application/x-www-form-urlencoded' -and + $Body.grant_type -eq 'client_credentials' -and + $Body.client_id -eq 'client-id' -and + $Body.client_secret -eq 'client-secret' + } + + Mock Invoke-RestMethod { + @{ + merchants = @( + @{ merchantId = 'merchant-1' }, + @{ MerchantId = 'merchant-2' } + ) + } + } -ParameterFilter { + $Method -eq 'Get' -and + $Uri -eq 'https://api.example/api/v2/estates/estate-123/merchants' -and + $Headers.Authorization -eq 'Bearer access-token' + } + + Mock Invoke-RestMethod { + $postedUris.Add($Uri) | Out-Null + } -ParameterFilter { + $Method -eq 'Post' -and + $Uri -like 'https://api.example/api/estates/estate-123/settlements/*/merchants/*' -and + $Headers.Authorization -eq 'Bearer access-token' + } + + & $scriptUnderTest ` + -EstateId 'estate-123' ` + -BaseUrl 'https://api.example/' ` + -SecurityServiceUrl 'https://security.example/' ` + -ClientId 'client-id' ` + -ClientSecret 'client-secret' + + Assert-MockCalled Invoke-RestMethod -Times 1 -ParameterFilter { + $Method -eq 'Post' -and $Uri -eq 'https://security.example/connect/token' + } + + Assert-MockCalled Invoke-RestMethod -Times 1 -ParameterFilter { + $Method -eq 'Get' -and $Uri -eq 'https://api.example/api/v2/estates/estate-123/merchants' + } + + $postedUris | Should -Be @( + "https://api.example/api/estates/estate-123/settlements/$settlementDate/merchants/merchant-1", + "https://api.example/api/estates/estate-123/settlements/$settlementDate/merchants/merchant-2" + ) + } +}