From 465e26a4b68becaa22368dff4b8b9d624b12bc75 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Tue, 7 Jul 2026 20:04:45 +0100 Subject: [PATCH 1/2] Add Recent Activity Receipt Report endpoint and tests Introduced a new POST /recentactivityreceiptreport endpoint for transaction reporting. Added DTOs, MediatR query, handler, and reporting manager logic to support date, merchant, and search filtering with paging. Included integration tests and a development plan. --- .../Queries/TransactionQueries.cs | 3 +- .../ReportingManager.cs | 115 ++++++++++++- .../TransactionRequestHandler.cs | 9 +- .../GetRecentActivityReceiptReportRequest.cs | 12 ++ .../GetRecentActivityReceiptReportResponse.cs | 25 +++ .../TransactionsEndpointTests.cs | 161 ++++++++++++++++++ .../GetRecentActivityReceiptReportRequest.cs | 12 ++ .../GetRecentActivityReceiptReportResponse.cs | 25 +++ .../Endpoints/TransactionEndpoints.cs | 2 + .../Handlers/TransactionHandler.cs | 44 ++++- ...26-07-07-recent-activity-receipt-report.md | 88 ++++++++++ 11 files changed, 488 insertions(+), 8 deletions(-) create mode 100644 EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportRequest.cs create mode 100644 EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs create mode 100644 EstateReportingAPI.Models/GetRecentActivityReceiptReportRequest.cs create mode 100644 EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs create mode 100644 docs/superpowers/plans/2026-07-07-recent-activity-receipt-report.md diff --git a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs index 9adb3d5..97c10fa 100644 --- a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs +++ b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs @@ -14,6 +14,7 @@ public record TransactionSummaryByMerchantQuery(Guid EstateId, TransactionSummar public record TransactionSummaryByOperatorQuery(Guid EstateId, TransactionSummaryByOperatorRequest Request) : IRequest>; public record ProductPerformanceQuery(Guid EstateId, DateTime StartDate, DateTime EndDate) : 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>>; public record MerchantDailyPerformanceSummaryQuery(Guid EstateId, MerchantDailyPerformanceSummaryRequest Request) : IRequest>; -} \ No newline at end of file +} diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index 4e98e40..80c3ad0 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -47,11 +47,13 @@ Task> GetTransactionDetailReport(Transac Task> GetTransactionSummaryByMerchantReport(TransactionQueries.TransactionSummaryByMerchantQuery request, CancellationToken cancellationToken); Task> GetTransactionSummaryByOperatorReport(TransactionQueries.TransactionSummaryByOperatorQuery request, - CancellationToken cancellationToken); + CancellationToken cancellationToken); Task> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request, CancellationToken cancellationToken); Task> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request, - CancellationToken cancellationToken); + CancellationToken cancellationToken); + Task> GetRecentActivityReceiptReport(TransactionQueries.GetRecentActivityReceiptReportQuery request, + CancellationToken cancellationToken); Task>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request, CancellationToken cancellationToken); @@ -108,6 +110,19 @@ private static async Task>> ExecuteQuerySafeToList(IQueryable< } } + private static async Task> ExecuteQuerySafeCount(IQueryable query, + CancellationToken cancellationToken, + string contextMessage = null) { + try { + int count = await query.CountAsync(cancellationToken); + return Result.Success(count); + } + catch (Exception ex) { + string msg = contextMessage == null ? $"Error executing query: {ex.Message}" : $"{contextMessage}: {ex.Message}"; + return Result.Failure(msg); + } + } + private static async Task> ExecuteQuerySafeSingleOrDefault(IQueryable query, CancellationToken cancellationToken, string contextMessage = null) { @@ -1293,9 +1308,89 @@ public async Task> GetTransactionMixSummar return Result.Success(BuildTransactionMixSummaryResponse(queryResults, request.Request)); } + public async Task> GetRecentActivityReceiptReport(TransactionQueries.GetRecentActivityReceiptReportQuery request, + CancellationToken cancellationToken) { + using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); + await using EstateManagementContext context = resolvedContext.Context; + + DateTime reportDate = request.Request.ReportDate.Date; + int pageNumber = request.Request.PageNumber > 0 ? request.Request.PageNumber : 1; + int pageSize = request.Request.PageSize > 0 ? request.Request.PageSize : 10; + + IQueryable query = from t in context.Transactions + join m in context.Merchants on t.MerchantId equals m.MerchantId + 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 == reportDate + select new RecentActivityReceiptQueryResult { + TransactionDateTime = t.TransactionDateTime, + MerchantReportingId = m.MerchantReportingId, + Reference = t.TransactionNumber, + TransactionType = t.TransactionType, + Product = cp.ProductName, + Operator = o.Name, + Status = t.IsAuthorised ? "Successful" : "Failed", + Amount = t.TransactionAmount, + ReceiptReference = t.TransactionReference + }; + + if (request.Request.MerchantReportingId.HasValue) + query = query.Where(q => q.MerchantReportingId == request.Request.MerchantReportingId.Value); + + if (string.IsNullOrWhiteSpace(request.Request.SearchText) == false) { + string searchText = request.Request.SearchText.Trim(); + query = query.Where(q => + q.Reference.Contains(searchText) || + q.TransactionType.Contains(searchText) || + q.Product.Contains(searchText) || + q.Operator.Contains(searchText) || + q.Status.Contains(searchText) || + q.ReceiptReference.Contains(searchText)); + } + + query = query.OrderByDescending(q => q.TransactionDateTime); + + var totalCountResult = await ExecuteQuerySafeCount(query, cancellationToken, "Error counting recent activity receipt report rows"); + if (totalCountResult.IsFailed) + return ResultHelpers.CreateFailure(totalCountResult); + + int totalCount = totalCountResult.Data; + if (totalCount == 0) { + return Result.Success(new GetRecentActivityReceiptReportResponse { + ReportDate = reportDate, + PageNumber = pageNumber, + PageSize = pageSize, + TotalCount = 0, + Items = [] + }); + } + + var pageQuery = query.Skip((pageNumber - 1) * pageSize).Take(pageSize); + var pageResult = await ExecuteQuerySafeToList(pageQuery, cancellationToken, "Error retrieving recent activity receipt report"); + if (pageResult.IsFailed) + return ResultHelpers.CreateFailure(pageResult); + + return Result.Success(new GetRecentActivityReceiptReportResponse { + ReportDate = reportDate, + PageNumber = pageNumber, + PageSize = pageSize, + TotalCount = totalCount, + Items = pageResult.Data.Select(item => new RecentActivityReceiptItemDto { + Reference = item.Reference, + TransactionType = item.TransactionType, + Product = item.Product, + Operator = item.Operator, + Status = item.Status, + Amount = item.Amount, + TransactionDateTime = item.TransactionDateTime, + ReceiptReference = item.ReceiptReference + }).ToList() + }); + } + private static IQueryable BuildProductPerformanceQuery(EstateManagementContext context, - DateTime startDate, - DateTime endDate, + DateTime startDate, + DateTime endDate, decimal grandTotalAmount) { return from t in context.Transactions join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId } @@ -2026,6 +2121,18 @@ private static List MapMerchantDailyPerformanceRecentSales }).ToList(); } + private sealed class RecentActivityReceiptQueryResult { + public DateTime TransactionDateTime { get; init; } + public int MerchantReportingId { get; init; } + public string Reference { get; init; } + public string? TransactionType { get; init; } + public string? Product { get; init; } + public string? Operator { get; init; } + public string? Status { get; init; } + public decimal Amount { get; init; } + public string? ReceiptReference { get; init; } + } + private sealed class TransactionDetailQueryResult { public Guid TransactionId { get; init; } public DateTime TransactionDateTime { get; init; } diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs index f915b06..c5ac3f8 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs @@ -12,6 +12,7 @@ public class TransactionRequestHandler : IRequestHandler>, IRequestHandler>, IRequestHandler>, + IRequestHandler>, IRequestHandler>>, IRequestHandler> @@ -60,6 +61,12 @@ public async Task> Handle(TransactionQueri return await this.Manager.GetTransactionMixSummary(request, cancellationToken); } + public async Task> Handle(TransactionQueries.GetRecentActivityReceiptReportQuery request, + CancellationToken cancellationToken) + { + return await this.Manager.GetRecentActivityReceiptReport(request, cancellationToken); + } + public async Task>> Handle(TransactionQueries.TodaysSalesByHour request, CancellationToken cancellationToken) { return await this.Manager.GetTodaysSalesByHour(request, cancellationToken); @@ -69,4 +76,4 @@ public async Task> Handle(Transa CancellationToken cancellationToken) { return await this.Manager.GetMerchantDailyPerformanceSummary(request, cancellationToken); } -} \ No newline at end of file +} diff --git a/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportRequest.cs b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportRequest.cs new file mode 100644 index 0000000..b2ee95b --- /dev/null +++ b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportRequest.cs @@ -0,0 +1,12 @@ +using System; + +namespace EstateReportingAPI.DataTransferObjects; + +public class GetRecentActivityReceiptReportRequest +{ + public DateTime ReportDate { get; set; } + public int? MerchantReportingId { get; set; } + public string? SearchText { get; set; } + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 10; +} diff --git a/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs new file mode 100644 index 0000000..48ededc --- /dev/null +++ b/EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace EstateReportingAPI.DataTransferObjects; + +public class GetRecentActivityReceiptReportResponse +{ + public DateTime ReportDate { get; set; } + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + public List Items { get; set; } = []; +} + +public class RecentActivityReceiptItemDto +{ + public string? Reference { get; set; } + public string? TransactionType { get; set; } + public string? Product { get; set; } + public string? Operator { get; set; } + public string? Status { get; set; } + public decimal Amount { get; set; } + public DateTime TransactionDateTime { get; set; } + public string? ReceiptReference { get; set; } +} diff --git a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs index 8d42947..5516d52 100644 --- a/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs +++ b/EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using EstateReportingAPI.DataTransferObjects; @@ -9,6 +10,7 @@ using Shouldly; using SimpleResults; using TransactionProcessor.Database.Entities; +using System.Text.Json; using Xunit; namespace EstateReportingAPI.IntegrationTests; @@ -20,6 +22,33 @@ public TransactionsEndpointTests(ITestOutputHelper testOutputHelper) { this.TestOutputHelper = testOutputHelper; } + private static JsonElement GetPropertyCaseInsensitive(JsonElement element, string propertyName) { + if (element.TryGetProperty(propertyName, out JsonElement value)) + return value; + + string pascalCase = char.ToUpperInvariant(propertyName[0]) + propertyName[1..]; + if (element.TryGetProperty(pascalCase, out value)) + return value; + + var snakeCaseBuilder = new System.Text.StringBuilder(); + for (int i = 0; i < propertyName.Length; i++) { + char current = propertyName[i]; + if (char.IsUpper(current) && i > 0) { + snakeCaseBuilder.Append('_'); + snakeCaseBuilder.Append(char.ToLowerInvariant(current)); + } + else { + snakeCaseBuilder.Append(char.ToLowerInvariant(current)); + } + } + + string snakeCase = snakeCaseBuilder.ToString(); + if (element.TryGetProperty(snakeCase, out value)) + return value; + + throw new KeyNotFoundException($"Property '{propertyName}' was not found."); + } + protected override async Task ClearStandingData() { @@ -1132,4 +1161,136 @@ public async Task TransactionsEndpoint_MerchantDailyPerformanceSummary_ReturnsMe response.DrillDownTransactions.Select(x => x.Reference).ShouldBe(new[] { "0006", "0005", "0004", "0003", "0002" }); response.DrillDownTransactions.Select(x => x.Status).ShouldBe(new[] { "Failed", "Successful", "Failed", "Successful", "Failed" }); } + + [Fact] + public async Task TransactionsEndpoint_RecentActivityReceiptReport_MerchantFilter_ReturnsDescendingResults() { + var reportDate = DateTime.Now.Date; + var merchant = this.merchantsList.Single(m => m.Name == "Test Merchant 1"); + var otherMerchant = this.merchantsList.Single(m => m.Name == "Test Merchant 2"); + var contract = this.contractList.Single(c => c.operatorName == "Safaricom"); + var product = this.contractProducts.Single(cp => cp.Key == contract.contractId).Value.First(); + var @operator = this.operatorsList.Single(o => o.Name == "Safaricom"); + + List transactions = new() { + await this.helper.BuildTransactionX(reportDate.AddHours(8), merchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue, 11), + await this.helper.BuildTransactionX(reportDate.AddHours(10), merchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue, 12), + await this.helper.BuildTransactionX(reportDate.AddHours(9), otherMerchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue, 21) + }; + + transactions[0].TransactionReference = "receipt-001"; + transactions[1].TransactionReference = "receipt-002"; + transactions[2].TransactionReference = "receipt-003"; + + await this.helper.AddTransactionsX(transactions); + await this.helper.RunTodaysTransactionsSummaryProcessing(reportDate); + await this.helper.RunHistoricTransactionsSummaryProcessing(reportDate); + + var payload = JsonSerializer.Serialize(new Dictionary { + ["report_date"] = reportDate, + ["merchant_reporting_id"] = merchant.MerchantReportingId, + ["search_text"] = null, + ["page_number"] = 1, + ["page_size"] = 10 + }); + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, $"{this.BaseRoute}/recentactivityreceiptreport"); + requestMessage.Headers.Add("estateId", this.TestId.ToString()); + requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Test"); + requestMessage.Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await this.Client.SendAsync(requestMessage, CancellationToken.None); + response.IsSuccessStatusCode.ShouldBeTrue(response.StatusCode.ToString()); + + string content = await response.Content.ReadAsStringAsync(CancellationToken.None); + using JsonDocument document = JsonDocument.Parse(content); + + static JsonElement GetPropertyCaseInsensitive(JsonElement element, string propertyName) { + if (element.TryGetProperty(propertyName, out JsonElement value)) + return value; + + string pascalCase = char.ToUpperInvariant(propertyName[0]) + propertyName[1..]; + if (element.TryGetProperty(pascalCase, out value)) + return value; + + var snakeCaseBuilder = new System.Text.StringBuilder(); + for (int i = 0; i < propertyName.Length; i++) { + char current = propertyName[i]; + if (char.IsUpper(current) && i > 0) { + snakeCaseBuilder.Append('_'); + snakeCaseBuilder.Append(char.ToLowerInvariant(current)); + } + else { + snakeCaseBuilder.Append(char.ToLowerInvariant(current)); + } + } + + string snakeCase = snakeCaseBuilder.ToString(); + if (element.TryGetProperty(snakeCase, out value)) + return value; + + throw new KeyNotFoundException($"Property '{propertyName}' was not found."); + } + + DateTime.Parse(GetPropertyCaseInsensitive(document.RootElement, "reportDate").GetString()!).Date.ShouldBe(reportDate); + GetPropertyCaseInsensitive(document.RootElement, "pageNumber").GetInt32().ShouldBe(1); + GetPropertyCaseInsensitive(document.RootElement, "pageSize").GetInt32().ShouldBe(10); + GetPropertyCaseInsensitive(document.RootElement, "totalCount").GetInt32().ShouldBe(2); + + JsonElement items = GetPropertyCaseInsensitive(document.RootElement, "items"); + items.GetArrayLength().ShouldBe(2); + GetPropertyCaseInsensitive(items[0], "reference").GetString().ShouldBe("0012"); + GetPropertyCaseInsensitive(items[1], "reference").GetString().ShouldBe("0011"); + GetPropertyCaseInsensitive(items[0], "receiptReference").GetString().ShouldBe("receipt-002"); + GetPropertyCaseInsensitive(items[1], "receiptReference").GetString().ShouldBe("receipt-001"); + GetPropertyCaseInsensitive(items[0], "operator").GetString().ShouldBe(@operator.Name); + DateTime.Parse(GetPropertyCaseInsensitive(items[0], "transactionDateTime").GetString()!).ShouldBe(reportDate.AddHours(10)); + } + + [Fact] + public async Task TransactionsEndpoint_RecentActivityReceiptReport_SearchText_IsAppliedBeforePaging() { + var reportDate = DateTime.Now.Date; + var merchant = this.merchantsList.Single(m => m.Name == "Test Merchant 1"); + var otherMerchant = this.merchantsList.Single(m => m.Name == "Test Merchant 2"); + var contract = this.contractList.Single(c => c.operatorName == "Safaricom"); + var product = this.contractProducts.Single(cp => cp.Key == contract.contractId).Value.First(); + + List transactions = new() { + await this.helper.BuildTransactionX(reportDate.AddHours(8), merchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue, 11), + await this.helper.BuildTransactionX(reportDate.AddHours(10), merchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue, 12), + await this.helper.BuildTransactionX(reportDate.AddHours(9), otherMerchant.MerchantId, contract.operatorId, contract.contractId, product.productId, "0000", product.productValue, 21) + }; + + transactions[0].TransactionReference = "receipt-001"; + transactions[1].TransactionReference = "receipt-002"; + transactions[2].TransactionReference = "receipt-003"; + + await this.helper.AddTransactionsX(transactions); + await this.helper.RunTodaysTransactionsSummaryProcessing(reportDate); + await this.helper.RunHistoricTransactionsSummaryProcessing(reportDate); + + var payload = JsonSerializer.Serialize(new Dictionary { + ["report_date"] = reportDate, + ["merchant_reporting_id"] = merchant.MerchantReportingId, + ["search_text"] = "receipt", + ["page_number"] = 1, + ["page_size"] = 1 + }); + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, $"{this.BaseRoute}/recentactivityreceiptreport"); + requestMessage.Headers.Add("estateId", this.TestId.ToString()); + requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Test"); + requestMessage.Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await this.Client.SendAsync(requestMessage, CancellationToken.None); + response.IsSuccessStatusCode.ShouldBeTrue(response.StatusCode.ToString()); + + string content = await response.Content.ReadAsStringAsync(CancellationToken.None); + using JsonDocument document = JsonDocument.Parse(content); + + GetPropertyCaseInsensitive(document.RootElement, "totalCount").GetInt32().ShouldBe(2); + JsonElement items = GetPropertyCaseInsensitive(document.RootElement, "items"); + items.GetArrayLength().ShouldBe(1); + GetPropertyCaseInsensitive(items[0], "reference").GetString().ShouldBe("0012"); + GetPropertyCaseInsensitive(items[0], "receiptReference").GetString().ShouldBe("receipt-002"); + } } diff --git a/EstateReportingAPI.Models/GetRecentActivityReceiptReportRequest.cs b/EstateReportingAPI.Models/GetRecentActivityReceiptReportRequest.cs new file mode 100644 index 0000000..b1434f4 --- /dev/null +++ b/EstateReportingAPI.Models/GetRecentActivityReceiptReportRequest.cs @@ -0,0 +1,12 @@ +using System; + +namespace EstateReportingAPI.Models; + +public class GetRecentActivityReceiptReportRequest +{ + public DateTime ReportDate { get; set; } + public int? MerchantReportingId { get; set; } + public string? SearchText { get; set; } + public int PageNumber { get; set; } = 1; + public int PageSize { get; set; } = 10; +} diff --git a/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs b/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs new file mode 100644 index 0000000..b517b96 --- /dev/null +++ b/EstateReportingAPI.Models/GetRecentActivityReceiptReportResponse.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace EstateReportingAPI.Models; + +public class GetRecentActivityReceiptReportResponse +{ + public DateTime ReportDate { get; set; } + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + public List Items { get; set; } = []; +} + +public class RecentActivityReceiptItemDto +{ + public string? Reference { get; set; } + public string? TransactionType { get; set; } + public string? Product { get; set; } + public string? Operator { get; set; } + public string? Status { get; set; } + public decimal Amount { get; set; } + public DateTime TransactionDateTime { get; set; } + public string? ReceiptReference { get; set; } +} diff --git a/EstateReportingAPI/Endpoints/TransactionEndpoints.cs b/EstateReportingAPI/Endpoints/TransactionEndpoints.cs index 58d2647..aa2a9f5 100644 --- a/EstateReportingAPI/Endpoints/TransactionEndpoints.cs +++ b/EstateReportingAPI/Endpoints/TransactionEndpoints.cs @@ -32,6 +32,8 @@ public static void MapTransactionEndpoints(this IEndpointRouteBuilder app) .WithStandardProduces(); group.MapPost("transactionmixsummary", TransactionHandler.TransactionMixSummary) .WithStandardProduces(); + group.MapPost("recentactivityreceiptreport", TransactionHandler.RecentActivityReceiptReport) + .WithStandardProduces(); group.MapGet("todayssalesbyhour", TransactionHandler.TodaysSalesByHour) .WithStandardProduces>(); group.MapPost("merchantdailyperformancesummary", TransactionHandler.MerchantDailyPerformanceSummary) diff --git a/EstateReportingAPI/Handlers/TransactionHandler.cs b/EstateReportingAPI/Handlers/TransactionHandler.cs index 49bdcfc..2d24d17 100644 --- a/EstateReportingAPI/Handlers/TransactionHandler.cs +++ b/EstateReportingAPI/Handlers/TransactionHandler.cs @@ -247,9 +247,49 @@ TransactionMixSummaryResponse SuccessFactory(Models.TransactionMixSummaryRespons return ResponseFactory.FromResult(result, SuccessFactory); } + public static async Task RecentActivityReceiptReport([FromHeader] Guid estateId, + [FromBody] GetRecentActivityReceiptReportRequest request, + IMediator mediator, + CancellationToken cancellationToken) + { + Models.GetRecentActivityReceiptReportRequest queryRequest = new() + { + ReportDate = request.ReportDate, + MerchantReportingId = request.MerchantReportingId, + SearchText = request.SearchText, + PageNumber = request.PageNumber, + PageSize = request.PageSize + }; + + var query = new TransactionQueries.GetRecentActivityReceiptReportQuery(estateId, queryRequest); + var result = await mediator.Send(query, cancellationToken); + + GetRecentActivityReceiptReportResponse SuccessFactory(Models.GetRecentActivityReceiptReportResponse r) => + new GetRecentActivityReceiptReportResponse + { + ReportDate = r.ReportDate, + PageNumber = r.PageNumber, + PageSize = r.PageSize, + TotalCount = r.TotalCount, + Items = r.Items.Select(item => new RecentActivityReceiptItemDto + { + Reference = item.Reference, + TransactionType = item.TransactionType, + Product = item.Product, + Operator = item.Operator, + Status = item.Status, + Amount = item.Amount, + TransactionDateTime = item.TransactionDateTime, + ReceiptReference = item.ReceiptReference + }).ToList() + }; + + return ResponseFactory.FromResult(result, SuccessFactory); + } + public static async Task TodaysSalesByHour([FromHeader] Guid estateId, - [FromQuery] DateTime comparisonDate, - IMediator mediator, + [FromQuery] DateTime comparisonDate, + IMediator mediator, CancellationToken cancellationToken) { var query = new TransactionQueries.TodaysSalesByHour(estateId, comparisonDate); diff --git a/docs/superpowers/plans/2026-07-07-recent-activity-receipt-report.md b/docs/superpowers/plans/2026-07-07-recent-activity-receipt-report.md new file mode 100644 index 0000000..3b53cf5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-recent-activity-receipt-report.md @@ -0,0 +1,88 @@ +# Recent Activity Receipt Report Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new transaction report endpoint that returns a date-scoped, merchant-filterable, paged list of recent activity receipts for the mobile app. + +**Architecture:** Keep the endpoint thin and follow the existing `api/transactions` flow: Minimal API route -> handler -> MediatR query -> `ReportingManager` projection. Reuse the repository's DTO split between `Models` and `DataTrasferObjects`, and keep the new report isolated from the existing transaction detail report so the mobile contract stays narrow. + +**Tech Stack:** ASP.NET Core Minimal APIs, MediatR, Entity Framework Core, xUnit integration tests. + +--- + +### Task 1: Add the request/response contract and wire the endpoint + +**Files:** +- Create: `EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportRequest.cs` +- Create: `EstateReportingAPI.DataTrasferObjects/GetRecentActivityReceiptReportResponse.cs` +- Modify: `EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs` +- Modify: `EstateReportingAPI/Endpoints/TransactionEndpoints.cs` +- Modify: `EstateReportingAPI/Handlers/TransactionHandler.cs` + +**Interfaces:** +- Consumes: the existing `api/transactions` Minimal API group and the MediatR transaction query pattern. +- Produces: `TransactionQueries.GetRecentActivityReceiptReportQuery`, handler forwarding, and a new `POST /api/transactions/recentactivityreceiptreport` route. + +- [ ] **Step 1: Write the failing endpoint test** + +```csharp +var payload = """ +{ + "reportDate": "2026-07-07", + "merchantReportingId": 1, + "searchText": "coffee", + "pageNumber": 1, + "pageSize": 10 +} +"""; + +var result = await this.CreateAndSendHttpRequestMessage($"{BaseRoute}/recentactivityreceiptreport", payload, CancellationToken.None); +``` + +- [ ] **Step 2: Run the test and confirm it fails because the route is missing** + +Run: `dotnet test EstateReportingAPI.IntegrationTests/EstateReportingAPI.IntegrationTests.csproj --filter RecentActivityReceipt` +Expected: 404 or unhandled route failure before implementation. + +- [ ] **Step 3: Add the DTOs, query record, handler forwarding method, and endpoint mapping** + +```csharp +group.MapPost("recentactivityreceiptreport", TransactionHandler.RecentActivityReceiptReport) + .WithStandardProduces(); + +public record GetRecentActivityReceiptReportQuery(Guid EstateId, GetRecentActivityReceiptReportRequest Request) + : IRequest>; +``` + +- [ ] **Step 4: Re-run the test and confirm the route is now wired** + +Run: `dotnet test EstateReportingAPI.IntegrationTests/EstateReportingAPI.IntegrationTests.csproj --filter RecentActivityReceipt` +Expected: request reaches the handler path and fails only until the manager implementation is added. + +### Task 2: Implement the reporting query and verify behavior + +**Files:** +- Modify: `EstateReportingAPI.BusinessLogic/ReportingManager.cs` +- Modify: `EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs` + +**Interfaces:** +- Consumes: `TransactionQueries.GetRecentActivityReceiptReportQuery` and the new DTOs. +- Produces: ordered, paged receipt rows filtered by report date, merchant reporting id, and optional search text. + +- [ ] **Step 1: Add a failing integration test for filtering and paging** + +Seed two merchants worth of transactions on the same day and assert the merchant filter, search filter, descending order, and paging metadata behave as expected. + +- [ ] **Step 2: Implement the query projection** + +Filter by `ReportDate`, optionally restrict by `MerchantReportingId`, apply `SearchText` before paging, sort by transaction datetime descending, and populate `TotalCount` from the pre-paged result set. + +- [ ] **Step 3: Map the response** + +Populate `Reference`, `TransactionType`, `Product`, `Operator`, `Status`, `Amount`, `TransactionDateTime`, and `ReceiptReference` with the minimum data required by the mobile UI. + +- [ ] **Step 4: Run the targeted transaction integration tests** + +Run: `dotnet test EstateReportingAPI.IntegrationTests/EstateReportingAPI.IntegrationTests.csproj --filter TransactionsEndpoint` +Expected: the new recent-activity report tests pass alongside the existing transaction endpoint coverage. From c60099f171379056116688b042ced7404cec8878 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Tue, 7 Jul 2026 20:12:48 +0100 Subject: [PATCH 2/2] minor refactor from codacy warnings --- .../ReportingManager.cs | 136 +++++++++++------- 1 file changed, 86 insertions(+), 50 deletions(-) diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index 80c3ad0..58132d9 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -1313,15 +1313,42 @@ public async Task> GetRecentActiv using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - DateTime reportDate = request.Request.ReportDate.Date; - int pageNumber = request.Request.PageNumber > 0 ? request.Request.PageNumber : 1; - int pageSize = request.Request.PageSize > 0 ? request.Request.PageSize : 10; + RecentActivityReceiptRequestParameters parameters = BuildRecentActivityReceiptRequestParameters(request.Request); + IQueryable query = BuildRecentActivityReceiptQuery(context, parameters); + var totalCountResult = await ExecuteQuerySafeCount(query, cancellationToken, "Error counting recent activity receipt report rows"); + if (totalCountResult.IsFailed) + return ResultHelpers.CreateFailure(totalCountResult); + + int totalCount = totalCountResult.Data; + if (totalCount == 0) + return Result.Success(BuildEmptyRecentActivityReceiptReportResponse(parameters)); + + var pageQuery = query.Skip((parameters.PageNumber - 1) * parameters.PageSize).Take(parameters.PageSize); + var pageResult = await ExecuteQuerySafeToList(pageQuery, cancellationToken, "Error retrieving recent activity receipt report"); + if (pageResult.IsFailed) + return ResultHelpers.CreateFailure(pageResult); + + return Result.Success(BuildRecentActivityReceiptReportResponse(parameters, totalCount, pageResult.Data)); + } + + private static RecentActivityReceiptRequestParameters BuildRecentActivityReceiptRequestParameters(GetRecentActivityReceiptReportRequest request) { + return new RecentActivityReceiptRequestParameters { + ReportDate = request.ReportDate.Date, + MerchantReportingId = request.MerchantReportingId, + SearchText = request.SearchText?.Trim(), + PageNumber = request.PageNumber > 0 ? request.PageNumber : 1, + PageSize = request.PageSize > 0 ? request.PageSize : 10 + }; + } + + private static IQueryable BuildRecentActivityReceiptQuery(EstateManagementContext context, + RecentActivityReceiptRequestParameters parameters) { IQueryable query = from t in context.Transactions join m in context.Merchants on t.MerchantId equals m.MerchantId 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 == reportDate + where t.TransactionDate == parameters.ReportDate select new RecentActivityReceiptQueryResult { TransactionDateTime = t.TransactionDateTime, MerchantReportingId = m.MerchantReportingId, @@ -1334,58 +1361,59 @@ join o in context.Operators on t.OperatorId equals o.OperatorId ReceiptReference = t.TransactionReference }; - if (request.Request.MerchantReportingId.HasValue) - query = query.Where(q => q.MerchantReportingId == request.Request.MerchantReportingId.Value); - - if (string.IsNullOrWhiteSpace(request.Request.SearchText) == false) { - string searchText = request.Request.SearchText.Trim(); - query = query.Where(q => - q.Reference.Contains(searchText) || - q.TransactionType.Contains(searchText) || - q.Product.Contains(searchText) || - q.Operator.Contains(searchText) || - q.Status.Contains(searchText) || - q.ReceiptReference.Contains(searchText)); - } + return ApplyRecentActivityReceiptFilters(query, parameters).OrderByDescending(q => q.TransactionDateTime); + } - query = query.OrderByDescending(q => q.TransactionDateTime); + private static IQueryable ApplyRecentActivityReceiptFilters(IQueryable query, + RecentActivityReceiptRequestParameters parameters) { + if (parameters.MerchantReportingId.HasValue) + query = query.Where(q => q.MerchantReportingId == parameters.MerchantReportingId.Value); - var totalCountResult = await ExecuteQuerySafeCount(query, cancellationToken, "Error counting recent activity receipt report rows"); - if (totalCountResult.IsFailed) - return ResultHelpers.CreateFailure(totalCountResult); + if (string.IsNullOrWhiteSpace(parameters.SearchText) == false) + query = query.Where(q => + q.Reference.Contains(parameters.SearchText) || + q.TransactionType.Contains(parameters.SearchText) || + q.Product.Contains(parameters.SearchText) || + q.Operator.Contains(parameters.SearchText) || + q.Status.Contains(parameters.SearchText) || + q.ReceiptReference.Contains(parameters.SearchText)); - int totalCount = totalCountResult.Data; - if (totalCount == 0) { - return Result.Success(new GetRecentActivityReceiptReportResponse { - ReportDate = reportDate, - PageNumber = pageNumber, - PageSize = pageSize, - TotalCount = 0, - Items = [] - }); - } + return query; + } - var pageQuery = query.Skip((pageNumber - 1) * pageSize).Take(pageSize); - var pageResult = await ExecuteQuerySafeToList(pageQuery, cancellationToken, "Error retrieving recent activity receipt report"); - if (pageResult.IsFailed) - return ResultHelpers.CreateFailure(pageResult); + private static GetRecentActivityReceiptReportResponse BuildEmptyRecentActivityReceiptReportResponse(RecentActivityReceiptRequestParameters parameters) { + return new GetRecentActivityReceiptReportResponse { + ReportDate = parameters.ReportDate, + PageNumber = parameters.PageNumber, + PageSize = parameters.PageSize, + TotalCount = 0, + Items = [] + }; + } - return Result.Success(new GetRecentActivityReceiptReportResponse { - ReportDate = reportDate, - PageNumber = pageNumber, - PageSize = pageSize, + private static GetRecentActivityReceiptReportResponse BuildRecentActivityReceiptReportResponse(RecentActivityReceiptRequestParameters parameters, + int totalCount, + List queryResults) { + return new GetRecentActivityReceiptReportResponse { + ReportDate = parameters.ReportDate, + PageNumber = parameters.PageNumber, + PageSize = parameters.PageSize, TotalCount = totalCount, - Items = pageResult.Data.Select(item => new RecentActivityReceiptItemDto { - Reference = item.Reference, - TransactionType = item.TransactionType, - Product = item.Product, - Operator = item.Operator, - Status = item.Status, - Amount = item.Amount, - TransactionDateTime = item.TransactionDateTime, - ReceiptReference = item.ReceiptReference - }).ToList() - }); + Items = queryResults.Select(MapRecentActivityReceiptItem).ToList() + }; + } + + private static RecentActivityReceiptItemDto MapRecentActivityReceiptItem(RecentActivityReceiptQueryResult item) { + return new RecentActivityReceiptItemDto { + Reference = item.Reference, + TransactionType = item.TransactionType, + Product = item.Product, + Operator = item.Operator, + Status = item.Status, + Amount = item.Amount, + TransactionDateTime = item.TransactionDateTime, + ReceiptReference = item.ReceiptReference + }; } private static IQueryable BuildProductPerformanceQuery(EstateManagementContext context, @@ -2133,6 +2161,14 @@ private sealed class RecentActivityReceiptQueryResult { public string? ReceiptReference { get; init; } } + private sealed class RecentActivityReceiptRequestParameters { + public DateTime ReportDate { get; init; } + public int? MerchantReportingId { get; init; } + public string? SearchText { get; init; } + public int PageNumber { get; init; } + public int PageSize { get; init; } + } + private sealed class TransactionDetailQueryResult { public Guid TransactionId { get; init; } public DateTime TransactionDateTime { get; init; }