diff --git a/.gitignore b/.gitignore index 841973049..cf56f6bd3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.rsuser *.userosscache *.sln.docstates +*.code-workspace # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs new file mode 100644 index 000000000..71053bd12 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs @@ -0,0 +1,11 @@ +using System; + +namespace Unity.Payments.PaymentRequests; + +[Serializable] +public class ApplicationPaymentRollupDto +{ + 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..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,5 +21,7 @@ public interface IPaymentRequestAppService : IApplicationService Task GetUserPaymentThresholdAsync(); Task ManuallyAddPaymentRequestsToReconciliationQueue(List paymentRequestIds); Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); + 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 5ae55debb..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 @@ -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> 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 222dc9f83..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 @@ -35,5 +35,9 @@ public interface IPaymentRequestQueryManager // Pending Payments Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); + + // 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 f5fe8e972..41aa2b516 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,98 @@ public async Task> GetPaymentPendingListByCorrelationIdA var payments = await paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(applicationId); return objectMapper.Map, List>(payments); } + + /// + /// 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 batchRollup = await paymentRequestRepository.GetBatchPaymentRollupsByCorrelationIdsAsync(allCorrelationIds); + + return new ApplicationPaymentRollupDto + { + ApplicationId = applicationId, + TotalPaid = batchRollup.Sum(s => s.TotalPaid), + TotalPending = batchRollup.Sum(s => s.TotalPending) + }; + } + + /// + /// 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) + { + // 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 paymentRollups = await paymentRequestRepository.GetBatchPaymentRollupsByCorrelationIdsAsync(allCorrelationIds.ToList()); + var rollupLookup = paymentRollups.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 (rollupLookup.TryGetValue(applicationId, out var parentRollup)) + { + totalPaid += parentRollup.TotalPaid; + totalPending += parentRollup.TotalPending; + } + + // Add child application amounts + if (childApplicationIdsByParent.TryGetValue(applicationId, out var childIds)) + { + foreach (var childId in childIds) + { + if (rollupLookup.TryGetValue(childId, out var childApplicationRollup)) + { + totalPaid += childApplicationRollup.TotalPaid; + totalPending += childApplicationRollup.TotalPending; + } + } + } + + result[applicationId] = new ApplicationPaymentRollupDto + { + 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..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 @@ -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,52 @@ 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(); + } + + /// + /// 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> GetBatchPaymentRollupsByCorrelationIdsAsync(List correlationIds) + { + var dbSet = await GetDbSetAsync(); + + var results = await dbSet + .Where(p => correlationIds.Contains(p.CorrelationId)) + .GroupBy(p => p.CorrelationId) + .Select(g => new ApplicationPaymentRollupDto + { + ApplicationId = g.Key, + TotalPaid = g + .Where(p => p.PaymentStatus != null + && p.PaymentStatus.Trim().ToUpper() == CasPaymentRequestStatus.FullyPaid.ToUpper()) + .Sum(p => p.Amount), + TotalPending = g + .Where(p => p.Status == PaymentRequestStatus.L1Pending + || p.Status == PaymentRequestStatus.L2Pending + || p.Status == PaymentRequestStatus.L3Pending + || (p.Status == PaymentRequestStatus.Submitted + && string.IsNullOrEmpty(p.PaymentStatus) + && (string.IsNullOrEmpty(p.InvoiceStatus) + || !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/Integrations/Cas/InvoiceService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs index 148749505..b0d366d9f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs @@ -163,9 +163,9 @@ public async Task GetCasInvoiceAsync(string invoiceNumbe } } - public async Task GetCasPaymentAsync(string invoiceNumber, string supplierNumber, string siteNumber) + public async Task GetCasPaymentAsync(Guid tenantId, string invoiceNumber, string supplierNumber, string siteNumber) { - var authToken = await iTokenService.GetAuthTokenAsync(CurrentTenant.Id ?? Guid.Empty); + var authToken = await iTokenService.GetAuthTokenAsync(tenantId); var casBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE); var resource = $"{casBaseUrl}/{CFS_APINVOICE}/{invoiceNumber}/{supplierNumber}/{siteNumber}"; var response = await resilientHttpRequest.HttpAsync(HttpMethod.Get, resource, body: null, authToken); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs index 5ee1bed7b..20bd5b882 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs @@ -16,10 +16,10 @@ public async Task ConsumeAsync(ReconcilePaymentMessages reconcilePaymentMessage) { if (reconcilePaymentMessage != null && !reconcilePaymentMessage.InvoiceNumber.IsNullOrEmpty() && reconcilePaymentMessage.TenantId != Guid.Empty) { - // string invoiceNumber, string supplierNumber, string siteNumber) // Go to CAS retrieve the status of the payment CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync( + reconcilePaymentMessage.TenantId, reconcilePaymentMessage.InvoiceNumber, reconcilePaymentMessage.SupplierNumber, reconcilePaymentMessage.SiteNumber); 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..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 @@ -4,19 +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; namespace Unity.Payments.PaymentRequests { @@ -29,7 +30,8 @@ public class PaymentRequestAppService( IPaymentsManager paymentsManager, FsbPaymentNotifier fsbPaymentNotifier, IPaymentRequestQueryManager paymentRequestQueryManager, - IPaymentRequestConfigurationManager paymentRequestConfigurationManager) : PaymentsAppService, IPaymentRequestAppService + IPaymentRequestConfigurationManager paymentRequestConfigurationManager, + Lazy applicationLinksService) : PaymentsAppService, IPaymentRequestAppService { public async Task GetDefaultAccountCodingId() @@ -328,5 +330,38 @@ public async Task> GetPaymentPendingListByCorrelationIdA { return await paymentRequestQueryManager.GetPaymentPendingListByCorrelationIdAsync(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.GetApplicationPaymentRollupAsync(applicationId, childApplicationIds); + } + + /// + /// 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.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 063347227..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 @@ -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) @@ -52,42 +45,10 @@ public async Task InvokeAsync(Guid applicationId, Guid app ApplicationFormVersionId = applicationFormVersionId, 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 rollup = await _paymentRequestService.GetApplicationPaymentRollupAsync(applicationId); + model.TotalPaid = rollup.TotalPaid; + model.TotalPendingAmounts = rollup.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/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs new file mode 100644 index 000000000..49c2c4bd1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs @@ -0,0 +1,412 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +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_PaymentRollup_Tests +{ + #region GetApplicationPaymentRollupAsync (Single Application) + + [Fact] + public async Task Should_Return_Rollup_For_Single_Application_With_NoChildren() + { + // Arrange + var appId = Guid.NewGuid(); + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = appId, TotalPaid = 1500m, TotalPending = 2000m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupAsync(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).GetBatchPaymentRollupsByCorrelationIdsAsync( + Arg.Is>(ids => ids.Count == 1 && ids.Contains(appId))); + } + + [Fact] + public async Task Should_Aggregate_Rollup_From_Parent_And_Children() + { + // Arrange + var parentId = Guid.NewGuid(); + var child1Id = Guid.NewGuid(); + var child2Id = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(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.GetApplicationPaymentRollupAsync(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).GetBatchPaymentRollupsByCorrelationIdsAsync( + 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.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupAsync(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.GetBatchPaymentRollupsByCorrelationIdsAsync(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.GetApplicationPaymentRollupAsync(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.GetBatchPaymentRollupsByCorrelationIdsAsync(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.GetApplicationPaymentRollupAsync(parentId, [childId]); + + // Assert + result.ApplicationId.ShouldBe(parentId); + result.TotalPaid.ShouldBe(1500m); // 1000 + 500 + result.TotalPending.ShouldBe(300m); // 0 + 300 + } + + #endregion + + #region GetApplicationPaymentRollupsAsync (Batch) + + [Fact] + public async Task Should_Return_Batch_Rollups_For_Multiple_Applications_Without_Children() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + var app3Id = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(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.GetApplicationPaymentRollupBatchAsync( + [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_Rollup() + { + // 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.GetBatchPaymentRollupsByCorrelationIdsAsync(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.GetApplicationPaymentRollupBatchAsync( + [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_Rollup_In_Batch() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(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.GetApplicationPaymentRollupBatchAsync( + [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.GetBatchPaymentRollupsByCorrelationIdsAsync(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.GetApplicationPaymentRollupBatchAsync( + [parentAId, parentBId], childMap); + + // Assert + // Verify repository was called with deduplicated IDs (3 unique, not 4) + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync( + 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.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); + + var manager = CreateManager(repo); + + // Act + await manager.GetApplicationPaymentRollupBatchAsync([app1Id, app2Id], childMap); + + // Assert - should only call repository once (batch optimization) + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()); + } + + [Fact] + public async Task Should_Return_Empty_Dictionary_For_Empty_Application_List() + { + // Arrange + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupBatchAsync( + [], 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.GetBatchPaymentRollupsByCorrelationIdsAsync(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.GetApplicationPaymentRollupBatchAsync( + [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 Rollup methods + null! // IObjectMapper - not used by Rollup methods + ); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs new file mode 100644 index 000000000..17a7da11f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_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_PaymentRollup_Tests : PaymentsApplicationTestBase +{ + private readonly IPaymentRequestRepository _paymentRequestRepository; + private readonly ISupplierRepository _supplierRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public PaymentRequestRepository_PaymentRollup_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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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_Rollup_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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([app1Id, app2Id]); + + // Assert + results.Count.ShouldBe(2); + + var app1Rollup = results.Find(r => r.ApplicationId == app1Id); + app1Rollup.ShouldNotBeNull(); + app1Rollup!.TotalPaid.ShouldBe(1000m); + app1Rollup.TotalPending.ShouldBe(500m); + + var app2Rollup = results.Find(r => r.ApplicationId == app2Id); + app2Rollup.ShouldNotBeNull(); + app2Rollup!.TotalPaid.ShouldBe(2000m); + app2Rollup.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 + .GetBatchPaymentRollupsByCorrelationIdsAsync([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 +} 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 0cd3660d8..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 @@ -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_Rollup() { // 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 rollup = new ApplicationPaymentRollupDto { - 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.GetApplicationPaymentRollupAsync(applicationId).Returns(rollup); 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 rollup = new ApplicationPaymentRollupDto { - 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.GetApplicationPaymentRollupAsync(applicationId).Returns(rollup); 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_By_Rollup() { + // The ViewComponent now delegates child aggregation to the service layer. + // This test verifies it correctly uses the pre-aggregated rollup. // 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 + // Rollup includes parent + child amounts (pre-aggregated by service) + var rollup = new ApplicationPaymentRollupDto { - 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.GetApplicationPaymentRollupAsync(parentAppId).Returns(rollup); 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 rollup = new ApplicationPaymentRollupDto { - 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.GetApplicationPaymentRollupAsync(appId).Returns(rollup); 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 rollup = new ApplicationPaymentRollupDto { - 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.GetApplicationPaymentRollupAsync(appId).Returns(rollup); 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().GetApplicationPaymentRollupAsync(Arg.Any()); } [Fact] - public async Task PaymentInfo_Should_Include_All_Pending_Levels_InPending() + public async Task PaymentInfo_Should_Call_GetApplicationPaymentRollupAsync_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 rollup = new ApplicationPaymentRollupDto { - 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.GetApplicationPaymentRollupAsync(appId).Returns(rollup); 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).GetApplicationPaymentRollupAsync(appId); } private PaymentInfoViewComponent CreateViewComponent( IGrantApplicationAppService appService, IPaymentRequestAppService paymentRequestService, - IFeatureChecker featureChecker, - IApplicationLinksService applicationLinksService) + IFeatureChecker featureChecker) { var viewContext = new ViewContext { @@ -663,10 +331,9 @@ private PaymentInfoViewComponent CreateViewComponent( }; var viewComponent = new PaymentInfoViewComponent( - appService, + appService, paymentRequestService, - featureChecker, - applicationLinksService) + featureChecker) { ViewComponentContext = viewComponentContext, LazyServiceProvider = _lazyServiceProvider 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..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,19 +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 Volo.Abp.Identity; -using Unity.Notifications.EmailGroups; +using Volo.Abp.Testing; +using Volo.Abp.Uow; +using Volo.Abp.Users; namespace Unity.Payments; @@ -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); @@ -82,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/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..f536bb271 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($('#quickDateRange option[selected]').val()).trigger('change'); // Update button state this._updateButtonState(); 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.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.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/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 1374af052..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,11 +19,23 @@ 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 readonly string NoKeyError = "OpenAI API key is not configured"; - - public OpenAIService(HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService) + 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("Azure:Logging:EnablePromptFileLog") ?? false; + private const string PromptLogDirectoryName = "logs"; + private static readonly string PromptLogFileName = $"ai-prompts-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}.log"; + + private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; + + public OpenAIService( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger, + ITextExtractionService textExtractionService) { _httpClient = httpClient; _configuration = configuration; @@ -35,7 +47,7 @@ public Task IsAvailableAsync() { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("Error: {Message}", NoKeyError); + _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); return Task.FromResult(false); } @@ -46,22 +58,23 @@ 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."; } - _logger.LogDebug("Calling OpenAI with prompt: {Prompt}", content); + _logger.LogDebug("Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", content?.Length ?? 0, maxTokens); 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 @@ -76,7 +89,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) { @@ -84,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) @@ -125,7 +146,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); + await LogPromptInputAsync("AttachmentSummary", prompt, contentToAnalyze); + var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); + await LogPromptOutputAsync("AttachmentSummary", modelOutput); + return modelOutput; } catch (Exception ex) { @@ -138,7 +162,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."; } @@ -202,7 +226,9 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis Respond only with valid JSON in the exact format requested."; + await LogPromptInputAsync("ApplicationAnalysis", systemPrompt, analysisContent); var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); + await LogPromptOutputAsync("ApplicationAnalysis", rawAnalysis); // Post-process the AI response to add unique IDs to errors and warnings return AddIdsToAnalysisItems(rawAnalysis); @@ -279,7 +305,7 @@ public async Task GenerateScoresheetAnswersAsync(string applicationConte { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("{Message}", NoKeyError); + _logger.LogWarning("{Message}", MissingApiKeyMessage); return "{}"; } @@ -320,7 +346,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); + await LogPromptInputAsync("ScoresheetAll", systemPrompt, analysisContent); + var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + await LogPromptOutputAsync("ScoresheetAll", modelOutput); + return modelOutput; } catch (Exception ex) { @@ -333,7 +362,7 @@ public async Task GenerateScoresheetSectionAnswersAsync(string applicati { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("{Message}", NoKeyError); + _logger.LogWarning("{Message}", MissingApiKeyMessage); return "{}"; } @@ -394,7 +423,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); + await LogPromptInputAsync("ScoresheetSection", systemPrompt, analysisContent); + var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + await LogPromptOutputAsync("ScoresheetSection", modelOutput); + return modelOutput; } catch (Exception ex) { @@ -402,5 +434,155 @@ Always provide citations that reference specific parts of the application conten return "{}"; } } + + private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) + { + var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); + _logger.LogInformation("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); + await WritePromptLogFileAsync(promptType, "INPUT", formattedInput); + } + + private async Task LogPromptOutputAsync(string promptType, string output) + { + var formattedOutput = FormatPromptOutputForLog(output); + _logger.LogInformation("AI {PromptType} model output payload: {ModelOutput}", promptType, formattedOutput); + await WritePromptLogFileAsync(promptType, "OUTPUT", formattedOutput); + } + + private async Task WritePromptLogFileAsync(string promptType, string payloadType, string payload) + { + if (!CanWritePromptFileLog()) + { + return; + } + + try + { + var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); + var logDirectory = Path.Combine(AppContext.BaseDirectory, PromptLogDirectoryName); + Directory.CreateDirectory(logDirectory); + + var logPath = Path.Combine(logDirectory, PromptLogFileName); + var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; + await File.AppendAllTextAsync(logPath, entry); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to write AI prompt log file."); + } + } + + private bool CanWritePromptFileLog() + { + return IsPromptFileLoggingEnabled; + } + + 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, JsonLogOptions); + } + + 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) + { + // 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)) + { + var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); + if (lastIndex > 0) + { + cleaned = cleaned[..lastIndex]; + } + } + + 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; + } } } 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..44d54844b 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.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); + 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 9bd91fcbd..60232d7e2 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,139 @@ +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 { + /// + /// 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 : 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 subject = request.Subject ?? string.Empty; + var normalizedSubject = subject.Contains('@') + ? subject[..subject.IndexOf('@')].ToUpperInvariant() + : 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; + } + } + + /// + /// 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 + { + 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/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 3ce28a569..f67026cfb 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) @@ -96,6 +96,58 @@ join applicant in applicantsQuery on application.ApplicantId equals applicant.Id return resultList; } + /// + /// Retrieves a list of application links of the specified type for a given application. + /// + /// Use this method to obtain links associated with a particular application and link type. The + /// returned list is mapped to ApplicationLinksDto for convenient consumption in client code. + /// The unique identifier of the application for which to retrieve links. Must be a valid GUID. + /// The type of application link to retrieve, specified by the enumeration. + /// A task that represents the asynchronous operation. The task result contains a list of + /// objects matching the specified application ID and link type. The list will be empty if no links are found. + 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); + } + + /// + /// Retrieves a list of child application links associated with the specified applicationId. + /// + /// This method is asynchronous and may involve network or database calls, which could affect + /// performance. Ensure that the applicationId provided is valid to avoid exceptions. + /// The unique identifier of the application for which child applications are being retrieved. This parameter cannot + /// be an empty GUID. + /// A task that represents the asynchronous operation. The task result contains a list of + /// objects representing the child applications. The list will be empty if no child applications are found. + public async Task> GetChildApplications(Guid applicationId) + { + return await GetApplicationLinksByType(applicationId, ApplicationLinkType.Child); + } + + /// + /// Retrieves a dictionary that maps each specified parent application ID to a list of its associated child application IDs. + /// + /// The method fetches application links from the repository and groups them by parent + /// application ID. Ensure that the provided parent application IDs exist in the repository to obtain meaningful + /// results. + /// A list of GUIDs representing the parent application IDs for which to retrieve child application IDs. This + /// parameter cannot be null or empty. + /// A dictionary where each key is a parent application ID and the corresponding value is a list of child + /// application IDs linked to that parent. The dictionary will be empty if no child applications are found. + 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(); @@ -154,7 +206,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(); @@ -162,7 +214,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); @@ -249,13 +301,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 { @@ -276,16 +328,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); @@ -295,7 +347,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(); @@ -303,7 +355,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); @@ -375,7 +427,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, @@ -394,46 +446,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; @@ -444,31 +496,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) @@ -481,25 +533,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); @@ -513,29 +565,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 d590a5482..159f77336 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; @@ -40,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, @@ -63,22 +62,16 @@ 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); - List paymentRequests = []; + Dictionary paymentRollupBatch = []; if (paymentsFeatureEnabled && applicationIds.Count > 0) { - paymentRequests = await paymentRequestService.GetListByApplicationIdsAsync(applicationIds); + paymentRollupBatch = await paymentRequestService.GetApplicationPaymentRollupBatchAsync(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 +95,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 && paymentRollupBatch.Count > 0) { + paymentRollupBatch.TryGetValue(app.Id, out var rollup); appDto.PaymentInfo = new PaymentInfoDto { ApprovedAmount = app.ApprovedAmount, - TotalPaid = paymentRequestsByApplication.GetValueOrDefault(app.Id) + TotalPaid = rollup?.TotalPaid ?? 0 }; } return appDto; @@ -216,7 +210,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) @@ -672,7 +666,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) @@ -817,7 +811,7 @@ public async Task UpdateApplicationStatus(Guid[] applicationIds, Guid statusId) Debug.WriteLine(ex.ToString()); } } - } + } public async Task> GetApplicationListAsync(List applicationIds) { 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; +} 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..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 @@ -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 paymentRollupBatch = []; 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)); + paymentRollupBatch = await _paymentRequestService.GetApplicationPaymentRollupBatchAsync(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 && paymentRollupBatch.Count > 0) { - paymentRequestsByApplication.TryGetValue(app.Id, out var totalPaid); + paymentRollupBatch.TryGetValue(app.Id, out var paymentRollup); dto.PaymentInfo = new PaymentInfoDto { ApprovedAmount = app.ApprovedAmount, - TotalPaid = totalPaid + TotalPaid = paymentRollup?.TotalPaid ?? 0 }; } 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 74ccc3f9a..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 @@ -6,6 +6,10 @@ 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.Repositories; using Volo.Abp.MultiTenancy; using Xunit; @@ -33,6 +37,35 @@ 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(); + 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>(); + return new SubmissionInfoDataProvider(currentTenant, submissionRepo, applicationRepo, statusRepo, endpointManagementAppService, logger); + } + [Fact] public void ContactInfoDataProvider_Key_ShouldMatchExpected() { @@ -68,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(); @@ -84,14 +117,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(); @@ -120,8 +153,8 @@ public void AllProviders_ShouldHaveUniqueKeys() [ CreateContactInfoDataProvider(), new OrgInfoDataProvider(), - new AddressInfoDataProvider(), - new SubmissionInfoDataProvider(), + 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(); + } + } +} 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,