diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index e1968299ec..22096b09d9 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -125,10 +125,10 @@ jobs: echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry run: | - docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator - docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator - docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web - docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web + docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest - name: Disconnect docker from JFrog Artifactory run: | docker logout diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml index 96a43e594d..f6a35b804a 100644 --- a/.github/workflows/docker-build-test.yml +++ b/.github/workflows/docker-build-test.yml @@ -158,10 +158,10 @@ jobs: echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry run: | - docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator - docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator - docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web - docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web + docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest - name: Disconnect docker from JFrog Artifactory run: | docker logout diff --git a/.github/workflows/pr-check-dev-branch.yml b/.github/workflows/pr-check-dev-branch.yml index 80f6731abd..6e5b709ff2 100644 --- a/.github/workflows/pr-check-dev-branch.yml +++ b/.github/workflows/pr-check-dev-branch.yml @@ -69,7 +69,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v5 with: dotnet-version: "9.0.x" @@ -85,7 +85,7 @@ jobs: --logger "trx;LogFileName=${NAME}.trx" \ --results-directory TestResults - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: test-output-${{ strategy.job-index }} path: TestResults/ @@ -133,7 +133,7 @@ jobs: echo "failed=$FAILED" >> $GITHUB_OUTPUT echo "skipped=$SKIPPED" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: merged-test-results path: merged/ diff --git a/.github/workflows/pr-check-main-branch.yml b/.github/workflows/pr-check-main-branch.yml index cbb297ba79..d81966efe6 100644 --- a/.github/workflows/pr-check-main-branch.yml +++ b/.github/workflows/pr-check-main-branch.yml @@ -65,7 +65,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v5 with: dotnet-version: "9.0.x" @@ -81,7 +81,7 @@ jobs: --logger "trx;LogFileName=${NAME}.trx" \ --results-directory TestResults - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: test-output-${{ strategy.job-index }} path: TestResults/ @@ -129,7 +129,7 @@ jobs: echo "failed=$FAILED" >> $GITHUB_OUTPUT echo "skipped=$SKIPPED" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: merged-test-results path: merged/ diff --git a/.github/workflows/pr-check-test-branch.yml b/.github/workflows/pr-check-test-branch.yml index 7deb972735..9fea720bbd 100644 --- a/.github/workflows/pr-check-test-branch.yml +++ b/.github/workflows/pr-check-test-branch.yml @@ -67,7 +67,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@v5 with: dotnet-version: "9.0.x" @@ -83,7 +83,7 @@ jobs: --logger "trx;LogFileName=${NAME}.trx" \ --results-directory TestResults - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: test-output-${{ strategy.job-index }} path: TestResults/ @@ -131,7 +131,7 @@ jobs: echo "failed=$FAILED" >> $GITHUB_OUTPUT echo "skipped=$SKIPPED" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: merged-test-results path: merged/ diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs index 475368d829..053fcdf634 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/UpsertSupplierHandler.cs @@ -1,6 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -12,9 +12,9 @@ using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus; using Volo.Abp.EventBus.Local; - -namespace Unity.Payments.Handlers -{ + +namespace Unity.Payments.Handlers +{ public class UpsertSupplierHandler(ISupplierAppService supplierAppService, SiteAppService siteAppService, ILogger logger, @@ -22,7 +22,7 @@ public class UpsertSupplierHandler(ISupplierAppService supplierAppService, IApplicationRepository applicationRepository) : ILocalEventHandler, ITransientDependency { - public async Task HandleEventAsync(UpsertSupplierEto eventData) + public async Task HandleEventAsync(UpsertSupplierEto eventData) { SupplierDto supplierDto = await GetSupplierFromEvent(eventData); var existingSites = await siteAppService.GetSitesBySupplierIdAsync(supplierDto.Id); @@ -35,34 +35,40 @@ public async Task HandleEventAsync(UpsertSupplierEto eventData) await localEventBus.PublishAsync( new ApplicantSupplierEto { - SupplierId = supplierDto.Id, - ApplicantId = eventData.CorrelationId, - ExistingSitesDictionary = existingSitesDictionary, - SiteEtos = eventData.SiteEtos - } - ); - } - + SupplierId = supplierDto.Id, + ApplicantId = eventData.CorrelationId, + ExistingSitesDictionary = existingSitesDictionary, + SiteEtos = eventData.SiteEtos + } + ); + } + private async Task> UpsertSitesFromEventDtoAsync( Dictionary existingSitesDictionary, Guid supplierId, UpsertSupplierEto upsertSupplierEto, PaymentGroup defaultPaymentGroup) { - foreach (var siteEto in upsertSupplierEto.SiteEtos) + // Deduplicate incoming SiteEtos by SupplierSiteCode — CAS can return duplicate site codes + var uniqueSiteEtos = upsertSupplierEto.SiteEtos + .GroupBy(s => s.SupplierSiteCode) + .Select(g => g.First()) + .ToList(); + + foreach (var siteEto in uniqueSiteEtos) { var siteDto = supplierAppService.GetSiteDtoFromSiteEto(siteEto, supplierId, defaultPaymentGroup); - if (existingSitesDictionary.TryGetValue(siteDto.Number, out var existingSite)) - { - siteDto.Id = existingSite.Id; - await siteAppService.UpdateAsync(siteDto); - } - else - { - await siteAppService.InsertAsync(siteDto); - } - } + if (existingSitesDictionary.TryGetValue(siteDto.Number, out var existingSite)) + { + siteDto.Id = existingSite.Id; + await siteAppService.UpdateAsync(siteDto); + } + else + { + await siteAppService.InsertAsync(siteDto); + } + } return existingSitesDictionary; } @@ -107,57 +113,57 @@ private async Task GetSupplierFromEvent(UpsertSupplierEto eventData { var existing = await supplierAppService.GetBySupplierNumberAsync(eventData.Number); logger.LogInformation("Upserting supplier from event data: {Existing}", existing); - - // This is subject to some business rules and a domain implementation - if (existing != null) - { - existing.Number = eventData.Number; - UpdateSupplierDto updateSupplierDto = GetUpdateSupplierDtoFromEvent(eventData); - SupplierDto updatedSupplierDto = await supplierAppService.UpdateAsync(existing.Id, updateSupplierDto); - return updatedSupplierDto; - } - - CreateSupplierDto createSupplierDto = GetCreateSupplierDtoFromEvent(eventData); - SupplierDto supplierDto = await supplierAppService.CreateAsync(createSupplierDto); - - return supplierDto; - } - - - private static UpdateSupplierDto GetUpdateSupplierDtoFromEvent(UpsertSupplierEto eventData) - { - return new UpdateSupplierDto() - { - Name = eventData.Name, - Number = eventData.Number, - Subcategory = eventData.Subcategory, - ProviderId = eventData.ProviderId, - BusinessNumber = eventData.BusinessNumber, - Status = eventData.Status, - SupplierProtected = eventData.SupplierProtected, - StandardIndustryClassification = eventData.StandardIndustryClassification, - LastUpdatedInCAS = eventData.LastUpdatedInCAS, - CorrelationId = eventData.CorrelationId, - CorrelationProvider = eventData.CorrelationProvider, - }; - } - - private static CreateSupplierDto GetCreateSupplierDtoFromEvent(UpsertSupplierEto eventData) - { - return new CreateSupplierDto() - { - Name = eventData.Name, - Number = eventData.Number, - Subcategory = eventData.Subcategory, - ProviderId = eventData.ProviderId, - BusinessNumber = eventData.BusinessNumber, - Status = eventData.Status, - SupplierProtected = eventData.SupplierProtected, - StandardIndustryClassification = eventData.StandardIndustryClassification, - LastUpdatedInCAS = eventData.LastUpdatedInCAS, - CorrelationId = eventData.CorrelationId, - CorrelationProvider = eventData.CorrelationProvider, - }; - } - } -} + + // This is subject to some business rules and a domain implementation + if (existing != null) + { + existing.Number = eventData.Number; + UpdateSupplierDto updateSupplierDto = GetUpdateSupplierDtoFromEvent(eventData); + SupplierDto updatedSupplierDto = await supplierAppService.UpdateAsync(existing.Id, updateSupplierDto); + return updatedSupplierDto; + } + + CreateSupplierDto createSupplierDto = GetCreateSupplierDtoFromEvent(eventData); + SupplierDto supplierDto = await supplierAppService.CreateAsync(createSupplierDto); + + return supplierDto; + } + + + private static UpdateSupplierDto GetUpdateSupplierDtoFromEvent(UpsertSupplierEto eventData) + { + return new UpdateSupplierDto() + { + Name = eventData.Name, + Number = eventData.Number, + Subcategory = eventData.Subcategory, + ProviderId = eventData.ProviderId, + BusinessNumber = eventData.BusinessNumber, + Status = eventData.Status, + SupplierProtected = eventData.SupplierProtected, + StandardIndustryClassification = eventData.StandardIndustryClassification, + LastUpdatedInCAS = eventData.LastUpdatedInCAS, + CorrelationId = eventData.CorrelationId, + CorrelationProvider = eventData.CorrelationProvider, + }; + } + + private static CreateSupplierDto GetCreateSupplierDtoFromEvent(UpsertSupplierEto eventData) + { + return new CreateSupplierDto() + { + Name = eventData.Name, + Number = eventData.Number, + Subcategory = eventData.Subcategory, + ProviderId = eventData.ProviderId, + BusinessNumber = eventData.BusinessNumber, + Status = eventData.Status, + SupplierProtected = eventData.SupplierProtected, + StandardIndustryClassification = eventData.StandardIndustryClassification, + LastUpdatedInCAS = eventData.LastUpdatedInCAS, + CorrelationId = eventData.CorrelationId, + CorrelationProvider = eventData.CorrelationProvider, + }; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs index b9286fee55..a3cca3fa53 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SiteAppService.cs @@ -40,6 +40,18 @@ public virtual async Task InsertAsync(SiteDto siteDto) { try { + // Guard against duplicates — if a site with the same Number already exists + // for this supplier, update it instead of creating a second row + var existing = (await siteRepository.GetBySupplierAsync(siteDto.SupplierId)) + .Find(s => s.Number == siteDto.Number); + + if (existing != null) + { + logger.LogWarning("Site with Number {Number} already exists for SupplierId {SupplierId}. Updating instead of inserting.", siteDto.Number, siteDto.SupplierId); + siteDto.Id = existing.Id; + return await UpdateAsync(siteDto); + } + Site site = new Site(siteDto); await siteRepository.InsertAsync(site, true); return site.Id; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js index 6e9b9346f5..b4a2d9f208 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/SupplierInfo/SupplierInfo.js @@ -212,6 +212,9 @@ $(function () { return; } + const $btn = $(this); + $btn.attr('disabled', 'disabled'); + const applicantId = $('#PaymentInfo_ApplicantId').val(); const applicationId = $('#PaymentInfoViewApplicationId').val() || ''; $.ajax({ @@ -222,6 +225,9 @@ $(function () { console.error('Error loading sites:', error); abp.notify.error('Failed to refresh sites'); }, + complete: function () { + $btn.removeAttr('disabled'); + }, }); }); } diff --git a/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md b/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md new file mode 100644 index 0000000000..f9218514d9 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/ApplicantElectoralUpdate.md @@ -0,0 +1,64 @@ +# Applicant Electoral District Update + +## Overview + +This process cross-references application electoral districts against the BC Geocoder API and generates SQL fix scripts for any mismatches. + +## Steps + +### 1. Extract Data from the Database + +Run `GetElectoralDistrictData.sql` against the database and save the results as a CSV file in the `data/` folder: + +``` +data/electoral_districts.csv +``` + +The query returns all applications joined with their applicant addresses, including `ApplicationId`, `ReferenceNo`, `ApplicantElectoralDistrict`, `Street`, `Street2`, `City`, and `AddressType`. + +### 2. Validate Electoral Districts + +Run `Validate-ElectoralDistricts.ps1` with the extracted CSV. This geocodes each unique address via the BC Geocoder API, looks up the electoral district from the WFS endpoint, and compares it to the value stored in the database. + +```powershell +.\Validate-ElectoralDistricts.ps1 ` + -InputCsv ".\data\electoral_districts.csv" ` + -GeocoderLocationBase "https://geocoder.api.gov.bc.ca" ` + -GeocoderApiBase "https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=" +``` + +This produces a validated CSV with match results: + +``` +data/electoral_districts_validated.csv +``` + +Each row includes the expected electoral district, geocoder score, and a `DistrictMatch` column (`MATCH`, `MISMATCH`, `UNKNOWN`, or `SKIPPED`). + +### 3. Generate SQL Fix Scripts + +Run `Generate-ElectoralDistrictFixes.ps1` against the validated CSV. This produces SQL UPDATE statements for mismatched rows. + +```powershell +.\Generate-ElectoralDistrictFixes.ps1 ` + -InputCsv ".\data\electoral_districts_validated.csv" ` + -MinScore 70 ` + -AddressType 1 ` + -IncludeLowConfidence +``` + +This generates two SQL scripts: + +| File | Description | +|------|-------------| +| `electoral_districts_validated_update.sql` | High-confidence updates (score >= 70) — sets the district to the expected value | +| `electoral_districts_validated_nullify.sql` | Low-confidence updates (score < 70) — sets the district to NULL | + +Both scripts use conditional updates that only apply when the current database value still matches the value at extract time, preventing overwrites of legitimate changes. + +### 4. Apply the Fix Scripts + +Review the generated SQL files, then execute them against the database: + +1. **Always apply the high-confidence script first** (`_update.sql`). +2. **Optionally apply the low-confidence script** (`_nullify.sql`) if you want to clear unreliable district values. diff --git a/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 new file mode 100644 index 0000000000..4dbe27756c --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Generate-ElectoralDistrictFixes.ps1 @@ -0,0 +1,198 @@ +<# +.SYNOPSIS + Generates PostgreSQL UPDATE statements to fix mismatched electoral districts. + +.DESCRIPTION + Reads the validated CSV output from Validate-ElectoralDistricts.ps1 and generates + SQL UPDATE statements for rows where: + - DistrictMatch is MISMATCH and GeocoderScore >= MinScore: SET to the expected district (main file) + - DistrictMatch is MISMATCH and GeocoderScore < MinScore: SET to NULL (separate file, opt-in) + - AddressType matches the specified type (1=Physical, 2=Mailing) + +.PARAMETER InputCsv + Path to the validated CSV file (output of Validate-ElectoralDistricts.ps1). + +.PARAMETER OutputSql + Path to the high-confidence output .sql file. Defaults to _update.sql. + +.PARAMETER MinScore + Minimum geocoder score to trust. Rows at or above get the expected district; + rows below get NULL (if -IncludeLowConfidence is set). Default: 70. + +.PARAMETER AddressType + AddressType to filter on. 1 = Physical, 2 = Mailing. Default: 1 (Physical). + +.PARAMETER IncludeLowConfidence + When set, generates a separate .sql file for low-confidence rows (score < MinScore) + that sets ApplicantElectoralDistrict to NULL. File is _nullify.sql. + +.EXAMPLE + .\Generate-ElectoralDistrictFixes.ps1 ` + -InputCsv ".\data\electoral_districts_validated.csv" ` + -MinScore 70 ` + -AddressType 1 ` + -IncludeLowConfidence +#> +param( + [Parameter(Mandatory)] + [string]$InputCsv, + + [string]$OutputSql = "", + + [int]$MinScore = 70, + + [ValidateSet("1", "2")] + [string]$AddressType = "1", + + [switch]$IncludeLowConfidence +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ── Output path defaults ────────────────────────────────────────────── +$dir = [System.IO.Path]::GetDirectoryName((Resolve-Path $InputCsv)) +$name = [System.IO.Path]::GetFileNameWithoutExtension($InputCsv) + +if (-not $OutputSql) { + $OutputSql = Join-Path $dir "${name}_update.sql" +} +$NullifySql = Join-Path $dir "${name}_nullify.sql" + +# ── Read CSV ────────────────────────────────────────────────────────── +$data = Import-Csv -Path $InputCsv +Write-Host "Loaded $($data.Count) rows from $InputCsv" -ForegroundColor Cyan + +# ── Resolve address type label ──────────────────────────────────────── +$addressTypeLabel = switch ($AddressType) { + "1" { "Physical" } + "2" { "Mailing" } +} +Write-Host "Filtering for AddressType: $AddressType ($addressTypeLabel)" -ForegroundColor Cyan + +# ── Filter all mismatches for this address type ────────────────────── +$allMismatches = @($data | Where-Object { + $_.DistrictMatch -eq "MISMATCH" -and + $_.GeocoderScore -ne "" -and + $_.AddressType -eq $AddressType +}) + +# Split into high-confidence (update to expected) and low-confidence (set to NULL) +$highConfidence = @($allMismatches | Where-Object { [int]$_.GeocoderScore -ge $MinScore }) +$lowConfidence = @($allMismatches | Where-Object { [int]$_.GeocoderScore -lt $MinScore }) + +Write-Host "Found $($allMismatches.Count) total MISMATCH rows for AddressType = $AddressType ($addressTypeLabel)" -ForegroundColor Cyan +Write-Host " High confidence (score >= $MinScore): $($highConfidence.Count) -> will SET to expected district" -ForegroundColor Green +$lowConfMsg = if ($IncludeLowConfidence) { " -> will SET to NULL (separate file)" } else { " (skipped, use -IncludeLowConfidence to generate)" } +Write-Host " Low confidence (score < $MinScore): $($lowConfidence.Count)$lowConfMsg" -ForegroundColor Yellow + +if ($highConfidence.Count -eq 0 -and (-not $IncludeLowConfidence -or $lowConfidence.Count -eq 0)) { + Write-Host "No rows to update. Exiting." -ForegroundColor Yellow + return +} + +# ── Helper ─────────────────────────────────────────────────────────── +function Escape-SqlString { + param([string]$value) + return $value.Replace("'", "''") +} + +# ── Generate high-confidence SQL ───────────────────────────────────── +if ($highConfidence.Count -gt 0) { + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine("-- Electoral District Fix Script (High Confidence)") + [void]$sb.AppendLine("-- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + [void]$sb.AppendLine("-- Source: $InputCsv") + [void]$sb.AppendLine("-- Filter: MISMATCH rows, AddressType = $AddressType ($addressTypeLabel), Score >= $MinScore") + [void]$sb.AppendLine("-- Total updates: $($highConfidence.Count)") + [void]$sb.AppendLine("") + [void]$sb.AppendLine("BEGIN;") + [void]$sb.AppendLine("") + + foreach ($row in $highConfidence) { + $appId = $row.ApplicationId.Trim() + $parsedGuid = [System.Guid]::Empty + if (-not [System.Guid]::TryParse($appId, [ref]$parsedGuid)) { + Write-Host " SKIPPED: Invalid ApplicationId '$appId' (ReferenceNo: $($row.ReferenceNo))" -ForegroundColor Red + continue + } + $appId = $parsedGuid.ToString() + $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim() + $expectedED = Escape-SqlString $row.ExpectedElectoralDistrict.Trim() + $score = $row.GeocoderScore + $refNo = $row.ReferenceNo + + [void]$sb.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> '$expectedED'") + [void]$sb.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = '$expectedED' WHERE ""Id"" = '$appId' AND ""ApplicantElectoralDistrict"" = '$currentED';") + [void]$sb.AppendLine("") + } + + [void]$sb.AppendLine("COMMIT;") + $sb.ToString() | Out-File -FilePath $OutputSql -Encoding UTF8 + Write-Host "`nHigh-confidence SQL written to: $OutputSql" -ForegroundColor Green +} +else { + Write-Host "`nNo high-confidence rows to write." -ForegroundColor DarkGray +} + +# ── Generate low-confidence SQL (separate file, opt-in) ────────────── +if ($IncludeLowConfidence -and $lowConfidence.Count -gt 0) { + $sbNull = [System.Text.StringBuilder]::new() + [void]$sbNull.AppendLine("-- Electoral District Nullify Script (Low Confidence)") + [void]$sbNull.AppendLine("-- Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") + [void]$sbNull.AppendLine("-- Source: $InputCsv") + [void]$sbNull.AppendLine("-- Filter: MISMATCH rows, AddressType = $AddressType ($addressTypeLabel), Score < $MinScore") + [void]$sbNull.AppendLine("-- Total updates: $($lowConfidence.Count)") + [void]$sbNull.AppendLine("-- Action: SET ApplicantElectoralDistrict = NULL (unreliable geocoding)") + [void]$sbNull.AppendLine("") + [void]$sbNull.AppendLine("BEGIN;") + [void]$sbNull.AppendLine("") + + foreach ($row in $lowConfidence) { + $appId = $row.ApplicationId.Trim() + $parsedGuid = [System.Guid]::Empty + if (-not [System.Guid]::TryParse($appId, [ref]$parsedGuid)) { + Write-Host " SKIPPED: Invalid ApplicationId '$appId' (ReferenceNo: $($row.ReferenceNo))" -ForegroundColor Red + continue + } + $appId = $parsedGuid.ToString() + $currentED = Escape-SqlString $row.ApplicantElectoralDistrict.Trim() + $score = $row.GeocoderScore + $refNo = $row.ReferenceNo + + [void]$sbNull.AppendLine("-- ReferenceNo: $refNo | Score: $score | '$currentED' -> NULL") + [void]$sbNull.AppendLine("UPDATE ""Applications"" SET ""ApplicantElectoralDistrict"" = NULL WHERE ""Id"" = '$appId' AND ""ApplicantElectoralDistrict"" = '$currentED';") + [void]$sbNull.AppendLine("") + } + + [void]$sbNull.AppendLine("COMMIT;") + $sbNull.ToString() | Out-File -FilePath $NullifySql -Encoding UTF8 + Write-Host "Low-confidence SQL written to: $NullifySql" -ForegroundColor Yellow +} +elseif ($IncludeLowConfidence) { + Write-Host "`nNo low-confidence rows to write." -ForegroundColor DarkGray +} + +# ── Summary ─────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "=============================" -ForegroundColor White +Write-Host " SQL Generation Summary" -ForegroundColor White +Write-Host "=============================" -ForegroundColor White +Write-Host "Address type: $AddressType ($addressTypeLabel)" +Write-Host "Score threshold: $MinScore" +Write-Host "-----------------------------" +Write-Host "High confidence: $($highConfidence.Count) (SET to expected)" -ForegroundColor Green +Write-Host "Low confidence: $($lowConfidence.Count) (SET to NULL)" -ForegroundColor Yellow +Write-Host "Total mismatches: $($allMismatches.Count)" +Write-Host "-----------------------------" +if ($highConfidence.Count -gt 0) { + Write-Host "Update file: $OutputSql" -ForegroundColor White +} +if ($IncludeLowConfidence -and $lowConfidence.Count -gt 0) { + Write-Host "Nullify file: $NullifySql" -ForegroundColor White +} +elseif ($lowConfidence.Count -gt 0) { + Write-Host "Nullify file: (not generated - use -IncludeLowConfidence)" -ForegroundColor DarkGray +} +Write-Host "" +Write-Host "Review the SQL file(s), then execute against your PostgreSQL database." -ForegroundColor Yellow diff --git a/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql b/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql new file mode 100644 index 0000000000..1fa5aa3a0b --- /dev/null +++ b/applications/Unity.GrantManager/scripts/GetElectoralDistrictData.sql @@ -0,0 +1,12 @@ +SELECT ap."CreationTime", +ap."Id" as "ApplicationId", +ap."ReferenceNo", +ap."ApplicantElectoralDistrict", +ad."Id" as "AddressId", +ad."Street", +ad."Street2", +ad."City", +ad."AddressType" +FROM public."Applications" ap +LEFT JOIN "ApplicantAddresses" ad on ad."ApplicationId" = ap."Id" +ORDER BY ap."CreationTime" DESC \ No newline at end of file diff --git a/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 new file mode 100644 index 0000000000..5889bf4c2a --- /dev/null +++ b/applications/Unity.GrantManager/scripts/Validate-ElectoralDistricts.ps1 @@ -0,0 +1,310 @@ +<# +.SYNOPSIS + Cross-references electoral districts in a CSV against the BC Geocoder API. + +.DESCRIPTION + Reads a CSV with address data, looks up each unique address via the BC Geocoder + location API to get coordinates, then queries the electoral district WFS endpoint. + Deduplicates addresses so identical Street+Street2+City combinations are only + looked up once. Handles rate limiting with exponential backoff. + Outputs a new CSV with all original columns plus the expected electoral district. + +.PARAMETER InputCsv + Path to the input CSV file. + +.PARAMETER OutputCsv + Path to the output CSV file. Defaults to _validated.csv. + +.PARAMETER GeocoderLocationBase + Base URL for the geocoder location API (GEOCODER_LOCATION_API_BASE). + Example: https://geocoder.api.gov.bc.ca + +.PARAMETER GeocoderApiBase + Base URL for the geocoder WFS API (GEOCODER_API_BASE). + Example: https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName= + +.PARAMETER InitialDelayMs + Initial delay between API calls in milliseconds. Default: 250. + +.EXAMPLE + .\Validate-ElectoralDistricts.ps1 ` + -InputCsv ".\data\electoral_districts.csv" ` + -GeocoderLocationBase "https://geocoder.api.gov.bc.ca" ` + -GeocoderApiBase "https://openmaps.gov.bc.ca/geo/pub/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=" +#> +param( + [Parameter(Mandatory)] + [string]$InputCsv, + + [string]$OutputCsv = "", + + [Parameter(Mandatory)] + [string]$GeocoderLocationBase, + + [Parameter(Mandatory)] + [string]$GeocoderApiBase, + + [int]$InitialDelayMs = 100 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ── Output path defaults ────────────────────────────────────────────── +if (-not $OutputCsv) { + $dir = [System.IO.Path]::GetDirectoryName((Resolve-Path $InputCsv)) + $name = [System.IO.Path]::GetFileNameWithoutExtension($InputCsv) + $OutputCsv = Join-Path $dir "${name}_validated.csv" +} + +# ── Read CSV ────────────────────────────────────────────────────────── +$data = Import-Csv -Path $InputCsv +Write-Host "Loaded $($data.Count) rows from $InputCsv" -ForegroundColor Cyan + +# ── Helpers ─────────────────────────────────────────────────────────── +function Test-NullOrEmpty { + param([string]$value) + return [string]::IsNullOrWhiteSpace($value) -or $value.Trim() -eq 'NULL' +} + +function Get-CleanValue { + param([string]$value) + if (Test-NullOrEmpty $value) { return "" } + return $value.Trim() +} + +function Get-AddressKey { + param($row) + $street = Get-CleanValue $row.Street + $street2 = Get-CleanValue $row.Street2 + $city = Get-CleanValue $row.City + return "$street|$street2|$city".ToLowerInvariant() +} + +function Build-AddressString { + param($street, $street2, $city) + $parts = @($street, $street2, $city) | ForEach-Object { Get-CleanValue $_ } | Where-Object { $_ -ne "" } + return ($parts -join ", ") +} + +function Get-AddressTypeName { + param([string]$value) + $clean = Get-CleanValue $value + switch ($clean) { + "1" { return "Physical" } + "2" { return "Mailing" } + default { return "" } + } +} + +# ── Deduplicate addresses ──────────────────────────────────────────── +$addressLookup = [ordered]@{} +$skippedCount = 0 + +foreach ($row in $data) { + $street = Get-CleanValue $row.Street + $street2 = Get-CleanValue $row.Street2 + $city = Get-CleanValue $row.City + + # Skip rows where both street fields and city are empty/NULL + if ($street -eq "" -and $street2 -eq "" -and $city -eq "") { + $skippedCount++ + continue + } + + $key = Get-AddressKey $row + if (-not $addressLookup.Contains($key)) { + $addressLookup[$key] = @{ + Street = $row.Street + Street2 = $row.Street2 + City = $row.City + ExpectedED = $null + Score = $null + FullAddress = $null + Error = $null + } + } +} + +$uniqueCount = $addressLookup.Count +Write-Host "Unique addresses to look up: $uniqueCount (skipped $skippedCount rows with empty address)" -ForegroundColor Cyan + +# ── Rate-limited HTTP caller with exponential backoff ───────────────── +$script:currentDelayMs = $InitialDelayMs +$maxDelayMs = 30000 +$minDelayMs = 100 + +function Invoke-GeocoderRequest { + param( + [string]$Uri, + [int]$MaxRetries = 6 + ) + + for ($attempt = 1; $attempt -le ($MaxRetries + 1); $attempt++) { + try { + $response = Invoke-RestMethod -Uri $Uri -Method Get -ErrorAction Stop + + # On success, gently reduce the inter-call delay + $script:currentDelayMs = [Math]::Max($minDelayMs, [int]([Math]::Floor($script:currentDelayMs * 0.95))) + + return $response + } + catch { + $statusCode = 0 + if ($_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + + if (($statusCode -eq 429 -or $statusCode -eq 503) -and $attempt -le $MaxRetries) { + # Exponential backoff + $script:currentDelayMs = [Math]::Min($maxDelayMs, $script:currentDelayMs * 2) + Write-Host " Rate limited (HTTP $statusCode). Backing off $($script:currentDelayMs)ms (attempt $attempt/$MaxRetries)..." -ForegroundColor Yellow + Start-Sleep -Milliseconds $script:currentDelayMs + } + else { + throw + } + } + } +} + +# ── Process each unique address ─────────────────────────────────────── +$processed = 0 +$errorCount = 0 + +# Electoral district WFS parameters (from appsettings.json Geocoder:ElectoralDistrict) +$edFeature = "pub:WHSE_ADMIN_BOUNDARIES.EBC_PROV_ELECTORAL_DIST_SVW" +$edProperty = "ED_NAME" +$edQueryType = "SHAPE" + +foreach ($entry in $addressLookup.GetEnumerator()) { + $processed++ + $addr = $entry.Value + $addressString = Build-AddressString $addr.Street $addr.Street2 $addr.City + + Write-Host "[$processed/$uniqueCount] $addressString" -ForegroundColor Cyan + + try { + # Step 1: Geocode address → coordinates + $encodedAddress = [System.Uri]::EscapeDataString($addressString) + $locationUri = "$GeocoderLocationBase/addresses.json?outputSRS=3005&addressString=$encodedAddress" + + Start-Sleep -Milliseconds $script:currentDelayMs + $locationResult = Invoke-GeocoderRequest -Uri $locationUri + + if (-not $locationResult.features -or $locationResult.features.Count -eq 0) { + Write-Host " No location results" -ForegroundColor Yellow + $addr.Error = "No location results" + $errorCount++ + continue + } + + $coords = $locationResult.features[0].geometry.coordinates + $coordX = $coords[0] # EPSG:3005 easting (mirrors C# ResultMapper: coordinates[0]) + $coordY = $coords[1] # EPSG:3005 northing (mirrors C# ResultMapper: coordinates[1]) + $score = $locationResult.features[0].properties.score + $fullAddr = $locationResult.features[0].properties.fullAddress + + $addr.Score = $score + $addr.FullAddress = $fullAddr + Write-Host " Resolved: $fullAddr (score: $score)" -ForegroundColor DarkGray + + # Step 2: Look up electoral district from coordinates + $edUri = "${GeocoderApiBase}${edFeature}" + + "&srsname=EPSG:3005" + + "&propertyName=${edProperty}" + + "&outputFormat=application/json" + + "&cql_filter=INTERSECTS(${edQueryType},POINT($coordX $coordY))" + + Start-Sleep -Milliseconds $script:currentDelayMs + $edResult = Invoke-GeocoderRequest -Uri $edUri + + if (-not $edResult.features -or $edResult.features.Count -eq 0) { + Write-Host " No electoral district found for coordinates" -ForegroundColor Yellow + $addr.ExpectedED = "" + $addr.Error = "No electoral district for coordinates ($coordX, $coordY)" + $errorCount++ + continue + } + + $expectedED = $edResult.features[0].properties.ED_NAME + $addr.ExpectedED = $expectedED + Write-Host " Electoral District: $expectedED" -ForegroundColor Green + } + catch { + Write-Host " ERROR: $_" -ForegroundColor Red + $addr.Error = $_.ToString() + $errorCount++ + } +} + +# ── Build output CSV ────────────────────────────────────────────────── +Write-Host "`nBuilding output CSV..." -ForegroundColor Cyan + +$output = [System.Collections.Generic.List[PSCustomObject]]::new() + +foreach ($row in $data) { + $key = Get-AddressKey $row + + $expectedED = "" + $lookupError = "" + $geoScore = "" + $geoFullAddr = "" + $matchResult = "SKIPPED" + + if ($addressLookup.Contains($key)) { + $lookup = $addressLookup[$key] + $expectedED = if ($lookup.ExpectedED) { $lookup.ExpectedED } else { "" } + $lookupError = if ($lookup.Error) { $lookup.Error } else { "" } + $geoScore = if ($lookup.Score -ne $null) { $lookup.Score } else { "" } + $geoFullAddr = if ($lookup.FullAddress) { $lookup.FullAddress } else { "" } + + if ($expectedED -and $row.ApplicantElectoralDistrict) { + if ($expectedED.Trim() -eq $row.ApplicantElectoralDistrict.Trim()) { + $matchResult = "MATCH" + } + else { + $matchResult = "MISMATCH" + } + } + else { + $matchResult = "UNKNOWN" + } + } + + $outRow = [ordered]@{} + foreach ($prop in $row.PSObject.Properties) { + $outRow[$prop.Name] = $prop.Value + } + $outRow["AddressTypeName"] = Get-AddressTypeName $row.AddressType + $outRow["ExpectedElectoralDistrict"] = $expectedED + $outRow["DistrictMatch"] = $matchResult + $outRow["GeocoderScore"] = $geoScore + $outRow["GeocoderFullAddress"] = $geoFullAddr + $outRow["LookupError"] = $lookupError + + $output.Add([PSCustomObject]$outRow) +} + +$output | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8 + +# ── Summary ─────────────────────────────────────────────────────────── +$matchCount = @($output | Where-Object { $_.DistrictMatch -eq "MATCH" }).Count +$mismatchCount = @($output | Where-Object { $_.DistrictMatch -eq "MISMATCH" }).Count +$unknownCount = @($output | Where-Object { $_.DistrictMatch -eq "UNKNOWN" }).Count +$skippedRows = @($output | Where-Object { $_.DistrictMatch -eq "SKIPPED" }).Count + +Write-Host "" +Write-Host "=============================" -ForegroundColor White +Write-Host " Electoral District Audit" -ForegroundColor White +Write-Host "=============================" -ForegroundColor White +Write-Host "Total rows: $($data.Count)" +Write-Host "Unique addresses: $uniqueCount" +Write-Host "API errors: $errorCount" -ForegroundColor $(if ($errorCount -gt 0) { "Red" } else { "Green" }) +Write-Host "-----------------------------" +Write-Host "MATCH: $matchCount" -ForegroundColor Green +Write-Host "MISMATCH: $mismatchCount" -ForegroundColor $(if ($mismatchCount -gt 0) { "Red" } else { "Green" }) +Write-Host "UNKNOWN: $unknownCount" -ForegroundColor Yellow +Write-Host "SKIPPED: $skippedRows" -ForegroundColor DarkGray +Write-Host "-----------------------------" +Write-Host "Output: $OutputCsv" -ForegroundColor White diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs index e6ea8baa78..9de866e604 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs @@ -9,8 +9,8 @@ public class CreateUpdateFundingHistoryDto public string? FundingYear { get; set; } public bool? RenewedFunding { get; set; } public decimal? ApprovedAmount { get; set; } + public decimal? OneTimeConsideration { get; set; } public decimal? ReconsiderationAmount { get; set; } - public decimal? OneTimeConsideration { get; set; } public decimal? TotalGrantAmount { get; set; } public string? FundingNotes { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs index 7c5660dbd8..dfdf5afaad 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs @@ -10,8 +10,8 @@ public class FundingHistoryDto : AuditedEntityDto public string? FundingYear { get; set; } public bool? RenewedFunding { get; set; } public decimal? ApprovedAmount { get; set; } + public decimal? OneTimeConsideration { get; set; } public decimal? ReconsiderationAmount { get; set; } - public decimal? OneTimeConsideration { get; set; } public decimal? TotalGrantAmount { get; set; } public string? FundingNotes { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs index fc73e7f825..973cc86a38 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -5,6 +5,7 @@ using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.Applications; using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Codes; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; @@ -47,16 +48,22 @@ from submission in submissionsQuery join application in applicationsQuery on submission.ApplicationId equals application.Id where submission.OidcSub == normalizedSubject select new { application.Id, application.ReferenceNo } - ).Distinct().ToDictionaryAsync(a => a.Id, a => a.ReferenceNo); + ) + .Distinct() + .ToDictionaryAsync(a => a.Id, a => a.ReferenceNo); if (applicationLookup.Count == 0) return dto; // Payment info is secured via feature flags and permissions, so direct query for this data instead of using module service var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); +#pragma warning disable CA1862 // EF Core does not support StringComparison overloads - https://github.com/dotnet/efcore/issues/1222 var paymentDetails = await paymentsQueryable - .Where(pr => applicationLookup.Keys.Contains(pr.CorrelationId)) + .Where(pr => applicationLookup.Keys.Contains(pr.CorrelationId) + && pr.PaymentStatus != null + && pr.PaymentStatus.Trim().ToUpper() == CasPaymentRequestStatus.FullyPaid.ToUpper()) .ToListAsync(); +#pragma warning restore CA1862 dto.Payments.AddRange(paymentDetails.Select(p => new PaymentInfoItemDto { @@ -65,7 +72,7 @@ join application in applicationsQuery on submission.ApplicationId equals applica ReferenceNo = applicationLookup.TryGetValue(p.CorrelationId, out var refNo) ? refNo : string.Empty, Amount = p.Amount, PaymentDate = p.PaymentDate, - PaymentStatus = p.Status.ToString() + PaymentStatus = CasPaymentRequestStatus.FullyPaid })); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs index f757747e49..f1d92723ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/DetermineElectoralDistrictHandler.cs @@ -91,12 +91,35 @@ public async Task DetermineElectoralDistrictAsync(Application? application, Appl } // Extract from geo services - var address = matchedAddressType.GetFullAddress(); + var address = matchedAddressType.GetSearchAddress(); var geoAddressDetails = await geocoderApiService.GetAddressDetailsAsync(address); if (geoAddressDetails == null || geoAddressDetails.Coordinates == null) { - logger.LogWarning("No coordinates found for address: {Address}", address); + logger.LogWarning("No coordinates found for address: {Address} for application {ApplicationId}.", + address, application.Id); + return; + } + + logger.LogInformation( + "Geocoder resolved address for application {ApplicationId}: " + + "Input={Address}, Resolved={ResolvedAddress}, Score={GeocoderScore}, " + + "Coordinates=({Latitude}, {Longitude})", + application.Id, + address, + geoAddressDetails.FullAddress, + geoAddressDetails.Score, + geoAddressDetails.Coordinates.Latitude, + geoAddressDetails.Coordinates.Longitude); + + if (geoAddressDetails.Score < 60) + { + application.ApplicantElectoralDistrict = null; + logger.LogWarning( + "Low geocoder confidence score {GeocoderScore} for application {ApplicationId}. " + + "Input={Address}, Resolved={ResolvedAddress}. " + + "Electoral district set to null due to unreliable geocoding.", + geoAddressDetails.Score, application.Id, address, geoAddressDetails.FullAddress); return; } @@ -105,12 +128,17 @@ public async Task DetermineElectoralDistrictAsync(Application? application, Appl if (electoralDistrict.Name != null) { application.ApplicantElectoralDistrict = electoralDistrict.Name; - logger.LogInformation("Electoral district '{ElectoralDistrict}' determined for address: {Address}", - electoralDistrict.Name, address); + logger.LogInformation( + "Electoral district '{ElectoralDistrict}' determined for application {ApplicationId}. " + + "Address={Address}, GeocoderScore={GeocoderScore}", + electoralDistrict.Name, application.Id, address, geoAddressDetails.Score); } else { - logger.LogWarning("Electoral district could not be determined for address: {Address}", address); + logger.LogWarning( + "Electoral district could not be determined for application {ApplicationId}. " + + "Address={Address}, GeocoderScore={GeocoderScore}", + application.Id, address, geoAddressDetails.Score); } } catch (Exception ex) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs index f82c83afa4..d06415e6ca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicantAddress.cs @@ -42,19 +42,15 @@ public virtual Application Application public Guid? TenantId { get; set; } /// - /// Returns the address as a single comma-separated string, ordered by relevance. + /// Returns a search-friendly address string (Street, Street2, City) for geocoding lookups. /// - public string GetFullAddress() + public string GetSearchAddress() { var parts = new[] { Street, Street2, - Unit, - City, - Province, - Postal, - Country + City }; var address = string.Join(", ", parts diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/FundingHistory.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/FundingHistory.cs index 9925c356ce..5a601ac7c8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/FundingHistory.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/FundingHistory.cs @@ -11,8 +11,8 @@ public class FundingHistory : AuditedAggregateRoot, IMultiTenant public string? FundingYear { get; set; } public bool? RenewedFunding { get; set; } public decimal? ApprovedAmount { get; set; } + public decimal? OneTimeConsideration { get; set; } public decimal? ReconsiderationAmount { get; set; } - public decimal? OneTimeConsideration { get; set; } public decimal? TotalGrantAmount { get; set; } public string? FundingNotes { get; set; } public Guid? TenantId { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/CreateFundingHistoryModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/CreateFundingHistoryModal.cshtml index bfda1ec3f2..6db3da1854 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/CreateFundingHistoryModal.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/CreateFundingHistoryModal.cshtml @@ -49,21 +49,22 @@
$ - - One-Time Consideration + + value="@Model.FundingHistoryForm?.OneTimeConsideration" />
+
$ - - Reconsideration Amount + + value="@Model.FundingHistoryForm?.ReconsiderationAmount" />
-
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml index 4c50b48d8f..983d0d4cef 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml @@ -50,21 +50,23 @@
$ - - One-Time Consideration + + value="@Model.FundingHistoryForm?.OneTimeConsideration" />
-
+
+
$ - - Reconsideration Amount + + value="@Model.FundingHistoryForm?.ReconsiderationAmount" />
+
$ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml.cs index 6685e83806..6abe31a19c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/EditFundingHistoryModal.cshtml.cs @@ -32,8 +32,8 @@ public async Task OnGetAsync(Guid id) FundingYear = record.FundingYear, RenewedFunding = record.RenewedFunding, ApprovedAmount = record.ApprovedAmount, - ReconsiderationAmount = record.ReconsiderationAmount, - OneTimeConsideration = record.OneTimeConsideration, + OneTimeConsideration = record.OneTimeConsideration, + ReconsiderationAmount = record.ReconsiderationAmount, TotalGrantAmount = record.TotalGrantAmount, FundingNotes = record.FundingNotes }; @@ -48,8 +48,8 @@ public async Task OnPostAsync() FundingYear = FundingHistoryForm.FundingYear, RenewedFunding = FundingHistoryForm.RenewedFunding, ApprovedAmount = FundingHistoryForm.ApprovedAmount, + OneTimeConsideration = FundingHistoryForm.OneTimeConsideration, ReconsiderationAmount = FundingHistoryForm.ReconsiderationAmount, - OneTimeConsideration = FundingHistoryForm.OneTimeConsideration, TotalGrantAmount = FundingHistoryForm.TotalGrantAmount, FundingNotes = FundingHistoryForm.FundingNotes }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/FundingHistoryModalViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/FundingHistoryModalViewModel.cs index 111f994821..814b9ed8f5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/FundingHistoryModalViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicantHistory/FundingHistoryModalViewModel.cs @@ -23,13 +23,13 @@ public class FundingHistoryModalViewModel [DataType(DataType.Currency)] public decimal? ApprovedAmount { get; set; } - [DisplayName("Reconsideration Amount")] + [DisplayName("One-Time Consideration")] [DataType(DataType.Currency)] - public decimal? ReconsiderationAmount { get; set; } + public decimal? OneTimeConsideration { get; set; } - [DisplayName("One-Time Consideration")] + [DisplayName("Reconsideration Amount")] [DataType(DataType.Currency)] - public decimal? OneTimeConsideration { get; set; } + public decimal? ReconsiderationAmount { get; set; } [DisplayName("Total Grant Amount")] [DataType(DataType.Currency)] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js index 507ae4c905..61450ef2d4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js @@ -46,8 +46,8 @@ $(function () { { title: 'Funding Year', data: 'fundingYear', name: 'fundingYear', className: 'data-table-header', width: '80px', render: (d) => d ?? nullPlaceholder }, { title: 'Renewed Funding', data: 'renewedFunding', name: 'renewedFunding', className: 'data-table-header', width: '110px', render: (d) => d === true ? 'Yes' : 'No' }, { title: 'Approved Amount', data: 'approvedAmount', name: 'approvedAmount', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, - { title: 'Reconsideration Amount', data: 'reconsiderationAmount', name: 'reconsiderationAmount', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, { title: 'One-Time Consideration', data: 'oneTimeConsideration', name: 'oneTimeConsideration', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, + { title: 'Reconsideration Amount', data: 'reconsiderationAmount', name: 'reconsiderationAmount', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, { title: 'Total Grant Amount', data: 'totalGrantAmount', name: 'totalGrantAmount', className: 'data-table-header currency-display', render: (d) => formatCurrency(d) }, { title: 'Notes', data: 'fundingNotes', name: 'fundingNotes', className: 'data-table-header', width: '200px', diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs index c3429dc54e..a5e8872213 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/PaymentInfoDataProviderTests.cs @@ -8,8 +8,8 @@ using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.Applications; using Unity.GrantManager.TestHelpers; +using Unity.Payments.Codes; using Unity.Payments.Domain.PaymentRequests; -using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; @@ -84,7 +84,7 @@ private static Application CreateApplication(Guid id, string referenceNo = "") return entity; } - private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m, string invoiceNumber = "INV-001") + private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal amount = 1000m, string invoiceNumber = "INV-001", string? paymentStatus = CasPaymentRequestStatus.FullyPaid) { var siteId = Guid.NewGuid(); var dto = new CreatePaymentRequestDto @@ -99,7 +99,12 @@ private static PaymentRequest CreatePaymentRequest(Guid correlationId, decimal a CorrelationId = correlationId, CorrelationProvider = "Application" }; - return new PaymentRequest(Guid.NewGuid(), dto); + var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto); + if (paymentStatus is not null) + { + paymentRequest.SetPaymentStatus(paymentStatus); + } + return paymentRequest; } [Fact] @@ -170,7 +175,6 @@ public async Task GetDataAsync_ShouldMapPaymentFields() var payment = CreatePaymentRequest(applicationId, 5000m); payment.SetPaymentDate("15-Jan-2025"); - payment.SetPaymentRequestStatus(PaymentRequestStatus.Paid); SetupQueryables( [CreateSubmission(applicationId, "TESTUSER")], @@ -187,7 +191,7 @@ public async Task GetDataAsync_ShouldMapPaymentFields() item.ReferenceNo.ShouldBe("REF-001"); item.Amount.ShouldBe(5000m); item.PaymentDate.ShouldBe("2025-01-15"); - item.PaymentStatus.ShouldBe("Paid"); + item.PaymentStatus.ShouldBe(CasPaymentRequestStatus.FullyPaid); } [Fact] @@ -294,28 +298,55 @@ public async Task GetDataAsync_ShouldHandleEmptyInvoiceNumber() dto.Payments[0].PaymentNumber.ShouldBe(string.Empty); } - [Theory] - [InlineData(PaymentRequestStatus.L1Pending, "L1Pending")] - [InlineData(PaymentRequestStatus.Submitted, "Submitted")] - [InlineData(PaymentRequestStatus.Paid, "Paid")] - [InlineData(PaymentRequestStatus.Failed, "Failed")] - public async Task GetDataAsync_ShouldMapPaymentStatus(PaymentRequestStatus status, string expectedStatusString) + [Fact] + public async Task GetDataAsync_ShouldOnlyReturnPaidPayments() { var request = CreateRequest(); var applicationId = Guid.NewGuid(); - var payment = CreatePaymentRequest(applicationId); - payment.SetPaymentRequestStatus(status); + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, "REF-001")], + [ + CreatePaymentRequest(applicationId, 1000m, paymentStatus: CasPaymentRequestStatus.FullyPaid), + CreatePaymentRequest(applicationId, 2000m, paymentStatus: null), + CreatePaymentRequest(applicationId, 3000m, paymentStatus: CasPaymentRequestStatus.NotPaid), + CreatePaymentRequest(applicationId, 4000m, paymentStatus: CasPaymentRequestStatus.ErrorFromCas) + ]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Payments.Count.ShouldBe(1); + dto.Payments[0].Amount.ShouldBe(1000m); + } + + [Theory] + [InlineData("FULLY PAID")] + [InlineData("fully paid")] + [InlineData("Fully Paid")] + [InlineData(" Fully Paid ")] + [InlineData(" FULLY PAID ")] + public async Task GetDataAsync_ShouldMatchFullyPaidCaseInsensitiveWithWhitespace(string paymentStatus) + { + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); SetupQueryables( [CreateSubmission(applicationId, "TESTUSER")], [CreateApplication(applicationId, "REF-001")], - [payment]); + [ + CreatePaymentRequest(applicationId, 1500m, paymentStatus: paymentStatus), + CreatePaymentRequest(applicationId, 2000m, paymentStatus: null), + CreatePaymentRequest(applicationId, 3000m, paymentStatus: CasPaymentRequestStatus.NotPaid) + ]); var result = await _provider.GetDataAsync(request); var dto = result.ShouldBeOfType(); - dto.Payments[0].PaymentStatus.ShouldBe(expectedStatusString); + dto.Payments.Count.ShouldBe(1); + dto.Payments[0].Amount.ShouldBe(1500m); + dto.Payments[0].PaymentStatus.ShouldBe(CasPaymentRequestStatus.FullyPaid); } [Fact] diff --git a/documentation/applicant-portal/applicant-profile-data-providers.md b/documentation/applicant-portal/applicant-profile-data-providers.md index 8cc4eafa7d..75dcabc94b 100644 --- a/documentation/applicant-portal/applicant-profile-data-providers.md +++ b/documentation/applicant-portal/applicant-profile-data-providers.md @@ -422,7 +422,7 @@ flowchart LR **Source**: `PaymentRequest` entity (from `Unity.Payments` module), linked via `ApplicationFormSubmission` → `Application` where `PaymentRequest.CorrelationId` matches the application ID. -**Query**: Normalizes the OIDC subject, then joins `ApplicationFormSubmission` → `Application` to build a lookup of `ApplicationId → ReferenceNo`. Payment requests whose `CorrelationId` is in that set are returned with the application's `ReferenceNo` resolved from the lookup. +**Query**: Normalizes the OIDC subject, then joins `ApplicationFormSubmission` → `Application` to build a lookup of `ApplicationId → ReferenceNo`. Payment requests whose `CorrelationId` is in that set **and** whose CAS `PaymentStatus` is `"Fully Paid"` are returned with the application's `ReferenceNo` resolved from the lookup. **Response DTO**: `ApplicantPaymentInfoDto` @@ -436,7 +436,7 @@ flowchart LR "referenceNo": "REF-001", "amount": 5000.00, "paymentDate": "2025-01-15", - "paymentStatus": "Paid" + "paymentStatus": "Fully Paid" } ] } @@ -451,7 +451,7 @@ flowchart LR | `ReferenceNo` | `Application.ReferenceNo` | `string` | Application reference number, resolved via `CorrelationId → Application` lookup | | `Amount` | `PaymentRequest.Amount` | `decimal` | Requested payment amount | | `PaymentDate` | `PaymentRequest.PaymentDate` | `string?` | Date string populated during CAS reconciliation | -| `PaymentStatus` | `PaymentRequest.Status` | `string` | Enum converted to string (e.g. `L1Pending`, `Submitted`, `Paid`, `Failed`) | +| `PaymentStatus` | Constant `"Fully Paid"` | `string` | Always `"Fully Paid"` — only payments with CAS `PaymentStatus` of `"Fully Paid"` are returned | **Cross-module note**: This provider queries the `PaymentRequest` entity directly from the `Unity.Payments` module via `IRepository`. The `CorrelationId` on `PaymentRequest` corresponds to the `Application.Id` in the grant manager domain.