From 6ae65ee98c8dfeb905aa2ec2e0392b0e2b919c96 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Fri, 6 Feb 2026 15:27:49 -0800 Subject: [PATCH 01/36] AB#31824: Dont delete supplier --- .../Suppliers/ISupplierAppService.cs | 1 - .../Suppliers/SupplierAppService.cs | 5 ----- .../Applicants/ApplicantSupplierAppService.cs | 8 +------- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs index d173f9b2f..0bd0c2ded 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs @@ -15,7 +15,6 @@ public interface ISupplierAppService : IApplicationService Task CreateSiteAsync(Guid id, CreateSiteDto createSiteDto); Task UpdateSiteAsync(Guid id, Guid siteId, UpdateSiteDto updateSiteDto); Task GetSitesBySupplierNumberAsync(string? supplierNumber, Guid applicantId, Guid? applicationId = null); - Task DeleteAsync(Guid id); SiteDto GetSiteDtoFromSiteEto(SiteEto siteEto, Guid supplierId, PaymentGroup? defaultPaymentGroup = null); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs index 9b6b1bf2c..dbe859730 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs @@ -252,11 +252,6 @@ public virtual async Task UpdateSiteAsync(Guid id, Guid siteId, UpdateS return ObjectMapper.Map(updateSupplier.Sites.First(s => s.Id == siteId)); } - public virtual async Task DeleteAsync(Guid id) - { - await supplierRepository.DeleteAsync(id); - } - public SiteDto GetSiteDtoFromSiteEto(SiteEto siteEto, Guid supplierId, PaymentGroup? defaultPaymentGroup = null) { var resolvedPaymentGroup = defaultPaymentGroup ?? PaymentGroup.EFT; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs index c29c34ed6..1bdbfbbf9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs @@ -74,18 +74,12 @@ public async Task ClearApplicantSupplierAsync(Guid applicantId) { await applicantRepository.EnsureExistsAsync(applicantId); - var applicant = await applicantRepository.GetAsync(applicantId); - var supplierId = applicant.SupplierId; // Store the supplier ID before clearing + var applicant = await applicantRepository.GetAsync(applicantId); // Clear the applicant references first applicant.SupplierId = null; applicant.SiteId = null; await applicantRepository.UpdateAsync(applicant); - - if (supplierId.HasValue) - { - await supplierAppService.DeleteAsync(supplierId.Value); - } } } From 65c95994e7d9dfc46ce5dfac61dca5850dfaadb2 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Mon, 9 Feb 2026 12:32:30 -0800 Subject: [PATCH 02/36] AB#31824: Deassociate Applicant from Supplier --- .../Suppliers/ISupplierAppService.cs | 1 + .../Suppliers/SupplierAppService.cs | 8 +++ .../Applicants/ApplicantSupplierAppService.cs | 56 +++++++++++++------ 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs index 0bd0c2ded..49ec8b4b0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs @@ -16,5 +16,6 @@ public interface ISupplierAppService : IApplicationService Task UpdateSiteAsync(Guid id, Guid siteId, UpdateSiteDto updateSiteDto); Task GetSitesBySupplierNumberAsync(string? supplierNumber, Guid applicantId, Guid? applicationId = null); SiteDto GetSiteDtoFromSiteEto(SiteEto siteEto, Guid supplierId, PaymentGroup? defaultPaymentGroup = null); + Task ClearCorrelationAsync(Guid supplierId); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs index dbe859730..cddee30a7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs @@ -277,6 +277,14 @@ public SiteDto GetSiteDtoFromSiteEto(SiteEto siteEto, Guid supplierId, PaymentGr }; } + public async Task ClearCorrelationAsync(Guid supplierId) + { + var supplier = await supplierRepository.GetAsync(supplierId); + supplier.CorrelationId = Guid.Empty; + supplier.CorrelationProvider = string.Empty; + await supplierRepository.UpdateAsync(supplier); + } + private async Task ResolveDefaultPaymentGroupForApplicantAsync(Guid applicantId, Guid? applicationId = null) { const PaymentGroup fallbackPaymentGroup = PaymentGroup.EFT; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs index 1bdbfbbf9..06b7871c6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs @@ -43,11 +43,11 @@ public async Task GetSupplierByBusinessNumber(string bn9) /// Update the supplier number for the applicant regardless of application. /// [Authorize(UnitySelector.Payment.Supplier.Update)] - public async Task UpdateApplicantSupplierNumberAsync(Guid applicantId, string supplierNumber, Guid? applicationId = null) - { - if (await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature)) - { - await applicantRepository.EnsureExistsAsync(applicantId); + public async Task UpdateApplicantSupplierNumberAsync(Guid applicantId, string supplierNumber, Guid? applicationId = null) + { + if (await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature)) + { + await applicantRepository.EnsureExistsAsync(applicantId); // Handle clearing supplier information if (string.IsNullOrEmpty(supplierNumber)) @@ -58,14 +58,14 @@ public async Task UpdateApplicantSupplierNumberAsync(Guid applicantId, string su var supplier = await GetSupplierByApplicantIdAsync(applicantId); - if (supplier != null && string.Compare(supplierNumber, supplier?.Number, true) == 0) - { - return; // No change in supplier number, so no action needed - } - - await supplierService.UpdateApplicantSupplierInfo(supplierNumber, applicantId, applicationId); - } - } + if (supplier != null && string.Compare(supplierNumber, supplier?.Number, true) == 0) + { + return; // No change in supplier number, so no action needed + } + + await supplierService.UpdateApplicantSupplierInfo(supplierNumber, applicantId, applicationId); + } + } [Authorize(UnitySelector.Payment.Supplier.Update)] public async Task ClearApplicantSupplierAsync(Guid applicantId) @@ -74,12 +74,36 @@ public async Task ClearApplicantSupplierAsync(Guid applicantId) { await applicantRepository.EnsureExistsAsync(applicantId); - var applicant = await applicantRepository.GetAsync(applicantId); - + var applicant = await applicantRepository.GetAsync(applicantId); + var supplierId = applicant.SupplierId; + // Clear the applicant references first applicant.SupplierId = null; applicant.SiteId = null; await applicantRepository.UpdateAsync(applicant); + + if (supplierId.HasValue) + { + await supplierAppService.ClearCorrelationAsync(supplierId.Value); + } + else + { + // Handle existing data where SupplierId was already cleared + // but the supplier's correlation was never removed + var supplier = await supplierAppService.GetByCorrelationAsync( + new GetSupplierByCorrelationDto() + { + CorrelationId = applicantId, + CorrelationProvider = CorrelationConsts.Applicant, + IncludeDetails = false + }); + + if (supplier != null) + { + await supplierAppService.ClearCorrelationAsync(supplier.Id); + } + } + } } @@ -117,4 +141,4 @@ public async Task DefaultApplicantSite(Guid applicantId, Guid siteId) IncludeDetails = true }); } -} +} From 5c258617a25d20f4db6b9a8f76df02d34f44d590 Mon Sep 17 00:00:00 2001 From: aurelio-aot <97022120+aurelio-aot@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:47:36 -0800 Subject: [PATCH 03/36] Potential fix for pull request finding 'Constant condition' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../Applicants/ApplicantSupplierAppService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs index 06b7871c6..3e2f37b36 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs @@ -58,7 +58,7 @@ public async Task UpdateApplicantSupplierNumberAsync(Guid applicantId, string su var supplier = await GetSupplierByApplicantIdAsync(applicantId); - if (supplier != null && string.Compare(supplierNumber, supplier?.Number, true) == 0) + if (supplier != null && string.Compare(supplierNumber, supplier.Number, true) == 0) { return; // No change in supplier number, so no action needed } From 16607a97516d748d001caf519949d193715e31fb Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Tue, 10 Feb 2026 19:53:48 -0800 Subject: [PATCH 04/36] AB#31867: Validate empty SupplierNumber in PaymentRequest frontend --- .../Domain/Exceptions/ErrorConsts.cs | 1 + .../Domain/PaymentRequests/PaymentRequest.cs | 4 ++++ .../Localization/Payments/en.json | 1 + .../PaymentRequests/CreatePaymentRequests.cshtml.cs | 11 ++++++++++- .../PaymentRequestAppService_Tests.cs | 6 +++--- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs index f9ab61c36..be14623c4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs @@ -8,5 +8,6 @@ public static class ErrorConsts public const string ConfigurationDoesNotExist = "Unity.Payments:Errors:ConfigurationDoesNotExist"; public const string InvalidAccountCodingField = "Unity.Payments:Errors:InvalidAccountCodingFiled"; public const string L2ApproverRestriction = "Unity.Payments:Errors:L2ApproverRestriction"; + public const string MissingSupplierNumber = "Unity.Payments:Errors:MissingSupplierNumber"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs index 91edb85a6..629eab4a6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs @@ -205,6 +205,10 @@ public PaymentRequest ValidatePaymentRequest() throw new BusinessException(ErrorConsts.ZeroPayment); } + if (string.IsNullOrWhiteSpace(SupplierNumber)) + { + throw new BusinessException(ErrorConsts.MissingSupplierNumber); + } return this; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json index 7e9985a4e..0a18cb85e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json @@ -6,6 +6,7 @@ "Unity.Payments:Errors:ThresholdExceeded": "There are payments in this batch that require a third level of approval. Please remove them from this batch and add to another for the appropriate level of approval", "Unity.Payments:Errors:ZeroPayment": "Cannot submit a payment request for $0.00", + "Unity.Payments:Errors:MissingSupplierNumber": "Cannot submit a payment request without a supplier number", "Unity.Payments:Errors:ConfigurationExists": "Configuration already exitst", "Unity.Payments:Errors:ConfigurationDoesNotExist": "Configuration does not exits", "Unity.Payments:Errors:InvalidAccountCodingFiled": "Invalid account coding field {field} : {length}", diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs index d54ee06cf..83b951ce2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs @@ -160,7 +160,7 @@ public async Task OnGetAsync(string cacheKey) bool missingFields = false; List errorList = []; - if (supplier == null || site == null || supplier.Number == null) + if (supplier == null || site == null || string.IsNullOrWhiteSpace(supplier.Number)) { missingFields = true; } @@ -308,6 +308,15 @@ public async Task OnPostAsync() throw new UserFriendlyException(string.Join(" ", validationErrors)); } + foreach (var payment in ApplicationPaymentRequestForm) + { + if (string.IsNullOrWhiteSpace(payment.SupplierNumber)) + { + throw new UserFriendlyException( + "Cannot submit payment request: Supplier number is missing for one or more applications."); + } + } + var payments = MapPaymentRequests(); await paymentRequestAppService.CreateAsync(payments); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs index 162fa4f7d..6fd509b3e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs @@ -70,10 +70,10 @@ public async Task CreateAsync_CreatesPaymentRequest() Description = "", PayeeName= "", SiteId= siteId, - SupplierNumber = "", + SupplierNumber = "SUP-TEST", } ]; - // Act + // Act var insertedPaymentRequest = await _paymentRequestAppService .CreateAsync(paymentRequests); @@ -97,7 +97,7 @@ public async Task GetListAsync_ReturnsPaymentsList() Amount = 100, PayeeName = "Test", ContractNumber = "0000000000", - SupplierNumber = "", + SupplierNumber = "SUP-TEST", SiteId = addedSupplier.Sites[0].Id, CorrelationId = Guid.NewGuid(), CorrelationProvider = "", From f41b5a1446349855387a2972f3b46e6a464e4a6e Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Tue, 10 Feb 2026 19:54:30 -0800 Subject: [PATCH 05/36] AB#31867: Dont save empty suppliernumber from CAS --- .../Integrations/Cas/SupplierService.cs | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs index 489790435..a6dccf31e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs @@ -47,18 +47,18 @@ private static async Task InitializeBaseApiAsync(IEndpointManagementAppS return url ?? throw new UserFriendlyException("Payment API base URL is not configured."); } - public virtual async Task UpdateApplicantSupplierInfo(string? supplierNumber, Guid applicantId, Guid? applicationId = null) - { - Logger.LogInformation("SupplierService->UpdateApplicantSupplierInfo: {SupplierNumber}, {ApplicantId}, {ApplicationId}", supplierNumber, applicantId, applicationId); - - // Integrate with payments module to update / insert supplier - if (await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature) - && !string.IsNullOrEmpty(supplierNumber)) - { - dynamic casSupplierResponse = await GetCasSupplierInformationAsync(supplierNumber); - await UpdateSupplierInfo(casSupplierResponse, applicantId, applicationId); - } - } + public virtual async Task UpdateApplicantSupplierInfo(string? supplierNumber, Guid applicantId, Guid? applicationId = null) + { + Logger.LogInformation("SupplierService->UpdateApplicantSupplierInfo: {SupplierNumber}, {ApplicantId}, {ApplicationId}", supplierNumber, applicantId, applicationId); + + // Integrate with payments module to update / insert supplier + if (await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature) + && !string.IsNullOrEmpty(supplierNumber)) + { + dynamic casSupplierResponse = await GetCasSupplierInformationAsync(supplierNumber); + await UpdateSupplierInfo(casSupplierResponse, applicantId, applicationId); + } + } public async Task UpdateApplicantSupplierInfoByBn9(string? bn9, Guid applicantId) { @@ -92,21 +92,25 @@ public async Task UpdateApplicantSupplierInfoByBn9(string? bn9, Guid ap return casSupplierResponse; } - public async Task UpdateSupplierInfo(dynamic casSupplierResponse, Guid applicantId, Guid? applicationId = null) - { - try - { - var casSupplierJson = casSupplierResponse is string str ? str : casSupplierResponse.ToString(); - using var doc = JsonDocument.Parse(casSupplierJson); + public async Task UpdateSupplierInfo(dynamic casSupplierResponse, Guid applicantId, Guid? applicationId = null) + { + try + { + var casSupplierJson = casSupplierResponse is string str ? str : casSupplierResponse.ToString(); + using var doc = JsonDocument.Parse(casSupplierJson); var rootElement = doc.RootElement; if (rootElement.TryGetProperty("code", out JsonElement codeProp) && codeProp.GetString() == "Unauthorized") throw new UserFriendlyException("Unauthorized access to CAS supplier information."); - UpsertSupplierEto supplierEto = GetEventDtoFromCasResponse(rootElement); - supplierEto.CorrelationId = applicantId; - supplierEto.CorrelationProvider = CorrelationConsts.Applicant; - supplierEto.ApplicationId = applicationId; - await localEventBus.PublishAsync(supplierEto); - } + UpsertSupplierEto supplierEto = GetEventDtoFromCasResponse(rootElement); + supplierEto.CorrelationId = applicantId; + supplierEto.CorrelationProvider = CorrelationConsts.Applicant; + supplierEto.ApplicationId = applicationId; + await localEventBus.PublishAsync(supplierEto); + } + catch (UserFriendlyException) + { + throw; + } catch (Exception ex) { Logger.LogError(ex, "An exception occurred updating the supplier: {ExceptionMessage}", ex.Message); @@ -123,6 +127,13 @@ string GetProp(string name) => string lastUpdated = GetProp("lastupdated"); string suppliernumber = GetProp("suppliernumber"); + + if (string.IsNullOrWhiteSpace(suppliernumber)) + { + throw new UserFriendlyException( + "CAS integration returned an empty Supplier Number. Please verify the supplier information in CAS."); + } + string suppliername = GetProp("suppliername"); string subcategory = GetProp("subcategory"); string providerid = GetProp("providerid"); From dbcb1e89ac1e2f548a86b252f2707e1f74c07407 Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Tue, 10 Feb 2026 20:17:00 -0800 Subject: [PATCH 06/36] AB#31867: Fix sonarqube issue --- .../PaymentRequests/CreatePaymentRequests.cshtml.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs index 83b951ce2..956d5b516 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs @@ -308,13 +308,10 @@ public async Task OnPostAsync() throw new UserFriendlyException(string.Join(" ", validationErrors)); } - foreach (var payment in ApplicationPaymentRequestForm) + if (ApplicationPaymentRequestForm.Exists(payment => string.IsNullOrWhiteSpace(payment.SupplierNumber))) { - if (string.IsNullOrWhiteSpace(payment.SupplierNumber)) - { - throw new UserFriendlyException( - "Cannot submit payment request: Supplier number is missing for one or more applications."); - } + throw new UserFriendlyException( + "Cannot submit payment request: Supplier number is missing for one or more applications."); } var payments = MapPaymentRequests(); From fa897bfd9e8dee739f1ba6bc7f267f059c7cea3b Mon Sep 17 00:00:00 2001 From: aurelio-aot Date: Wed, 11 Feb 2026 09:53:35 -0800 Subject: [PATCH 07/36] AB#31867: Improve validation message wording --- .../Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs index 956d5b516..caf1aee4a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs @@ -178,7 +178,7 @@ public async Task OnGetAsync(string cacheKey) if (missingFields) { - errorList.Add("Some payment information is missing for this applicant, please make sure Supplier info is provided and default site is selected."); + errorList.Add("Some payment information is missing for this applicant. Please make sure supplier information is provided and default site is selected."); } if (application.StatusCode != GrantApplicationState.GRANT_APPROVED) From 27733348b71562aaf01de1b5c4941b2fe4d87dcf Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 20 Feb 2026 13:11:08 -0800 Subject: [PATCH 08/36] AB#25283 submission data provider for profiles --- .../ProfileData/ApplicantSubmissionInfoDto.cs | 5 + .../ProfileData/SubmissionInfoItemDto.cs | 15 +++ .../SubmissionInfoDataProvider.cs | 114 +++++++++++++++++- .../ApplicantProfileDataProviderTests.cs | 22 +++- 4 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs index 5c3618c30..9c1fc36c7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs @@ -1,7 +1,12 @@ +using System.Collections.Generic; + namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantSubmissionInfoDto : ApplicantProfileDataDto { public override string DataType => "SUBMISSIONINFO"; + + public List Submissions { get; set; } = []; + public string LinkSource { get; set; } = string.Empty; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs new file mode 100644 index 000000000..bc1b54c02 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs @@ -0,0 +1,15 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class SubmissionInfoItemDto + { + public Guid Id { get; set; } + public string LinkId { get; set; } = string.Empty; + public DateTime ReceivedTime { get; set; } + public DateTime SubmissionTime { get; set; } + public string ReferenceNo { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs index 9bd91fcbd..298f53c7f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -1,17 +1,125 @@ +using System; +using System.Linq; +using System.Text.Json; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Integrations; using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.ApplicantProfile { [ExposeServices(typeof(IApplicantProfileDataProvider))] - public class SubmissionInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency + public class SubmissionInfoDataProvider( + ICurrentTenant currentTenant, + IRepository applicationFormSubmissionRepository, + IRepository applicationRepository, + IRepository applicationStatusRepository, + IEndpointManagementAppService endpointManagementAppService, + ILogger logger) + : IApplicantProfileDataProvider, ITransientDependency { public string Key => ApplicantProfileKeys.SubmissionInfo; - public Task GetDataAsync(ApplicantProfileInfoRequest request) + public async Task GetDataAsync(ApplicantProfileInfoRequest request) { - return Task.FromResult(new ApplicantSubmissionInfoDto()); + var dto = new ApplicantSubmissionInfoDto + { + Submissions = [] + }; + + var normalizedSubject = request.Subject.Contains('@') + ? request.Subject[..request.Subject.IndexOf('@')].ToUpperInvariant() + : request.Subject.ToUpperInvariant(); + + dto.LinkSource = await ResolveFormViewUrlAsync(); + + using (currentTenant.Change(request.TenantId)) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + var statusesQuery = await applicationStatusRepository.GetQueryableAsync(); + + var results = await ( + from submission in submissionsQuery + join application in applicationsQuery on submission.ApplicationId equals application.Id + join status in statusesQuery on application.ApplicationStatusId equals status.Id + where submission.OidcSub == normalizedSubject + select new + { + submission.Id, + LinkId = submission.ChefsSubmissionGuid, + submission.CreationTime, + submission.Submission, + application.ReferenceNo, + application.ProjectName, + Status = status.ExternalStatus + }).ToListAsync(); + + dto.Submissions.AddRange(results.Select(s => new SubmissionInfoItemDto + { + Id = s.Id, + LinkId = s.LinkId, + ReceivedTime = s.CreationTime, + SubmissionTime = ResolveSubmissionTime(s.Submission, s.CreationTime), + ReferenceNo = s.ReferenceNo, + ProjectName = s.ProjectName, + Status = s.Status + })); + } + + return dto; + } + + /// + /// Derives the CHEFS form view URL from the INTAKE_API_BASE dynamic URL setting. + /// e.g. https://chefs-dev.apps.silver.devops.gov.bc.ca/app/api/v1 + /// -> https://chefs-dev.apps.silver.devops.gov.bc.ca/app/form/view?s= + /// + private async Task ResolveFormViewUrlAsync() + { + try + { + var chefsApiBaseUrl = await endpointManagementAppService.GetChefsApiBaseUrlAsync(); + var trimmed = chefsApiBaseUrl.TrimEnd('/'); + const string apiSegment = "/api/v1"; + if (trimmed.EndsWith(apiSegment, StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^apiSegment.Length]; + } + return $"{trimmed}/form/view?s="; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to resolve CHEFS form view URL from INTAKE_API_BASE setting."); + return string.Empty; + } + } + + private DateTime ResolveSubmissionTime(string submissionJson, DateTime fallback) + { + try + { + if (!string.IsNullOrEmpty(submissionJson)) + { + using var doc = JsonDocument.Parse(submissionJson); + if (doc.RootElement.TryGetProperty("createdAt", out var createdAt) && + createdAt.TryGetDateTime(out var dateTime)) + { + return dateTime; + } + } + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Failed to parse submission JSON for submission time. Falling back to received time."); + } + + return fallback; } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs index 74ccc3f9a..3ff284cb4 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs @@ -6,6 +6,9 @@ using System.Threading.Tasks; using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Integrations; +using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Xunit; @@ -33,6 +36,19 @@ private static ContactInfoDataProvider CreateContactInfoDataProvider() return new ContactInfoDataProvider(currentTenant, applicantProfileContactService); } + private static SubmissionInfoDataProvider CreateSubmissionInfoDataProvider() + { + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + var submissionRepo = Substitute.For>(); + var applicationRepo = Substitute.For>(); + var statusRepo = Substitute.For>(); + var endpointManagementAppService = Substitute.For(); + endpointManagementAppService.GetChefsApiBaseUrlAsync().Returns(Task.FromResult(string.Empty)); + var logger = Substitute.For>(); + return new SubmissionInfoDataProvider(currentTenant, submissionRepo, applicationRepo, statusRepo, endpointManagementAppService, logger); + } + [Fact] public void ContactInfoDataProvider_Key_ShouldMatchExpected() { @@ -84,14 +100,14 @@ public async Task AddressInfoDataProvider_GetDataAsync_ShouldReturnAddressInfoDt [Fact] public void SubmissionInfoDataProvider_Key_ShouldMatchExpected() { - var provider = new SubmissionInfoDataProvider(); + var provider = CreateSubmissionInfoDataProvider(); provider.Key.ShouldBe(ApplicantProfileKeys.SubmissionInfo); } [Fact] public async Task SubmissionInfoDataProvider_GetDataAsync_ShouldReturnSubmissionInfoDto() { - var provider = new SubmissionInfoDataProvider(); + var provider = CreateSubmissionInfoDataProvider(); var result = await provider.GetDataAsync(CreateRequest(ApplicantProfileKeys.SubmissionInfo)); result.ShouldNotBeNull(); result.ShouldBeOfType(); @@ -121,7 +137,7 @@ public void AllProviders_ShouldHaveUniqueKeys() CreateContactInfoDataProvider(), new OrgInfoDataProvider(), new AddressInfoDataProvider(), - new SubmissionInfoDataProvider(), + CreateSubmissionInfoDataProvider(), new PaymentInfoDataProvider() ]; From f316ee3a3bcae83eb306e85f1299dab8aed9d69c Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 20 Feb 2026 15:03:38 -0800 Subject: [PATCH 09/36] AB#25283 fix unit test --- .../Applicants/ApplicantProfileDataProviderTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs index 3ff284cb4..51b1c94f2 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs @@ -8,6 +8,7 @@ using Unity.GrantManager.ApplicantProfile.ProfileData; using Unity.GrantManager.Applications; using Unity.GrantManager.Integrations; +using Unity.GrantManager.TestHelpers; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Xunit; @@ -41,8 +42,11 @@ private static SubmissionInfoDataProvider CreateSubmissionInfoDataProvider() var currentTenant = Substitute.For(); currentTenant.Change(Arg.Any()).Returns(Substitute.For()); var submissionRepo = Substitute.For>(); + submissionRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); var applicationRepo = Substitute.For>(); + applicationRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); var statusRepo = Substitute.For>(); + statusRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); var endpointManagementAppService = Substitute.For(); endpointManagementAppService.GetChefsApiBaseUrlAsync().Returns(Task.FromResult(string.Empty)); var logger = Substitute.For>(); From f43730d98ee4fcb066b5c0dece7bf6e21d5b5d9a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 20 Feb 2026 15:10:25 -0800 Subject: [PATCH 10/36] AB#25283 missing xml docs --- .../ApplicantProfile/SubmissionInfoDataProvider.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs index 298f53c7f..abd04e2f9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -13,6 +13,12 @@ namespace Unity.GrantManager.ApplicantProfile { + /// + /// Provides submission information for the applicant profile by querying + /// application form submissions linked to the applicant's OIDC subject. + /// Resolves actual submission timestamps from CHEFS JSON data and derives + /// the form view URL from the INTAKE_API_BASE dynamic URL setting. + /// [ExposeServices(typeof(IApplicantProfileDataProvider))] public class SubmissionInfoDataProvider( ICurrentTenant currentTenant, @@ -23,8 +29,10 @@ public class SubmissionInfoDataProvider( ILogger logger) : IApplicantProfileDataProvider, ITransientDependency { + /// public string Key => ApplicantProfileKeys.SubmissionInfo; + /// public async Task GetDataAsync(ApplicantProfileInfoRequest request) { var dto = new ApplicantSubmissionInfoDto From 10f8f60e9825f13c3e828ac7d09a22ae14c0cb96 Mon Sep 17 00:00:00 2001 From: Andre Goncalves <98196495+AndreGAot@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:23:47 -0800 Subject: [PATCH 11/36] Update applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ApplicantProfile/SubmissionInfoDataProvider.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs index abd04e2f9..de1b1a29a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -40,9 +40,10 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ Submissions = [] }; - var normalizedSubject = request.Subject.Contains('@') - ? request.Subject[..request.Subject.IndexOf('@')].ToUpperInvariant() - : request.Subject.ToUpperInvariant(); + var subject = request.Subject ?? string.Empty; + var normalizedSubject = subject.Contains('@') + ? subject[..subject.IndexOf('@')].ToUpperInvariant() + : subject.ToUpperInvariant(); dto.LinkSource = await ResolveFormViewUrlAsync(); From 0cc221b1f1d6d43c45f19868fafe4a1aef712224 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 20 Feb 2026 16:22:05 -0800 Subject: [PATCH 12/36] AB#32001 Improve AI payload logging controls and output formatting --- .../AI/OpenAIService.cs | 166 +++++++++++++++++- 1 file changed, 161 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 1374af052..742a9abe0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -7,6 +7,7 @@ using System.Net.Http; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Volo.Abp.DependencyInjection; @@ -21,7 +22,10 @@ public class OpenAIService : IAIService, ITransientDependency private string? ApiKey => _configuration["AI:OpenAI:ApiKey"]; private string? ApiUrl => _configuration["AI:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; + private bool LogPayloads => _configuration.GetValue("AI:Logging:LogPayloads") ?? false; private readonly string NoKeyError = "OpenAI API key is not configured"; + private const string AiPromptLogRelativePath = "logs/ai-prompts.log"; + private static int _aiPromptLogInitialized; public OpenAIService(HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService) { @@ -50,7 +54,7 @@ public async Task GenerateSummaryAsync(string content, string? prompt = return "AI analysis not available - service not configured."; } - _logger.LogDebug("Calling OpenAI with prompt: {Prompt}", content); + _logger.LogDebug("Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", content?.Length ?? 0, maxTokens); try { @@ -76,7 +80,10 @@ public async Task GenerateSummaryAsync(string content, string? prompt = var response = await _httpClient.PostAsync(ApiUrl, httpContent); var responseContent = await response.Content.ReadAsStringAsync(); - _logger.LogDebug("Response: {Response}", responseContent); + _logger.LogDebug( + "OpenAI chat completions response received. StatusCode: {StatusCode}, ResponseLength: {ResponseLength}", + response.StatusCode, + responseContent?.Length ?? 0); if (!response.IsSuccessStatusCode) { @@ -125,7 +132,10 @@ public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] prompt = "Please analyze this document and provide a concise summary of its content, purpose, and key information, for use by your fellow grant analysts. It should be 1-2 sentences long and about 46 tokens."; } - return await GenerateSummaryAsync(contentToAnalyze, prompt, 150); + LogPromptInput("AttachmentSummary", prompt, contentToAnalyze); + var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); + LogPromptOutput("AttachmentSummary", modelOutput); + return modelOutput; } catch (Exception ex) { @@ -202,7 +212,9 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis Respond only with valid JSON in the exact format requested."; + LogPromptInput("ApplicationAnalysis", systemPrompt, analysisContent); var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); + LogPromptOutput("ApplicationAnalysis", rawAnalysis); // Post-process the AI response to add unique IDs to errors and warnings return AddIdsToAnalysisItems(rawAnalysis); @@ -320,7 +332,10 @@ Base your answers on the application content and attachment summaries provided. Be thorough, objective, and fair in your assessment. Base your answers strictly on the provided application content. Respond only with valid JSON in the exact format requested."; - return await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + LogPromptInput("ScoresheetAll", systemPrompt, analysisContent); + var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + LogPromptOutput("ScoresheetAll", modelOutput); + return modelOutput; } catch (Exception ex) { @@ -394,7 +409,10 @@ Always provide citations that reference specific parts of the application conten Be honest about your confidence level - if information is missing or unclear, reflect this in a lower confidence score. Respond only with valid JSON in the exact format requested."; - return await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + LogPromptInput("ScoresheetSection", systemPrompt, analysisContent); + var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + LogPromptOutput("ScoresheetSection", modelOutput); + return modelOutput; } catch (Exception ex) { @@ -402,5 +420,143 @@ Always provide citations that reference specific parts of the application conten return "{}"; } } + + private void LogPromptInput(string promptType, string? systemPrompt, string userPrompt) + { + if (!LogPayloads) + { + return; + } + + var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); + _logger.LogDebug("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); + WriteAiPromptLog(promptType, "INPUT", formattedInput); + } + + private void LogPromptOutput(string promptType, string output) + { + if (!LogPayloads) + { + return; + } + + var formattedOutput = FormatPromptOutputForLog(output); + _logger.LogDebug("AI {PromptType} model output payload: {ModelOutput}", promptType, formattedOutput); + WriteAiPromptLog(promptType, "OUTPUT", formattedOutput); + } + + private void WriteAiPromptLog(string promptType, string payloadType, string payload) + { + if (!LogPayloads) + { + return; + } + + try + { + var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); + var logPath = Path.Combine(AppContext.BaseDirectory, AiPromptLogRelativePath); + EnsureAiPromptLogInitialized(logPath); + + var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; + File.AppendAllText(logPath, entry); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to write AI prompt log file."); + } + } + + private static void EnsureAiPromptLogInitialized(string logPath) + { + var directory = Path.GetDirectoryName(logPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + if (Interlocked.Exchange(ref _aiPromptLogInitialized, 1) == 0) + { + File.WriteAllText(logPath, string.Empty); + } + } + + private static string FormatPromptInputForLog(string? systemPrompt, string userPrompt) + { + var normalizedSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? string.Empty : systemPrompt.Trim(); + var normalizedUserPrompt = string.IsNullOrWhiteSpace(userPrompt) ? string.Empty : userPrompt.Trim(); + return $"SYSTEM_PROMPT\n{normalizedSystemPrompt}\n\nUSER_PROMPT\n{normalizedUserPrompt}"; + } + + private static string FormatPromptOutputForLog(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return string.Empty; + } + + if (TryParseJsonObjectFromResponse(output, out var jsonObject)) + { + return JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }); + } + + return output.Trim(); + } + + private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) + { + objectElement = default; + var cleaned = CleanJsonResponse(response); + if (string.IsNullOrWhiteSpace(cleaned)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(cleaned); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + objectElement = doc.RootElement.Clone(); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static string CleanJsonResponse(string response) + { + if (string.IsNullOrWhiteSpace(response)) + { + return string.Empty; + } + + var cleaned = response.Trim(); + + if (cleaned.StartsWith("```json", StringComparison.OrdinalIgnoreCase) || cleaned.StartsWith("```")) + { + var startIndex = cleaned.IndexOf('\n'); + if (startIndex >= 0) + { + cleaned = cleaned[(startIndex + 1)..]; + } + } + + if (cleaned.EndsWith("```", StringComparison.Ordinal)) + { + var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); + if (lastIndex > 0) + { + cleaned = cleaned[..lastIndex]; + } + } + + return cleaned.Trim(); + } } } From 77ca0f3a8780c558ca633fc2f8294a323398fdc6 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 20 Feb 2026 17:53:09 -0800 Subject: [PATCH 13/36] AB#32001 Update gitignore for local dev artifacts --- .gitignore | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 841973049..5ccccd8b9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.rsuser *.userosscache *.sln.docstates +*.code-workspace # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -23,6 +24,7 @@ bld/ [Oo]bj/ [Ll]og/ [Ll]ogs/ +*.log # Visual Studio cache/options directory .vs/ @@ -116,4 +118,7 @@ appsettings.json /applications/Orchestrator *.env -/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package-lock.json \ No newline at end of file +/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package-lock.json + +# Local dev artifacts +/AGENTS.md From de0914357887f5a871e34ddd6cf191e497d48c89 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:01:26 -0800 Subject: [PATCH 14/36] AB#31384 - Add aggregated payment summary DTO and batch queries --- .../ApplicationPaymentSummaryDto.cs | 11 +++ .../IPaymentRequestAppService.cs | 2 + .../IPaymentRequestRepository.cs | 3 +- .../Services/IPaymentRequestQueryManager.cs | 4 + .../Services/PaymentRequestQueryManager.cs | 81 ++++++++++++++++++- .../Repositories/PaymentRequestRepository.cs | 66 +++++++++++---- .../PaymentRequestAppService.cs | 17 +++- .../PaymentInfo/PaymentInfoViewComponent.cs | 68 +--------------- .../PaymentInfoViewComponentTests.cs | 3 +- .../IApplicationLinksService.cs | 3 + .../ApplicationLinksAppService.cs | 24 ++++++ .../GrantApplicationAppService.cs | 17 ++-- .../ApplicantSubmissionsViewComponent.cs | 16 ++-- 13 files changed, 204 insertions(+), 111 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentSummaryDto.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentSummaryDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentSummaryDto.cs new file mode 100644 index 000000000..0d6706572 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentSummaryDto.cs @@ -0,0 +1,11 @@ +using System; + +namespace Unity.Payments.PaymentRequests; + +[Serializable] +public class ApplicationPaymentSummaryDto +{ + public Guid ApplicationId { get; set; } + public decimal TotalPaid { get; set; } + public decimal TotalPending { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs index be35d094c..ae1fc15a4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs @@ -21,5 +21,7 @@ public interface IPaymentRequestAppService : IApplicationService Task GetUserPaymentThresholdAsync(); Task ManuallyAddPaymentRequestsToReconciliationQueue(List paymentRequestIds); Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); + Task GetApplicationPaymentSummaryAsync(Guid applicationId); + Task> GetApplicationPaymentSummariesAsync(List applicationIds); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs index 5ae55debb..9a27897a1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Unity.Payments.PaymentRequests; using Volo.Abp.Domain.Repositories; namespace Unity.Payments.Domain.PaymentRequests @@ -14,6 +15,6 @@ public interface IPaymentRequestRepository : IRepository Task GetPaymentRequestByInvoiceNumber(string invoiceNumber); Task> GetPaymentRequestsByFailedsStatusAsync(); Task> GetPaymentPendingListByCorrelationIdAsync(Guid correlationId); - + Task> GetPaymentSummariesByCorrelationIdsAsync(List correlationIds); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs index 222dc9f83..d7c2c36cd 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs @@ -35,5 +35,9 @@ public interface IPaymentRequestQueryManager // Pending Payments Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); + + // Payment Summaries (paid + pending aggregation) + Task GetApplicationPaymentSummaryAsync(Guid applicationId, List childApplicationIds); + Task> GetApplicationPaymentSummariesAsync(List applicationIds, Dictionary> childApplicationIdsByParent); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs index f5fe8e972..b7b76ddcb 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs @@ -88,8 +88,10 @@ public async Task CreatePaymentRequestDtoAsync(Guid paymentRe public async Task> GetListByApplicationIdsAsync(List applicationIds) { var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); - var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync(); - var filteredPayments = payments.Where(pr => applicationIds.Contains(pr.CorrelationId)).ToList(); + var filteredPayments = await paymentsQueryable + .Include(pr => pr.Site) + .Where(pr => applicationIds.Contains(pr.CorrelationId)) + .ToListAsync(); return objectMapper.Map, List>(filteredPayments); } @@ -97,8 +99,10 @@ public async Task> GetListByApplicationIdsAsync(List> GetListByApplicationIdAsync(Guid applicationId) { var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); - var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync(); - var filteredPayments = payments.Where(e => e.CorrelationId == applicationId).ToList(); + var filteredPayments = await paymentsQueryable + .Include(pr => pr.Site) + .Where(e => e.CorrelationId == applicationId) + .ToListAsync(); return objectMapper.Map, List>(filteredPayments); } @@ -218,5 +222,74 @@ public async Task> GetPaymentPendingListByCorrelationIdA var payments = await paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(applicationId); return objectMapper.Map, List>(payments); } + + public async Task GetApplicationPaymentSummaryAsync(Guid applicationId, List childApplicationIds) + { + var allCorrelationIds = new List { applicationId }; + allCorrelationIds.AddRange(childApplicationIds); + + var summaries = await paymentRequestRepository.GetPaymentSummariesByCorrelationIdsAsync(allCorrelationIds); + + return new ApplicationPaymentSummaryDto + { + ApplicationId = applicationId, + TotalPaid = summaries.Sum(s => s.TotalPaid), + TotalPending = summaries.Sum(s => s.TotalPending) + }; + } + + public async Task> GetApplicationPaymentSummariesAsync( + List applicationIds, + Dictionary> childApplicationIdsByParent) + { + // Collect all unique correlation IDs (parents + all children) for a single DB query + var allCorrelationIds = new HashSet(applicationIds); + foreach (var childIds in childApplicationIdsByParent.Values) + { + foreach (var childId in childIds) + { + allCorrelationIds.Add(childId); + } + } + + var summaries = await paymentRequestRepository.GetPaymentSummariesByCorrelationIdsAsync(allCorrelationIds.ToList()); + var summaryLookup = summaries.ToDictionary(s => s.ApplicationId); + + var result = new Dictionary(); + foreach (var applicationId in applicationIds) + { + decimal totalPaid = 0; + decimal totalPending = 0; + + // Add the parent application's own amounts + if (summaryLookup.TryGetValue(applicationId, out var parentSummary)) + { + totalPaid += parentSummary.TotalPaid; + totalPending += parentSummary.TotalPending; + } + + // Add child application amounts + if (childApplicationIdsByParent.TryGetValue(applicationId, out var childIds)) + { + foreach (var childId in childIds) + { + if (summaryLookup.TryGetValue(childId, out var childSummary)) + { + totalPaid += childSummary.TotalPaid; + totalPending += childSummary.TotalPending; + } + } + } + + result[applicationId] = new ApplicationPaymentSummaryDto + { + ApplicationId = applicationId, + TotalPaid = totalPaid, + TotalPending = totalPending + }; + } + + return result; + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index acb7a4a8b..01307ab2f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -1,4 +1,5 @@ -using System; +using Microsoft.EntityFrameworkCore; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -6,6 +7,7 @@ using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.EntityFrameworkCore; using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; using Volo.Abp.Domain.Repositories.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore; @@ -16,9 +18,6 @@ public class PaymentRequestRepository : EfCoreRepository ReCheckStatusList { get; set; } = new List(); private List FailedStatusList { get; set; } = new List(); - - - public PaymentRequestRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider) { ReCheckStatusList.Add(CasPaymentRequestStatus.ServiceUnavailable); @@ -32,25 +31,27 @@ public PaymentRequestRepository(IDbContextProvider dbContextP public async Task GetCountByCorrelationId(Guid correlationId) { var dbSet = await GetDbSetAsync(); - return dbSet.Count(s => s.CorrelationId == correlationId); + return await dbSet.CountAsync(s => s.CorrelationId == correlationId); } public async Task GetPaymentRequestCountBySiteId(Guid siteId) { var dbSet = await GetDbSetAsync(); - return dbSet.Where(s => s.SiteId == siteId).Count(); - } + return await dbSet.Where(s => s.SiteId == siteId) + .CountAsync(); + } public async Task GetPaymentRequestByInvoiceNumber(string invoiceNumber) { var dbSet = await GetDbSetAsync(); - return dbSet.Where(s => s.InvoiceNumber == invoiceNumber).FirstOrDefault(); + return await dbSet.Where(s => s.InvoiceNumber == invoiceNumber) + .FirstOrDefaultAsync(); } public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId) { var dbSet = await GetDbSetAsync(); - decimal applicationPaymentRequestsTotal = dbSet + decimal applicationPaymentRequestsTotal = await dbSet .Where(p => p.CorrelationId.Equals(correlationId)) .Where(p => p.Status != PaymentRequestStatus.L1Declined && p.Status != PaymentRequestStatus.L2Declined @@ -59,7 +60,7 @@ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid && p.InvoiceStatus != CasPaymentRequestStatus.ErrorFromCas) .GroupBy(p => p.CorrelationId) .Select(p => p.Sum(q => q.Amount)) - .FirstOrDefault(); + .FirstOrDefaultAsync(); return applicationPaymentRequestsTotal; } @@ -67,15 +68,17 @@ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid public async Task> GetPaymentRequestsBySentToCasStatusAsync() { var dbSet = await GetDbSetAsync(); - return dbSet.Where(p => p.InvoiceStatus != null && ReCheckStatusList.Contains(p.InvoiceStatus)).IncludeDetails().ToList(); + return await dbSet.Where(p => p.InvoiceStatus != null && ReCheckStatusList.Contains(p.InvoiceStatus)) + .IncludeDetails() + .ToListAsync(); } public async Task> GetPaymentRequestsByFailedsStatusAsync() { var dbSet = await GetDbSetAsync(); - return dbSet.Where(p => p.InvoiceStatus != null - && FailedStatusList.Contains(p.InvoiceStatus) - && p.LastModificationTime >= DateTime.Now.AddDays(-2)).IncludeDetails().ToList(); + return await dbSet.Where(p => p.InvoiceStatus != null + && FailedStatusList.Contains(p.InvoiceStatus) + && p.LastModificationTime >= DateTime.Now.AddDays(-2)).IncludeDetails().ToListAsync(); } public override async Task> WithDetailsAsync() @@ -87,8 +90,39 @@ public override async Task> WithDetailsAsync() public async Task> GetPaymentPendingListByCorrelationIdAsync(Guid correlationId) { var dbSet = await GetDbSetAsync(); - return dbSet.Where(p => p.CorrelationId.Equals(correlationId)) - .Where(p => p.Status == PaymentRequestStatus.L1Pending || p.Status == PaymentRequestStatus.L2Pending).IncludeDetails().ToList(); + return await dbSet.Where(p => p.CorrelationId.Equals(correlationId)) + .Where(p => p.Status == PaymentRequestStatus.L1Pending || p.Status == PaymentRequestStatus.L2Pending) + .IncludeDetails() + .ToListAsync(); + } + + public async Task> GetPaymentSummariesByCorrelationIdsAsync(List correlationIds) + { + var dbSet = await GetDbSetAsync(); + + var results = await dbSet + .Where(p => correlationIds.Contains(p.CorrelationId)) + .GroupBy(p => p.CorrelationId) + .Select(g => new ApplicationPaymentSummaryDto + { + ApplicationId = g.Key, + TotalPaid = g + .Where(p => p.PaymentStatus != null + && p.PaymentStatus == CasPaymentRequestStatus.FullyPaid) + .Sum(p => p.Amount), + TotalPending = g + .Where(p => p.Status == PaymentRequestStatus.L1Pending + || p.Status == PaymentRequestStatus.L2Pending + || p.Status == PaymentRequestStatus.L3Pending + || (p.Status == PaymentRequestStatus.Submitted + && (p.PaymentStatus == null || p.PaymentStatus == string.Empty) + && (p.InvoiceStatus == null || p.InvoiceStatus == string.Empty + || !p.InvoiceStatus.Contains(CasPaymentRequestStatus.ErrorFromCas)))) + .Sum(p => p.Amount) + }) + .ToListAsync(); + + return results; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index 81c63c3b8..3ecdf011a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -17,6 +17,7 @@ using Volo.Abp.Authorization.Permissions; using Volo.Abp.Users; using Unity.Payments.PaymentRequests.Notifications; +using Unity.GrantManager.GrantApplications; namespace Unity.Payments.PaymentRequests { @@ -29,7 +30,8 @@ public class PaymentRequestAppService( IPaymentsManager paymentsManager, FsbPaymentNotifier fsbPaymentNotifier, IPaymentRequestQueryManager paymentRequestQueryManager, - IPaymentRequestConfigurationManager paymentRequestConfigurationManager) : PaymentsAppService, IPaymentRequestAppService + IPaymentRequestConfigurationManager paymentRequestConfigurationManager, + IApplicationLinksService applicationLinksService) : PaymentsAppService, IPaymentRequestAppService { public async Task GetDefaultAccountCodingId() @@ -328,5 +330,18 @@ public async Task> GetPaymentPendingListByCorrelationIdA { return await paymentRequestQueryManager.GetPaymentPendingListByCorrelationIdAsync(applicationId); } + + public async Task GetApplicationPaymentSummaryAsync(Guid applicationId) + { + var childLinks = await applicationLinksService.GetChildApplications(applicationId); + var childApplicationIds = childLinks.Select(l => l.LinkedApplicationId).ToList(); + return await paymentRequestQueryManager.GetApplicationPaymentSummaryAsync(applicationId, childApplicationIds); + } + + public async Task> GetApplicationPaymentSummariesAsync(List applicationIds) + { + var childApplicationIdsByParent = await applicationLinksService.GetChildApplicationIdsByParentIdsAsync(applicationIds); + return await paymentRequestQueryManager.GetApplicationPaymentSummariesAsync(applicationIds, childApplicationIdsByParent); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs index 063347227..b422c9783 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs @@ -1,12 +1,8 @@ using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; -using Unity.Payments.Codes; -using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; @@ -25,17 +21,14 @@ public class PaymentInfoViewComponent : AbpViewComponent private readonly IGrantApplicationAppService _grantApplicationAppService; private readonly IPaymentRequestAppService _paymentRequestService; private readonly IFeatureChecker _featureChecker; - private readonly IApplicationLinksService _applicationLinksService; public PaymentInfoViewComponent(IGrantApplicationAppService grantApplicationAppService, IPaymentRequestAppService paymentRequestService, - IFeatureChecker featureChecker, - IApplicationLinksService applicationLinksService) + IFeatureChecker featureChecker) { _grantApplicationAppService = grantApplicationAppService; _paymentRequestService = paymentRequestService; _featureChecker = featureChecker; - _applicationLinksService = applicationLinksService; } public async Task InvokeAsync(Guid applicationId, Guid applicationFormVersionId) @@ -53,41 +46,9 @@ public async Task InvokeAsync(Guid applicationId, Guid app ApplicantId = application.Applicant.Id }; - var paymentRequests = await _paymentRequestService.GetListByApplicationIdAsync(applicationId); - - // Calculate Total Paid and Total Pending Amounts for current application - var (paidAmount, pendingAmount) = CalculatePaymentAmounts(paymentRequests); - model.TotalPaid = paidAmount; - model.TotalPendingAmounts = pendingAmount; - - // Add Total Paid and Total Pending Amounts from child applications - var applicationLinks = await _applicationLinksService.GetListByApplicationAsync(applicationId); - var childApplications = applicationLinks - .Where(link => link.LinkType == ApplicationLinkType.Child - && link.ApplicationId != applicationId) // Exclude self-references - .ToList(); - - // Batch fetch payment requests for all child applications to avoid N+1 queries - var childApplicationIds = childApplications.Select(ca => ca.ApplicationId).ToList(); - if (childApplicationIds.Count != 0) - { - var childPaymentRequests = await _paymentRequestService.GetListByApplicationIdsAsync(childApplicationIds); - var paymentRequestsByAppId = childPaymentRequests - .GroupBy(pr => pr.CorrelationId) - .ToDictionary(g => g.Key, g => g.ToList()); - - foreach (var childApp in childApplications) - { - if (paymentRequestsByAppId.TryGetValue(childApp.ApplicationId, out var requests)) - { - // Add child's Total Paid and Total Pending Amounts - var (childPaidAmount, childPendingAmount) = CalculatePaymentAmounts(requests); - model.TotalPaid += childPaidAmount; - model.TotalPendingAmounts += childPendingAmount; - } - } - } - + var summary = await _paymentRequestService.GetApplicationPaymentSummaryAsync(applicationId); + model.TotalPaid = summary.TotalPaid; + model.TotalPendingAmounts = summary.TotalPending; model.RemainingAmount = application.ApprovedAmount - model.TotalPaid; return View(model); @@ -96,27 +57,6 @@ public async Task InvokeAsync(Guid applicationId, Guid app return View(new PaymentInfoViewModel()); } - private static (decimal paidAmount, decimal pendingAmount) CalculatePaymentAmounts(List paymentRequests) - { - var requestsList = paymentRequests; - - var paidAmount = requestsList - .Where(e => !string.IsNullOrWhiteSpace(e.PaymentStatus) - && e.PaymentStatus.Trim().Equals(CasPaymentRequestStatus.FullyPaid, StringComparison.OrdinalIgnoreCase)) - .Sum(e => e.Amount); - - var pendingAmount = requestsList - .Where(e => e.Status == PaymentRequestStatus.L1Pending - || e.Status == PaymentRequestStatus.L2Pending - || e.Status == PaymentRequestStatus.L3Pending - || (e.Status == PaymentRequestStatus.Submitted - && string.IsNullOrWhiteSpace(e.PaymentStatus) - && (string.IsNullOrWhiteSpace(e.InvoiceStatus) || !e.InvoiceStatus.Trim().Contains("Error", StringComparison.OrdinalIgnoreCase)))) - .Sum(e => e.Amount); - - return (paidAmount, pendingAmount); - } - public class PaymentInfoStyleBundleContributor : BundleContributor { public override void ConfigureBundle(BundleConfigurationContext context) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs index 0cd3660d8..58d8df519 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs @@ -665,8 +665,7 @@ private PaymentInfoViewComponent CreateViewComponent( var viewComponent = new PaymentInfoViewComponent( appService, paymentRequestService, - featureChecker, - applicationLinksService) + featureChecker) { ViewComponentContext = viewComponentContext, LazyServiceProvider = _lazyServiceProvider diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationLinksService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationLinksService.cs index 833fa4232..c8df0b91a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationLinksService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationLinksService.cs @@ -11,6 +11,9 @@ public interface IApplicationLinksService : ICrudAppService< Guid> { Task> GetListByApplicationAsync(Guid applicationId); + Task> GetApplicationLinksByType(Guid applicationId, ApplicationLinkType linkType); + Task> GetChildApplications(Guid applicationId); + Task>> GetChildApplicationIdsByParentIdsAsync(List parentApplicationIds); Task GetLinkedApplicationAsync(Guid currentApplicationId, Guid linkedApplicationId); Task GetCurrentApplicationInfoAsync(Guid applicationId); Task DeleteWithPairAsync(Guid applicationLinkId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs index 3ce28a569..fd765b4b0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs @@ -96,6 +96,30 @@ join applicant in applicantsQuery on application.ApplicantId equals applicant.Id return resultList; } + public async Task> GetApplicationLinksByType(Guid applicationId, ApplicationLinkType linkType) + { + var applicationLinksQuery = await ApplicationLinksRepository + .GetListAsync(al => al.ApplicationId == applicationId && al.LinkType == linkType); + + return ObjectMapper.Map, List>(applicationLinksQuery); + } + + public async Task> GetChildApplications(Guid applicationId) + { + return await GetApplicationLinksByType(applicationId, ApplicationLinkType.Child); + } + + public async Task>> GetChildApplicationIdsByParentIdsAsync(List parentApplicationIds) + { + var links = await ApplicationLinksRepository + .GetListAsync(al => parentApplicationIds.Contains(al.ApplicationId) + && al.LinkType == ApplicationLinkType.Child); + + return links + .GroupBy(l => l.ApplicationId) + .ToDictionary(g => g.Key, g => g.Select(l => l.LinkedApplicationId).ToList()); + } + public async Task GetLinkedApplicationAsync(Guid currentApplicationId, Guid linkedApplicationId) { var applicationLinksQuery = await ApplicationLinksRepository.GetQueryableAsync(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index d590a5482..5b6df4c68 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -23,7 +23,6 @@ using Unity.GrantManager.Payments; using Unity.Modules.Shared; using Unity.Modules.Shared.Correlation; -using Unity.Payments.Codes; using Unity.Payments.PaymentRequests; using Volo.Abp; using Volo.Abp.Application.Dtos; @@ -65,20 +64,13 @@ public async Task> GetListAsync(GrantApplica bool paymentsFeatureEnabled = await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature); - List paymentRequests = []; + Dictionary paymentSummaries = []; if (paymentsFeatureEnabled && applicationIds.Count > 0) { - paymentRequests = await paymentRequestService.GetListByApplicationIdsAsync(applicationIds); + paymentSummaries = await paymentRequestService.GetApplicationPaymentSummariesAsync(applicationIds); } - // 2️ Pre-aggregate payment amounts for O(1) lookup - var paymentRequestsByApplication = paymentRequests - .Where(pr => !string.IsNullOrWhiteSpace(pr.PaymentStatus) - && pr.PaymentStatus.Trim().Equals(CasPaymentRequestStatus.FullyPaid, StringComparison.OrdinalIgnoreCase)) - .GroupBy(pr => pr.CorrelationId) - .ToDictionary(g => g.Key, g => g.Sum(pr => pr.Amount)); - // 3️ Map applications to DTOs var appDtos = applications.Select(app => { @@ -102,12 +94,13 @@ public async Task> GetListAsync(GrantApplica appDto.ContactCellPhone = app.ApplicantAgent?.Phone2; appDto.ApplicationLinks = ObjectMapper.Map, List>(app.ApplicationLinks?.ToList() ?? []); - if (paymentsFeatureEnabled && paymentRequestsByApplication.Count > 0) + if (paymentsFeatureEnabled && paymentSummaries.Count > 0) { + paymentSummaries.TryGetValue(app.Id, out var summary); appDto.PaymentInfo = new PaymentInfoDto { ApprovedAmount = app.ApprovedAmount, - TotalPaid = paymentRequestsByApplication.GetValueOrDefault(app.Id) + TotalPaid = summary?.TotalPaid ?? 0 }; } return appDto; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs index 355613cac..59cd647b9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs @@ -6,7 +6,6 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Payments; -using Unity.Payments.Codes; using Unity.Payments.PaymentRequests; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; @@ -57,15 +56,10 @@ public async Task InvokeAsync(Guid applicantId) var applicationIds = applications.Select(app => app.Id).ToList(); var paymentsFeatureEnabled = await _featureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature); - Dictionary paymentRequestsByApplication = []; + Dictionary paymentSummaries = []; if (paymentsFeatureEnabled && applicationIds.Count > 0) { - var paymentRequests = await _paymentRequestService.GetListByApplicationIdsAsync(applicationIds); - paymentRequestsByApplication = paymentRequests - .Where(pr => !string.IsNullOrWhiteSpace(pr.PaymentStatus) - && pr.PaymentStatus.Trim().Equals(CasPaymentRequestStatus.FullyPaid, StringComparison.OrdinalIgnoreCase)) - .GroupBy(pr => pr.CorrelationId) - .ToDictionary(g => g.Key, g => g.Sum(pr => pr.Amount)); + paymentSummaries = await _paymentRequestService.GetApplicationPaymentSummariesAsync(applicationIds); } // Map to DTOs (similar to GrantApplicationAppService.GetListAsync) @@ -132,13 +126,13 @@ public async Task InvokeAsync(Guid applicantId) } dto.Assignees = assigneeDtos; - if (paymentsFeatureEnabled && paymentRequestsByApplication.Count > 0) + if (paymentsFeatureEnabled && paymentSummaries.Count > 0) { - paymentRequestsByApplication.TryGetValue(app.Id, out var totalPaid); + paymentSummaries.TryGetValue(app.Id, out var summary); dto.PaymentInfo = new PaymentInfoDto { ApprovedAmount = app.ApprovedAmount, - TotalPaid = totalPaid + TotalPaid = summary?.TotalPaid ?? 0 }; } From 15ae1611f883713ae33588a905f7e86d14a63905 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:40:28 -0800 Subject: [PATCH 15/36] AB#31384 - Add aggregated payment summary tests and test modifications --- ...equestQueryManager_PaymentSummary_Tests.cs | 413 ++++++++++++++ .../PaymentInfoViewComponentTests.cs | 510 +++--------------- .../PaymentsTestBase.cs | 4 +- 3 files changed, 505 insertions(+), 422 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs new file mode 100644 index 000000000..0d29fbd87 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs @@ -0,0 +1,413 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Unity.Payments.Domain.PaymentRequests; +using Unity.Payments.Domain.Services; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.PaymentRequests; +using Volo.Abp.Users; +using Xunit; + +namespace Unity.Payments.Domain.PaymentRequests; + +[Category("Domain")] +public class PaymentRequestQueryManager_PaymentSummary_Tests +{ + #region GetApplicationPaymentSummaryAsync (Single Application) + + [Fact] + public async Task Should_Return_Summary_For_Single_Application_With_NoChildren() + { + // Arrange + var appId = Guid.NewGuid(); + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = appId, TotalPaid = 1500m, TotalPending = 2000m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummaryAsync(appId, []); + + // Assert + result.ShouldNotBeNull(); + result.ApplicationId.ShouldBe(appId); + result.TotalPaid.ShouldBe(1500m); + result.TotalPending.ShouldBe(2000m); + + // Verify repo was called with only the parent ID + await repo.Received(1).GetPaymentSummariesByCorrelationIdsAsync( + Arg.Is>(ids => ids.Count == 1 && ids.Contains(appId))); + } + + [Fact] + public async Task Should_Aggregate_Summary_From_Parent_And_Children() + { + // Arrange + var parentId = Guid.NewGuid(); + var child1Id = Guid.NewGuid(); + var child2Id = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentId, TotalPaid = 1000m, TotalPending = 500m }, + new() { ApplicationId = child1Id, TotalPaid = 300m, TotalPending = 200m }, + new() { ApplicationId = child2Id, TotalPaid = 700m, TotalPending = 100m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummaryAsync(parentId, [child1Id, child2Id]); + + // Assert + result.ShouldNotBeNull(); + result.ApplicationId.ShouldBe(parentId); + result.TotalPaid.ShouldBe(2000m); // 1000 + 300 + 700 + result.TotalPending.ShouldBe(800m); // 500 + 200 + 100 + + // Verify all IDs were sent to the repository + await repo.Received(1).GetPaymentSummariesByCorrelationIdsAsync( + Arg.Is>(ids => ids.Count == 3 + && ids.Contains(parentId) && ids.Contains(child1Id) && ids.Contains(child2Id))); + } + + [Fact] + public async Task Should_Return_Zeros_When_No_Payment_Data_Exists() + { + // Arrange + var appId = Guid.NewGuid(); + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummaryAsync(appId, []); + + // Assert + result.ShouldNotBeNull(); + result.ApplicationId.ShouldBe(appId); + result.TotalPaid.ShouldBe(0m); + result.TotalPending.ShouldBe(0m); + } + + [Fact] + public async Task Should_Return_Zeros_When_Children_Have_No_Payments() + { + // Arrange + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentId, TotalPaid = 500m, TotalPending = 0m } + // childId has no payments - not returned by repository + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummaryAsync(parentId, [childId]); + + // Assert + result.TotalPaid.ShouldBe(500m); // Only parent amount + result.TotalPending.ShouldBe(0m); + } + + [Fact] + public async Task Should_Handle_Single_Child_Application() + { + // Arrange + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentId, TotalPaid = 1000m, TotalPending = 0m }, + new() { ApplicationId = childId, TotalPaid = 500m, TotalPending = 300m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummaryAsync(parentId, [childId]); + + // Assert + result.ApplicationId.ShouldBe(parentId); + result.TotalPaid.ShouldBe(1500m); // 1000 + 500 + result.TotalPending.ShouldBe(300m); // 0 + 300 + } + + #endregion + + #region GetApplicationPaymentSummariesAsync (Batch) + + [Fact] + public async Task Should_Return_Batch_Summaries_For_Multiple_Applications_Without_Children() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + var app3Id = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 200m }, + new() { ApplicationId = app2Id, TotalPaid = 500m, TotalPending = 100m }, + new() { ApplicationId = app3Id, TotalPaid = 0m, TotalPending = 3000m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummariesAsync( + [app1Id, app2Id, app3Id], + new Dictionary>()); + + // Assert + result.Count.ShouldBe(3); + result[app1Id].TotalPaid.ShouldBe(1000m); + result[app1Id].TotalPending.ShouldBe(200m); + result[app2Id].TotalPaid.ShouldBe(500m); + result[app2Id].TotalPending.ShouldBe(100m); + result[app3Id].TotalPaid.ShouldBe(0m); + result[app3Id].TotalPending.ShouldBe(3000m); + } + + [Fact] + public async Task Should_Aggregate_Child_Amounts_In_Batch_Summaries() + { + // Arrange + var parentAId = Guid.NewGuid(); + var parentBId = Guid.NewGuid(); + var childA1Id = Guid.NewGuid(); + var childA2Id = Guid.NewGuid(); + var childB1Id = Guid.NewGuid(); + + var childMap = new Dictionary> + { + { parentAId, [childA1Id, childA2Id] }, + { parentBId, [childB1Id] } + }; + + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentAId, TotalPaid = 1000m, TotalPending = 100m }, + new() { ApplicationId = childA1Id, TotalPaid = 200m, TotalPending = 50m }, + new() { ApplicationId = childA2Id, TotalPaid = 300m, TotalPending = 75m }, + new() { ApplicationId = parentBId, TotalPaid = 500m, TotalPending = 0m }, + new() { ApplicationId = childB1Id, TotalPaid = 400m, TotalPending = 200m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummariesAsync( + [parentAId, parentBId], childMap); + + // Assert + result.Count.ShouldBe(2); + + // Parent A: 1000+200+300 paid, 100+50+75 pending + result[parentAId].TotalPaid.ShouldBe(1500m); + result[parentAId].TotalPending.ShouldBe(225m); + result[parentAId].ApplicationId.ShouldBe(parentAId); + + // Parent B: 500+400 paid, 0+200 pending + result[parentBId].TotalPaid.ShouldBe(900m); + result[parentBId].TotalPending.ShouldBe(200m); + result[parentBId].ApplicationId.ShouldBe(parentBId); + } + + [Fact] + public async Task Should_Handle_Application_With_No_Matching_Summary_In_Batch() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + // Only app1 has payment data, app2 doesn't + new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 500m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummariesAsync( + [app1Id, app2Id], + new Dictionary>()); + + // Assert + result.Count.ShouldBe(2); + result[app1Id].TotalPaid.ShouldBe(1000m); + result[app1Id].TotalPending.ShouldBe(500m); + + // app2 gets zero amounts since no data was returned + result[app2Id].TotalPaid.ShouldBe(0m); + result[app2Id].TotalPending.ShouldBe(0m); + result[app2Id].ApplicationId.ShouldBe(app2Id); + } + + [Fact] + public async Task Should_Deduplicate_CorrelationIds_In_Batch_Repository_Call() + { + // Arrange - A child is shared between two parents (edge case) + var parentAId = Guid.NewGuid(); + var parentBId = Guid.NewGuid(); + var sharedChildId = Guid.NewGuid(); + + var childMap = new Dictionary> + { + { parentAId, [sharedChildId] }, + { parentBId, [sharedChildId] } + }; + + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentAId, TotalPaid = 100m, TotalPending = 0m }, + new() { ApplicationId = parentBId, TotalPaid = 200m, TotalPending = 0m }, + new() { ApplicationId = sharedChildId, TotalPaid = 50m, TotalPending = 25m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummariesAsync( + [parentAId, parentBId], childMap); + + // Assert + // Verify repository was called with deduplicated IDs (3 unique, not 4) + await repo.Received(1).GetPaymentSummariesByCorrelationIdsAsync( + Arg.Is>(ids => ids.Distinct().Count() == 3)); + + // Both parents should include the shared child's amounts + result[parentAId].TotalPaid.ShouldBe(150m); // 100 + 50 + result[parentAId].TotalPending.ShouldBe(25m); // 0 + 25 + result[parentBId].TotalPaid.ShouldBe(250m); // 200 + 50 + result[parentBId].TotalPending.ShouldBe(25m); // 0 + 25 + } + + [Fact] + public async Task Should_Make_Single_Repository_Call_For_Batch() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + var child1Id = Guid.NewGuid(); + + var childMap = new Dictionary> + { + { app1Id, [child1Id] } + }; + + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); + + var manager = CreateManager(repo); + + // Act + await manager.GetApplicationPaymentSummariesAsync([app1Id, app2Id], childMap); + + // Assert - should only call repository once (batch optimization) + await repo.Received(1).GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()); + } + + [Fact] + public async Task Should_Return_Empty_Dictionary_For_Empty_Application_List() + { + // Arrange + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummariesAsync( + [], new Dictionary>()); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + [Fact] + public async Task Should_Handle_Parent_Without_Children_In_Mixed_Batch() + { + // Arrange - app1 has children, app2 does not + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var childMap = new Dictionary> + { + { app1Id, [childId] } + // app2 has no entry in childMap + }; + + var repo = Substitute.For(); + repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 0m }, + new() { ApplicationId = childId, TotalPaid = 500m, TotalPending = 100m }, + new() { ApplicationId = app2Id, TotalPaid = 300m, TotalPending = 50m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentSummariesAsync( + [app1Id, app2Id], childMap); + + // Assert + result[app1Id].TotalPaid.ShouldBe(1500m); // 1000 + 500 + result[app1Id].TotalPending.ShouldBe(100m); // 0 + 100 + + result[app2Id].TotalPaid.ShouldBe(300m); // Only own amount + result[app2Id].TotalPending.ShouldBe(50m); // Only own amount + } + + #endregion + + #region Helpers + + private static PaymentRequestQueryManager CreateManager(IPaymentRequestRepository repo) + { + return new PaymentRequestQueryManager( + repo, + Substitute.For(), + Substitute.For(), + null!, // CasPaymentRequestCoordinator - not used by summary methods + null! // IObjectMapper - not used by summary methods + ); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs index 58d8df519..18fba64ff 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs @@ -4,12 +4,8 @@ using NSubstitute; using Shouldly; using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; -using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Unity.Payments.Web.Views.Shared.Components.PaymentInfo; using Volo.Abp.DependencyInjection; @@ -28,7 +24,7 @@ public PaymentInfoViewComponentTests() } [Fact] - public async Task PaymentInfo_Should_Calculate_TotalPaid_And_TotalPending_For_Current_Application() + public async Task PaymentInfo_Should_Display_TotalPaid_And_TotalPending_From_Summary() { // Arrange var applicationId = Guid.NewGuid(); @@ -41,29 +37,25 @@ public async Task PaymentInfo_Should_Calculate_TotalPaid_And_TotalPending_For_Cu RequestedAmount = 10000, RecommendedAmount = 8000, ApprovedAmount = 7000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var paymentRequests = new List + var summary = new ApplicationPaymentSummaryDto { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Paid - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Paid - new() { Amount = 2000, Status = PaymentRequestStatus.L1Pending }, // Pending - new() { Amount = 1500, Status = PaymentRequestStatus.L1Declined }, // Not counted - new() { Amount = 800, Status = PaymentRequestStatus.Paid }, // Not counted (no PaymentStatus = "Fully Paid") + ApplicationId = applicationId, + TotalPaid = 1500m, + TotalPending = 2000m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); appService.GetAsync(applicationId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(applicationId).Returns(paymentRequests); + paymentRequestService.GetApplicationPaymentSummaryAsync(applicationId).Returns(summary); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - applicationLinksService.GetListByApplicationAsync(applicationId).Returns([]); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(applicationId, applicationFormVersionId) as ViewViewComponentResult; @@ -71,338 +63,86 @@ public async Task PaymentInfo_Should_Calculate_TotalPaid_And_TotalPending_For_Cu // Assert model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1500m); // 1000 + 500 (PaymentStatus = "Fully Paid") - model.TotalPendingAmounts.ShouldBe(2000m); // 2000 (L1Pending only) + model.TotalPaid.ShouldBe(1500m); + model.TotalPendingAmounts.ShouldBe(2000m); model.RemainingAmount.ShouldBe(5500m); // 7000 - 1500 } [Fact] - public async Task PaymentInfo_Should_Aggregate_TotalPaid_From_Child_Applications() + public async Task PaymentInfo_Should_Calculate_RemainingAmount_From_ApprovedAmount_Minus_TotalPaid() { // Arrange - var parentAppId = Guid.NewGuid(); - var childApp1Id = Guid.NewGuid(); - var childApp2Id = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var parentApplicationDto = new GrantApplicationDto - { - Id = parentAppId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var parentPayments = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childApp1Payments = new List - { - new() { CorrelationId = childApp1Id, Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childApp2Payments = new List - { - new() { CorrelationId = childApp2Id, Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childLinks = new List - { - new() { ApplicationId = childApp1Id, LinkType = ApplicationLinkType.Child }, - new() { ApplicationId = childApp2Id, LinkType = ApplicationLinkType.Child } - }; - - var allChildPayments = childApp1Payments.Concat(childApp2Payments).ToList(); - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(parentAppId).Returns(parentApplicationDto); - paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(childLinks); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(allChildPayments); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(2300m); // 1000 (parent) + 500 (child1) + 800 (child2) - } - - [Fact] - public async Task PaymentInfo_Should_Aggregate_TotalPendingAmounts_From_Child_Applications() - { - // Arrange - var parentAppId = Guid.NewGuid(); - var childApp1Id = Guid.NewGuid(); - var childApp2Id = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var parentApplicationDto = new GrantApplicationDto - { - Id = parentAppId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var parentPayments = new List - { - new() { Amount = 2000, Status = PaymentRequestStatus.L1Pending } - }; - - var childApp1Payments = new List - { - new() { CorrelationId = childApp1Id, Amount = 1000, Status = PaymentRequestStatus.L1Pending } - }; - - var childApp2Payments = new List - { - new() { CorrelationId = childApp2Id, Amount = 500, Status = PaymentRequestStatus.L1Pending } - }; - - var childLinks = new List - { - new() { ApplicationId = childApp1Id, LinkType = ApplicationLinkType.Child }, - new() { ApplicationId = childApp2Id, LinkType = ApplicationLinkType.Child } - }; - - var allChildPayments = childApp1Payments.Concat(childApp2Payments).ToList(); - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(parentAppId).Returns(parentApplicationDto); - paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(childLinks); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(allChildPayments); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(3500m); // 2000 (parent) + 1000 (child1) + 500 (child2) - } - - [Fact] - public async Task PaymentInfo_Should_Filter_Only_Child_LinkType() - { - // Arrange - var parentAppId = Guid.NewGuid(); - var childAppId = Guid.NewGuid(); - var relatedAppId = Guid.NewGuid(); - var parentLinkAppId = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var parentApplicationDto = new GrantApplicationDto - { - Id = parentAppId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var parentPayments = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childPayments = new List - { - new() { CorrelationId = childAppId, Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var links = new List - { - new() { ApplicationId = childAppId, LinkType = ApplicationLinkType.Child }, // Should be included - new() { ApplicationId = relatedAppId, LinkType = ApplicationLinkType.Related }, // Should be excluded - new() { ApplicationId = parentLinkAppId, LinkType = ApplicationLinkType.Parent } // Should be excluded - }; - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(parentAppId).Returns(parentApplicationDto); - paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(links); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(childPayments); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1500m); // 1000 (parent) + 500 (only child, not related or parent links) - } - - [Fact] - public async Task PaymentInfo_Should_Exclude_SelfReferences() - { - // Arrange - var appId = Guid.NewGuid(); - var childAppId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); var applicationFormVersionId = Guid.NewGuid(); var applicantId = Guid.NewGuid(); var applicationDto = new GrantApplicationDto { - Id = appId, + Id = applicationId, ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var appPayments = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var childPayments = new List + var summary = new ApplicationPaymentSummaryDto { - new() { CorrelationId = childAppId, Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var links = new List - { - new() { ApplicationId = appId, LinkType = ApplicationLinkType.Child }, // Self-reference - should be excluded - new() { ApplicationId = childAppId, LinkType = ApplicationLinkType.Child } // Real child - should be included + ApplicationId = applicationId, + TotalPaid = 3500m, + TotalPending = 1000m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(appPayments); - applicationLinksService.GetListByApplicationAsync(appId).Returns(links); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(childPayments); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1500m); // 1000 (parent) + 500 (child only, self-reference excluded) - // Verify that GetListByApplicationIdsAsync was called with only the child app, not the self-reference - await paymentRequestService.Received(1).GetListByApplicationIdsAsync( - Arg.Is>(list => list.Count == 1 && list.Contains(childAppId) && !list.Contains(appId)) - ); - } - - [Fact] - public async Task PaymentInfo_Should_Handle_NoChildApplications() - { - // Arrange - var appId = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var applicationDto = new GrantApplicationDto - { - Id = appId, - RequestedAmount = 10000, - RecommendedAmount = 8000, - ApprovedAmount = 7000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var parentPayments = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, - new() { Amount = 2000, Status = PaymentRequestStatus.L1Pending } - }; - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(appId).Returns(new List()); + appService.GetAsync(applicationId).Returns(applicationDto); + paymentRequestService.GetApplicationPaymentSummaryAsync(applicationId).Returns(summary); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act - var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; + var result = await viewComponent.InvokeAsync(applicationId, applicationFormVersionId) as ViewViewComponentResult; var model = result!.ViewData!.Model as PaymentInfoViewModel; // Assert model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1000m); // Only parent payments (PaymentStatus = "Fully Paid") - model.TotalPendingAmounts.ShouldBe(2000m); // 2000 (L1Pending only) - model.RemainingAmount.ShouldBe(6000m); // 7000 - 1000 - - // Verify that GetListByApplicationIdsAsync was NOT called since there are no children - await paymentRequestService.DidNotReceive().GetListByApplicationIdsAsync(Arg.Any>()); + model.RemainingAmount.ShouldBe(6500m); // 10000 - 3500 } [Fact] - public async Task PaymentInfo_Should_Handle_ChildApplications_WithNoPaymentRequests() + public async Task PaymentInfo_Should_Include_Child_Application_Amounts_Via_Summary() { + // The ViewComponent now delegates child aggregation to the service layer. + // This test verifies it correctly uses the pre-aggregated summary. // Arrange var parentAppId = Guid.NewGuid(); - var childAppId = Guid.NewGuid(); var applicationFormVersionId = Guid.NewGuid(); var applicantId = Guid.NewGuid(); - var parentApplicationDto = new GrantApplicationDto + var applicationDto = new GrantApplicationDto { Id = parentAppId, ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var parentPayments = new List + // Summary includes parent + child amounts (pre-aggregated by service) + var summary = new ApplicationPaymentSummaryDto { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childLinks = new List - { - new() { ApplicationId = childAppId, LinkType = ApplicationLinkType.Child } + ApplicationId = parentAppId, + TotalPaid = 2300m, // e.g., 1000 (parent) + 500 (child1) + 800 (child2) + TotalPending = 3500m // e.g., 2000 (parent) + 1000 (child1) + 500 (child2) }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - appService.GetAsync(parentAppId).Returns(parentApplicationDto); - paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(childLinks); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns([]); // Empty list + appService.GetAsync(parentAppId).Returns(applicationDto); + paymentRequestService.GetApplicationPaymentSummaryAsync(parentAppId).Returns(summary); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult; @@ -410,12 +150,13 @@ public async Task PaymentInfo_Should_Handle_ChildApplications_WithNoPaymentReque // Assert model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1000m); // Only parent payments (child has none) - model.TotalPendingAmounts.ShouldBe(0m); // No pending payments + model.TotalPaid.ShouldBe(2300m); + model.TotalPendingAmounts.ShouldBe(3500m); + model.RemainingAmount.ShouldBe(7700m); // 10000 - 2300 } [Fact] - public async Task PaymentInfo_Should_Exclude_Declined_Statuses_From_Pending() + public async Task PaymentInfo_Should_Handle_Zero_Payments() { // Arrange var appId = Guid.NewGuid(); @@ -425,30 +166,26 @@ public async Task PaymentInfo_Should_Exclude_Declined_Statuses_From_Pending() var applicationDto = new GrantApplicationDto { Id = appId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + ApprovedAmount = 5000, + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var paymentRequests = new List + var summary = new ApplicationPaymentSummaryDto { - new() { Amount = 1000, Status = PaymentRequestStatus.L1Pending }, // Pending - new() { Amount = 500, Status = PaymentRequestStatus.Paid, PaymentStatus = "Fully Paid" }, // Paid - new() { Amount = 2000, Status = PaymentRequestStatus.L1Declined }, // Not pending - new() { Amount = 1500, Status = PaymentRequestStatus.L2Declined }, // Not pending - new() { Amount = 1200, Status = PaymentRequestStatus.L3Declined } // Not pending + ApplicationId = appId, + TotalPaid = 0m, + TotalPending = 0m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); + paymentRequestService.GetApplicationPaymentSummaryAsync(appId).Returns(summary); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; @@ -456,12 +193,13 @@ public async Task PaymentInfo_Should_Exclude_Declined_Statuses_From_Pending() // Assert model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(1000m); // Only L1Pending status - model.TotalPaid.ShouldBe(500m); // PaymentStatus = "Fully Paid" + model.TotalPaid.ShouldBe(0m); + model.TotalPendingAmounts.ShouldBe(0m); + model.RemainingAmount.ShouldBe(5000m); // 5000 - 0 } [Fact] - public async Task PaymentInfo_Should_Handle_PaymentStatus_FullyPaid_CaseInsensitive_WithSpaces() + public async Task PaymentInfo_Should_Map_RequestedAmount_And_RecommendedAmount() { // Arrange var appId = Guid.NewGuid(); @@ -471,30 +209,28 @@ public async Task PaymentInfo_Should_Handle_PaymentStatus_FullyPaid_CaseInsensit var applicationDto = new GrantApplicationDto { Id = appId, + RequestedAmount = 15000, + RecommendedAmount = 12000, ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var paymentRequests = new List + var summary = new ApplicationPaymentSummaryDto { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Exact match - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "FULLY PAID" }, // Upper case - new() { Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = "fully paid" }, // Lower case - new() { Amount = 300, Status = PaymentRequestStatus.Submitted, PaymentStatus = " Fully Paid " }, // With spaces - new() { Amount = 200, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Paid" }, // Not fully paid + ApplicationId = appId, + TotalPaid = 0m, + TotalPending = 0m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); + paymentRequestService.GetApplicationPaymentSummaryAsync(appId).Returns(summary); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; @@ -502,44 +238,28 @@ public async Task PaymentInfo_Should_Handle_PaymentStatus_FullyPaid_CaseInsensit // Assert model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(2600m); // 1000 + 500 + 800 + 300 (all "Fully Paid" variations, excluding 200) + model.RequestedAmount.ShouldBe(15000); + model.RecommendedAmount.ShouldBe(12000); + model.ApprovedAmount.ShouldBe(10000); + model.ApplicationId.ShouldBe(appId); + model.ApplicationFormVersionId.ShouldBe(applicationFormVersionId); + model.ApplicantId.ShouldBe(applicantId); } [Fact] - public async Task PaymentInfo_Should_Include_Submitted_WithNullPaymentStatus_InPending() + public async Task PaymentInfo_Should_Return_Empty_View_When_Feature_Disabled() { // Arrange var appId = Guid.NewGuid(); var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var applicationDto = new GrantApplicationDto - { - Id = appId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var paymentRequests = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = null }, // Pending - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "SentToCas" }, // Pending - new() { Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "Validated" }, // Pending - new() { Amount = 300, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = " " }, // Pending (whitespace) - new() { Amount = 200, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid", InvoiceStatus = "Validated" }, // Not pending (paid) - }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); + featureChecker.IsEnabledAsync("Unity.Payments").Returns(false); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; @@ -547,58 +267,17 @@ public async Task PaymentInfo_Should_Include_Submitted_WithNullPaymentStatus_InP // Assert model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(2600m); // 1000 + 500 + 800 + 300 (all with null PaymentStatus) - model.TotalPaid.ShouldBe(200m); // Only the one with PaymentStatus = "Fully Paid" - } - - [Fact] - public async Task PaymentInfo_Should_Exclude_Submitted_WithError_InvoiceStatus_FromPending() - { - // Arrange - var appId = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); + model.TotalPaid.ShouldBeNull(); + model.TotalPendingAmounts.ShouldBeNull(); + model.RemainingAmount.ShouldBeNull(); - var applicationDto = new GrantApplicationDto - { - Id = appId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var paymentRequests = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "ErrorFromCas" }, // Not pending (error) - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = " Error " }, // Not pending (error with spaces) - new() { Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "ServiceUnavailable" }, // Pending (will be retried) - new() { Amount = 300, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "SentToCas" }, // Pending (no error) - new() { Amount = 200, Status = PaymentRequestStatus.L1Pending }, // Pending (L1) - }; - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(1300m); // 800 (ServiceUnavailable - will retry) + 300 (SentToCas) + 200 (L1Pending) - model.TotalPaid.ShouldBe(0m); // None with PaymentStatus = "Fully Paid" + // Verify no service calls were made + await appService.DidNotReceive().GetAsync(Arg.Any()); + await paymentRequestService.DidNotReceive().GetApplicationPaymentSummaryAsync(Arg.Any()); } [Fact] - public async Task PaymentInfo_Should_Include_All_Pending_Levels_InPending() + public async Task PaymentInfo_Should_Call_GetApplicationPaymentSummaryAsync_With_ApplicationId() { // Arrange var appId = Guid.NewGuid(); @@ -608,49 +287,38 @@ public async Task PaymentInfo_Should_Include_All_Pending_Levels_InPending() var applicationDto = new GrantApplicationDto { Id = appId, - ApprovedAmount = 20000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + ApprovedAmount = 5000, + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var paymentRequests = new List + var summary = new ApplicationPaymentSummaryDto { - new() { Amount = 1000, Status = PaymentRequestStatus.L1Pending }, // Pending - new() { Amount = 2000, Status = PaymentRequestStatus.L2Pending }, // Pending - new() { Amount = 3000, Status = PaymentRequestStatus.L3Pending }, // Pending - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "SentToCas" }, // Pending - new() { Amount = 4000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Paid - new() { Amount = 100, Status = PaymentRequestStatus.L1Declined }, // Not pending - new() { Amount = 200, Status = PaymentRequestStatus.L2Declined }, // Not pending - new() { Amount = 300, Status = PaymentRequestStatus.L3Declined }, // Not pending + ApplicationId = appId, + TotalPaid = 100m, + TotalPending = 200m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); + paymentRequestService.GetApplicationPaymentSummaryAsync(appId).Returns(summary); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act - var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; + await viewComponent.InvokeAsync(appId, applicationFormVersionId); - // Assert - model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(6500m); // 1000 (L1) + 2000 (L2) + 3000 (L3) + 500 (Submitted with null PaymentStatus) - model.TotalPaid.ShouldBe(4000m); // Only PaymentStatus = "Fully Paid" + // Assert - Verify the correct service method was called with the right ID + await paymentRequestService.Received(1).GetApplicationPaymentSummaryAsync(appId); } private PaymentInfoViewComponent CreateViewComponent( IGrantApplicationAppService appService, IPaymentRequestAppService paymentRequestService, - IFeatureChecker featureChecker, - IApplicationLinksService applicationLinksService) + IFeatureChecker featureChecker) { var viewContext = new ViewContext { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs index a64ffdf37..ce9e36c46 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs @@ -12,6 +12,7 @@ using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; using Volo.Abp.Identity; using Unity.Notifications.EmailGroups; @@ -72,9 +73,10 @@ protected override void AfterAddApplication(IServiceCollection services) featureMock.IsEnabledAsync(Arg.Any()).Returns(true); services.AddSingleton(featureMock); - // Mock the repositories to avoid database access + // Mock the repositories and services to avoid database access services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); var externalUserLookupMock = Substitute.For(); services.AddSingleton(externalUserLookupMock); From 13f58bdb9c67ffccc39590209da2bb99f88db344 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:40:38 -0800 Subject: [PATCH 16/36] AB#31384 - Fix file encoding issue raising SQ warning --- .../Contacts/ContactAppServiceTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs index e44be9fb4..ba28c5800 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactAppServiceTests.cs @@ -1,4 +1,4 @@ -using NSubstitute; +using NSubstitute; using Shouldly; using System; using System.Linq; @@ -352,7 +352,7 @@ public async Task CreateContactAsync_NonPrimary_ShouldNotClearExistingPrimary() // Act await _service.CreateContactAsync(input); - // Assert GetQueryableAsync should not be called (ClearPrimaryAsync not invoked) + // Assert - GetQueryableAsync should not be called (ClearPrimaryAsync not invoked) await _contactLinkRepository.DidNotReceive().GetQueryableAsync(); } @@ -484,7 +484,7 @@ public async Task SetPrimaryContactAsync_WithNoExistingPrimary_ShouldSetNew() // Act await _service.SetPrimaryContactAsync("TestEntity", entityId, contactId); - // Assert only the target link should be updated (set to primary) + // Assert — only the target link should be updated (set to primary) await _contactLinkRepository.Received(1).UpdateAsync( Arg.Is(l => l.Id == targetLinkId && l.IsPrimary), true, @@ -539,7 +539,7 @@ public async Task SetPrimaryContactAsync_WithMultipleExistingPrimaries_ShouldCle // Act await _service.SetPrimaryContactAsync("TestEntity", entityId, contactId); - // Assert both existing primaries cleared + // Assert — both existing primaries cleared await _contactLinkRepository.Received(1).UpdateAsync( Arg.Is(l => l.Id == primaryLinkId1 && !l.IsPrimary), true, From 535dab33b84f55ca555ad49411d9c5e2b96f9aac Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:52:20 -0800 Subject: [PATCH 17/36] AB#31384 - Fix circular dependency on IApplicationLinksService --- .../PaymentRequests/PaymentRequestAppService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index 3ecdf011a..b73281088 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -31,7 +31,7 @@ public class PaymentRequestAppService( FsbPaymentNotifier fsbPaymentNotifier, IPaymentRequestQueryManager paymentRequestQueryManager, IPaymentRequestConfigurationManager paymentRequestConfigurationManager, - IApplicationLinksService applicationLinksService) : PaymentsAppService, IPaymentRequestAppService + Lazy applicationLinksService) : PaymentsAppService, IPaymentRequestAppService { public async Task GetDefaultAccountCodingId() @@ -333,14 +333,14 @@ public async Task> GetPaymentPendingListByCorrelationIdA public async Task GetApplicationPaymentSummaryAsync(Guid applicationId) { - var childLinks = await applicationLinksService.GetChildApplications(applicationId); + var childLinks = await applicationLinksService.Value.GetChildApplications(applicationId); var childApplicationIds = childLinks.Select(l => l.LinkedApplicationId).ToList(); return await paymentRequestQueryManager.GetApplicationPaymentSummaryAsync(applicationId, childApplicationIds); } public async Task> GetApplicationPaymentSummariesAsync(List applicationIds) { - var childApplicationIdsByParent = await applicationLinksService.GetChildApplicationIdsByParentIdsAsync(applicationIds); + var childApplicationIdsByParent = await applicationLinksService.Value.GetChildApplicationIdsByParentIdsAsync(applicationIds); return await paymentRequestQueryManager.GetApplicationPaymentSummariesAsync(applicationIds, childApplicationIdsByParent); } } From 9a9d32ad1ad5ed3a5780dae92b4361e3a9e30c20 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:20:09 -0800 Subject: [PATCH 18/36] AB#31384 - Payment Request query code quality improvements --- .../Repositories/PaymentRequestRepository.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index 01307ab2f..d26865a5e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -108,15 +108,15 @@ public async Task> GetPaymentSummariesByCorre ApplicationId = g.Key, TotalPaid = g .Where(p => p.PaymentStatus != null - && p.PaymentStatus == CasPaymentRequestStatus.FullyPaid) + && p.PaymentStatus.Trim() == CasPaymentRequestStatus.FullyPaid) .Sum(p => p.Amount), TotalPending = g .Where(p => p.Status == PaymentRequestStatus.L1Pending || p.Status == PaymentRequestStatus.L2Pending || p.Status == PaymentRequestStatus.L3Pending || (p.Status == PaymentRequestStatus.Submitted - && (p.PaymentStatus == null || p.PaymentStatus == string.Empty) - && (p.InvoiceStatus == null || p.InvoiceStatus == string.Empty + && string.IsNullOrEmpty(p.PaymentStatus) + && (string.IsNullOrEmpty(p.InvoiceStatus) || !p.InvoiceStatus.Contains(CasPaymentRequestStatus.ErrorFromCas)))) .Sum(p => p.Amount) }) From 19bb97f2935c3b411132d575303035e0e8a28b15 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:45:58 -0800 Subject: [PATCH 19/36] AB#31384 - Add Payment Request Repository Tests --- .../Repositories/PaymentRequestRepository.cs | 5 +- ...tRequestRepository_PaymentSummary_Tests.cs | 411 ++++++++++++++++++ 2 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentSummary_Tests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index d26865a5e..54bb01504 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -96,6 +96,9 @@ public async Task> GetPaymentPendingListByCorrelationIdAsyn .ToListAsync(); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", + "CA1862:Use the 'StringComparison' method overloads to perform case-insensitive string comparisons", + Justification = "EF Core does not support StringComparison - https://github.com/dotnet/efcore/issues/1222")] public async Task> GetPaymentSummariesByCorrelationIdsAsync(List correlationIds) { var dbSet = await GetDbSetAsync(); @@ -108,7 +111,7 @@ public async Task> GetPaymentSummariesByCorre ApplicationId = g.Key, TotalPaid = g .Where(p => p.PaymentStatus != null - && p.PaymentStatus.Trim() == CasPaymentRequestStatus.FullyPaid) + && p.PaymentStatus.Trim().ToUpper() == CasPaymentRequestStatus.FullyPaid.ToUpper()) .Sum(p => p.Amount), TotalPending = g .Where(p => p.Status == PaymentRequestStatus.L1Pending diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentSummary_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentSummary_Tests.cs new file mode 100644 index 000000000..0b8df1af6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentSummary_Tests.cs @@ -0,0 +1,411 @@ +using Shouldly; +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using Unity.Modules.Shared.Correlation; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; +using Volo.Abp.Uow; +using Xunit; + +namespace Unity.Payments.Domain.PaymentRequests; + +[Category("Integration")] +public class PaymentRequestRepository_PaymentSummary_Tests : PaymentsApplicationTestBase +{ + private readonly IPaymentRequestRepository _paymentRequestRepository; + private readonly ISupplierRepository _supplierRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public PaymentRequestRepository_PaymentSummary_Tests() + { + _paymentRequestRepository = GetRequiredService(); + _supplierRepository = GetRequiredService(); + _unitOfWorkManager = GetRequiredService(); + } + + #region PaymentStatus Case-Insensitive Matching + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_FullyPaid_With_Exact_Case() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid"); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].ApplicationId.ShouldBe(correlationId); + results[0].TotalPaid.ShouldBe(1000m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_FullyPaid_With_UpperCase() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 500m, + PaymentRequestStatus.Submitted, paymentStatus: "FULLY PAID"); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(500m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_FullyPaid_With_LowerCase() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 800m, + PaymentRequestStatus.Submitted, paymentStatus: "fully paid"); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(800m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_FullyPaid_With_LeadingAndTrailingSpaces() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 300m, + PaymentRequestStatus.Submitted, paymentStatus: " Fully Paid "); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(300m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Aggregate_FullyPaid_Across_All_Case_Variations() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid"); + await InsertPaymentRequestAsync(siteId, correlationId, 500m, + PaymentRequestStatus.Submitted, paymentStatus: "FULLY PAID"); + await InsertPaymentRequestAsync(siteId, correlationId, 800m, + PaymentRequestStatus.Submitted, paymentStatus: "fully paid"); + await InsertPaymentRequestAsync(siteId, correlationId, 300m, + PaymentRequestStatus.Submitted, paymentStatus: " Fully Paid "); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(2600m); // 1000 + 500 + 800 + 300 + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Not_Count_PartialMatch_PaymentStatus_As_Paid() + { + // Arrange - "Paid" alone should not match "Fully Paid" + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 200m, + PaymentRequestStatus.Submitted, paymentStatus: "Paid"); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(0m); // "Paid" != "Fully Paid" + } + + #endregion + + #region Pending Status Calculation + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Sum_All_Pending_Levels() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, + PaymentRequestStatus.L2Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, + PaymentRequestStatus.L3Pending); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPending.ShouldBe(6000m); // 1000 + 2000 + 3000 + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Include_Submitted_WithNullPaymentStatus_InPending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 500m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: null); + await InsertPaymentRequestAsync(siteId, correlationId, 300m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "SentToCas"); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPending.ShouldBe(800m); // 500 + 300 + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Exclude_Submitted_WithErrorFromCas_FromPending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + // This one has ErrorFromCas - should NOT be pending + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "Error"); + // This one has no error - SHOULD be pending + await InsertPaymentRequestAsync(siteId, correlationId, 200m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "SentToCas"); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPending.ShouldBe(200m); // Only the non-error one + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Exclude_Declined_Statuses_From_Both_Paid_And_Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.L1Declined); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, + PaymentRequestStatus.L2Declined); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, + PaymentRequestStatus.L3Declined); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(0m); + results[0].TotalPending.ShouldBe(0m); + } + + #endregion + + #region Mixed Paid and Pending + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Correctly_Separate_Paid_And_Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + // Paid + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid"); + await InsertPaymentRequestAsync(siteId, correlationId, 500m, + PaymentRequestStatus.Submitted, paymentStatus: "FULLY PAID"); + // Pending + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, + PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 800m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: null); + // Neither paid nor pending (declined) + await InsertPaymentRequestAsync(siteId, correlationId, 5000m, + PaymentRequestStatus.L1Declined); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(1500m); // 1000 + 500 + results[0].TotalPending.ShouldBe(2800m); // 2000 + 800 + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Return_Summaries_For_Multiple_CorrelationIds() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + // App 1 payments + await InsertPaymentRequestAsync(siteId, app1Id, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid"); + await InsertPaymentRequestAsync(siteId, app1Id, 500m, + PaymentRequestStatus.L1Pending); + // App 2 payments + await InsertPaymentRequestAsync(siteId, app2Id, 2000m, + PaymentRequestStatus.Submitted, paymentStatus: "fully paid"); + await InsertPaymentRequestAsync(siteId, app2Id, 300m, + PaymentRequestStatus.L2Pending); + + // Act + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([app1Id, app2Id]); + + // Assert + results.Count.ShouldBe(2); + + var app1Summary = results.Find(r => r.ApplicationId == app1Id); + app1Summary.ShouldNotBeNull(); + app1Summary!.TotalPaid.ShouldBe(1000m); + app1Summary.TotalPending.ShouldBe(500m); + + var app2Summary = results.Find(r => r.ApplicationId == app2Id); + app2Summary.ShouldNotBeNull(); + app2Summary!.TotalPaid.ShouldBe(2000m); + app2Summary.TotalPending.ShouldBe(300m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Return_Empty_For_Unknown_CorrelationIds() + { + // Arrange & Act + using var uow = _unitOfWorkManager.Begin(); + var results = await _paymentRequestRepository + .GetPaymentSummariesByCorrelationIdsAsync([Guid.NewGuid()]); + + // Assert + results.ShouldBeEmpty(); + } + + #endregion + + #region Helpers + + private async Task CreateSupplierAndSiteAsync() + { + using var uow = _unitOfWorkManager.Begin(); + var siteId = Guid.NewGuid(); + var supplier = new Supplier(Guid.NewGuid(), "TestSupplier", "SUP-001", + new Correlation(Guid.NewGuid(), "Test")); + supplier.AddSite(new Site(siteId, "001", PaymentGroup.EFT)); + await _supplierRepository.InsertAsync(supplier, true); + await uow.CompleteAsync(); + return siteId; + } + + private async Task InsertPaymentRequestAsync( + Guid siteId, + Guid correlationId, + decimal amount, + PaymentRequestStatus status, + string? paymentStatus = null, + string? invoiceStatus = null) + { + var dto = new CreatePaymentRequestDto + { + InvoiceNumber = $"INV-{Guid.NewGuid():N}", + Amount = amount, + PayeeName = "Test Payee", + ContractNumber = "0000000000", + SupplierNumber = "SUP-001", + SiteId = siteId, + CorrelationId = correlationId, + CorrelationProvider = "Test", + ReferenceNumber = $"REF-{Guid.NewGuid():N}", + BatchName = "TEST_BATCH", + BatchNumber = 1 + }; + + var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto); + paymentRequest.SetPaymentRequestStatus(status); + + if (paymentStatus != null) + { + paymentRequest.SetPaymentStatus(paymentStatus); + } + + if (invoiceStatus != null) + { + paymentRequest.SetInvoiceStatus(invoiceStatus); + } + + await _paymentRequestRepository.InsertAsync(paymentRequest, true); + } + + #endregion +} From 97c8d6254401651ed317848908541a5325f62419 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:22:31 -0800 Subject: [PATCH 20/36] AB#31384 - Add integration tests for PaymentRequestRepository and clean up whitespace --- .../Repositories/PaymentRequestRepository.cs | 4 +- .../PaymentRequestAppService.cs | 6 +- .../PaymentInfo/PaymentInfoViewComponent.cs | 2 +- ...equestQueryManager_PaymentSummary_Tests.cs | 1 - .../PaymentRequestRepository_Tests.cs | 847 ++++++++++++++++++ .../PaymentInfoViewComponentTests.cs | 2 +- .../PaymentsTestBase.cs | 28 +- .../Contacts/ContactAppService.cs | 4 +- .../ApplicationLinksAppService.cs | 82 +- .../GrantApplicationAppService.cs | 10 +- 10 files changed, 916 insertions(+), 70 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index 54bb01504..8c89f2be6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -96,8 +96,8 @@ public async Task> GetPaymentPendingListByCorrelationIdAsyn .ToListAsync(); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", - "CA1862:Use the 'StringComparison' method overloads to perform case-insensitive string comparisons", + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", + "CA1862:Use the 'StringComparison' method overloads to perform case-insensitive string comparisons", Justification = "EF Core does not support StringComparison - https://github.com/dotnet/efcore/issues/1222")] public async Task> GetPaymentSummariesByCorrelationIdsAsync(List correlationIds) { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index b73281088..c9c33f89c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -4,20 +4,20 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; using Unity.Payments.Domain.Exceptions; using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Domain.Services; using Unity.Payments.Domain.Shared; using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests.Notifications; using Unity.Payments.Permissions; using Volo.Abp; using Volo.Abp.Application.Dtos; +using Volo.Abp.Authorization.Permissions; using Volo.Abp.Data; using Volo.Abp.Features; -using Volo.Abp.Authorization.Permissions; using Volo.Abp.Users; -using Unity.Payments.PaymentRequests.Notifications; -using Unity.GrantManager.GrantApplications; namespace Unity.Payments.PaymentRequests { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs index b422c9783..f6db79533 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs @@ -45,7 +45,7 @@ public async Task InvokeAsync(Guid applicationId, Guid app ApplicationFormVersionId = applicationFormVersionId, ApplicantId = application.Applicant.Id }; - + var summary = await _paymentRequestService.GetApplicationPaymentSummaryAsync(applicationId); model.TotalPaid = summary.TotalPaid; model.TotalPendingAmounts = summary.TotalPending; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs index 0d29fbd87..d97ab38b3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs @@ -5,7 +5,6 @@ using System.ComponentModel; using System.Linq; using System.Threading.Tasks; -using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Domain.Services; using Unity.Payments.Domain.Suppliers; using Unity.Payments.PaymentRequests; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs new file mode 100644 index 000000000..58b82f166 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs @@ -0,0 +1,847 @@ +using Shouldly; +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Unity.Modules.Shared.Correlation; +using Unity.Payments.Codes; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; +using Volo.Abp.Uow; +using Xunit; + +namespace Unity.Payments.Domain.PaymentRequests; + +[Category("Integration")] +public class PaymentRequestRepository_Tests : PaymentsApplicationTestBase +{ + private readonly IPaymentRequestRepository _paymentRequestRepository; + private readonly ISupplierRepository _supplierRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public PaymentRequestRepository_Tests() + { + _paymentRequestRepository = GetRequiredService(); + _supplierRepository = GetRequiredService(); + _unitOfWorkManager = GetRequiredService(); + } + + #region GetCountByCorrelationId + + [Fact] + [Trait("Category", "Integration")] + public async Task GetCountByCorrelationId_Should_Return_Zero_When_No_Payments_Exist() + { + // Arrange + var correlationId = Guid.NewGuid(); + + // Act + var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId); + + // Assert + count.ShouldBe(0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetCountByCorrelationId_Should_Return_Correct_Count_For_Single_Payment() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + + // Act + var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId); + + // Assert + count.ShouldBe(1); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetCountByCorrelationId_Should_Return_Correct_Count_For_Multiple_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L2Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.Submitted); + + // Act + var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId); + + // Assert + count.ShouldBe(3); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetCountByCorrelationId_Should_Not_Count_Other_CorrelationIds() + { + // Arrange + var correlationId1 = Guid.NewGuid(); + var correlationId2 = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId1, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId2, 2000m, PaymentRequestStatus.L1Pending); + + // Act + var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId1); + + // Assert + count.ShouldBe(1); + } + + #endregion + + #region GetPaymentRequestCountBySiteId + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestCountBySiteId_Should_Return_Zero_When_No_Payments_Exist() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + + // Act + var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId); + + // Assert + count.ShouldBe(0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestCountBySiteId_Should_Return_Correct_Count_For_Single_Payment() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + var correlationId = Guid.NewGuid(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + + // Act + var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId); + + // Assert + count.ShouldBe(1); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestCountBySiteId_Should_Return_Correct_Count_For_Multiple_Payments() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + var correlationId1 = Guid.NewGuid(); + var correlationId2 = Guid.NewGuid(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId1, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId2, 2000m, PaymentRequestStatus.L2Pending); + + // Act + var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId); + + // Assert + count.ShouldBe(2); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestCountBySiteId_Should_Not_Count_Other_Sites() + { + // Arrange + var siteId1 = await CreateSupplierAndSiteAsync(); + var siteId2 = await CreateSupplierAndSiteAsync(); + var correlationId = Guid.NewGuid(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId1, correlationId, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId2, correlationId, 2000m, PaymentRequestStatus.L1Pending); + + // Act + var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId1); + + // Assert + count.ShouldBe(1); + } + + #endregion + + #region GetPaymentRequestByInvoiceNumber + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestByInvoiceNumber_Should_Return_Null_When_Not_Found() + { + // Arrange + var invoiceNumber = "NONEXISTENT-INV-001"; + + // Act + var payment = await _paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber); + + // Assert + payment.ShouldBeNull(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestByInvoiceNumber_Should_Return_Payment_When_Found() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + var correlationId = Guid.NewGuid(); + var invoiceNumber = $"TEST-INV-{Guid.NewGuid():N}"; + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending, invoiceNumber); + + // Act + var payment = await _paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber); + + // Assert + payment.ShouldNotBeNull(); + payment.InvoiceNumber.ShouldBe(invoiceNumber); + payment.Amount.ShouldBe(1000m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestByInvoiceNumber_Should_Return_Only_Matching_Invoice() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + var correlationId = Guid.NewGuid(); + var invoiceNumber1 = $"TEST-INV-{Guid.NewGuid():N}"; + var invoiceNumber2 = $"TEST-INV-{Guid.NewGuid():N}"; + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending, invoiceNumber1); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L2Pending, invoiceNumber2); + + // Act + var payment = await _paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber1); + + // Assert + payment.ShouldNotBeNull(); + payment.InvoiceNumber.ShouldBe(invoiceNumber1); + payment.Amount.ShouldBe(1000m); + } + + #endregion + + #region GetTotalPaymentRequestAmountByCorrelationIdAsync + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Return_Zero_When_No_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(0m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Sum_Single_Payment() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Sum_Multiple_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 2500m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.L1Pending); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(6500m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_L1Declined() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 5000m, PaymentRequestStatus.L1Declined); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // Declined amount excluded + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_L2Declined() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.L2Declined); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // Declined amount excluded + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_L3Declined() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L3Declined); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // Declined amount excluded + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_NotFound_InvoiceStatus() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 4000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.NotFound); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // NotFound invoice excluded + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_ErrorFromCas_InvoiceStatus() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 6000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ErrorFromCas); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // ErrorFromCas invoice excluded + } + + #endregion + + #region GetPaymentRequestsBySentToCasStatusAsync + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_Empty_When_No_Matching_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.Validated); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_ServiceUnavailable_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ServiceUnavailable); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_SentToCas_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.SentToCas); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.SentToCas); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_NeverValidated_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.NeverValidated); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.NeverValidated); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_All_ReCheck_Statuses() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.SentToCas); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.NeverValidated); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(3); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.ServiceUnavailable).ShouldBeTrue(); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.SentToCas).ShouldBeTrue(); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.NeverValidated).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Not_Return_Null_InvoiceStatus() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: null); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.SentToCas); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.SentToCas); + } + + #endregion + + #region GetPaymentRequestsByFailedsStatusAsync + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_Empty_When_No_Matching_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.Validated); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_ServiceUnavailable_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + var paymentRequest = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable); + + // Update to trigger LastModificationTime + paymentRequest.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest, true); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ServiceUnavailable); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_ErrorFromCas_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + var paymentRequest = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ErrorFromCas); + + // Update to trigger LastModificationTime + paymentRequest.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest, true); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ErrorFromCas); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_Both_Failed_Statuses() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + var paymentRequest1 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable); + var paymentRequest2 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ErrorFromCas); + + // Update to trigger LastModificationTime + paymentRequest1.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest1, true); + paymentRequest2.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest2, true); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.Count.ShouldBe(2); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.ServiceUnavailable).ShouldBeTrue(); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.ErrorFromCas).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Not_Return_Null_InvoiceStatus() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + var paymentRequest1 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: null); + var paymentRequest2 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ErrorFromCas); + + // Update to trigger LastModificationTime (only update the one with ErrorFromCas) + paymentRequest2.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest2, true); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ErrorFromCas); + } + + #endregion + + #region GetPaymentPendingListByCorrelationIdAsync + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_Empty_When_No_Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_L1Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.Count.ShouldBe(1); + payments[0].Status.ShouldBe(PaymentRequestStatus.L1Pending); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_L2Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L2Pending); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.Count.ShouldBe(1); + payments[0].Status.ShouldBe(PaymentRequestStatus.L2Pending); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_All_Pending_Statuses() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L2Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.Submitted); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.Count.ShouldBe(2); + payments.Any(p => p.Status == PaymentRequestStatus.L1Pending).ShouldBeTrue(); + payments.Any(p => p.Status == PaymentRequestStatus.L2Pending).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Not_Return_L3Pending() + { + // Arrange - The method only returns L1 and L2 pending + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L3Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L1Pending); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.Count.ShouldBe(1); + payments[0].Status.ShouldBe(PaymentRequestStatus.L1Pending); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Only_Return_Matching_CorrelationId() + { + // Arrange + var correlationId1 = Guid.NewGuid(); + var correlationId2 = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId1, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId2, 2000m, PaymentRequestStatus.L1Pending); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId1); + + // Assert + payments.Count.ShouldBe(1); + payments[0].CorrelationId.ShouldBe(correlationId1); + } + + #endregion + + #region Helpers + + private async Task CreateSupplierAndSiteAsync() + { + using var uow = _unitOfWorkManager.Begin(); + var siteId = Guid.NewGuid(); + var supplier = new Supplier(Guid.NewGuid(), "TestSupplier", "SUP-001", + new Correlation(Guid.NewGuid(), "Test")); + supplier.AddSite(new Site(siteId, "001", PaymentGroup.EFT)); + await _supplierRepository.InsertAsync(supplier, true); + await uow.CompleteAsync(); + return siteId; + } + + private async Task InsertPaymentRequestAsync( + Guid siteId, + Guid correlationId, + decimal amount, + PaymentRequestStatus status, + string? customInvoiceNumber = null, + string? paymentStatus = null, + string? invoiceStatus = null) + { + await InsertAndGetPaymentRequestAsync(siteId, correlationId, amount, status, + customInvoiceNumber, paymentStatus, invoiceStatus); + } + + private async Task InsertAndGetPaymentRequestAsync( + Guid siteId, + Guid correlationId, + decimal amount, + PaymentRequestStatus status, + string? customInvoiceNumber = null, + string? paymentStatus = null, + string? invoiceStatus = null) + { + var invoiceNumber = customInvoiceNumber ?? $"INV-{Guid.NewGuid():N}"; + var dto = new CreatePaymentRequestDto + { + InvoiceNumber = invoiceNumber, + Amount = amount, + PayeeName = "Test Payee", + ContractNumber = "0000000000", + SupplierNumber = "SUP-001", + SiteId = siteId, + CorrelationId = correlationId, + CorrelationProvider = "Test", + ReferenceNumber = $"REF-{Guid.NewGuid():N}", + BatchName = "TEST_BATCH", + BatchNumber = 1 + }; + + var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto); + paymentRequest.SetPaymentRequestStatus(status); + + if (paymentStatus != null) + { + paymentRequest.SetPaymentStatus(paymentStatus); + } + + if (invoiceStatus != null) + { + paymentRequest.SetInvoiceStatus(invoiceStatus); + } + + await _paymentRequestRepository.InsertAsync(paymentRequest, true); + return paymentRequest; + } + + #endregion +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs index 18fba64ff..549a5cab2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs @@ -331,7 +331,7 @@ private PaymentInfoViewComponent CreateViewComponent( }; var viewComponent = new PaymentInfoViewComponent( - appService, + appService, paymentRequestService, featureChecker) { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs index ce9e36c46..b223ff74d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs @@ -1,20 +1,20 @@ -using System; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using System; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.Notifications.EmailGroups; +using Unity.Payments.Security; using Volo.Abp; -using Volo.Abp.Modularity; -using Volo.Abp.Uow; -using Volo.Abp.Testing; -using NSubstitute; using Volo.Abp.Features; -using Volo.Abp.Users; -using Unity.Payments.Security; +using Volo.Abp.Identity; +using Volo.Abp.Modularity; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; -using Unity.GrantManager.Applications; -using Unity.GrantManager.GrantApplications; -using Volo.Abp.Identity; -using Unity.Notifications.EmailGroups; +using Volo.Abp.Testing; +using Volo.Abp.Uow; +using Volo.Abp.Users; namespace Unity.Payments; @@ -84,8 +84,8 @@ protected override void AfterAddApplication(IServiceCollection services) var currentUser = Substitute.For(); currentUser.Id.Returns(ci => CurrentUserId); services.AddSingleton(currentUser); - - // We add a mock of this service to satisfy the IOC without having to spin up a whole settings table + + // We add a mock of this service to satisfy the IOC without having to spin up a whole settings table var settingManagerMock = Substitute.For(); // Mock required calls services.AddSingleton(settingManagerMock); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs index 8a70946c5..f8fcc31f7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Contacts/ContactAppService.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; @@ -11,7 +11,7 @@ namespace Unity.GrantManager.Contacts; /// /// Generic contact management service. Manages contacts and their links to arbitrary entity types. -/// Currently marked as [RemoteService(false)] not exposed as an HTTP endpoint. +/// Currently marked as [RemoteService(false)] — not exposed as an HTTP endpoint. /// Authorization roles to be configured before enabling remote access. /// diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs index fd765b4b0..6e23ce9b6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Unity.GrantManager.Applications; using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Applications; using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -32,7 +32,7 @@ public class ApplicationLinksAppService : CrudAppService< public IApplicationRepository ApplicationRepository { get; set; } = null!; public IApplicantRepository ApplicantRepository { get; set; } = null!; public IApplicationFormAppService ApplicationFormAppService { get; set; } = null!; - + public ApplicationLinksAppService(IRepository repository) : base(repository) { } public async Task> GetListByApplicationAsync(Guid applicationId) @@ -178,7 +178,7 @@ join applicant in applicantsQuery on application.ApplicantId equals applicant.Id public async Task GetCurrentApplicationInfoAsync(Guid applicationId) { Logger.LogInformation("GetCurrentApplicationInfoAsync called with applicationId: {ApplicationId}", applicationId); - + try { var applicationsQuery = await ApplicationRepository.GetQueryableAsync(); @@ -186,7 +186,7 @@ public async Task GetCurrentApplicationInfoAsync(Guid a .Include(a => a.ApplicationStatus) .Where(a => a.Id == applicationId) .FirstOrDefaultAsync(); - + if (application == null) { Logger.LogWarning("Application not found with ID: {ApplicationId}", applicationId); @@ -273,13 +273,13 @@ public async Task GetCurrentApplicationInfoAsync(Guid a LinkType = ApplicationLinkType.Related, FormVersion = formVersion }; - + return result; } catch (Exception ex) { Logger.LogError(ex, "Critical error in GetCurrentApplicationInfoAsync for applicationId: {ApplicationId}", applicationId); - + // If all else fails, return a basic structure return new ApplicationLinksInfoDto { @@ -300,16 +300,16 @@ public async Task DeleteWithPairAsync(Guid applicationLinkId) { // Get the link to find the paired record var link = await Repository.GetAsync(applicationLinkId); - + // Find the paired link (reverse direction) var applicationLinksQuery = await ApplicationLinksRepository.GetQueryableAsync(); var pairedLink = await applicationLinksQuery .Where(x => x.ApplicationId == link.LinkedApplicationId && x.LinkedApplicationId == link.ApplicationId) .FirstOrDefaultAsync(); - + // Delete both links await Repository.DeleteAsync(applicationLinkId); - + if (pairedLink != null) { await Repository.DeleteAsync(pairedLink.Id); @@ -319,7 +319,7 @@ public async Task DeleteWithPairAsync(Guid applicationLinkId) public async Task GetApplicationDetailsByReferenceAsync(string referenceNumber) { Logger.LogInformation("GetApplicationDetailsByReferenceAsync called with referenceNumber: {ReferenceNumber}", referenceNumber); - + try { var applicationsQuery = await ApplicationRepository.GetQueryableAsync(); @@ -327,7 +327,7 @@ public async Task GetApplicationDetailsByReferenceAsync .Include(a => a.ApplicationStatus) .Where(a => a.ReferenceNo == referenceNumber) .FirstOrDefaultAsync(); - + if (application == null) { Logger.LogWarning("Application not found with ReferenceNumber: {ReferenceNumber}", referenceNumber); @@ -399,7 +399,7 @@ public async Task GetApplicationDetailsByReferenceAsync catch (Exception ex) { Logger.LogError(ex, "Critical error in GetApplicationDetailsByReferenceAsync for referenceNumber: {ReferenceNumber}", referenceNumber); - + return new ApplicationLinksInfoDto { Id = Guid.Empty, @@ -418,46 +418,46 @@ public async Task GetApplicationDetailsByReferenceAsync public async Task UpdateLinkTypeAsync(Guid applicationLinkId, ApplicationLinkType newLinkType) { Logger.LogInformation("UpdateLinkTypeAsync called with linkId: {LinkId}, newLinkType: {LinkType}", applicationLinkId, newLinkType); - + // Get the existing link var link = await Repository.GetAsync(applicationLinkId); - + if (link != null) { // Update the link type link.LinkType = newLinkType; await Repository.UpdateAsync(link); - + Logger.LogInformation("Successfully updated link type for linkId: {LinkId}", applicationLinkId); } else { Logger.LogWarning("Link not found with ID: {LinkId}", applicationLinkId); } - + } public async Task ValidateApplicationLinksAsync( - Guid currentApplicationId, + Guid currentApplicationId, List proposedLinks) { var result = new ApplicationLinkValidationResult(); - + // Skip validation for empty or Related-only links var hierarchicalLinks = proposedLinks.Where(l => l.LinkType != ApplicationLinkType.Related).ToList(); if (hierarchicalLinks.Count == 0) { return result; } - + // Validate current app constraints var currentAppError = ValidateCurrentApplicationConstraints(hierarchicalLinks); - + // Process each proposed link foreach (var proposedLink in hierarchicalLinks) { var errorMessage = await ValidateLinkBasedOnType(currentApplicationId, proposedLink, currentAppError, hierarchicalLinks); - + if (!string.IsNullOrEmpty(errorMessage)) { result.ValidationErrors[proposedLink.ReferenceNumber] = true; @@ -468,31 +468,31 @@ public async Task ValidateApplicationLinksAsync result.ValidationErrors[proposedLink.ReferenceNumber] = false; } } - + return result; } - + private static string ValidateCurrentApplicationConstraints(List proposedLinks) { var parentCount = proposedLinks.Count(l => l.LinkType == ApplicationLinkType.Parent); - var hasParent = proposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Parent); - var hasChild = proposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Child); + var hasParent = proposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Parent); + var hasChild = proposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Child); if (parentCount > 1) { return ERROR_MULTIPLE_PARENTS; } - + if (hasParent && hasChild) { return ERROR_PARENT_WITH_CHILDREN; } - + return string.Empty; } - + private async Task ValidateLinkBasedOnType( - Guid currentApplicationId, + Guid currentApplicationId, ApplicationLinkValidationRequest proposedLink, string currentAppError, List allProposedLinks) @@ -505,25 +505,25 @@ private async Task ValidateLinkBasedOnType( { return currentAppError; } - + // Then check if the proposed parent is already a child of another app return await ValidateTargetCannotBeParentIfAlreadyChild(currentApplicationId, proposedLink); - + case ApplicationLinkType.Child: // Check if current app is trying to be both parent and child if (!string.IsNullOrEmpty(currentAppError) && allProposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Parent)) { return ERROR_CURRENT_APP_IS_CHILD; } - + // Check target app conflicts return await ValidateTargetCanAcceptChildLink(currentApplicationId, proposedLink); - + default: return string.Empty; } } - + private async Task ValidateTargetCannotBeParentIfAlreadyChild(Guid currentApplicationId, ApplicationLinkValidationRequest proposedLink) { var targetLinks = await GetListByApplicationAsync(proposedLink.TargetApplicationId); @@ -537,29 +537,29 @@ private async Task ValidateTargetCannotBeParentIfAlreadyChild(Guid curre { return ERROR_TARGET_CHILD_CANNOT_BE_PARENT; } - + return string.Empty; } - + private async Task ValidateTargetCanAcceptChildLink(Guid currentApplicationId, ApplicationLinkValidationRequest proposedLink) { var targetLinks = await GetListByApplicationAsync(proposedLink.TargetApplicationId); // Exclude reverse links and self-references - var targetExternalLinks = targetLinks.Where(l => - l.ApplicationId != currentApplicationId && + var targetExternalLinks = targetLinks.Where(l => + l.ApplicationId != currentApplicationId && l.ApplicationId != proposedLink.TargetApplicationId).ToList(); - + if (targetExternalLinks.Exists(l => l.LinkType == ApplicationLinkType.Parent)) { return ERROR_TARGET_ALREADY_HAS_PARENT; } - + if (targetExternalLinks.Exists(l => l.LinkType == ApplicationLinkType.Child)) { return ERROR_TARGET_IS_PARENT_TO_OTHERS; } - + return string.Empty; } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 5b6df4c68..cca8d808c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -39,10 +39,10 @@ namespace Unity.GrantManager.GrantApplications; public class GrantApplicationAppService( IApplicationManager applicationManager, IApplicationRepository applicationRepository, - IApplicationStatusRepository applicationStatusRepository, + IApplicationStatusRepository applicationStatusRepository, IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicantRepository applicantRepository, - IApplicationFormRepository applicationFormRepository, + IApplicationFormRepository applicationFormRepository, IApplicantAgentRepository applicantAgentRepository, IApplicantAddressRepository applicantAddressRepository, IApplicantSupplierAppService applicantSupplierService, @@ -209,7 +209,7 @@ public async Task GetAsync(Guid id) public async Task GetApplicationFormAsync(Guid applicationFormId) { return await (await applicationFormRepository.GetQueryableAsync()).FirstOrDefaultAsync(s => s.Id == applicationFormId); - } + } [Authorize(UnitySelector.Review.AssessmentResults.Update.Default)] public async Task UpdateAssessmentResultsAsync(Guid id, CreateUpdateAssessmentResultsDto input) @@ -665,7 +665,7 @@ public async Task UpdateSupplierNumberAsync(Guid applicationId, string? supplier } return await applicantAgentRepository.UpdateAsync(applicantAgent); - } + } [Authorize(UnitySelector.Applicant.UpdatePolicy)] public async Task UpdateMergedApplicantAsync(Guid applicationId, CreateUpdateApplicantInfoDto input) @@ -810,7 +810,7 @@ public async Task UpdateApplicationStatus(Guid[] applicationIds, Guid statusId) Debug.WriteLine(ex.ToString()); } } - } + } public async Task> GetApplicationListAsync(List applicationIds) { From d729e49ea6a0ad1f8f6f1a1d5fbd8a29f8951b62 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:45:56 -0800 Subject: [PATCH 21/36] AB#31384 - Rename calculation objects from Payment Summary to Payment Rollup --- ...yDto.cs => ApplicationPaymentRollupDto.cs} | 2 +- .../IPaymentRequestAppService.cs | 4 +- .../IPaymentRequestRepository.cs | 2 +- .../Services/IPaymentRequestQueryManager.cs | 6 +- .../Services/PaymentRequestQueryManager.cs | 52 ++++++--- .../Repositories/PaymentRequestRepository.cs | 14 ++- .../PaymentRequestAppService.cs | 28 ++++- .../PaymentInfo/PaymentInfoViewComponent.cs | 6 +- ...equestQueryManager_PaymentRollup_Tests.cs} | 100 +++++++++--------- ...tRequestRepository_PaymentRollup_Tests.cs} | 48 ++++----- .../PaymentInfoViewComponentTests.cs | 38 +++---- .../GrantApplicationAppService.cs | 11 +- .../ApplicantSubmissionsViewComponent.cs | 10 +- 13 files changed, 188 insertions(+), 133 deletions(-) rename applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/{ApplicationPaymentSummaryDto.cs => ApplicationPaymentRollupDto.cs} (83%) rename applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/{PaymentRequestQueryManager_PaymentSummary_Tests.cs => PaymentRequestQueryManager_PaymentRollup_Tests.cs} (75%) rename applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/{PaymentRequestRepository_PaymentSummary_Tests.cs => PaymentRequestRepository_PaymentRollup_Tests.cs} (89%) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentSummaryDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs similarity index 83% rename from applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentSummaryDto.cs rename to applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs index 0d6706572..71053bd12 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentSummaryDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs @@ -3,7 +3,7 @@ namespace Unity.Payments.PaymentRequests; [Serializable] -public class ApplicationPaymentSummaryDto +public class ApplicationPaymentRollupDto { public Guid ApplicationId { get; set; } public decimal TotalPaid { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs index ae1fc15a4..7d1295eee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs @@ -21,7 +21,7 @@ public interface IPaymentRequestAppService : IApplicationService Task GetUserPaymentThresholdAsync(); Task ManuallyAddPaymentRequestsToReconciliationQueue(List paymentRequestIds); Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); - Task GetApplicationPaymentSummaryAsync(Guid applicationId); - Task> GetApplicationPaymentSummariesAsync(List applicationIds); + Task GetApplicationPaymentRollupAsync(Guid applicationId); + Task> GetApplicationPaymentRollupBatchAsync(List applicationIds); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs index 9a27897a1..e4d488fcf 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs @@ -15,6 +15,6 @@ public interface IPaymentRequestRepository : IRepository Task GetPaymentRequestByInvoiceNumber(string invoiceNumber); Task> GetPaymentRequestsByFailedsStatusAsync(); Task> GetPaymentPendingListByCorrelationIdAsync(Guid correlationId); - Task> GetPaymentSummariesByCorrelationIdsAsync(List correlationIds); + Task> GetBatchPaymentRollupsByCorrelationIdsAsync(List correlationIds); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs index d7c2c36cd..3578156da 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs @@ -36,8 +36,8 @@ public interface IPaymentRequestQueryManager // Pending Payments Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); - // Payment Summaries (paid + pending aggregation) - Task GetApplicationPaymentSummaryAsync(Guid applicationId, List childApplicationIds); - Task> GetApplicationPaymentSummariesAsync(List applicationIds, Dictionary> childApplicationIdsByParent); + // Payment Rollups (paid + pending aggregation) + Task GetApplicationPaymentRollupAsync(Guid applicationId, List childApplicationIds); + Task> GetApplicationPaymentRollupBatchAsync(List applicationIds, Dictionary> childApplicationIdsByParent); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs index b7b76ddcb..30a4fdb7a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs @@ -223,14 +223,25 @@ public async Task> GetPaymentPendingListByCorrelationIdA return objectMapper.Map, List>(payments); } - public async Task GetApplicationPaymentSummaryAsync(Guid applicationId, List childApplicationIds) + /// + /// Retrieves a payment rollup for the specified application and its associated child applications. + /// + /// This method combines payment information from both the main application and its child + /// applications, providing an overall view of payment status. Use this method to obtain a single rollup when + /// displaying applications with related child records. + /// The unique identifier of the main application for which the payment rollup is requested. + /// A list of unique identifiers representing child applications whose payment data will be included in the + /// rollup. Cannot be null. + /// An instance of containing the aggregated total paid and pending + /// amounts for the main application and its child applications. + public async Task GetApplicationPaymentRollupAsync(Guid applicationId, List childApplicationIds) { var allCorrelationIds = new List { applicationId }; allCorrelationIds.AddRange(childApplicationIds); - var summaries = await paymentRequestRepository.GetPaymentSummariesByCorrelationIdsAsync(allCorrelationIds); + var summaries = await paymentRequestRepository.GetBatchPaymentRollupsByCorrelationIdsAsync(allCorrelationIds); - return new ApplicationPaymentSummaryDto + return new ApplicationPaymentRollupDto { ApplicationId = applicationId, TotalPaid = summaries.Sum(s => s.TotalPaid), @@ -238,7 +249,20 @@ public async Task GetApplicationPaymentSummaryAsyn }; } - public async Task> GetApplicationPaymentSummariesAsync( + /// + /// Retrieves batch payment rollup information for the specified application IDs, aggregating totals + /// for both paid and pending amounts from parent and child applications. + /// + /// This method performs a single database query to efficiently aggregate payment data + /// for all specified parent and child applications. The results include all relevant payment information + /// for each application ID provided. + /// A list of unique identifiers for the applications whose payment rollups are to be retrieved. Cannot be + /// null or empty. + /// A dictionary that maps each parent application ID to a list of its child application IDs. Must not be null + /// and should contain valid GUIDs. + /// A dictionary where each key is an application ID and the value is an containing + /// the total paid and pending amounts for that application, including amounts from any child applications. + public async Task> GetApplicationPaymentRollupBatchAsync( List applicationIds, Dictionary> childApplicationIdsByParent) { @@ -252,20 +276,20 @@ public async Task> GetApplication } } - var summaries = await paymentRequestRepository.GetPaymentSummariesByCorrelationIdsAsync(allCorrelationIds.ToList()); - var summaryLookup = summaries.ToDictionary(s => s.ApplicationId); + var paymentRollups = await paymentRequestRepository.GetBatchPaymentRollupsByCorrelationIdsAsync(allCorrelationIds.ToList()); + var rollupLookup = paymentRollups.ToDictionary(s => s.ApplicationId); - var result = new Dictionary(); + var result = new Dictionary(); foreach (var applicationId in applicationIds) { decimal totalPaid = 0; decimal totalPending = 0; // Add the parent application's own amounts - if (summaryLookup.TryGetValue(applicationId, out var parentSummary)) + if (rollupLookup.TryGetValue(applicationId, out var parentRollup)) { - totalPaid += parentSummary.TotalPaid; - totalPending += parentSummary.TotalPending; + totalPaid += parentRollup.TotalPaid; + totalPending += parentRollup.TotalPending; } // Add child application amounts @@ -273,15 +297,15 @@ public async Task> GetApplication { foreach (var childId in childIds) { - if (summaryLookup.TryGetValue(childId, out var childSummary)) + if (rollupLookup.TryGetValue(childId, out var childApplicationRollup)) { - totalPaid += childSummary.TotalPaid; - totalPending += childSummary.TotalPending; + totalPaid += childApplicationRollup.TotalPaid; + totalPending += childApplicationRollup.TotalPending; } } } - result[applicationId] = new ApplicationPaymentSummaryDto + result[applicationId] = new ApplicationPaymentRollupDto { ApplicationId = applicationId, TotalPaid = totalPaid, diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index 8c89f2be6..95ecbb5c3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -96,17 +96,27 @@ public async Task> GetPaymentPendingListByCorrelationIdAsyn .ToListAsync(); } + /// + /// Asynchronously retrieves payment rollup information for each specified correlation ID. + /// + /// This method queries the database for payment records associated with the provided + /// correlation IDs and aggregates payment amounts based on their status. Ensure that the correlation IDs are + /// valid to avoid empty results. + /// A list of correlation IDs used to filter payment records. Each ID must be a valid GUID. + /// A task that represents the asynchronous operation. The task result contains a list of + /// ApplicationPaymentRollupDto objects, each summarizing the total paid and total pending amounts for the + /// corresponding correlation ID. [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1862:Use the 'StringComparison' method overloads to perform case-insensitive string comparisons", Justification = "EF Core does not support StringComparison - https://github.com/dotnet/efcore/issues/1222")] - public async Task> GetPaymentSummariesByCorrelationIdsAsync(List correlationIds) + public async Task> GetBatchPaymentRollupsByCorrelationIdsAsync(List correlationIds) { var dbSet = await GetDbSetAsync(); var results = await dbSet .Where(p => correlationIds.Contains(p.CorrelationId)) .GroupBy(p => p.CorrelationId) - .Select(g => new ApplicationPaymentSummaryDto + .Select(g => new ApplicationPaymentRollupDto { ApplicationId = g.Key, TotalPaid = g diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index c9c33f89c..8af0d4d53 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -331,17 +331,37 @@ public async Task> GetPaymentPendingListByCorrelationIdA return await paymentRequestQueryManager.GetPaymentPendingListByCorrelationIdAsync(applicationId); } - public async Task GetApplicationPaymentSummaryAsync(Guid applicationId) + /// + /// Retrieves the payment rollup for the specified application, including any linked child + /// applications. + /// + /// This method first obtains any child applications associated with the given + /// application ID and then queries the payment rollup for both the application and its children. Use this + /// method to obtain a comprehensive payment overview when applications may be linked together. + /// The unique identifier of the application for which the payment rollup is requested. + /// An instance of containing the payment rollup details for the + /// specified application and its linked child applications. + public async Task GetApplicationPaymentRollupAsync(Guid applicationId) { var childLinks = await applicationLinksService.Value.GetChildApplications(applicationId); var childApplicationIds = childLinks.Select(l => l.LinkedApplicationId).ToList(); - return await paymentRequestQueryManager.GetApplicationPaymentSummaryAsync(applicationId, childApplicationIds); + return await paymentRequestQueryManager.GetApplicationPaymentRollupAsync(applicationId, childApplicationIds); } - public async Task> GetApplicationPaymentSummariesAsync(List applicationIds) + /// + /// Retrieves batch payment rollup information for the specified applications and their child applications. + /// + /// This method asynchronously resolves child applications linked to the provided + /// application identifiers before fetching a batch of payment rollups. Ensure that all application identifiers are valid + /// and exist in the system. + /// A list of application identifiers for which payment rollups are requested. This parameter cannot be null + /// or empty. + /// A dictionary mapping each application identifier to its corresponding . The dictionary + /// includes entries for both the specified applications and their child applications. + public async Task> GetApplicationPaymentRollupBatchAsync(List applicationIds) { var childApplicationIdsByParent = await applicationLinksService.Value.GetChildApplicationIdsByParentIdsAsync(applicationIds); - return await paymentRequestQueryManager.GetApplicationPaymentSummariesAsync(applicationIds, childApplicationIdsByParent); + return await paymentRequestQueryManager.GetApplicationPaymentRollupBatchAsync(applicationIds, childApplicationIdsByParent); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs index f6db79533..32ddf14fe 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs @@ -46,9 +46,9 @@ public async Task InvokeAsync(Guid applicationId, Guid app ApplicantId = application.Applicant.Id }; - var summary = await _paymentRequestService.GetApplicationPaymentSummaryAsync(applicationId); - model.TotalPaid = summary.TotalPaid; - model.TotalPendingAmounts = summary.TotalPending; + var rollup = await _paymentRequestService.GetApplicationPaymentRollupAsync(applicationId); + model.TotalPaid = rollup.TotalPaid; + model.TotalPendingAmounts = rollup.TotalPending; model.RemainingAmount = application.ApprovedAmount - model.TotalPaid; return View(model); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs similarity index 75% rename from applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs rename to applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs index d97ab38b3..49c2c4bd1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentSummary_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs @@ -14,18 +14,18 @@ namespace Unity.Payments.Domain.PaymentRequests; [Category("Domain")] -public class PaymentRequestQueryManager_PaymentSummary_Tests +public class PaymentRequestQueryManager_PaymentRollup_Tests { - #region GetApplicationPaymentSummaryAsync (Single Application) + #region GetApplicationPaymentRollupAsync (Single Application) [Fact] - public async Task Should_Return_Summary_For_Single_Application_With_NoChildren() + public async Task Should_Return_Rollup_For_Single_Application_With_NoChildren() { // Arrange var appId = Guid.NewGuid(); var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List { new() { ApplicationId = appId, TotalPaid = 1500m, TotalPending = 2000m } }); @@ -33,7 +33,7 @@ public async Task Should_Return_Summary_For_Single_Application_With_NoChildren() var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummaryAsync(appId, []); + var result = await manager.GetApplicationPaymentRollupAsync(appId, []); // Assert result.ShouldNotBeNull(); @@ -42,12 +42,12 @@ public async Task Should_Return_Summary_For_Single_Application_With_NoChildren() result.TotalPending.ShouldBe(2000m); // Verify repo was called with only the parent ID - await repo.Received(1).GetPaymentSummariesByCorrelationIdsAsync( + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync( Arg.Is>(ids => ids.Count == 1 && ids.Contains(appId))); } [Fact] - public async Task Should_Aggregate_Summary_From_Parent_And_Children() + public async Task Should_Aggregate_Rollup_From_Parent_And_Children() { // Arrange var parentId = Guid.NewGuid(); @@ -55,8 +55,8 @@ public async Task Should_Aggregate_Summary_From_Parent_And_Children() var child2Id = Guid.NewGuid(); var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List { new() { ApplicationId = parentId, TotalPaid = 1000m, TotalPending = 500m }, new() { ApplicationId = child1Id, TotalPaid = 300m, TotalPending = 200m }, @@ -66,7 +66,7 @@ public async Task Should_Aggregate_Summary_From_Parent_And_Children() var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummaryAsync(parentId, [child1Id, child2Id]); + var result = await manager.GetApplicationPaymentRollupAsync(parentId, [child1Id, child2Id]); // Assert result.ShouldNotBeNull(); @@ -75,7 +75,7 @@ public async Task Should_Aggregate_Summary_From_Parent_And_Children() result.TotalPending.ShouldBe(800m); // 500 + 200 + 100 // Verify all IDs were sent to the repository - await repo.Received(1).GetPaymentSummariesByCorrelationIdsAsync( + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync( Arg.Is>(ids => ids.Count == 3 && ids.Contains(parentId) && ids.Contains(child1Id) && ids.Contains(child2Id))); } @@ -86,13 +86,13 @@ public async Task Should_Return_Zeros_When_No_Payment_Data_Exists() // Arrange var appId = Guid.NewGuid(); var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List()); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummaryAsync(appId, []); + var result = await manager.GetApplicationPaymentRollupAsync(appId, []); // Assert result.ShouldNotBeNull(); @@ -109,8 +109,8 @@ public async Task Should_Return_Zeros_When_Children_Have_No_Payments() var childId = Guid.NewGuid(); var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List { new() { ApplicationId = parentId, TotalPaid = 500m, TotalPending = 0m } // childId has no payments - not returned by repository @@ -119,7 +119,7 @@ public async Task Should_Return_Zeros_When_Children_Have_No_Payments() var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummaryAsync(parentId, [childId]); + var result = await manager.GetApplicationPaymentRollupAsync(parentId, [childId]); // Assert result.TotalPaid.ShouldBe(500m); // Only parent amount @@ -134,8 +134,8 @@ public async Task Should_Handle_Single_Child_Application() var childId = Guid.NewGuid(); var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List { new() { ApplicationId = parentId, TotalPaid = 1000m, TotalPending = 0m }, new() { ApplicationId = childId, TotalPaid = 500m, TotalPending = 300m } @@ -144,7 +144,7 @@ public async Task Should_Handle_Single_Child_Application() var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummaryAsync(parentId, [childId]); + var result = await manager.GetApplicationPaymentRollupAsync(parentId, [childId]); // Assert result.ApplicationId.ShouldBe(parentId); @@ -154,10 +154,10 @@ public async Task Should_Handle_Single_Child_Application() #endregion - #region GetApplicationPaymentSummariesAsync (Batch) + #region GetApplicationPaymentRollupsAsync (Batch) [Fact] - public async Task Should_Return_Batch_Summaries_For_Multiple_Applications_Without_Children() + public async Task Should_Return_Batch_Rollups_For_Multiple_Applications_Without_Children() { // Arrange var app1Id = Guid.NewGuid(); @@ -165,8 +165,8 @@ public async Task Should_Return_Batch_Summaries_For_Multiple_Applications_Withou var app3Id = Guid.NewGuid(); var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List { new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 200m }, new() { ApplicationId = app2Id, TotalPaid = 500m, TotalPending = 100m }, @@ -176,7 +176,7 @@ public async Task Should_Return_Batch_Summaries_For_Multiple_Applications_Withou var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummariesAsync( + var result = await manager.GetApplicationPaymentRollupBatchAsync( [app1Id, app2Id, app3Id], new Dictionary>()); @@ -191,7 +191,7 @@ public async Task Should_Return_Batch_Summaries_For_Multiple_Applications_Withou } [Fact] - public async Task Should_Aggregate_Child_Amounts_In_Batch_Summaries() + public async Task Should_Aggregate_Child_Amounts_In_Batch_Rollup() { // Arrange var parentAId = Guid.NewGuid(); @@ -207,8 +207,8 @@ public async Task Should_Aggregate_Child_Amounts_In_Batch_Summaries() }; var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List { new() { ApplicationId = parentAId, TotalPaid = 1000m, TotalPending = 100m }, new() { ApplicationId = childA1Id, TotalPaid = 200m, TotalPending = 50m }, @@ -220,7 +220,7 @@ public async Task Should_Aggregate_Child_Amounts_In_Batch_Summaries() var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummariesAsync( + var result = await manager.GetApplicationPaymentRollupBatchAsync( [parentAId, parentBId], childMap); // Assert @@ -238,15 +238,15 @@ public async Task Should_Aggregate_Child_Amounts_In_Batch_Summaries() } [Fact] - public async Task Should_Handle_Application_With_No_Matching_Summary_In_Batch() + public async Task Should_Handle_Application_With_No_Matching_Rollup_In_Batch() { // Arrange var app1Id = Guid.NewGuid(); var app2Id = Guid.NewGuid(); var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List { // Only app1 has payment data, app2 doesn't new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 500m } @@ -255,7 +255,7 @@ public async Task Should_Handle_Application_With_No_Matching_Summary_In_Batch() var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummariesAsync( + var result = await manager.GetApplicationPaymentRollupBatchAsync( [app1Id, app2Id], new Dictionary>()); @@ -285,8 +285,8 @@ public async Task Should_Deduplicate_CorrelationIds_In_Batch_Repository_Call() }; var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List { new() { ApplicationId = parentAId, TotalPaid = 100m, TotalPending = 0m }, new() { ApplicationId = parentBId, TotalPaid = 200m, TotalPending = 0m }, @@ -296,12 +296,12 @@ public async Task Should_Deduplicate_CorrelationIds_In_Batch_Repository_Call() var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummariesAsync( + var result = await manager.GetApplicationPaymentRollupBatchAsync( [parentAId, parentBId], childMap); // Assert // Verify repository was called with deduplicated IDs (3 unique, not 4) - await repo.Received(1).GetPaymentSummariesByCorrelationIdsAsync( + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync( Arg.Is>(ids => ids.Distinct().Count() == 3)); // Both parents should include the shared child's amounts @@ -325,16 +325,16 @@ public async Task Should_Make_Single_Repository_Call_For_Batch() }; var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List()); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); var manager = CreateManager(repo); // Act - await manager.GetApplicationPaymentSummariesAsync([app1Id, app2Id], childMap); + await manager.GetApplicationPaymentRollupBatchAsync([app1Id, app2Id], childMap); // Assert - should only call repository once (batch optimization) - await repo.Received(1).GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()); + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()); } [Fact] @@ -342,13 +342,13 @@ public async Task Should_Return_Empty_Dictionary_For_Empty_Application_List() { // Arrange var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List()); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummariesAsync( + var result = await manager.GetApplicationPaymentRollupBatchAsync( [], new Dictionary>()); // Assert @@ -371,8 +371,8 @@ public async Task Should_Handle_Parent_Without_Children_In_Mixed_Batch() }; var repo = Substitute.For(); - repo.GetPaymentSummariesByCorrelationIdsAsync(Arg.Any>()) - .Returns(new List + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List { new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 0m }, new() { ApplicationId = childId, TotalPaid = 500m, TotalPending = 100m }, @@ -382,7 +382,7 @@ public async Task Should_Handle_Parent_Without_Children_In_Mixed_Batch() var manager = CreateManager(repo); // Act - var result = await manager.GetApplicationPaymentSummariesAsync( + var result = await manager.GetApplicationPaymentRollupBatchAsync( [app1Id, app2Id], childMap); // Assert @@ -403,8 +403,8 @@ private static PaymentRequestQueryManager CreateManager(IPaymentRequestRepositor repo, Substitute.For(), Substitute.For(), - null!, // CasPaymentRequestCoordinator - not used by summary methods - null! // IObjectMapper - not used by summary methods + null!, // CasPaymentRequestCoordinator - not used by Rollup methods + null! // IObjectMapper - not used by Rollup methods ); } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentSummary_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs similarity index 89% rename from applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentSummary_Tests.cs rename to applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs index 0b8df1af6..17a7da11f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentSummary_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs @@ -12,13 +12,13 @@ namespace Unity.Payments.Domain.PaymentRequests; [Category("Integration")] -public class PaymentRequestRepository_PaymentSummary_Tests : PaymentsApplicationTestBase +public class PaymentRequestRepository_PaymentRollup_Tests : PaymentsApplicationTestBase { private readonly IPaymentRequestRepository _paymentRequestRepository; private readonly ISupplierRepository _supplierRepository; private readonly IUnitOfWorkManager _unitOfWorkManager; - public PaymentRequestRepository_PaymentSummary_Tests() + public PaymentRequestRepository_PaymentRollup_Tests() { _paymentRequestRepository = GetRequiredService(); _supplierRepository = GetRequiredService(); @@ -41,7 +41,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -63,7 +63,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 500m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -84,7 +84,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 800m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -105,7 +105,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 300m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -132,7 +132,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 300m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -153,7 +153,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 200m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -182,7 +182,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 3000m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -205,7 +205,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 300m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -230,7 +230,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 200m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -255,7 +255,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 3000m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -292,7 +292,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 5000m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([correlationId]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); // Assert results.Count.ShouldBe(1); @@ -302,7 +302,7 @@ await InsertPaymentRequestAsync(siteId, correlationId, 5000m, [Fact] [Trait("Category", "Integration")] - public async Task Should_Return_Summaries_For_Multiple_CorrelationIds() + public async Task Should_Return_Rollup_For_Multiple_CorrelationIds() { // Arrange var app1Id = Guid.NewGuid(); @@ -323,20 +323,20 @@ await InsertPaymentRequestAsync(siteId, app2Id, 300m, // Act var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([app1Id, app2Id]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([app1Id, app2Id]); // Assert results.Count.ShouldBe(2); - var app1Summary = results.Find(r => r.ApplicationId == app1Id); - app1Summary.ShouldNotBeNull(); - app1Summary!.TotalPaid.ShouldBe(1000m); - app1Summary.TotalPending.ShouldBe(500m); + var app1Rollup = results.Find(r => r.ApplicationId == app1Id); + app1Rollup.ShouldNotBeNull(); + app1Rollup!.TotalPaid.ShouldBe(1000m); + app1Rollup.TotalPending.ShouldBe(500m); - var app2Summary = results.Find(r => r.ApplicationId == app2Id); - app2Summary.ShouldNotBeNull(); - app2Summary!.TotalPaid.ShouldBe(2000m); - app2Summary.TotalPending.ShouldBe(300m); + var app2Rollup = results.Find(r => r.ApplicationId == app2Id); + app2Rollup.ShouldNotBeNull(); + app2Rollup!.TotalPaid.ShouldBe(2000m); + app2Rollup.TotalPending.ShouldBe(300m); } [Fact] @@ -346,7 +346,7 @@ public async Task Should_Return_Empty_For_Unknown_CorrelationIds() // Arrange & Act using var uow = _unitOfWorkManager.Begin(); var results = await _paymentRequestRepository - .GetPaymentSummariesByCorrelationIdsAsync([Guid.NewGuid()]); + .GetBatchPaymentRollupsByCorrelationIdsAsync([Guid.NewGuid()]); // Assert results.ShouldBeEmpty(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs index 549a5cab2..58d70e1c0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs @@ -24,7 +24,7 @@ public PaymentInfoViewComponentTests() } [Fact] - public async Task PaymentInfo_Should_Display_TotalPaid_And_TotalPending_From_Summary() + public async Task PaymentInfo_Should_Display_TotalPaid_And_TotalPending_From_Rollup() { // Arrange var applicationId = Guid.NewGuid(); @@ -40,7 +40,7 @@ public async Task PaymentInfo_Should_Display_TotalPaid_And_TotalPending_From_Sum Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var summary = new ApplicationPaymentSummaryDto + var rollup = new ApplicationPaymentRollupDto { ApplicationId = applicationId, TotalPaid = 1500m, @@ -52,7 +52,7 @@ public async Task PaymentInfo_Should_Display_TotalPaid_And_TotalPending_From_Sum var featureChecker = Substitute.For(); appService.GetAsync(applicationId).Returns(applicationDto); - paymentRequestService.GetApplicationPaymentSummaryAsync(applicationId).Returns(summary); + paymentRequestService.GetApplicationPaymentRollupAsync(applicationId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); @@ -83,7 +83,7 @@ public async Task PaymentInfo_Should_Calculate_RemainingAmount_From_ApprovedAmou Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var summary = new ApplicationPaymentSummaryDto + var rollup = new ApplicationPaymentRollupDto { ApplicationId = applicationId, TotalPaid = 3500m, @@ -95,7 +95,7 @@ public async Task PaymentInfo_Should_Calculate_RemainingAmount_From_ApprovedAmou var featureChecker = Substitute.For(); appService.GetAsync(applicationId).Returns(applicationDto); - paymentRequestService.GetApplicationPaymentSummaryAsync(applicationId).Returns(summary); + paymentRequestService.GetApplicationPaymentRollupAsync(applicationId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); @@ -110,10 +110,10 @@ public async Task PaymentInfo_Should_Calculate_RemainingAmount_From_ApprovedAmou } [Fact] - public async Task PaymentInfo_Should_Include_Child_Application_Amounts_Via_Summary() + public async Task PaymentInfo_Should_Include_Child_Application_Amounts_By_Rollup() { // The ViewComponent now delegates child aggregation to the service layer. - // This test verifies it correctly uses the pre-aggregated summary. + // This test verifies it correctly uses the pre-aggregated rollup. // Arrange var parentAppId = Guid.NewGuid(); var applicationFormVersionId = Guid.NewGuid(); @@ -126,8 +126,8 @@ public async Task PaymentInfo_Should_Include_Child_Application_Amounts_Via_Summa Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - // Summary includes parent + child amounts (pre-aggregated by service) - var summary = new ApplicationPaymentSummaryDto + // Rollup includes parent + child amounts (pre-aggregated by service) + var rollup = new ApplicationPaymentRollupDto { ApplicationId = parentAppId, TotalPaid = 2300m, // e.g., 1000 (parent) + 500 (child1) + 800 (child2) @@ -139,7 +139,7 @@ public async Task PaymentInfo_Should_Include_Child_Application_Amounts_Via_Summa var featureChecker = Substitute.For(); appService.GetAsync(parentAppId).Returns(applicationDto); - paymentRequestService.GetApplicationPaymentSummaryAsync(parentAppId).Returns(summary); + paymentRequestService.GetApplicationPaymentRollupAsync(parentAppId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); @@ -170,7 +170,7 @@ public async Task PaymentInfo_Should_Handle_Zero_Payments() Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var summary = new ApplicationPaymentSummaryDto + var rollup = new ApplicationPaymentRollupDto { ApplicationId = appId, TotalPaid = 0m, @@ -182,7 +182,7 @@ public async Task PaymentInfo_Should_Handle_Zero_Payments() var featureChecker = Substitute.For(); appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetApplicationPaymentSummaryAsync(appId).Returns(summary); + paymentRequestService.GetApplicationPaymentRollupAsync(appId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); @@ -215,7 +215,7 @@ public async Task PaymentInfo_Should_Map_RequestedAmount_And_RecommendedAmount() Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var summary = new ApplicationPaymentSummaryDto + var rollup = new ApplicationPaymentRollupDto { ApplicationId = appId, TotalPaid = 0m, @@ -227,7 +227,7 @@ public async Task PaymentInfo_Should_Map_RequestedAmount_And_RecommendedAmount() var featureChecker = Substitute.For(); appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetApplicationPaymentSummaryAsync(appId).Returns(summary); + paymentRequestService.GetApplicationPaymentRollupAsync(appId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); @@ -273,11 +273,11 @@ public async Task PaymentInfo_Should_Return_Empty_View_When_Feature_Disabled() // Verify no service calls were made await appService.DidNotReceive().GetAsync(Arg.Any()); - await paymentRequestService.DidNotReceive().GetApplicationPaymentSummaryAsync(Arg.Any()); + await paymentRequestService.DidNotReceive().GetApplicationPaymentRollupAsync(Arg.Any()); } [Fact] - public async Task PaymentInfo_Should_Call_GetApplicationPaymentSummaryAsync_With_ApplicationId() + public async Task PaymentInfo_Should_Call_GetApplicationPaymentRollupAsync_With_ApplicationId() { // Arrange var appId = Guid.NewGuid(); @@ -291,7 +291,7 @@ public async Task PaymentInfo_Should_Call_GetApplicationPaymentSummaryAsync_With Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var summary = new ApplicationPaymentSummaryDto + var rollup = new ApplicationPaymentRollupDto { ApplicationId = appId, TotalPaid = 100m, @@ -303,7 +303,7 @@ public async Task PaymentInfo_Should_Call_GetApplicationPaymentSummaryAsync_With var featureChecker = Substitute.For(); appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetApplicationPaymentSummaryAsync(appId).Returns(summary); + paymentRequestService.GetApplicationPaymentRollupAsync(appId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); @@ -312,7 +312,7 @@ public async Task PaymentInfo_Should_Call_GetApplicationPaymentSummaryAsync_With await viewComponent.InvokeAsync(appId, applicationFormVersionId); // Assert - Verify the correct service method was called with the right ID - await paymentRequestService.Received(1).GetApplicationPaymentSummaryAsync(appId); + await paymentRequestService.Received(1).GetApplicationPaymentRollupAsync(appId); } private PaymentInfoViewComponent CreateViewComponent( diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index cca8d808c..159f77336 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -62,13 +62,14 @@ public async Task> GetListAsync(GrantApplica var applicationIds = applications.Select(a => a.Id).ToList(); + // 2️ Fetch payment rollup batch if feature enabled bool paymentsFeatureEnabled = await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature); - Dictionary paymentSummaries = []; + Dictionary paymentRollupBatch = []; if (paymentsFeatureEnabled && applicationIds.Count > 0) { - paymentSummaries = await paymentRequestService.GetApplicationPaymentSummariesAsync(applicationIds); + paymentRollupBatch = await paymentRequestService.GetApplicationPaymentRollupBatchAsync(applicationIds); } // 3️ Map applications to DTOs @@ -94,13 +95,13 @@ public async Task> GetListAsync(GrantApplica appDto.ContactCellPhone = app.ApplicantAgent?.Phone2; appDto.ApplicationLinks = ObjectMapper.Map, List>(app.ApplicationLinks?.ToList() ?? []); - if (paymentsFeatureEnabled && paymentSummaries.Count > 0) + if (paymentsFeatureEnabled && paymentRollupBatch.Count > 0) { - paymentSummaries.TryGetValue(app.Id, out var summary); + paymentRollupBatch.TryGetValue(app.Id, out var rollup); appDto.PaymentInfo = new PaymentInfoDto { ApprovedAmount = app.ApprovedAmount, - TotalPaid = summary?.TotalPaid ?? 0 + TotalPaid = rollup?.TotalPaid ?? 0 }; } return appDto; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs index 59cd647b9..7225e32ce 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/ApplicantSubmissionsViewComponent.cs @@ -56,10 +56,10 @@ public async Task InvokeAsync(Guid applicantId) var applicationIds = applications.Select(app => app.Id).ToList(); var paymentsFeatureEnabled = await _featureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature); - Dictionary paymentSummaries = []; + Dictionary paymentRollupBatch = []; if (paymentsFeatureEnabled && applicationIds.Count > 0) { - paymentSummaries = await _paymentRequestService.GetApplicationPaymentSummariesAsync(applicationIds); + paymentRollupBatch = await _paymentRequestService.GetApplicationPaymentRollupBatchAsync(applicationIds); } // Map to DTOs (similar to GrantApplicationAppService.GetListAsync) @@ -126,13 +126,13 @@ public async Task InvokeAsync(Guid applicantId) } dto.Assignees = assigneeDtos; - if (paymentsFeatureEnabled && paymentSummaries.Count > 0) + if (paymentsFeatureEnabled && paymentRollupBatch.Count > 0) { - paymentSummaries.TryGetValue(app.Id, out var summary); + paymentRollupBatch.TryGetValue(app.Id, out var paymentRollup); dto.PaymentInfo = new PaymentInfoDto { ApprovedAmount = app.ApprovedAmount, - TotalPaid = summary?.TotalPaid ?? 0 + TotalPaid = paymentRollup?.TotalPaid ?? 0 }; } From 9074cf2f86f1a77327fa3f61b7fc244cd0c9f149 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 24 Feb 2026 09:39:34 -0800 Subject: [PATCH 22/36] AB#32001 Update gitignore for only code-workspace --- .gitignore | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 5ccccd8b9..cf56f6bd3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,6 @@ bld/ [Oo]bj/ [Ll]og/ [Ll]ogs/ -*.log # Visual Studio cache/options directory .vs/ @@ -118,7 +117,4 @@ appsettings.json /applications/Orchestrator *.env -/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package-lock.json - -# Local dev artifacts -/AGENTS.md +/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package-lock.json \ No newline at end of file From d746aad57961c31b673502ce380bbcd22896cdfe Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 24 Feb 2026 11:52:12 -0800 Subject: [PATCH 23/36] AB#32001 Cache JsonSerializerOptions for AI payload log formatting --- .../src/Unity.GrantManager.Application/AI/OpenAIService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 742a9abe0..a412edf26 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -26,6 +26,7 @@ public class OpenAIService : IAIService, ITransientDependency private readonly string NoKeyError = "OpenAI API key is not configured"; private const string AiPromptLogRelativePath = "logs/ai-prompts.log"; private static int _aiPromptLogInitialized; + private static readonly JsonSerializerOptions IndentedJsonLogOptions = new() { WriteIndented = true }; public OpenAIService(HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService) { @@ -497,7 +498,7 @@ private static string FormatPromptOutputForLog(string output) if (TryParseJsonObjectFromResponse(output, out var jsonObject)) { - return JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }); + return JsonSerializer.Serialize(jsonObject, IndentedJsonLogOptions); } return output.Trim(); From c92ea2105cd199c2c6e01d21a6c0e45c211fe1e4 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 24 Feb 2026 12:01:15 -0800 Subject: [PATCH 24/36] AB#32001 Address Copilot feedback for AI payload logging safety and JSON cleanup --- .../AI/OpenAIService.cs | 88 ++++++++++++++----- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index a412edf26..64ef58a1e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -25,7 +25,8 @@ public class OpenAIService : IAIService, ITransientDependency private bool LogPayloads => _configuration.GetValue("AI:Logging:LogPayloads") ?? false; private readonly string NoKeyError = "OpenAI API key is not configured"; private const string AiPromptLogRelativePath = "logs/ai-prompts.log"; - private static int _aiPromptLogInitialized; + private static readonly SemaphoreSlim AiPromptLogWriteSemaphore = new(1, 1); + private static bool _aiPromptLogInitialized; private static readonly JsonSerializerOptions IndentedJsonLogOptions = new() { WriteIndented = true }; public OpenAIService(HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService) @@ -133,9 +134,9 @@ public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] prompt = "Please analyze this document and provide a concise summary of its content, purpose, and key information, for use by your fellow grant analysts. It should be 1-2 sentences long and about 46 tokens."; } - LogPromptInput("AttachmentSummary", prompt, contentToAnalyze); + await LogPromptInput("AttachmentSummary", prompt, contentToAnalyze); var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); - LogPromptOutput("AttachmentSummary", modelOutput); + await LogPromptOutput("AttachmentSummary", modelOutput); return modelOutput; } catch (Exception ex) @@ -213,9 +214,9 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis Respond only with valid JSON in the exact format requested."; - LogPromptInput("ApplicationAnalysis", systemPrompt, analysisContent); + await LogPromptInput("ApplicationAnalysis", systemPrompt, analysisContent); var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - LogPromptOutput("ApplicationAnalysis", rawAnalysis); + await LogPromptOutput("ApplicationAnalysis", rawAnalysis); // Post-process the AI response to add unique IDs to errors and warnings return AddIdsToAnalysisItems(rawAnalysis); @@ -333,9 +334,9 @@ Base your answers on the application content and attachment summaries provided. Be thorough, objective, and fair in your assessment. Base your answers strictly on the provided application content. Respond only with valid JSON in the exact format requested."; - LogPromptInput("ScoresheetAll", systemPrompt, analysisContent); + await LogPromptInput("ScoresheetAll", systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - LogPromptOutput("ScoresheetAll", modelOutput); + await LogPromptOutput("ScoresheetAll", modelOutput); return modelOutput; } catch (Exception ex) @@ -410,9 +411,9 @@ Always provide citations that reference specific parts of the application conten Be honest about your confidence level - if information is missing or unclear, reflect this in a lower confidence score. Respond only with valid JSON in the exact format requested."; - LogPromptInput("ScoresheetSection", systemPrompt, analysisContent); + await LogPromptInput("ScoresheetSection", systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - LogPromptOutput("ScoresheetSection", modelOutput); + await LogPromptOutput("ScoresheetSection", modelOutput); return modelOutput; } catch (Exception ex) @@ -422,7 +423,7 @@ Always provide citations that reference specific parts of the application conten } } - private void LogPromptInput(string promptType, string? systemPrompt, string userPrompt) + private async Task LogPromptInput(string promptType, string? systemPrompt, string userPrompt) { if (!LogPayloads) { @@ -431,10 +432,10 @@ private void LogPromptInput(string promptType, string? systemPrompt, string user var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); _logger.LogDebug("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); - WriteAiPromptLog(promptType, "INPUT", formattedInput); + await WriteAiPromptLog(promptType, "INPUT", formattedInput); } - private void LogPromptOutput(string promptType, string output) + private async Task LogPromptOutput(string promptType, string output) { if (!LogPayloads) { @@ -443,10 +444,10 @@ private void LogPromptOutput(string promptType, string output) var formattedOutput = FormatPromptOutputForLog(output); _logger.LogDebug("AI {PromptType} model output payload: {ModelOutput}", promptType, formattedOutput); - WriteAiPromptLog(promptType, "OUTPUT", formattedOutput); + await WriteAiPromptLog(promptType, "OUTPUT", formattedOutput); } - private void WriteAiPromptLog(string promptType, string payloadType, string payload) + private async Task WriteAiPromptLog(string promptType, string payloadType, string payload) { if (!LogPayloads) { @@ -455,12 +456,20 @@ private void WriteAiPromptLog(string promptType, string payloadType, string payl try { - var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); - var logPath = Path.Combine(AppContext.BaseDirectory, AiPromptLogRelativePath); - EnsureAiPromptLogInitialized(logPath); + await AiPromptLogWriteSemaphore.WaitAsync(); + try + { + var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); + var logPath = Path.Combine(AppContext.BaseDirectory, AiPromptLogRelativePath); + EnsureAiPromptLogInitialized(logPath); - var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; - File.AppendAllText(logPath, entry); + var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; + await File.AppendAllTextAsync(logPath, entry); + } + finally + { + AiPromptLogWriteSemaphore.Release(); + } } catch (Exception ex) { @@ -470,16 +479,19 @@ private void WriteAiPromptLog(string promptType, string payloadType, string payl private static void EnsureAiPromptLogInitialized(string logPath) { + if (_aiPromptLogInitialized) + { + return; + } + var directory = Path.GetDirectoryName(logPath); if (!string.IsNullOrWhiteSpace(directory)) { Directory.CreateDirectory(directory); } - if (Interlocked.Exchange(ref _aiPromptLogInitialized, 1) == 0) - { - File.WriteAllText(logPath, string.Empty); - } + File.WriteAllText(logPath, string.Empty); + _aiPromptLogInitialized = true; } private static string FormatPromptInputForLog(string? systemPrompt, string userPrompt) @@ -544,8 +556,20 @@ private static string CleanJsonResponse(string response) var startIndex = cleaned.IndexOf('\n'); if (startIndex >= 0) { + // Multi-line fenced code block: remove everything up to and including the first newline. cleaned = cleaned[(startIndex + 1)..]; } + else + { + // Single-line fenced JSON, e.g. ```json { ... } ``` or ```{ ... } ```. + // Strip everything before the first likely JSON payload token. + var jsonStart = FindFirstJsonTokenIndex(cleaned); + + if (jsonStart > 0) + { + cleaned = cleaned[jsonStart..]; + } + } } if (cleaned.EndsWith("```", StringComparison.Ordinal)) @@ -559,5 +583,23 @@ private static string CleanJsonResponse(string response) return cleaned.Trim(); } + + private static int FindFirstJsonTokenIndex(string value) + { + var objectStart = value.IndexOf('{'); + var arrayStart = value.IndexOf('['); + + if (objectStart >= 0 && arrayStart >= 0) + { + return Math.Min(objectStart, arrayStart); + } + + if (objectStart >= 0) + { + return objectStart; + } + + return arrayStart; + } } } From e6662e8762273b872585c8327d805004293f50ec Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 24 Feb 2026 13:45:57 -0800 Subject: [PATCH 25/36] AB#32001 Refine AI payload logging gates and local file logging behavior --- .../AI/OpenAIService.cs | 107 ++++++++---------- 1 file changed, 47 insertions(+), 60 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 64ef58a1e..122673051 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -7,7 +7,6 @@ using System.Net.Http; using System.Text; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; using Volo.Abp.DependencyInjection; @@ -22,14 +21,20 @@ public class OpenAIService : IAIService, ITransientDependency private string? ApiKey => _configuration["AI:OpenAI:ApiKey"]; private string? ApiUrl => _configuration["AI:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; - private bool LogPayloads => _configuration.GetValue("AI:Logging:LogPayloads") ?? false; - private readonly string NoKeyError = "OpenAI API key is not configured"; - private const string AiPromptLogRelativePath = "logs/ai-prompts.log"; - private static readonly SemaphoreSlim AiPromptLogWriteSemaphore = new(1, 1); - private static bool _aiPromptLogInitialized; - private static readonly JsonSerializerOptions IndentedJsonLogOptions = new() { WriteIndented = true }; - - public OpenAIService(HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService) + private readonly string MissingApiKeyMessage = "OpenAI API key is not configured"; + + private const string PromptLogDirectoryName = "logs"; + private static readonly string PromptLogFileName = $"ai-prompts-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}.log"; + private bool IsPayloadLoggingEnabled => _configuration.GetValue("AI:Logging:LogPayloads") ?? false; + private bool IsPromptFileLoggingEnabled => _configuration.GetValue("AI:Logging:EnablePromptFileLog") ?? false; + + private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; + + public OpenAIService( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger, + ITextExtractionService textExtractionService) { _httpClient = httpClient; _configuration = configuration; @@ -41,7 +46,7 @@ public Task IsAvailableAsync() { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("Error: {Message}", NoKeyError); + _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); return Task.FromResult(false); } @@ -52,7 +57,7 @@ public async Task GenerateSummaryAsync(string content, string? prompt = { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("Error: {Message}", NoKeyError); + _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); return "AI analysis not available - service not configured."; } @@ -134,9 +139,9 @@ public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] prompt = "Please analyze this document and provide a concise summary of its content, purpose, and key information, for use by your fellow grant analysts. It should be 1-2 sentences long and about 46 tokens."; } - await LogPromptInput("AttachmentSummary", prompt, contentToAnalyze); + await LogPromptInputAsync("AttachmentSummary", prompt, contentToAnalyze); var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); - await LogPromptOutput("AttachmentSummary", modelOutput); + await LogPromptOutputAsync("AttachmentSummary", modelOutput); return modelOutput; } catch (Exception ex) @@ -150,7 +155,7 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("{Message}", NoKeyError); + _logger.LogWarning("{Message}", MissingApiKeyMessage); return "AI analysis not available - service not configured."; } @@ -214,9 +219,9 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis Respond only with valid JSON in the exact format requested."; - await LogPromptInput("ApplicationAnalysis", systemPrompt, analysisContent); + await LogPromptInputAsync("ApplicationAnalysis", systemPrompt, analysisContent); var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - await LogPromptOutput("ApplicationAnalysis", rawAnalysis); + await LogPromptOutputAsync("ApplicationAnalysis", rawAnalysis); // Post-process the AI response to add unique IDs to errors and warnings return AddIdsToAnalysisItems(rawAnalysis); @@ -293,7 +298,7 @@ public async Task GenerateScoresheetAnswersAsync(string applicationConte { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("{Message}", NoKeyError); + _logger.LogWarning("{Message}", MissingApiKeyMessage); return "{}"; } @@ -334,9 +339,9 @@ Base your answers on the application content and attachment summaries provided. Be thorough, objective, and fair in your assessment. Base your answers strictly on the provided application content. Respond only with valid JSON in the exact format requested."; - await LogPromptInput("ScoresheetAll", systemPrompt, analysisContent); + await LogPromptInputAsync("ScoresheetAll", systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutput("ScoresheetAll", modelOutput); + await LogPromptOutputAsync("ScoresheetAll", modelOutput); return modelOutput; } catch (Exception ex) @@ -350,7 +355,7 @@ public async Task GenerateScoresheetSectionAnswersAsync(string applicati { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("{Message}", NoKeyError); + _logger.LogWarning("{Message}", MissingApiKeyMessage); return "{}"; } @@ -411,9 +416,9 @@ Always provide citations that reference specific parts of the application conten Be honest about your confidence level - if information is missing or unclear, reflect this in a lower confidence score. Respond only with valid JSON in the exact format requested."; - await LogPromptInput("ScoresheetSection", systemPrompt, analysisContent); + await LogPromptInputAsync("ScoresheetSection", systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutput("ScoresheetSection", modelOutput); + await LogPromptOutputAsync("ScoresheetSection", modelOutput); return modelOutput; } catch (Exception ex) @@ -423,53 +428,46 @@ Always provide citations that reference specific parts of the application conten } } - private async Task LogPromptInput(string promptType, string? systemPrompt, string userPrompt) + private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) { - if (!LogPayloads) + if (!IsPayloadLoggingEnabled) { return; } var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); - _logger.LogDebug("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); - await WriteAiPromptLog(promptType, "INPUT", formattedInput); + _logger.LogInformation("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); + await WritePromptLogFileAsync(promptType, "INPUT", formattedInput); } - private async Task LogPromptOutput(string promptType, string output) + private async Task LogPromptOutputAsync(string promptType, string output) { - if (!LogPayloads) + if (!IsPayloadLoggingEnabled) { return; } var formattedOutput = FormatPromptOutputForLog(output); - _logger.LogDebug("AI {PromptType} model output payload: {ModelOutput}", promptType, formattedOutput); - await WriteAiPromptLog(promptType, "OUTPUT", formattedOutput); + _logger.LogInformation("AI {PromptType} model output payload: {ModelOutput}", promptType, formattedOutput); + await WritePromptLogFileAsync(promptType, "OUTPUT", formattedOutput); } - private async Task WriteAiPromptLog(string promptType, string payloadType, string payload) + private async Task WritePromptLogFileAsync(string promptType, string payloadType, string payload) { - if (!LogPayloads) + if (!CanWritePromptFileLog()) { return; } try { - await AiPromptLogWriteSemaphore.WaitAsync(); - try - { - var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); - var logPath = Path.Combine(AppContext.BaseDirectory, AiPromptLogRelativePath); - EnsureAiPromptLogInitialized(logPath); + var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); + var logDirectory = Path.Combine(AppContext.BaseDirectory, PromptLogDirectoryName); + Directory.CreateDirectory(logDirectory); - var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; - await File.AppendAllTextAsync(logPath, entry); - } - finally - { - AiPromptLogWriteSemaphore.Release(); - } + var logPath = Path.Combine(logDirectory, PromptLogFileName); + var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; + await File.AppendAllTextAsync(logPath, entry); } catch (Exception ex) { @@ -477,21 +475,10 @@ private async Task WriteAiPromptLog(string promptType, string payloadType, strin } } - private static void EnsureAiPromptLogInitialized(string logPath) + private bool CanWritePromptFileLog() { - if (_aiPromptLogInitialized) - { - return; - } - - var directory = Path.GetDirectoryName(logPath); - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } - - File.WriteAllText(logPath, string.Empty); - _aiPromptLogInitialized = true; + return IsPayloadLoggingEnabled + && IsPromptFileLoggingEnabled; } private static string FormatPromptInputForLog(string? systemPrompt, string userPrompt) @@ -510,7 +497,7 @@ private static string FormatPromptOutputForLog(string output) if (TryParseJsonObjectFromResponse(output, out var jsonObject)) { - return JsonSerializer.Serialize(jsonObject, IndentedJsonLogOptions); + return JsonSerializer.Serialize(jsonObject, JsonLogOptions); } return output.Trim(); From 60b681b23428bbc85eb00b2e6190f6ee46269c6c Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 24 Feb 2026 14:14:49 -0800 Subject: [PATCH 26/36] AB#32001 AI logging single local file flag with comment --- .../AI/OpenAIService.cs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 122673051..2d343c739 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -23,10 +23,11 @@ public class OpenAIService : IAIService, ITransientDependency private string? ApiUrl => _configuration["AI:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; private readonly string MissingApiKeyMessage = "OpenAI API key is not configured"; + // Optional local debugging sink for prompt payload logs to a local file. + // Not intended for deployed/shared environments. + private bool IsPromptFileLoggingEnabled => _configuration.GetValue("AI:Logging:EnablePromptFileLog") ?? false; private const string PromptLogDirectoryName = "logs"; private static readonly string PromptLogFileName = $"ai-prompts-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}.log"; - private bool IsPayloadLoggingEnabled => _configuration.GetValue("AI:Logging:LogPayloads") ?? false; - private bool IsPromptFileLoggingEnabled => _configuration.GetValue("AI:Logging:EnablePromptFileLog") ?? false; private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; @@ -430,11 +431,6 @@ Always provide citations that reference specific parts of the application conten private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) { - if (!IsPayloadLoggingEnabled) - { - return; - } - var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); _logger.LogInformation("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); await WritePromptLogFileAsync(promptType, "INPUT", formattedInput); @@ -442,11 +438,6 @@ private async Task LogPromptInputAsync(string promptType, string? systemPrompt, private async Task LogPromptOutputAsync(string promptType, string output) { - if (!IsPayloadLoggingEnabled) - { - return; - } - var formattedOutput = FormatPromptOutputForLog(output); _logger.LogInformation("AI {PromptType} model output payload: {ModelOutput}", promptType, formattedOutput); await WritePromptLogFileAsync(promptType, "OUTPUT", formattedOutput); @@ -477,8 +468,7 @@ private async Task WritePromptLogFileAsync(string promptType, string payloadType private bool CanWritePromptFileLog() { - return IsPayloadLoggingEnabled - && IsPromptFileLoggingEnabled; + return IsPromptFileLoggingEnabled; } private static string FormatPromptInputForLog(string? systemPrompt, string userPrompt) From 6f0ca6de6e4e0bd6ddaddf200513675fd64c4cc5 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 24 Feb 2026 14:32:08 -0800 Subject: [PATCH 27/36] AB#001 Fix nullability warnings --- .../Unity.GrantManager.Application/AI/OpenAIService.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 2d343c739..1ea3e9360 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -67,13 +67,14 @@ public async Task GenerateSummaryAsync(string content, string? prompt = try { var systemPrompt = prompt ?? "You are a professional grant analyst for the BC Government."; + var userPrompt = content ?? string.Empty; var requestBody = new { messages = new[] { new { role = "system", content = systemPrompt }, - new { role = "user", content = content } + new { role = "user", content = userPrompt } }, max_tokens = maxTokens, temperature = 0.3 @@ -99,6 +100,11 @@ public async Task GenerateSummaryAsync(string content, string? prompt = return "AI analysis failed - service temporarily unavailable."; } + if (string.IsNullOrWhiteSpace(responseContent)) + { + return "No summary generated."; + } + using var jsonDoc = JsonDocument.Parse(responseContent); var choices = jsonDoc.RootElement.GetProperty("choices"); if (choices.GetArrayLength() > 0) From 31b4a29b3c88398aca0538c4f256bfe8e945039c Mon Sep 17 00:00:00 2001 From: David Bright Date: Tue, 24 Feb 2026 14:43:07 -0800 Subject: [PATCH 28/36] Quick Date Range now displayed with blue border to match the original date range fields --- .../Views/Shared/Components/ActionBar/Default.cshtml | 2 +- .../Views/Shared/Components/ActionBar/Default.css | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml index bb425251d..1134892b1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml @@ -18,7 +18,7 @@
- diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css index cca218855..5ccbfa7af 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.css @@ -43,3 +43,11 @@ padding: 0px; margin: 0px; } + +.quick-date-input { + font-size: var(--bc-font-size); + color: var(--bc-colors-grey-text-500); + border-radius: var(--bc-layout-margin-small) !important; + border: 2px solid var(--bc-colors-blue-primary); + text-overflow: ellipsis; +} From ea29ca110a8c6506710e01a753488134950bba17 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 24 Feb 2026 15:29:50 -0800 Subject: [PATCH 29/36] AB#30430 applicant profile address info provider --- .../ProfileData/AddressInfoItemDto.cs | 20 + .../ProfileData/ApplicantAddressInfoDto.cs | 4 + .../AddressInfoDataProvider.cs | 112 ++++- .../ApplicantProfile/OrgInfoDataProvider.cs | 6 + .../PaymentInfoDataProvider.cs | 6 + .../SubmissionInfoDataProvider.cs | 5 + .../AddressInfoDataProviderTests.cs | 437 ++++++++++++++++++ .../ApplicantProfileDataProviderTests.cs | 19 +- .../SubmissionInfoDataProviderTests.cs | 319 +++++++++++++ 9 files changed, 922 insertions(+), 6 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/AddressInfoItemDto.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/AddressInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/AddressInfoItemDto.cs new file mode 100644 index 000000000..5ad2a67c1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/AddressInfoItemDto.cs @@ -0,0 +1,20 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class AddressInfoItemDto + { + public Guid Id { get; set; } + public string AddressType { get; set; } = string.Empty; + public string Street { get; set; } = string.Empty; + public string Street2 { get; set; } = string.Empty; + public string Unit { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string Province { get; set; } = string.Empty; + public string PostalCode { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + public bool IsPrimary { get; set; } + public bool IsEditable { get; set; } + public string? ReferenceNo { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs index a532be406..f7b956aba 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; + namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantAddressInfoDto : ApplicantProfileDataDto { public override string DataType => "ADDRESSINFO"; + + public List Addresses { get; set; } = []; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs index f77a53000..7e14f7ab1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -1,17 +1,123 @@ +using System; +using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.Data; using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.ApplicantProfile { + /// + /// Provides address information for the applicant profile by querying + /// application addresses linked to the applicant's form submissions. + /// Addresses are resolved via both the ApplicationId and ApplicantId + /// relationships, with duplicates removed. + /// [ExposeServices(typeof(IApplicantProfileDataProvider))] - public class AddressInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency + public class AddressInfoDataProvider( + ICurrentTenant currentTenant, + IRepository applicationFormSubmissionRepository, + IRepository applicantAddressRepository, + IRepository applicationRepository) + : IApplicantProfileDataProvider, ITransientDependency { + /// public string Key => ApplicantProfileKeys.AddressInfo; - public Task GetDataAsync(ApplicantProfileInfoRequest request) + /// + public async Task GetDataAsync(ApplicantProfileInfoRequest request) { - return Task.FromResult(new ApplicantAddressInfoDto()); + var dto = new ApplicantAddressInfoDto + { + Addresses = [] + }; + + var subject = request.Subject ?? string.Empty; + var normalizedSubject = subject.Contains('@') + ? subject[..subject.IndexOf('@')].ToUpperInvariant() + : subject.ToUpperInvariant(); + + using (currentTenant.Change(request.TenantId)) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var addressesQuery = await applicantAddressRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + var matchingSubmissions = submissionsQuery + .Where(s => s.OidcSub == normalizedSubject); + + // Addresses linked via ApplicationId — not editable (owned by an application) + var byApplicationId = + from submission in matchingSubmissions + join address in addressesQuery on submission.ApplicationId equals address.ApplicationId + join application in applicationsQuery on address.ApplicationId equals application.Id + select new { address, address.CreationTime, application.ReferenceNo, IsEditable = false }; + + // Addresses linked via ApplicantId — editable (directly from the applicant) + var byApplicantId = + from submission in matchingSubmissions + join address in addressesQuery on submission.ApplicantId equals address.ApplicantId + join application in applicationsQuery on address.ApplicationId equals application.Id into apps + from application in apps.DefaultIfEmpty() + select new { address, address.CreationTime, ReferenceNo = application != null ? application.ReferenceNo : null, IsEditable = true }; + + var results = await byApplicationId + .Concat(byApplicantId) + .ToListAsync(); + + // Deduplicate by address Id — application-linked (IsEditable = false) takes priority + var deduplicated = results + .GroupBy(r => r.address.Id) + .Select(g => g.OrderBy(r => r.IsEditable).First()) + .ToList(); + + var addressDtos = deduplicated.Select(r => new AddressInfoItemDto + { + Id = r.address.Id, + AddressType = GetAddressTypeName(r.address.AddressType), + Street = r.address.Street ?? string.Empty, + Street2 = r.address.Street2 ?? string.Empty, + Unit = r.address.Unit ?? string.Empty, + City = r.address.City ?? string.Empty, + Province = r.address.Province ?? string.Empty, + PostalCode = r.address.Postal ?? string.Empty, + Country = r.address.Country ?? string.Empty, + IsPrimary = r.address.HasProperty("isPrimary") && r.address.GetProperty("isPrimary"), + IsEditable = r.IsEditable, + ReferenceNo = r.ReferenceNo + }).ToList(); + + // If no address is marked as primary, mark the most recent one as primary + if (addressDtos.Any() && !addressDtos.Any(a => a.IsPrimary)) + { + var mostRecent = deduplicated.OrderByDescending(r => r.CreationTime).First(); + var mostRecentDto = addressDtos.First(a => a.Id == mostRecent.address.Id); + mostRecentDto.IsPrimary = true; + } + + dto.Addresses.AddRange(addressDtos); + } + + return dto; + } + + /// + /// Maps an enum value to a human-readable display name. + /// + private static string GetAddressTypeName(AddressType addressType) + { + return addressType switch + { + AddressType.PhysicalAddress => "Physical", + AddressType.MailingAddress => "Mailing", + AddressType.BusinessAddress => "Business", + _ => addressType.ToString() + }; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs index 2d1ced24c..cc0bc9368 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs @@ -4,11 +4,17 @@ namespace Unity.GrantManager.ApplicantProfile { + /// + /// Provides organization information for the applicant profile. + /// This is a placeholder provider for future implementation. + /// [ExposeServices(typeof(IApplicantProfileDataProvider))] public class OrgInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency { + /// public string Key => ApplicantProfileKeys.OrgInfo; + /// public Task GetDataAsync(ApplicantProfileInfoRequest request) { return Task.FromResult(new ApplicantOrgInfoDto()); 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 8e1ddde04..9b7b86e62 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -4,11 +4,17 @@ namespace Unity.GrantManager.ApplicantProfile { + /// + /// Provides payment information for the applicant profile. + /// This is a placeholder provider for future implementation. + /// [ExposeServices(typeof(IApplicantProfileDataProvider))] public class PaymentInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency { + /// public string Key => ApplicantProfileKeys.PaymentInfo; + /// public Task GetDataAsync(ApplicantProfileInfoRequest request) { return Task.FromResult(new ApplicantPaymentInfoDto()); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs index de1b1a29a..60232d7e2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -109,6 +109,11 @@ private async Task ResolveFormViewUrlAsync() } } + /// + /// Extracts the submission timestamp from the CHEFS JSON createdAt field. + /// Falls back to the provided value if the field is + /// missing or the JSON cannot be parsed. + /// private DateTime ResolveSubmissionTime(string submissionJson, DateTime fallback) { try diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs new file mode 100644 index 000000000..7b38a48e8 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs @@ -0,0 +1,437 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.TestHelpers; +using Volo.Abp.Data; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Applicants +{ + public class AddressInfoDataProviderTests + { + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _submissionRepo; + private readonly IRepository _addressRepo; + private readonly IRepository _applicationRepo; + private readonly AddressInfoDataProvider _provider; + + public AddressInfoDataProviderTests() + { + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + _submissionRepo = Substitute.For>(); + _addressRepo = Substitute.For>(); + _applicationRepo = Substitute.For>(); + + SetupEmptyQueryables(); + + _provider = new AddressInfoDataProvider(_currentTenant, _submissionRepo, _addressRepo, _applicationRepo); + } + + private void SetupEmptyQueryables() + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _addressRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + } + + private void SetupQueryables( + IEnumerable submissions, + IEnumerable addresses, + IEnumerable? applications = null) + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(submissions.AsAsyncQueryable())); + _addressRepo.GetQueryableAsync() + .Returns(Task.FromResult(addresses.AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult((applications ?? []).AsAsyncQueryable())); + } + + private static ApplicantProfileInfoRequest CreateRequest() => new() + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.AddressInfo + }; + + private static ApplicationFormSubmission CreateSubmission( + Guid applicationId, string oidcSub, Action? configure = null) + { + var entity = new ApplicationFormSubmission { ApplicationId = applicationId, OidcSub = oidcSub }; + EntityHelper.TrySetId(entity, () => Guid.NewGuid()); + configure?.Invoke(entity); + return entity; + } + + private static ApplicantAddress CreateAddress(Action configure) + { + var entity = new ApplicantAddress(); + EntityHelper.TrySetId(entity, () => Guid.NewGuid()); + configure(entity); + return entity; + } + + private static Application CreateApplication(Guid id, Action? configure = null) + { + var entity = new Application(); + EntityHelper.TrySetId(entity, () => id); + configure?.Invoke(entity); + return entity; + } + + [Fact] + public async Task GetDataAsync_ShouldChangeTenant() + { + // Arrange + var request = CreateRequest(); + + // Act + await _provider.GetDataAsync(request); + + // Assert + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + // Arrange + var request = CreateRequest(); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + result.DataType.ShouldBe("ADDRESSINFO"); + } + + [Fact] + public async Task GetDataAsync_WithNoAddresses_ShouldReturnEmptyList() + { + // Arrange + var request = CreateRequest(); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldMapAddressFields() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateAddress(a => + { + a.ApplicationId = applicationId; + a.Street = "123 Main St"; + a.Street2 = "Suite 100"; + a.Unit = "4A"; + a.City = "Victoria"; + a.Province = "BC"; + a.Postal = "V8W 1A1"; + a.Country = "Canada"; + a.AddressType = AddressType.PhysicalAddress; + })], + [CreateApplication(applicationId, a => a.ReferenceNo = "REF-001")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(1); + + var address = dto.Addresses[0]; + address.Street.ShouldBe("123 Main St"); + address.Street2.ShouldBe("Suite 100"); + address.Unit.ShouldBe("4A"); + address.City.ShouldBe("Victoria"); + address.Province.ShouldBe("BC"); + address.PostalCode.ShouldBe("V8W 1A1"); + address.Country.ShouldBe("Canada"); + address.AddressType.ShouldBe("Physical"); + address.ReferenceNo.ShouldBe("REF-001"); + address.IsEditable.ShouldBeFalse(); + } + + [Theory] + [InlineData(AddressType.PhysicalAddress, "Physical")] + [InlineData(AddressType.MailingAddress, "Mailing")] + [InlineData(AddressType.BusinessAddress, "Business")] + public async Task GetDataAsync_ShouldMapAddressTypeName(AddressType addressType, string expectedName) + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateAddress(a => { a.ApplicationId = applicationId; a.AddressType = addressType; })], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses[0].AddressType.ShouldBe(expectedName); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnMultipleAddressesForSameSubmission() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [ + CreateAddress(a => { a.ApplicationId = applicationId; a.AddressType = AddressType.PhysicalAddress; a.City = "Victoria"; }), + CreateAddress(a => { a.ApplicationId = applicationId; a.AddressType = AddressType.MailingAddress; a.City = "Vancouver"; }) + ], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnAddressesForOtherSubjects() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "OTHERUSER")], + [CreateAddress(a => { a.ApplicationId = applicationId; a.City = "Victoria"; })], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldHandleNullAddressFields() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateAddress(a => + { + a.ApplicationId = applicationId; + a.Street = null; + a.Street2 = null; + a.Unit = null; + a.City = null; + a.Province = null; + a.Postal = null; + a.Country = null; + })], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + var address = dto.Addresses[0]; + address.Street.ShouldBe(string.Empty); + address.Street2.ShouldBe(string.Empty); + address.Unit.ShouldBe(string.Empty); + address.City.ShouldBe(string.Empty); + address.Province.ShouldBe(string.Empty); + address.PostalCode.ShouldBe(string.Empty); + address.Country.ShouldBe(string.Empty); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnAddressesLinkedByApplicantId() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => s.ApplicantId = applicantId)], + [CreateAddress(a => + { + a.ApplicantId = applicantId; + a.City = "Kelowna"; + a.AddressType = AddressType.MailingAddress; + })]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(1); + dto.Addresses[0].City.ShouldBe("Kelowna"); + dto.Addresses[0].ReferenceNo.ShouldBeNull(); + dto.Addresses[0].IsEditable.ShouldBeTrue(); + } + + [Fact] + public async Task GetDataAsync_ShouldCombineAddressesFromBothLinks() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => s.ApplicantId = applicantId)], + [ + CreateAddress(a => { a.ApplicationId = applicationId; a.City = "Victoria"; }), + CreateAddress(a => { a.ApplicantId = applicantId; a.City = "Kelowna"; }) + ], + [CreateApplication(applicationId, a => a.ReferenceNo = "REF-002")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + } + + [Fact] + public async Task GetDataAsync_ShouldDeduplicateAddressesMatchingBothLinks() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + var addressId = Guid.NewGuid(); + + // Same address linked by both ApplicationId and ApplicantId + var sharedAddress = new ApplicantAddress + { + ApplicationId = applicationId, + ApplicantId = applicantId, + City = "Victoria" + }; + EntityHelper.TrySetId(sharedAddress, () => addressId); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => s.ApplicantId = applicantId)], + [sharedAddress], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert — deduplicated to one entry, application-linked (not editable) wins + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(1); + dto.Addresses[0].City.ShouldBe("Victoria"); + dto.Addresses[0].IsEditable.ShouldBeFalse(); + } + + [Fact] + public async Task GetDataAsync_ShouldMarkMostRecentAddressAsPrimaryWhenNoneMarked() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var oldAddress = CreateAddress(a => + { + a.ApplicationId = applicationId; + a.City = "Vancouver"; + a.CreationTime = new DateTime(2023, 1, 1, 10, 0, 0, DateTimeKind.Utc); + }); + var recentAddress = CreateAddress(a => + { + a.ApplicationId = applicationId; + a.City = "Victoria"; + a.CreationTime = new DateTime(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + }); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [oldAddress, recentAddress], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + var primary = dto.Addresses.Single(a => a.IsPrimary); + primary.City.ShouldBe("Victoria"); + } + + [Fact] + public async Task GetDataAsync_ShouldNotOverridePrimaryWhenAlreadySet() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var primaryAddress = CreateAddress(a => + { + a.ApplicationId = applicationId; + a.City = "Vancouver"; + a.CreationTime = new DateTime(2023, 1, 1, 10, 0, 0, DateTimeKind.Utc); + a.SetProperty("isPrimary", true); + }); + var recentAddress = CreateAddress(a => + { + a.ApplicationId = applicationId; + a.City = "Victoria"; + a.CreationTime = new DateTime(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + }); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [primaryAddress, recentAddress], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + var primary = dto.Addresses.Single(a => a.IsPrimary); + primary.City.ShouldBe("Vancouver"); + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs index 51b1c94f2..cc8d41a68 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs @@ -37,6 +37,19 @@ private static ContactInfoDataProvider CreateContactInfoDataProvider() return new ContactInfoDataProvider(currentTenant, applicantProfileContactService); } + private static AddressInfoDataProvider CreateAddressInfoDataProvider() + { + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + var submissionRepo = Substitute.For>(); + submissionRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var addressRepo = Substitute.For>(); + addressRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var applicationRepo = Substitute.For>(); + applicationRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + return new AddressInfoDataProvider(currentTenant, submissionRepo, addressRepo, applicationRepo); + } + private static SubmissionInfoDataProvider CreateSubmissionInfoDataProvider() { var currentTenant = Substitute.For(); @@ -88,14 +101,14 @@ public async Task OrgInfoDataProvider_GetDataAsync_ShouldReturnOrgInfoDto() [Fact] public void AddressInfoDataProvider_Key_ShouldMatchExpected() { - var provider = new AddressInfoDataProvider(); + var provider = CreateAddressInfoDataProvider(); provider.Key.ShouldBe(ApplicantProfileKeys.AddressInfo); } [Fact] public async Task AddressInfoDataProvider_GetDataAsync_ShouldReturnAddressInfoDto() { - var provider = new AddressInfoDataProvider(); + var provider = CreateAddressInfoDataProvider(); var result = await provider.GetDataAsync(CreateRequest(ApplicantProfileKeys.AddressInfo)); result.ShouldNotBeNull(); result.ShouldBeOfType(); @@ -140,7 +153,7 @@ public void AllProviders_ShouldHaveUniqueKeys() [ CreateContactInfoDataProvider(), new OrgInfoDataProvider(), - new AddressInfoDataProvider(), + CreateAddressInfoDataProvider(), CreateSubmissionInfoDataProvider(), new PaymentInfoDataProvider() ]; diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs new file mode 100644 index 000000000..6e578e896 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs @@ -0,0 +1,319 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Integrations; +using Unity.GrantManager.TestHelpers; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Applicants +{ + public class SubmissionInfoDataProviderTests + { + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _submissionRepo; + private readonly IRepository _applicationRepo; + private readonly IRepository _statusRepo; + private readonly IEndpointManagementAppService _endpointManagementAppService; + private readonly ILogger _logger; + private readonly SubmissionInfoDataProvider _provider; + + public SubmissionInfoDataProviderTests() + { + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + _submissionRepo = Substitute.For>(); + _applicationRepo = Substitute.For>(); + _statusRepo = Substitute.For>(); + _endpointManagementAppService = Substitute.For(); + _endpointManagementAppService.GetChefsApiBaseUrlAsync() + .Returns(Task.FromResult(string.Empty)); + _logger = Substitute.For>(); + + SetupEmptyQueryables(); + + _provider = new SubmissionInfoDataProvider( + _currentTenant, _submissionRepo, _applicationRepo, + _statusRepo, _endpointManagementAppService, _logger); + } + + private void SetupEmptyQueryables() + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _statusRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + } + + private void SetupQueryables( + IEnumerable submissions, + IEnumerable applications, + IEnumerable statuses) + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(submissions.AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult(applications.AsAsyncQueryable())); + _statusRepo.GetQueryableAsync() + .Returns(Task.FromResult(statuses.AsAsyncQueryable())); + } + + private static ApplicantProfileInfoRequest CreateRequest() => new() + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.SubmissionInfo + }; + + private static ApplicationFormSubmission CreateSubmission( + Guid applicationId, string oidcSub, Action? configure = null) + { + var entity = new ApplicationFormSubmission + { + ApplicationId = applicationId, + OidcSub = oidcSub, + Submission = "{}" + }; + EntityHelper.TrySetId(entity, () => Guid.NewGuid()); + configure?.Invoke(entity); + return entity; + } + + private static Application CreateApplication(Guid id, Guid statusId, Action? configure = null) + { + var entity = new Application { ApplicationStatusId = statusId }; + EntityHelper.TrySetId(entity, () => id); + configure?.Invoke(entity); + return entity; + } + + private static ApplicationStatus CreateStatus(Guid id, string externalStatus) + { + var entity = new ApplicationStatus { ExternalStatus = externalStatus }; + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + [Fact] + public async Task GetDataAsync_ShouldChangeTenant() + { + // Arrange + var request = CreateRequest(); + + // Act + await _provider.GetDataAsync(request); + + // Assert + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + // Arrange + var request = CreateRequest(); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + result.DataType.ShouldBe("SUBMISSIONINFO"); + } + + [Fact] + public async Task GetDataAsync_WithNoSubmissions_ShouldReturnEmptyList() + { + // Arrange + var request = CreateRequest(); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldMapSubmissionFields() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.ChefsSubmissionGuid = "abc-123"; + s.CreationTime = creationTime; + })], + [CreateApplication(applicationId, statusId, a => + { + a.ReferenceNo = "REF-001"; + a.ProjectName = "Test Project"; + })], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.Count.ShouldBe(1); + + var sub = dto.Submissions[0]; + sub.LinkId.ShouldBe("abc-123"); + sub.ReceivedTime.ShouldBe(creationTime); + sub.ReferenceNo.ShouldBe("REF-001"); + sub.ProjectName.ShouldBe("Test Project"); + sub.Status.ShouldBe("Submitted"); + } + + [Fact] + public async Task GetDataAsync_ShouldResolveSubmissionTimeFromJson() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + var chefsCreatedAt = new DateTime(2025, 1, 14, 21, 37, 52, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.CreationTime = creationTime; + s.Submission = """{"createdAt": "2025-01-14T21:37:52.000Z"}"""; + })], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].SubmissionTime.ShouldBe(chefsCreatedAt); + dto.Submissions[0].ReceivedTime.ShouldBe(creationTime); + } + + [Fact] + public async Task GetDataAsync_ShouldFallBackToCreationTimeWhenNoCreatedAt() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.CreationTime = creationTime; + s.Submission = """{"id": "some-id"}"""; + })], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].SubmissionTime.ShouldBe(creationTime); + } + + [Fact] + public async Task GetDataAsync_ShouldFallBackToCreationTimeWhenInvalidJson() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.CreationTime = creationTime; + s.Submission = "not valid json"; + })], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].SubmissionTime.ShouldBe(creationTime); + } + + [Fact] + public async Task GetDataAsync_ShouldResolveLinkSourceFromIntakeApiBase() + { + // Arrange + var request = CreateRequest(); + _endpointManagementAppService.GetChefsApiBaseUrlAsync() + .Returns(Task.FromResult("https://chefs-dev.apps.silver.devops.gov.bc.ca/app/api/v1")); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.LinkSource.ShouldBe("https://chefs-dev.apps.silver.devops.gov.bc.ca/app/form/view?s="); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnEmptyLinkSourceWhenSettingFails() + { + // Arrange + var request = CreateRequest(); + _endpointManagementAppService.GetChefsApiBaseUrlAsync() + .Returns(x => throw new Exception("Not configured")); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.LinkSource.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnSubmissionsForOtherSubjects() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "OTHERUSER")], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.ShouldBeEmpty(); + } + } +} From 2d484ed4f81e0c96dff18498a1f1359c008dfe07 Mon Sep 17 00:00:00 2001 From: David Bright Date: Tue, 24 Feb 2026 15:37:46 -0800 Subject: [PATCH 30/36] When using clear filter, the search field will be reset to empty, and the quick date range dropdown will be reset to 6 months --- .../wwwroot/themes/ux2/plugins/filterRow.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js index 781616ad2..59f979ff5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js @@ -389,6 +389,9 @@ $(externalSearchId).val(''); } + // Clear the search input field + $('#search').val(''); + // Clear custom filter inputs $('.custom-filter-input').val(''); @@ -398,8 +401,9 @@ // Clear order dt.order(initialSortOrder); - // Reload data - dt.ajax.reload(); + // If we want to reset quick date range dropdown to default (last 6 months) and trigger change + // The change event handler will reload the table, so would need to remove ajax.reload() here + $('#quickDateRange').val('last6months').trigger('change'); // Update button state this._updateButtonState(); From f5e4f75eeea8f771a262c63634e566644811ac8c Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Tue, 24 Feb 2026 15:45:51 -0800 Subject: [PATCH 31/36] AB#32046 Rename OpenAI config keys to Azure prefix --- .../src/Unity.GrantManager.Application/AI/OpenAIService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 1ea3e9360..b2262ff7d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -19,13 +19,13 @@ public class OpenAIService : IAIService, ITransientDependency private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; - private string? ApiKey => _configuration["AI:OpenAI:ApiKey"]; - private string? ApiUrl => _configuration["AI:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; + private string? ApiKey => _configuration["Azure:OpenAI:ApiKey"]; + private string? ApiUrl => _configuration["Azure:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; private readonly string MissingApiKeyMessage = "OpenAI API key is not configured"; // Optional local debugging sink for prompt payload logs to a local file. // Not intended for deployed/shared environments. - private bool IsPromptFileLoggingEnabled => _configuration.GetValue("AI:Logging:EnablePromptFileLog") ?? false; + private bool IsPromptFileLoggingEnabled => _configuration.GetValue("Azure:Logging:EnablePromptFileLog") ?? false; private const string PromptLogDirectoryName = "logs"; private static readonly string PromptLogFileName = $"ai-prompts-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}.log"; From 2226ce4c6df3b28644cf11671929c1b0485ca865 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 24 Feb 2026 15:50:45 -0800 Subject: [PATCH 32/36] AB#30430 sonarQube fixes --- .../ApplicantProfile/AddressInfoDataProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs index 7e14f7ab1..44d54844b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -93,7 +93,7 @@ from application in apps.DefaultIfEmpty() }).ToList(); // If no address is marked as primary, mark the most recent one as primary - if (addressDtos.Any() && !addressDtos.Any(a => a.IsPrimary)) + if (addressDtos.Count > 0 && !addressDtos.Any(a => a.IsPrimary)) { var mostRecent = deduplicated.OrderByDescending(r => r.CreationTime).First(); var mostRecentDto = addressDtos.First(a => a.Id == mostRecent.address.Id); From 676fd06b0fa0d3dae9f01833d78587f4eee9a298 Mon Sep 17 00:00:00 2001 From: David Bright Date: Tue, 24 Feb 2026 16:20:06 -0800 Subject: [PATCH 33/36] Applied logic to use a default