From 58a362b63d55f8d28190b0eae23abaefc37fe122 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 30 Sep 2026 19:46:59 +0100 Subject: [PATCH 1/2] add endpoint restrictions --- .../ReportContractTests.cs | 29 ++ .../ReportQueryValidationTests.cs | 82 ++++ .../ReportingManagerReportTests.cs | 214 ++++++++- .../Queries/TransactionQueries.cs | 2 +- .../ReportQueryLimits.cs | 11 + .../ReportQueryValidation.cs | 51 ++ .../Services/TransactionReportingService.cs | 447 +++++++++++++----- .../GetRecentActivityReceiptReportResponse.cs | 4 +- .../PaginationMetadata.cs | 9 + .../TransactionDetailReportRequest.cs | 2 + .../TransactionDetailReportResponse.cs | 4 +- .../TransactionMixSummaryRequest.cs | 2 + .../TransactionMixSummaryResponse.cs | 1 + .../TransactionSummaryByMerchantRequest.cs | 2 + .../TransactionSummaryByMerchantResponse.cs | 3 +- .../TransactionSummaryByOperatorRequest.cs | 2 + .../TransactionSummaryByOperatorResponse.cs | 3 +- .../TransactionMixSummaryEndpointTests.cs | 5 + .../TransactionsEndpointTests.cs | 53 ++- .../GetRecentActivityReceiptReportResponse.cs | 4 +- .../PaginationMetadata.cs | 9 + .../ProductPerformanceResponse.cs | 3 +- .../TransactionDetailReportRequest.cs | 4 +- .../TransactionDetailReportResponse.cs | 3 +- .../TransactionMixSummaryRequest.cs | 2 + .../TransactionMixSummaryResponse.cs | 1 + .../TransactionSummaryByMerchantRequest.cs | 4 +- .../TransactionSummaryByMerchantResponse.cs | 3 +- .../TransactionSummaryByOperatorRequest.cs | 4 +- .../TransactionSummaryByOperatorResponse.cs | 1 + .../Handlers/TransactionHandler.cs | 122 ++++- README.md | 13 +- 32 files changed, 925 insertions(+), 174 deletions(-) create mode 100644 EstateReportingAPI.BusinessLogic.UnitTests/ReportContractTests.cs create mode 100644 EstateReportingAPI.BusinessLogic.UnitTests/ReportQueryValidationTests.cs create mode 100644 EstateReportingAPI.BusinessLogic/ReportQueryLimits.cs create mode 100644 EstateReportingAPI.BusinessLogic/ReportQueryValidation.cs create mode 100644 EstateReportingAPI.DataTrasferObjects/PaginationMetadata.cs create mode 100644 EstateReportingAPI.Models/PaginationMetadata.cs diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportContractTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportContractTests.cs new file mode 100644 index 0000000..ab7174b --- /dev/null +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportContractTests.cs @@ -0,0 +1,29 @@ +using EstateReportingAPI.Models; +using Shouldly; +using Xunit; + +namespace EstateReportingAPI.BusinessLogic.UnitTests; + +public sealed class ReportContractTests +{ + [Fact] + public void PagedRequestDefaultsUseApprovedPageSizes() + { + new TransactionDetailReportRequest().PageSize.ShouldBe(ReportQueryLimits.DefaultPageSize); + new TransactionMixSummaryRequest().PageSize.ShouldBe(ReportQueryLimits.DefaultPageSize); + new TransactionSummaryByMerchantRequest().PageSize.ShouldBe(ReportQueryLimits.DefaultPageSize); + new TransactionSummaryByOperatorRequest().PageSize.ShouldBe(ReportQueryLimits.DefaultPageSize); + new GetRecentActivityReceiptReportRequest().PageSize.ShouldBe(ReportQueryLimits.RecentActivityDefaultPageSize); + } + + [Fact] + public void PagedResponsesExposeSharedPaginationMetadata() + { + new TransactionDetailReportResponse().Pagination.ShouldNotBeNull(); + new TransactionMixSummaryResponse().Pagination.ShouldNotBeNull(); + new TransactionSummaryByMerchantResponse().Pagination.ShouldNotBeNull(); + new TransactionSummaryByOperatorResponse().Pagination.ShouldNotBeNull(); + new ProductPerformanceResponse().Pagination.ShouldNotBeNull(); + new GetRecentActivityReceiptReportResponse().Pagination.ShouldNotBeNull(); + } +} diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportQueryValidationTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportQueryValidationTests.cs new file mode 100644 index 0000000..c0a3b06 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportQueryValidationTests.cs @@ -0,0 +1,82 @@ +using System; +using Shouldly; +using Xunit; + +namespace EstateReportingAPI.BusinessLogic.UnitTests; + +public sealed class ReportQueryValidationTests +{ + [Fact] + public void ValidateDateRange_AllowsThirtyInclusiveDays() + { + ReportQueryValidation.ValidateDateRange( + new DateTime(2026, 9, 1), + new DateTime(2026, 9, 30)) + .ShouldBeNull(); + } + + [Fact] + public void ValidateDateRange_RejectsMoreThanThirtyInclusiveDays() + { + ReportQueryValidation.ValidateDateRange( + new DateTime(2026, 9, 1), + new DateTime(2026, 10, 1)) + .ShouldBe("date range must not exceed 30 inclusive calendar days."); + } + + [Fact] + public void ValidateDateRange_RejectsReversedDates() + { + ReportQueryValidation.ValidateDateRange( + new DateTime(2026, 9, 2), + new DateTime(2026, 9, 1)) + .ShouldBe("startDate must be less than or equal to endDate."); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(1, 50)] + public void ValidatePaging_AllowsPageNumbersAndSizesWithinBounds(int pageNumber, int pageSize) + { + ReportQueryValidation.ValidatePaging(pageNumber, pageSize).ShouldBeNull(); + } + + [Theory] + [InlineData(0, 1, "pageNumber must be greater than or equal to 1.")] + [InlineData(1, 0, "pageSize must be between 1 and 50.")] + [InlineData(1, 51, "pageSize must be between 1 and 50.")] + [InlineData(int.MaxValue, 50, "pageNumber is too large for this report.")] + public void ValidatePaging_RejectsInvalidValues(int pageNumber, int pageSize, string expected) + { + ReportQueryValidation.ValidatePaging(pageNumber, pageSize).ShouldBe(expected); + } + + [Theory] + [InlineData(1)] + [InlineData(20)] + public void ValidateTopN_AllowsValuesWithinBounds(int topN) + { + ReportQueryValidation.ValidateTopN(topN).ShouldBeNull(); + } + + [Theory] + [InlineData(0, "topN must be between 1 and 20.")] + [InlineData(21, "topN must be between 1 and 20.")] + public void ValidateTopN_RejectsValuesOutsideBounds(int topN, string expected) + { + ReportQueryValidation.ValidateTopN(topN).ShouldBe(expected); + } + + [Fact] + public void ValidateFilterList_AllowsFiftyIds() + { + ReportQueryValidation.ValidateFilterList("merchants", new int[50]).ShouldBeNull(); + } + + [Fact] + public void ValidateFilterList_RejectsMoreThanFiftyIds() + { + ReportQueryValidation.ValidateFilterList("merchants", new int[51]) + .ShouldBe("merchants must contain no more than 50 values."); + } +} diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs index c501dde..5806f56 100644 --- a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs +++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs @@ -308,6 +308,54 @@ public async Task GetTransactionDetailReport_AppliesMerchantProductAndOperatorFi result.Data.Summary.TotalValue.ShouldBe(10m); } + [Fact] + public async Task GetTransactionDetailReport_PaginatesRowsButKeepsFullSummary() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1, 9, 0, 0)); + database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1, 10, 0, 0)); + database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1, 11, 0, 0)); + await database.SaveAsync(); + + var result = await database.TransactionService.GetTransactionDetailReport( + new TransactionQueries.TransactionDetailReportQuery( + EstateId, + new TransactionDetailReportRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1), + PageNumber = 2, + PageSize = 2 + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Transactions.Count.ShouldBe(1); + result.Data.Summary.TransactionCount.ShouldBe(3); + result.Data.Summary.TotalValue.ShouldBe(60m); + result.Data.Pagination.PageNumber.ShouldBe(2); + result.Data.Pagination.PageSize.ShouldBe(2); + result.Data.Pagination.TotalItems.ShouldBe(3); + result.Data.Pagination.TotalPages.ShouldBe(2); + } + + [Fact] + public async Task GetTransactionDetailReport_RejectsDateRangesOverThirtyDays() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.TransactionService.GetTransactionDetailReport( + new TransactionQueries.TransactionDetailReportQuery( + EstateId, + new TransactionDetailReportRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 10, 1) + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeFalse(); + result.Message.ShouldBe("date range must not exceed 30 inclusive calendar days."); + } + [Fact] public async Task GetTransactionSummaryByOperatorReport_AppliesMerchantAndOperatorFilters() { @@ -351,6 +399,60 @@ public async Task GetTransactionSummaryByMerchantReport_WhenNoSales_ReturnsZeroR result.Data.Summary.TotalValue.ShouldBe(0m); } + [Fact] + public async Task GetTransactionSummaryByMerchantReport_PaginatesMerchantsButKeepsFullSummary() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1)); + database.AddSale(database.SecondMerchantId, database.SecondOperatorId, 20m, new DateTime(2026, 9, 1)); + await database.SaveAsync(); + + var result = await database.TransactionService.GetTransactionSummaryByMerchantReport( + new TransactionQueries.TransactionSummaryByMerchantQuery( + EstateId, + new TransactionSummaryByMerchantRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1), + PageNumber = 2, + PageSize = 1 + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Merchants.Count.ShouldBe(1); + result.Data.Summary.TotalCount.ShouldBe(2); + result.Data.Summary.TotalValue.ShouldBe(30m); + result.Data.Pagination.TotalItems.ShouldBe(2); + result.Data.Pagination.TotalPages.ShouldBe(2); + } + + [Fact] + public async Task GetTransactionSummaryByOperatorReport_PaginatesOperatorsButKeepsFullSummary() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1)); + database.AddSale(database.SecondMerchantId, database.SecondOperatorId, 20m, new DateTime(2026, 9, 1)); + await database.SaveAsync(); + + var result = await database.TransactionService.GetTransactionSummaryByOperatorReport( + new TransactionQueries.TransactionSummaryByOperatorQuery( + EstateId, + new TransactionSummaryByOperatorRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1), + PageNumber = 2, + PageSize = 1 + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Operators.Count.ShouldBe(1); + result.Data.Summary.TotalCount.ShouldBe(2); + result.Data.Summary.TotalValue.ShouldBe(30m); + result.Data.Pagination.TotalItems.ShouldBe(2); + result.Data.Pagination.TotalPages.ShouldBe(2); + } + [Fact] public async Task GetTransactionMixSummary_WhenDateRangeIsInvalid_ReturnsFailure() { @@ -390,7 +492,7 @@ public async Task GetTransactionMixSummary_WhenBreakdownIsUnsupported_ReturnsFai } [Fact] - public async Task GetRecentActivityReceiptReport_NormalizesInvalidPagingAndReturnsEmptyResult() + public async Task GetRecentActivityReceiptReport_RejectsInvalidPaging() { await using TestDatabase database = await TestDatabase.CreateAsync(); @@ -404,11 +506,8 @@ public async Task GetRecentActivityReceiptReport_NormalizesInvalidPagingAndRetur }), CancellationToken.None); - result.IsSuccess.ShouldBeTrue(); - result.Data.PageNumber.ShouldBe(1); - result.Data.PageSize.ShouldBe(10); - result.Data.TotalCount.ShouldBe(0); - result.Data.Items.ShouldBeEmpty(); + result.IsSuccess.ShouldBeFalse(); + result.Message.ShouldBe("pageNumber must be greater than or equal to 1."); } [Fact] @@ -431,7 +530,7 @@ public async Task GetRecentActivityReceiptReport_PaginatesOrderedRowsAndPreserve CancellationToken.None); result.IsSuccess.ShouldBeTrue(); - result.Data.TotalCount.ShouldBe(3); + result.Data.Pagination.TotalItems.ShouldBe(3); result.Data.Items.Count.ShouldBe(1); result.Data.Items.Single().Amount.ShouldBe(10m); } @@ -595,6 +694,25 @@ public async Task GetMerchantDailyPerformanceSummary_WhenNoTransactions_ReturnsB result.Data.DrillDownTransactions.ShouldBeEmpty(); } + [Fact] + public async Task GetMerchantDailyPerformanceSummary_RejectsDateRangesOverThirtyDays() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.TransactionService.GetMerchantDailyPerformanceSummary( + new TransactionQueries.MerchantDailyPerformanceSummaryQuery( + EstateId, + new MerchantDailyPerformanceSummaryRequest { + MerchantReportingId = 20, + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 10, 1) + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeFalse(); + result.Message.ShouldBe("date range must not exceed 30 inclusive calendar days."); + } + [Fact] public async Task GetMerchants_AppliesAllQueryFilters() { @@ -791,7 +909,60 @@ public async Task GetTransactionMixSummary_GroupsByStatusAndOrdersByValue() result.Data.Groups.Single().GroupName.ShouldBe("Declined"); result.Data.Groups.Single().TransactionValue.ShouldBe(25m); result.Data.Transactions.Count.ShouldBe(2); - result.Data.Transactions.First().Value.ShouldBe(25m); + result.Data.Transactions.Select(t => t.Value).ShouldContain(25m); + } + + [Fact] + public async Task GetTransactionMixSummary_PaginatesTransactionsAndLimitsGroups() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1, 9, 0, 0), authorised: true); + database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1, 10, 0, 0), authorised: false); + database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1, 11, 0, 0), authorised: false); + await database.SaveAsync(); + + var result = await database.TransactionService.GetTransactionMixSummary( + new TransactionQueries.TransactionMixSummaryQuery( + EstateId, + new TransactionMixSummaryRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1), + Breakdown = TransactionMixBreakdown.Status, + Measure = TransactionMixMeasure.Count, + TopN = 1, + PageNumber = 2, + PageSize = 2 + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.TotalCount.ShouldBe(3); + result.Data.TotalValue.ShouldBe(60m); + result.Data.Groups.Count.ShouldBe(1); + result.Data.Transactions.Count.ShouldBe(1); + result.Data.Pagination.TotalItems.ShouldBe(3); + result.Data.Pagination.TotalPages.ShouldBe(2); + } + + [Fact] + public async Task GetTransactionMixSummary_RejectsTopNAboveTwenty() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + + var result = await database.TransactionService.GetTransactionMixSummary( + new TransactionQueries.TransactionMixSummaryQuery( + EstateId, + new TransactionMixSummaryRequest { + StartDate = new DateTime(2026, 9, 1), + EndDate = new DateTime(2026, 9, 1), + Breakdown = TransactionMixBreakdown.Status, + Measure = TransactionMixMeasure.Count, + TopN = 21 + }), + CancellationToken.None); + + result.IsSuccess.ShouldBeFalse(); + result.Message.ShouldBe("topN must be between 1 and 20."); } [Fact] @@ -813,7 +984,7 @@ public async Task GetRecentActivityReceiptReport_AppliesMerchantAndSearchFilters CancellationToken.None); result.IsSuccess.ShouldBeTrue(); - result.Data.TotalCount.ShouldBe(1); + result.Data.Pagination.TotalItems.ShouldBe(1); result.Data.Items.Single().Amount.ShouldBe(10m); } @@ -832,6 +1003,31 @@ public async Task GetProductPerformanceReport_WhenNoSales_ReturnsZeroSummary() result.Data.Summary.TotalValue.ShouldBe(0m); } + [Fact] + public async Task GetProductPerformanceReport_ReturnsPagedProductsAndFullSummary() + { + await using TestDatabase database = await TestDatabase.CreateAsync(); + database.AddSale(database.MerchantId, database.OperatorId, 25m, new DateTime(2026, 9, 1)); + await database.SaveAsync(); + + var result = await database.TransactionService.GetProductPerformanceReport( + new TransactionQueries.ProductPerformanceQuery( + EstateId, + new DateTime(2026, 9, 1), + new DateTime(2026, 9, 1), + 1, + 1), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ProductDetails.Count.ShouldBe(1); + result.Data.Summary.TotalProducts.ShouldBe(1); + result.Data.Summary.TotalCount.ShouldBe(1); + result.Data.Summary.TotalValue.ShouldBe(25m); + result.Data.Pagination.TotalItems.ShouldBe(1); + result.Data.Pagination.TotalPages.ShouldBe(1); + } + [Fact] public async Task GetMerchants_WhenBalanceIsMissing_ReturnsFailureResult() { diff --git a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs index 97c10fa..770bbda 100644 --- a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs +++ b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs @@ -12,7 +12,7 @@ public record TodaysFailedSales(Guid EstateId, DateTime ComparisonDate, String R public record TransactionDetailReportQuery(Guid EstateId, TransactionDetailReportRequest Request) : IRequest>; public record TransactionSummaryByMerchantQuery(Guid EstateId, TransactionSummaryByMerchantRequest Request) : IRequest>; public record TransactionSummaryByOperatorQuery(Guid EstateId, TransactionSummaryByOperatorRequest Request) : IRequest>; - public record ProductPerformanceQuery(Guid EstateId, DateTime StartDate, DateTime EndDate) : IRequest>; + public record ProductPerformanceQuery(Guid EstateId, DateTime StartDate, DateTime EndDate, int PageNumber = 1, int PageSize = 50) : IRequest>; public record TransactionMixSummaryQuery(Guid EstateId, TransactionMixSummaryRequest Request) : IRequest>; public record GetRecentActivityReceiptReportQuery(Guid EstateId, GetRecentActivityReceiptReportRequest Request) : IRequest>; public record TodaysSalesByHour(Guid estateId, DateTime comparisonDate) : IRequest>>; diff --git a/EstateReportingAPI.BusinessLogic/ReportQueryLimits.cs b/EstateReportingAPI.BusinessLogic/ReportQueryLimits.cs new file mode 100644 index 0000000..b0cdce6 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/ReportQueryLimits.cs @@ -0,0 +1,11 @@ +namespace EstateReportingAPI.BusinessLogic; + +public static class ReportQueryLimits +{ + public const int MaxDateRangeDays = 30; + public const int MaxPageSize = 50; + public const int DefaultPageSize = 50; + public const int RecentActivityDefaultPageSize = 10; + public const int MaxTopN = 20; + public const int MaxFilterListSize = 50; +} diff --git a/EstateReportingAPI.BusinessLogic/ReportQueryValidation.cs b/EstateReportingAPI.BusinessLogic/ReportQueryValidation.cs new file mode 100644 index 0000000..fc563b4 --- /dev/null +++ b/EstateReportingAPI.BusinessLogic/ReportQueryValidation.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace EstateReportingAPI.BusinessLogic; + +public static class ReportQueryValidation +{ + public static string? ValidateDateRange(DateTime startDate, DateTime endDate) + { + DateTime start = startDate.Date; + DateTime end = endDate.Date; + + if (start > end) + return "startDate must be less than or equal to endDate."; + + if ((end - start).TotalDays + 1 > ReportQueryLimits.MaxDateRangeDays) + return "date range must not exceed 30 inclusive calendar days."; + + return null; + } + + public static string? ValidatePaging(int pageNumber, int pageSize) + { + if (pageNumber < 1) + return "pageNumber must be greater than or equal to 1."; + + if (pageSize < 1 || pageSize > ReportQueryLimits.MaxPageSize) + return "pageSize must be between 1 and 50."; + + if ((long)(pageNumber - 1) * pageSize > int.MaxValue) + return "pageNumber is too large for this report."; + + return null; + } + + public static string? ValidateTopN(int topN) + { + if (topN < 1 || topN > ReportQueryLimits.MaxTopN) + return "topN must be between 1 and 20."; + + return null; + } + + public static string? ValidateFilterList(string parameterName, IReadOnlyCollection? values) + { + if (values != null && values.Count > ReportQueryLimits.MaxFilterListSize) + return $"{parameterName} must contain no more than 50 values."; + + return null; + } +} diff --git a/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs b/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs index d59db9d..8ccef1e 100644 --- a/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs +++ b/EstateReportingAPI.BusinessLogic/Services/TransactionReportingService.cs @@ -121,21 +121,52 @@ private Decimal SafeDivide(Decimal number, } public async Task> GetTransactionDetailReport(TransactionQueries.TransactionDetailReportQuery request, CancellationToken cancellationToken) { + string? validationError = ReportQueryValidation.ValidateDateRange(request.Request.StartDate, request.Request.EndDate) + ?? ReportQueryValidation.ValidatePaging(request.Request.PageNumber, request.Request.PageSize) + ?? ReportQueryValidation.ValidateFilterList("merchants", request.Request.Merchants) + ?? ReportQueryValidation.ValidateFilterList("operators", request.Request.Operators) + ?? ReportQueryValidation.ValidateFilterList("products", request.Request.Products); + if (validationError != null) + return Result.Failure(validationError); + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; var query = ApplyTransactionDetailFilters(BuildTransactionDetailBaseQuery(context, request.Request), request.Request); - var queryResult = await ReportingQueryExecutor.ToListAsync(query, cancellationToken, "Error retrieving transaction details report"); + var summaryQuery = query + .GroupBy(_ => 1) + .Select(g => new TransactionDetailSummaryQueryResult { + TransactionCount = g.Count(), + TotalValue = g.Sum(q => q.Value), + TotalFees = g.Sum(q => q.FeeValue) + }); + var summaryResult = await ReportingQueryExecutor.ToListAsync(summaryQuery, cancellationToken, "Error calculating transaction details report summary"); + if (summaryResult.IsFailed) + return ResultHelpers.CreateFailure(summaryResult); + + TransactionDetailSummaryQueryResult summary = summaryResult.Data.SingleOrDefault() ?? new TransactionDetailSummaryQueryResult(); + var pageQuery = query + .OrderByDescending(q => q.TransactionDateTime) + .ThenByDescending(q => q.TransactionId) + .Skip((int)((long)(request.Request.PageNumber - 1) * request.Request.PageSize)) + .Take(request.Request.PageSize); + var queryResult = await ReportingQueryExecutor.ToListAsync(pageQuery, cancellationToken, "Error retrieving transaction details report"); if (queryResult.IsFailed) return ResultHelpers.CreateFailure(queryResult); - var queryResults = queryResult.Data; - - if (queryResults.Any() == false) - return new TransactionDetailReportResponse { Summary = new TransactionDetailSummary(), Transactions = new List() }; - - return Result.Success(MapToTransactionDetailResponse(queryResults)); + int totalPages = summary.TransactionCount == 0 + ? 0 + : (int)Math.Ceiling((double)summary.TransactionCount / request.Request.PageSize); + return Result.Success(MapToTransactionDetailResponse( + queryResult.Data, + summary, + new PaginationMetadata { + PageNumber = request.Request.PageNumber, + PageSize = request.Request.PageSize, + TotalItems = summary.TransactionCount, + TotalPages = totalPages + })); } private static IQueryable BuildTransactionDetailBaseQuery(EstateManagementContext context, @@ -182,7 +213,9 @@ private static IQueryable ApplyTransactionDetailFi return query; } - private static TransactionDetailReportResponse MapToTransactionDetailResponse(List queryResults) { + private static TransactionDetailReportResponse MapToTransactionDetailResponse(List queryResults, + TransactionDetailSummaryQueryResult summary, + PaginationMetadata pagination) { return new TransactionDetailReportResponse { Transactions = queryResults.Select(q => new TransactionDetail { Id = q.TransactionId, @@ -203,12 +236,20 @@ private static TransactionDetailReportResponse MapToTransactionDetailResponse(Li SettlementReference = q.SettlementId.ToString(), TransactionNumber = q.TransactionNumber }).ToList(), - Summary = new TransactionDetailSummary { TransactionCount = queryResults.Count(), TotalValue = queryResults.Sum(q => q.Value), TotalFees = queryResults.Sum(q => q.FeeValue) } + Summary = new TransactionDetailSummary { TransactionCount = summary.TransactionCount, TotalValue = summary.TotalValue, TotalFees = summary.TotalFees }, + Pagination = pagination }; } public async Task> GetTransactionSummaryByMerchantReport(TransactionQueries.TransactionSummaryByMerchantQuery request, CancellationToken cancellationToken) { + string? validationError = ReportQueryValidation.ValidateDateRange(request.Request.StartDate, request.Request.EndDate) + ?? ReportQueryValidation.ValidatePaging(request.Request.PageNumber, request.Request.PageSize) + ?? ReportQueryValidation.ValidateFilterList("merchants", request.Request.Merchants) + ?? ReportQueryValidation.ValidateFilterList("operators", request.Request.Operators); + if (validationError != null) + return Result.Failure(validationError); + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; @@ -222,21 +263,39 @@ public async Task> GetTransactionSu var finalQuery = BuildMerchantTransactionFinalQuery(query); - var queryResult = await ReportingQueryExecutor.ToListAsync(finalQuery, cancellationToken, "Error retrieving transaction summary by merchant report"); + var merchantCountResult = await ReportingQueryExecutor.CountAsync(finalQuery, cancellationToken, "Error counting transaction summary by merchant report rows"); + if (merchantCountResult.IsFailed) + return ResultHelpers.CreateFailure(merchantCountResult); + var totalCountResult = await ReportingQueryExecutor.SumAsync(finalQuery.Select(q => (int?)q.TotalCount), cancellationToken, "Error summing transaction summary by merchant counts"); + if (totalCountResult.IsFailed) + return ResultHelpers.CreateFailure(totalCountResult); + var totalValueResult = await ReportingQueryExecutor.SumAsync(finalQuery.Select(q => (decimal?)q.TotalValue), cancellationToken, "Error summing transaction summary by merchant values"); + if (totalValueResult.IsFailed) + return ResultHelpers.CreateFailure(totalValueResult); + var queryResult = await ReportingQueryExecutor.ToListAsync( + finalQuery + .OrderBy(q => string.IsNullOrWhiteSpace(q.MerchantName) ? "Unknown Merchant" : q.MerchantName) + .ThenBy(q => q.MerchantReportingId) + .ThenBy(q => q.MerchantId) + .Skip((int)((long)(request.Request.PageNumber - 1) * request.Request.PageSize)) + .Take(request.Request.PageSize), + cancellationToken, + "Error retrieving transaction summary by merchant report"); if (queryResult.IsFailed) return ResultHelpers.CreateFailure(queryResult); var queryResults = queryResult.Data; - if (queryResults.Any() == false) - return new TransactionSummaryByMerchantResponse { Summary = new MerchantDetailSummary(), Merchants = new List() }; + int totalCount = totalCountResult.Data ?? 0; + int totalMerchants = merchantCountResult.Data; + int totalPages = totalMerchants == 0 ? 0 : (int)Math.Ceiling((double)totalMerchants / request.Request.PageSize); return new TransactionSummaryByMerchantResponse { Merchants = queryResults.Select(q => new MerchantDetail { MerchantId = q.MerchantId, MerchantReportingId = q.MerchantReportingId, - MerchantName = q.MerchantName, + MerchantName = string.IsNullOrWhiteSpace(q.MerchantName) ? $"Unknown Merchant ({q.MerchantReportingId})" : q.MerchantName, AuthorisedCount = q.AuthorisedCount, AuthorisedPercentage = q.AuthorisedPercentage, AverageValue = q.AverageValue, @@ -245,10 +304,16 @@ public async Task> GetTransactionSu TotalValue = q.TotalValue }).ToList(), Summary = new MerchantDetailSummary { - TotalCount = queryResults.Sum(q => q.TotalCount), - TotalValue = queryResults.Sum(q => q.TotalValue), - AverageValue = SafeDivide(queryResults.Sum(q => q.TotalValue), queryResults.Sum(q => q.TotalCount)), - TotalMerchants = queryResults.Count() + TotalCount = totalCount, + TotalValue = totalValueResult.Data ?? 0m, + AverageValue = SafeDivide(totalValueResult.Data ?? 0m, totalCount), + TotalMerchants = totalMerchants + }, + Pagination = new PaginationMetadata { + PageNumber = request.Request.PageNumber, + PageSize = request.Request.PageSize, + TotalItems = totalMerchants, + TotalPages = totalPages } }; } @@ -294,23 +359,53 @@ into g public async Task> GetTransactionSummaryByOperatorReport(TransactionQueries.TransactionSummaryByOperatorQuery request, CancellationToken cancellationToken) { + string? validationError = ReportQueryValidation.ValidateDateRange(request.Request.StartDate, request.Request.EndDate) + ?? ReportQueryValidation.ValidatePaging(request.Request.PageNumber, request.Request.PageSize) + ?? ReportQueryValidation.ValidateFilterList("merchants", request.Request.Merchants) + ?? ReportQueryValidation.ValidateFilterList("operators", request.Request.Operators); + if (validationError != null) + return Result.Failure(validationError); + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; var baseQuery = BuildOperatorTransactionBaseQuery(context, request); var finalQuery = BuildOperatorFinalSummaryQuery(baseQuery); - var queryResult = await ReportingQueryExecutor.ToListAsync(finalQuery, cancellationToken, "Error retrieving transaction summary by operator report"); + var operatorCountResult = await ReportingQueryExecutor.CountAsync(finalQuery, cancellationToken, "Error counting transaction summary by operator report rows"); + if (operatorCountResult.IsFailed) + return ResultHelpers.CreateFailure(operatorCountResult); + var totalCountResult = await ReportingQueryExecutor.SumAsync(finalQuery.Select(q => (int?)q.TotalCount), cancellationToken, "Error summing transaction summary by operator counts"); + if (totalCountResult.IsFailed) + return ResultHelpers.CreateFailure(totalCountResult); + var totalValueResult = await ReportingQueryExecutor.SumAsync(finalQuery.Select(q => (decimal?)q.TotalValue), cancellationToken, "Error summing transaction summary by operator values"); + if (totalValueResult.IsFailed) + return ResultHelpers.CreateFailure(totalValueResult); + var queryResult = await ReportingQueryExecutor.ToListAsync( + finalQuery + .OrderBy(q => string.IsNullOrWhiteSpace(q.OperatorName) ? "Unknown Operator" : q.OperatorName) + .ThenBy(q => q.OperatorReportingId) + .ThenBy(q => q.OperatorId) + .Skip((int)((long)(request.Request.PageNumber - 1) * request.Request.PageSize)) + .Take(request.Request.PageSize), + cancellationToken, + "Error retrieving transaction summary by operator report"); if (queryResult.IsFailed) return ResultHelpers.CreateFailure(queryResult); var queryResults = queryResult.Data; - if (queryResults.Any() == false) - return new TransactionSummaryByOperatorResponse { Summary = new OperatorDetailSummary(), Operators = new List() }; - - return BuildOperatorSummaryResponse(queryResults); + int totalCount = totalCountResult.Data ?? 0; + int totalOperators = operatorCountResult.Data; + int totalPages = totalOperators == 0 ? 0 : (int)Math.Ceiling((double)totalOperators / request.Request.PageSize); + return BuildOperatorSummaryResponse(queryResults, totalCount, totalValueResult.Data ?? 0m, totalOperators, + new PaginationMetadata { + PageNumber = request.Request.PageNumber, + PageSize = request.Request.PageSize, + TotalItems = totalOperators, + TotalPages = totalPages + }); } private static IQueryable BuildOperatorTransactionBaseQuery(EstateManagementContext context, @@ -372,12 +467,16 @@ into g }; } - private TransactionSummaryByOperatorResponse BuildOperatorSummaryResponse(List queryResults) { + private TransactionSummaryByOperatorResponse BuildOperatorSummaryResponse(List queryResults, + int totalCount, + decimal totalValue, + int totalOperators, + PaginationMetadata pagination) { return new TransactionSummaryByOperatorResponse { Operators = queryResults.Select(q => new OperatorDetail { OperatorId = q.OperatorId, OperatorReportingId = q.OperatorReportingId, - OperatorName = q.OperatorName, + OperatorName = string.IsNullOrWhiteSpace(q.OperatorName) ? $"Unknown Operator ({q.OperatorReportingId})" : q.OperatorName, AuthorisedCount = q.AuthorisedCount, AuthorisedPercentage = q.AuthorisedPercentage, AverageValue = q.AverageValue, @@ -386,15 +485,20 @@ private TransactionSummaryByOperatorResponse BuildOperatorSummaryResponse(List q.TotalCount), - TotalValue = queryResults.Sum(q => q.TotalValue), - AverageValue = SafeDivide(queryResults.Sum(q => q.TotalValue), queryResults.Sum(q => q.TotalCount)), - TotalOperators = queryResults.Count() - } + TotalCount = totalCount, + TotalValue = totalValue, + AverageValue = SafeDivide(totalValue, totalCount), + TotalOperators = totalOperators + }, + Pagination = pagination }; } public async Task> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request, CancellationToken cancellationToken) { + string? validationError = ReportQueryValidation.ValidateDateRange(request.StartDate, request.EndDate) + ?? ReportQueryValidation.ValidatePaging(request.PageNumber, request.PageSize); + if (validationError != null) + return Result.Failure(validationError); using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; @@ -406,24 +510,53 @@ public async Task> GetProductPerformanceRepor var grandTotalAmount = grandTotalAmountResult.Data; var query = BuildProductPerformanceQuery(context, request.StartDate, request.EndDate, grandTotalAmount); - var queryResult = await ReportingQueryExecutor.ToListAsync(query, cancellationToken); + var productCountResult = await ReportingQueryExecutor.CountAsync(query, cancellationToken, "Error counting product performance rows"); + if (productCountResult.IsFailed) + return ResultHelpers.CreateFailure(productCountResult); + var totalCountResult = await ReportingQueryExecutor.SumAsync(query.Select(q => (int?)q.TransactionCount), cancellationToken, "Error summing product performance counts"); + if (totalCountResult.IsFailed) + return ResultHelpers.CreateFailure(totalCountResult); + var totalValueResult = await ReportingQueryExecutor.SumAsync(query.Select(q => (decimal?)q.TotalAmount), cancellationToken, "Error summing product performance values"); + if (totalValueResult.IsFailed) + return ResultHelpers.CreateFailure(totalValueResult); + var queryResult = await ReportingQueryExecutor.ToListAsync( + query + .OrderBy(q => string.IsNullOrWhiteSpace(q.ProductName) ? "Unknown Product" : q.ProductName) + .ThenBy(q => q.ContractReportingId) + .ThenBy(q => q.ContractProductReportingId) + .ThenBy(q => q.ContractProductId) + .Skip((int)((long)(request.PageNumber - 1) * request.PageSize)) + .Take(request.PageSize), + cancellationToken, + "Error retrieving product performance report"); if (queryResult.IsFailed) return ResultHelpers.CreateFailure(queryResult); var queryResults = queryResult.Data; - if (queryResults.Any() == false) - return Result.Success(new ProductPerformanceResponse() { Summary = new ProductPerformanceSummary(), ProductDetails = new List() }); - return Result.Success(BuildProductPerformanceResponse(queryResults)); + int totalProducts = productCountResult.Data; + int totalCount = totalCountResult.Data ?? 0; + int totalPages = totalProducts == 0 ? 0 : (int)Math.Ceiling((double)totalProducts / request.PageSize); + return Result.Success(BuildProductPerformanceResponse( + queryResults, + totalProducts, + totalCount, + totalValueResult.Data ?? 0m, + new PaginationMetadata { + PageNumber = request.PageNumber, + PageSize = request.PageSize, + TotalItems = totalProducts, + TotalPages = totalPages + })); } public async Task> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request, CancellationToken cancellationToken) { - using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); - await using EstateManagementContext context = resolvedContext.Context; - - if (request.Request.StartDate > request.Request.EndDate) - return Result.Failure("StartDate must be less than or equal to EndDate."); + string? validationError = ReportQueryValidation.ValidateDateRange(request.Request.StartDate, request.Request.EndDate) + ?? ReportQueryValidation.ValidatePaging(request.Request.PageNumber, request.Request.PageSize) + ?? ReportQueryValidation.ValidateTopN(request.Request.TopN); + if (validationError != null) + return Result.Failure(validationError); if (!Enum.IsDefined(typeof(TransactionMixBreakdown), request.Request.Breakdown)) return Result.Failure("Unsupported transaction mix breakdown."); @@ -431,6 +564,9 @@ public async Task> GetTransactionMixSummar if (!Enum.IsDefined(typeof(TransactionMixMeasure), request.Request.Measure)) return Result.Failure("Unsupported transaction mix measure."); + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + var detailRequest = new TransactionDetailReportRequest { Merchants = request.Request.MerchantReportingId.HasValue ? new List { request.Request.MerchantReportingId.Value } : [], @@ -441,33 +577,60 @@ public async Task> GetTransactionMixSummar }; var query = ApplyTransactionDetailFilters(BuildTransactionDetailBaseQuery(context, detailRequest), detailRequest); - var queryResult = await ReportingQueryExecutor.ToListAsync(query, cancellationToken, "Error retrieving transaction mix summary report"); + var totalCountResult = await ReportingQueryExecutor.CountAsync(query, cancellationToken, "Error counting transaction mix summary rows"); + if (totalCountResult.IsFailed) + return ResultHelpers.CreateFailure(totalCountResult); - if (queryResult.IsFailed) - return ResultHelpers.CreateFailure(queryResult); + var totalValueResult = await ReportingQueryExecutor.SumAsync(query.Select(q => (decimal?)q.Value), cancellationToken, "Error summing transaction mix summary values"); + if (totalValueResult.IsFailed) + return ResultHelpers.CreateFailure(totalValueResult); + + IQueryable groupQuery = BuildTransactionMixGroupQuery(query, request.Request.Breakdown); + IQueryable orderedGroupQuery = request.Request.Breakdown switch { + TransactionMixBreakdown.Product or TransactionMixBreakdown.Operator => request.Request.Measure == TransactionMixMeasure.Count + ? groupQuery.OrderByDescending(g => g.TransactionCount).ThenBy(g => g.GroupName).ThenBy(g => g.NumericKey) + : groupQuery.OrderByDescending(g => g.TransactionValue).ThenBy(g => g.GroupName).ThenBy(g => g.NumericKey), + TransactionMixBreakdown.TransactionType or TransactionMixBreakdown.Status => request.Request.Measure == TransactionMixMeasure.Count + ? groupQuery.OrderByDescending(g => g.TransactionCount).ThenBy(g => g.GroupName).ThenBy(g => g.TextKey) + : groupQuery.OrderByDescending(g => g.TransactionValue).ThenBy(g => g.GroupName).ThenBy(g => g.TextKey), + _ => throw new ArgumentOutOfRangeException(nameof(request.Request.Breakdown), request.Request.Breakdown, null) + }; + var groupResult = await ReportingQueryExecutor.ToListAsync(orderedGroupQuery.Take(request.Request.TopN), cancellationToken, "Error retrieving transaction mix groups"); + if (groupResult.IsFailed) + return ResultHelpers.CreateFailure(groupResult); - var queryResults = queryResult.Data; + var pageQuery = query + .OrderByDescending(q => q.TransactionDateTime) + .ThenByDescending(q => q.TransactionId) + .Skip((int)((long)(request.Request.PageNumber - 1) * request.Request.PageSize)) + .Take(request.Request.PageSize); + var pageResult = await ReportingQueryExecutor.ToListAsync(pageQuery, cancellationToken, "Error retrieving transaction mix summary transactions"); - if (queryResults.Any() == false) - { - return Result.Success(new TransactionMixSummaryResponse - { - FromDate = DateOnly.FromDateTime(request.Request.StartDate), - ToDate = DateOnly.FromDateTime(request.Request.EndDate), - Breakdown = request.Request.Breakdown, - Measure = request.Request.Measure, - TotalCount = 0, - TotalValue = 0m, - Groups = [], - Transactions = [] - }); - } + if (pageResult.IsFailed) + return ResultHelpers.CreateFailure(pageResult); - return Result.Success(BuildTransactionMixSummaryResponse(queryResults, request.Request)); + int totalCount = totalCountResult.Data; + int totalPages = totalCount == 0 ? 0 : (int)Math.Ceiling((double)totalCount / request.Request.PageSize); + return Result.Success(BuildTransactionMixSummaryResponse( + pageResult.Data, + groupResult.Data, + request.Request, + totalCount, + totalValueResult.Data ?? 0m, + new PaginationMetadata { + PageNumber = request.Request.PageNumber, + PageSize = request.Request.PageSize, + TotalItems = totalCount, + TotalPages = totalPages + })); } public async Task> GetRecentActivityReceiptReport(TransactionQueries.GetRecentActivityReceiptReportQuery request, CancellationToken cancellationToken) { + string? validationError = ReportQueryValidation.ValidatePaging(request.Request.PageNumber, request.Request.PageSize); + if (validationError != null) + return Result.Failure(validationError); + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; @@ -495,8 +658,8 @@ private static RecentActivityReceiptRequestParameters BuildRecentActivityReceipt ReportDate = DateOnly.FromDateTime(request.ReportDate.Date), MerchantReportingId = request.MerchantReportingId, SearchText = request.SearchText?.Trim(), - PageNumber = request.PageNumber > 0 ? request.PageNumber : 1, - PageSize = request.PageSize > 0 ? request.PageSize : 10 + PageNumber = request.PageNumber, + PageSize = request.PageSize }; } @@ -508,6 +671,7 @@ join o in context.Operators on t.OperatorId equals o.OperatorId join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } where t.TransactionDate == parameters.ReportDate.ToDateTime(TimeOnly.MinValue) select new RecentActivityReceiptQueryResult { + TransactionId = t.TransactionId, TransactionDateTime = t.TransactionDateTime, MerchantReportingId = m.MerchantReportingId, Reference = t.TransactionNumber, @@ -519,7 +683,9 @@ join o in context.Operators on t.OperatorId equals o.OperatorId ReceiptReference = t.TransactionReference }; - return ApplyRecentActivityReceiptFilters(query, parameters).OrderByDescending(q => q.TransactionDateTime); + return ApplyRecentActivityReceiptFilters(query, parameters) + .OrderByDescending(q => q.TransactionDateTime) + .ThenByDescending(q => q.TransactionId); } private static IQueryable ApplyRecentActivityReceiptFilters(IQueryable query, @@ -542,10 +708,13 @@ private static IQueryable ApplyRecentActivityR private static GetRecentActivityReceiptReportResponse BuildEmptyRecentActivityReceiptReportResponse(RecentActivityReceiptRequestParameters parameters) { return new GetRecentActivityReceiptReportResponse { ReportDate = parameters.ReportDate, - PageNumber = parameters.PageNumber, - PageSize = parameters.PageSize, - TotalCount = 0, - Items = [] + Items = [], + Pagination = new PaginationMetadata { + PageNumber = parameters.PageNumber, + PageSize = parameters.PageSize, + TotalItems = 0, + TotalPages = 0 + } }; } @@ -554,10 +723,13 @@ private static GetRecentActivityReceiptReportResponse BuildRecentActivityReceipt List queryResults) { return new GetRecentActivityReceiptReportResponse { ReportDate = parameters.ReportDate, - PageNumber = parameters.PageNumber, - PageSize = parameters.PageSize, - TotalCount = totalCount, - Items = queryResults.Select(MapRecentActivityReceiptItem).ToList() + Items = queryResults.Select(MapRecentActivityReceiptItem).ToList(), + Pagination = new PaginationMetadata { + PageNumber = parameters.PageNumber, + PageSize = parameters.PageSize, + TotalItems = totalCount, + TotalPages = (int)Math.Ceiling((double)totalCount / parameters.PageSize) + } }; } @@ -596,10 +768,14 @@ into g }; } - private ProductPerformanceResponse BuildProductPerformanceResponse(List queryResults) { + private ProductPerformanceResponse BuildProductPerformanceResponse(List queryResults, + int totalProducts, + int totalCount, + decimal totalValue, + PaginationMetadata pagination) { return new ProductPerformanceResponse { ProductDetails = queryResults.Select(q => new ProductPerformanceDetail { - ProductName = q.ProductName, + ProductName = string.IsNullOrWhiteSpace(q.ProductName) ? $"Unknown Product ({q.ContractProductReportingId})" : q.ProductName, ProductId = q.ContractProductId, ProductReportingId = q.ContractProductReportingId, ContractId = q.ContractId, @@ -608,58 +784,77 @@ private ProductPerformanceResponse BuildProductPerformanceResponse(List q.TransactionCount), TotalValue = queryResults.Sum(q => q.TotalAmount), AveragePerProduct = SafeDivide(queryResults.Sum(q => q.TotalAmount), queryResults.Count), TotalProducts = queryResults.Count() } + Summary = new ProductPerformanceSummary { TotalCount = totalCount, TotalValue = totalValue, AveragePerProduct = SafeDivide(totalValue, totalProducts), TotalProducts = totalProducts }, + Pagination = pagination }; } - private static TransactionMixSummaryResponse BuildTransactionMixSummaryResponse(List queryResults, - TransactionMixSummaryRequest request) + private static IQueryable BuildTransactionMixGroupQuery(IQueryable query, + TransactionMixBreakdown breakdown) { - var groupedResults = queryResults - .GroupBy(q => request.Breakdown switch - { - TransactionMixBreakdown.Product => q.ContractProductReportingId.ToString(), - TransactionMixBreakdown.TransactionType => q.TransactionType ?? string.Empty, - TransactionMixBreakdown.Operator => q.OperatorReportingId.ToString(), - TransactionMixBreakdown.Status => q.Status ?? string.Empty, - _ => string.Empty - }) - .Select(g => - { - var first = g.First(); - string groupName = request.Breakdown switch - { - TransactionMixBreakdown.Product => first.ProductName ?? string.Empty, - TransactionMixBreakdown.TransactionType => first.TransactionType ?? string.Empty, - TransactionMixBreakdown.Operator => first.OperatorName ?? string.Empty, - TransactionMixBreakdown.Status => first.Status ?? string.Empty, - _ => string.Empty - }; - - return new TransactionMixSummaryGroup - { - GroupKey = g.Key, - GroupName = groupName, - TransactionCount = g.Count(), - TransactionValue = g.Sum(x => x.Value) - }; - }); - - var orderedGroups = request.Measure == TransactionMixMeasure.Count - ? groupedResults.OrderByDescending(g => g.TransactionCount).ThenBy(g => g.GroupName) - : groupedResults.OrderByDescending(g => g.TransactionValue).ThenBy(g => g.GroupName); - - int topN = request.TopN > 0 ? request.TopN : 5; + return breakdown switch + { + TransactionMixBreakdown.Product => from q in query + group q by new { q.ContractProductReportingId, q.ProductName } + into g + select new TransactionMixGroupQueryResult { + NumericKey = g.Key.ContractProductReportingId, + GroupName = g.Key.ProductName, + TransactionCount = g.Count(), + TransactionValue = g.Sum(x => x.Value) + }, + TransactionMixBreakdown.TransactionType => from q in query + group q by q.TransactionType + into g + select new TransactionMixGroupQueryResult { + TextKey = g.Key, + GroupName = g.Key, + TransactionCount = g.Count(), + TransactionValue = g.Sum(x => x.Value) + }, + TransactionMixBreakdown.Operator => from q in query + group q by new { q.OperatorReportingId, q.OperatorName } + into g + select new TransactionMixGroupQueryResult { + NumericKey = g.Key.OperatorReportingId, + GroupName = g.Key.OperatorName, + TransactionCount = g.Count(), + TransactionValue = g.Sum(x => x.Value) + }, + TransactionMixBreakdown.Status => from q in query + group q by q.Status + into g + select new TransactionMixGroupQueryResult { + TextKey = g.Key, + GroupName = g.Key, + TransactionCount = g.Count(), + TransactionValue = g.Sum(x => x.Value) + }, + _ => throw new ArgumentOutOfRangeException(nameof(breakdown), breakdown, null) + }; + } + private static TransactionMixSummaryResponse BuildTransactionMixSummaryResponse(List queryResults, + List groupResults, + TransactionMixSummaryRequest request, + int totalCount, + decimal totalValue, + PaginationMetadata pagination) + { return new TransactionMixSummaryResponse { FromDate = DateOnly.FromDateTime(request.StartDate), ToDate = DateOnly.FromDateTime(request.EndDate), Breakdown = request.Breakdown, Measure = request.Measure, - TotalCount = queryResults.Count, - TotalValue = queryResults.Sum(q => q.Value), - Groups = orderedGroups.Take(topN).ToList(), + TotalCount = totalCount, + TotalValue = totalValue, + Groups = groupResults.Select(g => new TransactionMixSummaryGroup { + GroupKey = g.NumericKey?.ToString() ?? g.TextKey ?? string.Empty, + GroupName = g.GroupName ?? string.Empty, + TransactionCount = g.TransactionCount, + TransactionValue = g.TransactionValue + }).ToList(), Transactions = queryResults .OrderByDescending(q => q.TransactionDateTime) .Select(q => new TransactionMixSummaryTransaction @@ -682,7 +877,8 @@ private static TransactionMixSummaryResponse BuildTransactionMixSummaryResponse( SettlementReference = q.SettlementId == Guid.Empty ? null : q.SettlementId.ToString(), TransactionNumber = q.TransactionNumber }) - .ToList() + .ToList(), + Pagination = pagination }; } @@ -735,6 +931,10 @@ from today in todayGroup.DefaultIfEmpty() public async Task> GetMerchantDailyPerformanceSummary(TransactionQueries.MerchantDailyPerformanceSummaryQuery request, CancellationToken cancellationToken) { + string? validationError = ReportQueryValidation.ValidateDateRange(request.Request.StartDate, request.Request.EndDate); + if (validationError != null) + return Result.Failure(validationError); + using ResolvedDbContext? resolvedContext = Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; @@ -825,6 +1025,7 @@ private sealed class MerchantDailyPerformanceGroupProjection { } private sealed class MerchantDailyPerformanceRecentSaleProjection { + public Guid TransactionId { get; init; } public string Reference { get; init; } public string? Product { get; init; } public String Operator { get; init; } @@ -960,10 +1161,11 @@ from op in opJoin.DefaultIfEmpty() && t.TransactionDate >= startDate && t.TransactionDate < endDate.Date.AddDays(1) && m.MerchantReportingId == request.Request.MerchantReportingId - orderby t.TransactionDateTime descending + orderby t.TransactionDateTime descending, t.TransactionId descending select new MerchantDailyPerformanceRecentSaleProjection - { - Reference = t.TransactionNumber, + { + TransactionId = t.TransactionId, + Reference = t.TransactionNumber, Product = cp.ProductName, Operator = op == null ? string.Empty : op.Name, Status = t.IsAuthorised ? "Successful" : "Failed", @@ -986,6 +1188,7 @@ private static List MapMerchantDailyPerformanceRecentSales } private sealed class RecentActivityReceiptQueryResult { + public Guid TransactionId { get; init; } public DateTime TransactionDateTime { get; init; } public int MerchantReportingId { get; init; } public string Reference { get; init; } @@ -1025,6 +1228,20 @@ private sealed class TransactionDetailQueryResult { public Int32 TransactionNumber { get; init; } } + private sealed class TransactionDetailSummaryQueryResult { + public int TransactionCount { get; init; } + public decimal TotalValue { get; init; } + public decimal TotalFees { get; init; } + } + + private sealed class TransactionMixGroupQueryResult { + public int? NumericKey { get; init; } + public string? TextKey { get; init; } + public string? GroupName { get; init; } + public int TransactionCount { get; init; } + public decimal TransactionValue { get; init; } + } + private sealed class OperatorTransactionData { public Guid MerchantId { get; init; } public int MerchantReportingId { get; init; } diff --git a/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs index 87678c7..d295621 100644 --- a/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs +++ b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs @@ -6,10 +6,8 @@ namespace EstateReportingAPI.DataTransferObjects; public class GetRecentActivityReceiptReportResponse { public DateOnly ReportDate { get; set; } - public int PageNumber { get; set; } - public int PageSize { get; set; } - public int TotalCount { get; set; } public List Items { get; set; } = []; + public PaginationMetadata Pagination { get; set; } = new(); } public class RecentActivityReceiptItemDto diff --git a/EstateReportingAPI.DataTrasferObjects/PaginationMetadata.cs b/EstateReportingAPI.DataTrasferObjects/PaginationMetadata.cs new file mode 100644 index 0000000..35d4530 --- /dev/null +++ b/EstateReportingAPI.DataTrasferObjects/PaginationMetadata.cs @@ -0,0 +1,9 @@ +namespace EstateReportingAPI.DataTransferObjects; + +public class PaginationMetadata +{ + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalItems { get; set; } + public int TotalPages { get; set; } +} diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportRequest.cs b/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportRequest.cs index f768f91..aaedf3b 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportRequest.cs @@ -10,4 +10,6 @@ public class TransactionDetailReportRequest public List? Products { get; set; } public DateOnly StartDate { get; set; } public DateOnly EndDate { get; set; } + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 50; } diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportResponse.cs b/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportResponse.cs index fd939ca..4e2c337 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportResponse.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionDetailReportResponse.cs @@ -7,11 +7,13 @@ public class TransactionDetailReportResponse { public List Transactions { get; set; } public TransactionDetailSummary Summary { get; set; } + public PaginationMetadata Pagination { get; set; } = new(); } public class ProductPerformanceResponse { public List ProductDetails { get; set; } public ProductPerformanceSummary Summary { get; set; } + public PaginationMetadata Pagination { get; set; } = new(); } public class ProductPerformanceDetail { @@ -30,4 +32,4 @@ public class ProductPerformanceSummary { public Int32 TotalCount { get; set; } public Decimal TotalValue { get; set; } public Decimal AveragePerProduct { get; set; } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs index 3c2e3f9..1a9bced 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs @@ -27,4 +27,6 @@ public class TransactionMixSummaryRequest public TransactionMixBreakdown Breakdown { get; set; } public TransactionMixMeasure Measure { get; set; } public int TopN { get; set; } = 5; + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 50; } diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs index 9fdb02a..34a2eea 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs @@ -13,6 +13,7 @@ public class TransactionMixSummaryResponse public decimal TotalValue { get; set; } public List Groups { get; set; } = []; public List Transactions { get; set; } = []; + public PaginationMetadata Pagination { get; set; } = new(); } public class TransactionMixSummaryGroup diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantRequest.cs b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantRequest.cs index dae9ed3..81bf40e 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantRequest.cs @@ -9,4 +9,6 @@ public class TransactionSummaryByMerchantRequest public List? Merchants { get; set; } public DateOnly StartDate { get; set; } public DateOnly EndDate { get; set; } + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 50; } diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantResponse.cs b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantResponse.cs index 437f6df..770a5fb 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantResponse.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByMerchantResponse.cs @@ -5,4 +5,5 @@ public class TransactionSummaryByMerchantResponse { public List Merchants { get; set; } public MerchantDetailSummary Summary { get; set; } -} \ No newline at end of file + public PaginationMetadata Pagination { get; set; } = new(); +} diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorRequest.cs b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorRequest.cs index 4852088..6844567 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorRequest.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorRequest.cs @@ -8,4 +8,6 @@ public class TransactionSummaryByOperatorRequest public List? Merchants { get; set; } public DateOnly StartDate { get; set; } public DateOnly EndDate { get; set; } + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 50; } diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorResponse.cs b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorResponse.cs index 090886e..bee7450 100644 --- a/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorResponse.cs +++ b/EstateReportingAPI.DataTrasferObjects/TransactionSummaryByOperatorResponse.cs @@ -5,4 +5,5 @@ public class TransactionSummaryByOperatorResponse { public List Operators { get; set; } public OperatorDetailSummary Summary { get; set; } -} \ No newline at end of file + public PaginationMetadata Pagination { get; set; } = new(); +} diff --git a/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs b/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs index 10616cb..451b3fa 100644 --- a/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs @@ -10,6 +10,7 @@ using EstateReportingAPI.DataTransferObjects; using Shared.Serialisation; using Shouldly; +using System.Text.Json; using TransactionProcessor.Database.Entities; using Xunit; @@ -153,5 +154,9 @@ public async Task TransactionMixSummary_InvalidDateRange_ReturnsBadRequest() HttpResponseMessage response = await this.Client.SendAsync(requestMessage, CancellationToken.None); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + string responseContent = await response.Content.ReadAsStringAsync(CancellationToken.None); + using JsonDocument problemDocument = JsonDocument.Parse(responseContent); + problemDocument.RootElement.GetProperty("detail").GetString().ShouldBe("startDate must be less than or equal to endDate."); } } diff --git a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs index 7346d13..0c40c80 100644 --- a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs @@ -535,10 +535,12 @@ public async Task TransactionsEndpoint_TransactionDetailReport_NoFilters_Transac transactionDetailReportResponse.Summary.TotalValue.ShouldBe(transactions.Sum(t=> t.TransactionAmount)); transactionDetailReportResponse.Summary.TotalFees.ShouldBe(0m); - foreach (Transaction transaction in transactions) { - var foundTxn = transactionDetailReportResponse.Transactions.Single(t => t.Id == transaction.TransactionId); - AssertTransactionDetailMatches(foundTxn, transaction); - } + transactionDetailReportResponse.Transactions.Count.ShouldBe(50); + transactionDetailReportResponse.Pagination.PageNumber.ShouldBe(1); + transactionDetailReportResponse.Pagination.PageSize.ShouldBe(50); + transactionDetailReportResponse.Pagination.TotalItems.ShouldBe(transactions.Count); + transactionDetailReportResponse.Pagination.TotalPages.ShouldBe((int)Math.Ceiling(transactions.Count / 50m)); + transactionDetailReportResponse.Transactions.All(t => transactions.Any(source => source.TransactionId == t.Id)).ShouldBeTrue(); } [Fact] @@ -608,11 +610,12 @@ public async Task TransactionsEndpoint_TransactionDetailReport_MerchantFilter_Tr transactionDetailReportResponse.Summary.TotalValue.ShouldBe(filteredTransactions.Sum(t => t.TransactionAmount)); transactionDetailReportResponse.Summary.TotalFees.ShouldBe(0m); - foreach (Transaction transaction in filteredTransactions) - { - var foundTxn = transactionDetailReportResponse.Transactions.Single(t => t.Id == transaction.TransactionId); - AssertTransactionDetailMatches(foundTxn, transaction); - } + transactionDetailReportResponse.Transactions.Count.ShouldBe(50); + transactionDetailReportResponse.Pagination.PageNumber.ShouldBe(1); + transactionDetailReportResponse.Pagination.PageSize.ShouldBe(50); + transactionDetailReportResponse.Pagination.TotalItems.ShouldBe(filteredTransactions.Count()); + transactionDetailReportResponse.Pagination.TotalPages.ShouldBe((int)Math.Ceiling(filteredTransactions.Count() / 50m)); + transactionDetailReportResponse.Transactions.All(t => filteredTransactions.Any(source => source.TransactionId == t.Id)).ShouldBeTrue(); } [Fact] @@ -682,11 +685,12 @@ public async Task TransactionsEndpoint_TransactionDetailReport_OperatorFilter_Tr transactionDetailReportResponse.Summary.TotalValue.ShouldBe(filteredTransactions.Sum(t => t.TransactionAmount)); transactionDetailReportResponse.Summary.TotalFees.ShouldBe(0m); - foreach (Transaction transaction in filteredTransactions) - { - var foundTxn = transactionDetailReportResponse.Transactions.Single(t => t.Id == transaction.TransactionId); - AssertTransactionDetailMatches(foundTxn, transaction); - } + transactionDetailReportResponse.Transactions.Count.ShouldBe(50); + transactionDetailReportResponse.Pagination.PageNumber.ShouldBe(1); + transactionDetailReportResponse.Pagination.PageSize.ShouldBe(50); + transactionDetailReportResponse.Pagination.TotalItems.ShouldBe(filteredTransactions.Count()); + transactionDetailReportResponse.Pagination.TotalPages.ShouldBe((int)Math.Ceiling(filteredTransactions.Count() / 50m)); + transactionDetailReportResponse.Transactions.All(t => filteredTransactions.Any(source => source.TransactionId == t.Id)).ShouldBeTrue(); } [Fact] @@ -755,11 +759,12 @@ public async Task TransactionsEndpoint_TransactionDetailReport_ProductFilter_Tra transactionDetailReportResponse.Summary.TotalValue.ShouldBe(filteredTransactions.Sum(t => t.TransactionAmount)); transactionDetailReportResponse.Summary.TotalFees.ShouldBe(0m); - foreach (Transaction transaction in filteredTransactions) - { - var foundTxn = transactionDetailReportResponse.Transactions.Single(t => t.Id == transaction.TransactionId); - AssertTransactionDetailMatches(foundTxn, transaction); - } + transactionDetailReportResponse.Transactions.Count.ShouldBe(50); + transactionDetailReportResponse.Pagination.PageNumber.ShouldBe(1); + transactionDetailReportResponse.Pagination.PageSize.ShouldBe(50); + transactionDetailReportResponse.Pagination.TotalItems.ShouldBe(filteredTransactions.Count()); + transactionDetailReportResponse.Pagination.TotalPages.ShouldBe((int)Math.Ceiling(filteredTransactions.Count() / 50m)); + transactionDetailReportResponse.Transactions.All(t => filteredTransactions.Any(source => source.TransactionId == t.Id)).ShouldBeTrue(); } @@ -1675,9 +1680,10 @@ static JsonElement GetPropertyCaseInsensitive(JsonElement element, string proper } DateOnly.Parse(GetPropertyCaseInsensitive(document.RootElement, "reportDate").GetString()!).ShouldBe(DateOnly.FromDateTime(reportDate)); - GetPropertyCaseInsensitive(document.RootElement, "pageNumber").GetInt32().ShouldBe(1); - GetPropertyCaseInsensitive(document.RootElement, "pageSize").GetInt32().ShouldBe(10); - GetPropertyCaseInsensitive(document.RootElement, "totalCount").GetInt32().ShouldBe(2); + JsonElement pagination = GetPropertyCaseInsensitive(document.RootElement, "pagination"); + GetPropertyCaseInsensitive(pagination, "pageNumber").GetInt32().ShouldBe(1); + GetPropertyCaseInsensitive(pagination, "pageSize").GetInt32().ShouldBe(10); + GetPropertyCaseInsensitive(pagination, "totalItems").GetInt32().ShouldBe(2); JsonElement items = GetPropertyCaseInsensitive(document.RootElement, "items"); items.GetArrayLength().ShouldBe(2); @@ -1726,7 +1732,8 @@ await this.helper.BuildTransactionX(reportDate.AddHours(9), otherMerchant.Mercha string content = await response.Content.ReadAsStringAsync(CancellationToken.None); using JsonDocument document = JsonDocument.Parse(content); - GetPropertyCaseInsensitive(document.RootElement, "totalCount").GetInt32().ShouldBe(2); + JsonElement pagination = GetPropertyCaseInsensitive(document.RootElement, "pagination"); + GetPropertyCaseInsensitive(pagination, "totalItems").GetInt32().ShouldBe(2); JsonElement items = GetPropertyCaseInsensitive(document.RootElement, "items"); items.GetArrayLength().ShouldBe(1); AssertRecentActivityReceiptItemMatches(items[0], transactions[1], product.productName, contract.operatorName, "receipt-002"); diff --git a/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs b/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs index 967000b..3ed5eb7 100644 --- a/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs +++ b/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs @@ -6,10 +6,8 @@ namespace EstateReportingAPI.Models; public class GetRecentActivityReceiptReportResponse { public DateOnly ReportDate { get; set; } - public int PageNumber { get; set; } - public int PageSize { get; set; } - public int TotalCount { get; set; } public List Items { get; set; } = []; + public PaginationMetadata Pagination { get; set; } = new(); } public class RecentActivityReceiptItemDto diff --git a/EstateReportingAPI.Models/PaginationMetadata.cs b/EstateReportingAPI.Models/PaginationMetadata.cs new file mode 100644 index 0000000..d764279 --- /dev/null +++ b/EstateReportingAPI.Models/PaginationMetadata.cs @@ -0,0 +1,9 @@ +namespace EstateReportingAPI.Models; + +public class PaginationMetadata +{ + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalItems { get; set; } + public int TotalPages { get; set; } +} diff --git a/EstateReportingAPI.Models/ProductPerformanceResponse.cs b/EstateReportingAPI.Models/ProductPerformanceResponse.cs index abd1617..7061322 100644 --- a/EstateReportingAPI.Models/ProductPerformanceResponse.cs +++ b/EstateReportingAPI.Models/ProductPerformanceResponse.cs @@ -4,4 +4,5 @@ public class ProductPerformanceResponse { public List ProductDetails { get; set; } public ProductPerformanceSummary Summary { get; set; } -} \ No newline at end of file + public PaginationMetadata Pagination { get; set; } = new(); +} diff --git a/EstateReportingAPI.Models/TransactionDetailReportRequest.cs b/EstateReportingAPI.Models/TransactionDetailReportRequest.cs index 1c2a70c..9f2a6ca 100644 --- a/EstateReportingAPI.Models/TransactionDetailReportRequest.cs +++ b/EstateReportingAPI.Models/TransactionDetailReportRequest.cs @@ -7,4 +7,6 @@ public class TransactionDetailReportRequest public List? Products { get; set; } public DateTime StartDate { get; set; } public DateTime EndDate { get; set; } -} \ No newline at end of file + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 50; +} diff --git a/EstateReportingAPI.Models/TransactionDetailReportResponse.cs b/EstateReportingAPI.Models/TransactionDetailReportResponse.cs index ec1aed7..0aadf75 100644 --- a/EstateReportingAPI.Models/TransactionDetailReportResponse.cs +++ b/EstateReportingAPI.Models/TransactionDetailReportResponse.cs @@ -3,4 +3,5 @@ public class TransactionDetailReportResponse { public List Transactions { get; set; } public TransactionDetailSummary Summary { get; set; } -} \ No newline at end of file + public PaginationMetadata Pagination { get; set; } = new(); +} diff --git a/EstateReportingAPI.Models/TransactionMixSummaryRequest.cs b/EstateReportingAPI.Models/TransactionMixSummaryRequest.cs index 606a11d..f669476 100644 --- a/EstateReportingAPI.Models/TransactionMixSummaryRequest.cs +++ b/EstateReportingAPI.Models/TransactionMixSummaryRequest.cs @@ -26,4 +26,6 @@ public class TransactionMixSummaryRequest public TransactionMixBreakdown Breakdown { get; set; } public TransactionMixMeasure Measure { get; set; } public int TopN { get; set; } = 5; + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 50; } diff --git a/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs b/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs index e4a2c2b..8dfdb81 100644 --- a/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs +++ b/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs @@ -10,6 +10,7 @@ public class TransactionMixSummaryResponse public decimal TotalValue { get; set; } public List Groups { get; set; } = []; public List Transactions { get; set; } = []; + public PaginationMetadata Pagination { get; set; } = new(); } public class TransactionMixSummaryGroup diff --git a/EstateReportingAPI.Models/TransactionSummaryByMerchantRequest.cs b/EstateReportingAPI.Models/TransactionSummaryByMerchantRequest.cs index 2494926..b814215 100644 --- a/EstateReportingAPI.Models/TransactionSummaryByMerchantRequest.cs +++ b/EstateReportingAPI.Models/TransactionSummaryByMerchantRequest.cs @@ -6,4 +6,6 @@ public class TransactionSummaryByMerchantRequest public List? Merchants { get; set; } public DateTime StartDate { get; set; } public DateTime EndDate { get; set; } -} \ No newline at end of file + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 50; +} diff --git a/EstateReportingAPI.Models/TransactionSummaryByMerchantResponse.cs b/EstateReportingAPI.Models/TransactionSummaryByMerchantResponse.cs index bec4b76..bc589f8 100644 --- a/EstateReportingAPI.Models/TransactionSummaryByMerchantResponse.cs +++ b/EstateReportingAPI.Models/TransactionSummaryByMerchantResponse.cs @@ -4,4 +4,5 @@ public class TransactionSummaryByMerchantResponse { public List Merchants { get; set; } public MerchantDetailSummary Summary { get; set; } -} \ No newline at end of file + public PaginationMetadata Pagination { get; set; } = new(); +} diff --git a/EstateReportingAPI.Models/TransactionSummaryByOperatorRequest.cs b/EstateReportingAPI.Models/TransactionSummaryByOperatorRequest.cs index 23d88f2..ab4b088 100644 --- a/EstateReportingAPI.Models/TransactionSummaryByOperatorRequest.cs +++ b/EstateReportingAPI.Models/TransactionSummaryByOperatorRequest.cs @@ -6,4 +6,6 @@ public class TransactionSummaryByOperatorRequest public List? Merchants { get; set; } public DateTime StartDate { get; set; } public DateTime EndDate { get; set; } -} \ No newline at end of file + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 50; +} diff --git a/EstateReportingAPI.Models/TransactionSummaryByOperatorResponse.cs b/EstateReportingAPI.Models/TransactionSummaryByOperatorResponse.cs index d7f096c..709d6c5 100644 --- a/EstateReportingAPI.Models/TransactionSummaryByOperatorResponse.cs +++ b/EstateReportingAPI.Models/TransactionSummaryByOperatorResponse.cs @@ -4,4 +4,5 @@ public class TransactionSummaryByOperatorResponse { public List Operators { get; set; } public OperatorDetailSummary Summary { get; set; } + public PaginationMetadata Pagination { get; set; } = new(); } diff --git a/EstateReportingAPI/Handlers/TransactionHandler.cs b/EstateReportingAPI/Handlers/TransactionHandler.cs index bfeca40..c014581 100644 --- a/EstateReportingAPI/Handlers/TransactionHandler.cs +++ b/EstateReportingAPI/Handlers/TransactionHandler.cs @@ -56,12 +56,24 @@ public static async Task TransactionDetailReport(IEstateContext estateC [FromBody] TransactionDetailReportRequest request, BusinessLogic.ReportingDatePolicy datePolicy, IMediator mediator, CancellationToken cancellationToken) { + string? validationError = BusinessLogic.ReportQueryValidation.ValidateDateRange( + request.StartDate.ToDateTime(TimeOnly.MinValue), + request.EndDate.ToDateTime(TimeOnly.MinValue)) + ?? BusinessLogic.ReportQueryValidation.ValidatePaging(request.PageNumber, request.PageSize) + ?? BusinessLogic.ReportQueryValidation.ValidateFilterList("merchants", request.Merchants) + ?? BusinessLogic.ReportQueryValidation.ValidateFilterList("operators", request.Operators) + ?? BusinessLogic.ReportQueryValidation.ValidateFilterList("products", request.Products); + if (validationError != null) + return InvalidReportQuery(validationError); + Models.TransactionDetailReportRequest queryRequest = new() { Merchants = request.Merchants, Operators = request.Operators, Products = request.Products, StartDate = request.StartDate.ToDateTime(TimeOnly.MinValue), - EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue) + EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue), + PageNumber = request.PageNumber, + PageSize = request.PageSize }; var query = new TransactionQueries.TransactionDetailReportQuery(estateContext.EstateId, queryRequest); @@ -70,6 +82,12 @@ public static async Task TransactionDetailReport(IEstateContext estateC TransactionDetailReportResponse SuccessFactory (Models.TransactionDetailReportResponse r) => new TransactionDetailReportResponse { Summary = new TransactionDetailSummary { TotalFees = r.Summary.TotalFees, TotalValue = r.Summary.TotalValue, TransactionCount = r.Summary.TransactionCount }, + Pagination = new PaginationMetadata { + PageNumber = r.Pagination.PageNumber, + PageSize = r.Pagination.PageSize, + TotalItems = r.Pagination.TotalItems, + TotalPages = r.Pagination.TotalPages + }, Transactions = r.Transactions.Select(t => new TransactionDetail { TotalFees = t.TotalFees, DateTime = datePolicy.ToDateTimeOffset(t.DateTime), @@ -99,17 +117,34 @@ TransactionDetailReportResponse SuccessFactory (Models.TransactionDetailReportRe public static async Task TransactionSummaryByMerchantReport(IEstateContext estateContext, [FromBody] TransactionSummaryByMerchantRequest request, IMediator mediator, CancellationToken cancellationToken) { + string? validationError = BusinessLogic.ReportQueryValidation.ValidateDateRange( + request.StartDate.ToDateTime(TimeOnly.MinValue), + request.EndDate.ToDateTime(TimeOnly.MinValue)) + ?? BusinessLogic.ReportQueryValidation.ValidatePaging(request.PageNumber, request.PageSize) + ?? BusinessLogic.ReportQueryValidation.ValidateFilterList("merchants", request.Merchants) + ?? BusinessLogic.ReportQueryValidation.ValidateFilterList("operators", request.Operators); + if (validationError != null) + return InvalidReportQuery(validationError); + Models.TransactionSummaryByMerchantRequest queryRequest = new() { Merchants = request.Merchants, Operators = request.Operators, StartDate = request.StartDate.ToDateTime(TimeOnly.MinValue), - EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue) + EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue), + PageNumber = request.PageNumber, + PageSize = request.PageSize }; var query = new TransactionQueries.TransactionSummaryByMerchantQuery(estateContext.EstateId, queryRequest); var result = await mediator.Send(query, cancellationToken); TransactionSummaryByMerchantResponse SuccessFactory (Models.TransactionSummaryByMerchantResponse r) => new TransactionSummaryByMerchantResponse { Summary = new MerchantDetailSummary { TotalMerchants = r.Summary.TotalMerchants, TotalCount = r.Summary.TotalCount, TotalValue = r.Summary.TotalValue, AverageValue = r.Summary.AverageValue }, + Pagination = new PaginationMetadata { + PageNumber = r.Pagination.PageNumber, + PageSize = r.Pagination.PageSize, + TotalItems = r.Pagination.TotalItems, + TotalPages = r.Pagination.TotalPages + }, Merchants = r.Merchants.Select(m => new MerchantDetail { MerchantId = m.MerchantId, MerchantName = m.MerchantName, @@ -129,14 +164,29 @@ TransactionSummaryByMerchantResponse SuccessFactory (Models.TransactionSummaryBy public static async Task ProductPerformanceReport(IEstateContext estateContext, [FromQuery] DateOnly startDate, [FromQuery] DateOnly endDate, + [FromQuery] int? pageNumber, + [FromQuery] int? pageSize, IMediator mediator, CancellationToken cancellationToken) { - var query = new TransactionQueries.ProductPerformanceQuery(estateContext.EstateId, startDate.ToDateTime(TimeOnly.MinValue), endDate.ToDateTime(TimeOnly.MinValue)); + int requestedPageNumber = pageNumber ?? 1; + int requestedPageSize = pageSize ?? 50; + string? validationError = BusinessLogic.ReportQueryValidation.ValidateDateRange(startDate.ToDateTime(TimeOnly.MinValue), endDate.ToDateTime(TimeOnly.MinValue)) + ?? BusinessLogic.ReportQueryValidation.ValidatePaging(requestedPageNumber, requestedPageSize); + if (validationError != null) + return InvalidReportQuery(validationError); + + var query = new TransactionQueries.ProductPerformanceQuery(estateContext.EstateId, startDate.ToDateTime(TimeOnly.MinValue), endDate.ToDateTime(TimeOnly.MinValue), requestedPageNumber, requestedPageSize); var result = await mediator.Send(query, cancellationToken); ProductPerformanceResponse SuccessFactory(Models.ProductPerformanceResponse r) => new ProductPerformanceResponse { Summary = new ProductPerformanceSummary { TotalProducts = r.Summary.TotalProducts, TotalCount = r.Summary.TotalCount, TotalValue = r.Summary.TotalValue, AveragePerProduct = r.Summary.AveragePerProduct }, + Pagination = new PaginationMetadata { + PageNumber = r.Pagination.PageNumber, + PageSize = r.Pagination.PageSize, + TotalItems = r.Pagination.TotalItems, + TotalPages = r.Pagination.TotalPages + }, ProductDetails = r.ProductDetails.Select(p => new ProductPerformanceDetail { ProductId = p.ProductId, @@ -157,12 +207,23 @@ public static async Task TransactionSummaryByOperatorReport(IEstateCont [FromBody] TransactionSummaryByOperatorRequest request, IMediator mediator, CancellationToken cancellationToken) { + string? validationError = BusinessLogic.ReportQueryValidation.ValidateDateRange( + request.StartDate.ToDateTime(TimeOnly.MinValue), + request.EndDate.ToDateTime(TimeOnly.MinValue)) + ?? BusinessLogic.ReportQueryValidation.ValidatePaging(request.PageNumber, request.PageSize) + ?? BusinessLogic.ReportQueryValidation.ValidateFilterList("merchants", request.Merchants) + ?? BusinessLogic.ReportQueryValidation.ValidateFilterList("operators", request.Operators); + if (validationError != null) + return InvalidReportQuery(validationError); + Models.TransactionSummaryByOperatorRequest queryRequest = new() { Merchants = request.Merchants, Operators = request.Operators, StartDate = request.StartDate.ToDateTime(TimeOnly.MinValue), - EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue) + EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue), + PageNumber = request.PageNumber, + PageSize = request.PageSize }; var query = new TransactionQueries.TransactionSummaryByOperatorQuery(estateContext.EstateId, queryRequest); var result = await mediator.Send(query, cancellationToken); @@ -170,6 +231,12 @@ TransactionSummaryByOperatorResponse SuccessFactory(Models.TransactionSummaryByO new TransactionSummaryByOperatorResponse { Summary = new OperatorDetailSummary { TotalOperators = r.Summary.TotalOperators, TotalCount = r.Summary.TotalCount, TotalValue = r.Summary.TotalValue, AverageValue = r.Summary.AverageValue }, + Pagination = new PaginationMetadata { + PageNumber = r.Pagination.PageNumber, + PageSize = r.Pagination.PageSize, + TotalItems = r.Pagination.TotalItems, + TotalPages = r.Pagination.TotalPages + }, Operators = r.Operators.Select(o => new OperatorDetail { OperatorId = o.OperatorId, @@ -193,8 +260,17 @@ public static async Task TransactionMixSummary(IEstateContext estateCon IMediator mediator, CancellationToken cancellationToken) { - if (request.StartDate > request.EndDate) - return Results.BadRequest("StartDate must be less than or equal to EndDate."); + string? validationError = BusinessLogic.ReportQueryValidation.ValidateDateRange( + request.StartDate.ToDateTime(TimeOnly.MinValue), + request.EndDate.ToDateTime(TimeOnly.MinValue)) + ?? BusinessLogic.ReportQueryValidation.ValidatePaging(request.PageNumber, request.PageSize) + ?? BusinessLogic.ReportQueryValidation.ValidateTopN(request.TopN); + if (validationError != null) + return InvalidReportQuery(validationError); + if (!Enum.IsDefined(typeof(TransactionMixBreakdown), request.Breakdown)) + return InvalidReportQuery("breakdown is not supported."); + if (!Enum.IsDefined(typeof(TransactionMixMeasure), request.Measure)) + return InvalidReportQuery("measure is not supported."); Models.TransactionMixSummaryRequest queryRequest = new() { @@ -203,7 +279,9 @@ public static async Task TransactionMixSummary(IEstateContext estateCon EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue), Breakdown = (Models.TransactionMixBreakdown)request.Breakdown, Measure = (Models.TransactionMixMeasure)request.Measure, - TopN = request.TopN + TopN = request.TopN, + PageNumber = request.PageNumber, + PageSize = request.PageSize }; var query = new TransactionQueries.TransactionMixSummaryQuery(estateContext.EstateId, queryRequest); @@ -218,6 +296,12 @@ TransactionMixSummaryResponse SuccessFactory(Models.TransactionMixSummaryRespons Measure = (TransactionMixMeasure)r.Measure, TotalCount = r.TotalCount, TotalValue = r.TotalValue, + Pagination = new PaginationMetadata { + PageNumber = r.Pagination.PageNumber, + PageSize = r.Pagination.PageSize, + TotalItems = r.Pagination.TotalItems, + TotalPages = r.Pagination.TotalPages + }, Groups = r.Groups.Select(g => new TransactionMixSummaryGroup { GroupKey = g.GroupKey, @@ -256,6 +340,10 @@ public static async Task RecentActivityReceiptReport(IEstateContext est IMediator mediator, CancellationToken cancellationToken) { + string? validationError = BusinessLogic.ReportQueryValidation.ValidatePaging(request.PageNumber, request.PageSize); + if (validationError != null) + return InvalidReportQuery(validationError); + Models.GetRecentActivityReceiptReportRequest queryRequest = new() { ReportDate = request.ReportDate.ToDateTime(TimeOnly.MinValue), @@ -272,9 +360,12 @@ GetRecentActivityReceiptReportResponse SuccessFactory(Models.GetRecentActivityRe new GetRecentActivityReceiptReportResponse { ReportDate = r.ReportDate, - PageNumber = r.PageNumber, - PageSize = r.PageSize, - TotalCount = r.TotalCount, + Pagination = new PaginationMetadata { + PageNumber = r.Pagination.PageNumber, + PageSize = r.Pagination.PageSize, + TotalItems = r.Pagination.TotalItems, + TotalPages = r.Pagination.TotalPages + }, Items = r.Items.Select(item => new RecentActivityReceiptItemDto { Reference = item.Reference, @@ -318,6 +409,12 @@ public static async Task MerchantDailyPerformanceSummary(IEstateContext BusinessLogic.ReportingDatePolicy datePolicy, IMediator mediator, CancellationToken cancellationToken) { + string? validationError = BusinessLogic.ReportQueryValidation.ValidateDateRange( + request.StartDate.ToDateTime(TimeOnly.MinValue), + request.EndDate.ToDateTime(TimeOnly.MinValue)); + if (validationError != null) + return InvalidReportQuery(validationError); + TransactionQueries.MerchantDailyPerformanceSummaryQuery query = new(estateContext.EstateId, new Models.MerchantDailyPerformanceSummaryRequest { EndDate = request.EndDate.ToDateTime(TimeOnly.MinValue), MerchantReportingId = request.MerchantReportingId, @@ -348,5 +445,10 @@ MerchantDailyPerformanceSummaryResponse SuccessFactory(Models.MerchantDailyPerfo return ResponseFactory.FromResult(result, SuccessFactory); } + + private static IResult InvalidReportQuery(string detail) => Results.Problem( + statusCode: StatusCodes.Status400BadRequest, + title: "Invalid report query", + detail: detail); } diff --git a/README.md b/README.md index 4511a62..216cc70 100644 --- a/README.md +++ b/README.md @@ -1 +1,12 @@ -# EstateReportingAPI \ No newline at end of file +# EstateReportingAPI + +## Transaction report limits + +The transaction reporting endpoints use bounded queries: + +- Date-range reports accept an inclusive range of at most 30 calendar days. +- Paged reports accept `pageSize` values from 1 to 50; the default is 50, except recent activity, which defaults to 10. +- Transaction mix `topN` accepts values from 1 to 20 and defaults to 5. +- Merchant, operator, and product filter lists accept at most 50 IDs. + +Paged responses expose `pagination` with `pageNumber`, `pageSize`, `totalItems`, and `totalPages`. Summary totals cover the complete filtered result set, while detail rows contain only the requested page. Invalid values return HTTP 400 using `ProblemDetails` with a human-readable `detail` value. From 06bee27ac167bd8d99a240b9587fbfac3ef971f1 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Wed, 30 Sep 2026 19:58:09 +0100 Subject: [PATCH 2/2] Fix Codacy report limit constants --- .../ReportQueryLimits.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/EstateReportingAPI.BusinessLogic/ReportQueryLimits.cs b/EstateReportingAPI.BusinessLogic/ReportQueryLimits.cs index b0cdce6..b037bd6 100644 --- a/EstateReportingAPI.BusinessLogic/ReportQueryLimits.cs +++ b/EstateReportingAPI.BusinessLogic/ReportQueryLimits.cs @@ -2,10 +2,10 @@ namespace EstateReportingAPI.BusinessLogic; public static class ReportQueryLimits { - public const int MaxDateRangeDays = 30; - public const int MaxPageSize = 50; - public const int DefaultPageSize = 50; - public const int RecentActivityDefaultPageSize = 10; - public const int MaxTopN = 20; - public const int MaxFilterListSize = 50; + public static readonly int MaxDateRangeDays = 30; + public static readonly int MaxPageSize = 50; + public static readonly int DefaultPageSize = 50; + public static readonly int RecentActivityDefaultPageSize = 10; + public static readonly int MaxTopN = 20; + public static readonly int MaxFilterListSize = 50; }