diff --git a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs index ac97904..9adb3d5 100644 --- a/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs +++ b/EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs @@ -13,6 +13,7 @@ public record TransactionDetailReportQuery(Guid EstateId, TransactionDetailRepor 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 TransactionMixSummaryQuery(Guid EstateId, TransactionMixSummaryRequest 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 bc3a79a..4e98e40 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -50,6 +50,8 @@ Task> GetTransactionSummaryByOperat CancellationToken cancellationToken); Task> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request, CancellationToken cancellationToken); + Task> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request, + CancellationToken cancellationToken); Task>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request, CancellationToken cancellationToken); @@ -1241,6 +1243,56 @@ public async Task> GetProductPerformanceRepor return Result.Success(BuildProductPerformanceResponse(queryResults)); } + public async Task> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request, + CancellationToken cancellationToken) + { + using ResolvedDbContext? resolvedContext = this.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."); + + if (!Enum.IsDefined(typeof(TransactionMixBreakdown), request.Request.Breakdown)) + return Result.Failure("Unsupported transaction mix breakdown."); + + if (!Enum.IsDefined(typeof(TransactionMixMeasure), request.Request.Measure)) + return Result.Failure("Unsupported transaction mix measure."); + + var detailRequest = new TransactionDetailReportRequest + { + Merchants = request.Request.MerchantReportingId.HasValue ? new List { request.Request.MerchantReportingId.Value } : [], + Operators = [], + Products = [], + StartDate = request.Request.StartDate, + EndDate = request.Request.EndDate + }; + + var query = ApplyTransactionDetailFilters(BuildTransactionDetailBaseQuery(context, detailRequest), detailRequest); + var queryResult = await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving transaction mix summary report"); + + if (queryResult.IsFailed) + return ResultHelpers.CreateFailure(queryResult); + + var queryResults = queryResult.Data; + + if (queryResults.Any() == false) + { + return Result.Success(new TransactionMixSummaryResponse + { + FromDate = request.Request.StartDate, + ToDate = request.Request.EndDate, + Breakdown = request.Request.Breakdown, + Measure = request.Request.Measure, + TotalCount = 0, + TotalValue = 0m, + Groups = [], + Transactions = [] + }); + } + + return Result.Success(BuildTransactionMixSummaryResponse(queryResults, request.Request)); + } + private static IQueryable BuildProductPerformanceQuery(EstateManagementContext context, DateTime startDate, DateTime endDate, @@ -1279,6 +1331,80 @@ private ProductPerformanceResponse BuildProductPerformanceResponse(List queryResults, + TransactionMixSummaryRequest request) + { + 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 new TransactionMixSummaryResponse + { + FromDate = request.StartDate, + ToDate = request.EndDate, + Breakdown = request.Breakdown, + Measure = request.Measure, + TotalCount = queryResults.Count, + TotalValue = queryResults.Sum(q => q.Value), + Groups = orderedGroups.Take(topN).ToList(), + Transactions = queryResults + .OrderByDescending(q => q.TransactionDateTime) + .Select(q => new TransactionMixSummaryTransaction + { + Id = q.TransactionId, + DateTime = q.TransactionDateTime, + Merchant = q.MerchantName, + MerchantId = q.MerchantId, + MerchantReportingId = q.MerchantReportingId, + Operator = q.OperatorName, + OperatorId = q.OperatorId, + OperatorReportingId = q.OperatorReportingId, + Product = q.ProductName, + ProductId = q.ContractProductId, + ProductReportingId = q.ContractProductReportingId, + Type = q.TransactionType, + Status = q.Status, + Value = q.Value, + TotalFees = q.FeeValue, + SettlementReference = q.SettlementId == Guid.Empty ? null : q.SettlementId.ToString(), + TransactionNumber = q.TransactionNumber + }) + .ToList() + }; + } + public async Task>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request, CancellationToken cancellationToken) { using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.estateId.ToString()); diff --git a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs index a94f2dc..f915b06 100644 --- a/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs +++ b/EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs @@ -11,6 +11,7 @@ public class TransactionRequestHandler : IRequestHandler>, IRequestHandler>, IRequestHandler>, + IRequestHandler>, IRequestHandler>>, IRequestHandler> @@ -53,6 +54,12 @@ public async Task> Handle(TransactionQueries. return await this.Manager.GetProductPerformanceReport(request, cancellationToken); } + public async Task> Handle(TransactionQueries.TransactionMixSummaryQuery request, + CancellationToken cancellationToken) + { + return await this.Manager.GetTransactionMixSummary(request, cancellationToken); + } + public async Task>> Handle(TransactionQueries.TodaysSalesByHour request, CancellationToken cancellationToken) { return await this.Manager.GetTodaysSalesByHour(request, cancellationToken); diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs new file mode 100644 index 0000000..f89a212 --- /dev/null +++ b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs @@ -0,0 +1,30 @@ +using System; +using System.Text.Json.Serialization; + +namespace EstateReportingAPI.DataTransferObjects; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TransactionMixBreakdown +{ + Product, + TransactionType, + Operator, + Status +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TransactionMixMeasure +{ + Count, + Value +} + +public class TransactionMixSummaryRequest +{ + public int? MerchantReportingId { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public TransactionMixBreakdown Breakdown { get; set; } + public TransactionMixMeasure Measure { get; set; } + public int TopN { get; set; } = 5; +} diff --git a/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs new file mode 100644 index 0000000..2109cc4 --- /dev/null +++ b/EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace EstateReportingAPI.DataTransferObjects; + +public class TransactionMixSummaryResponse +{ + public DateTime FromDate { get; set; } + public DateTime ToDate { get; set; } + public TransactionMixBreakdown Breakdown { get; set; } + public TransactionMixMeasure Measure { get; set; } + public int TotalCount { get; set; } + public decimal TotalValue { get; set; } + public List Groups { get; set; } = []; + public List Transactions { get; set; } = []; +} + +public class TransactionMixSummaryGroup +{ + public string? GroupKey { get; set; } + public string? GroupName { get; set; } + public int TransactionCount { get; set; } + public decimal TransactionValue { get; set; } +} + +public class TransactionMixSummaryTransaction +{ + public Guid Id { get; set; } + public DateTime DateTime { get; set; } + public string? Merchant { get; set; } + public Guid MerchantId { get; set; } + public int MerchantReportingId { get; set; } + public string? Operator { get; set; } + public Guid OperatorId { get; set; } + public int OperatorReportingId { get; set; } + public string? Product { get; set; } + public Guid ProductId { get; set; } + public int ProductReportingId { get; set; } + public string? Type { get; set; } + public string? Status { get; set; } + public decimal Value { get; set; } + public decimal TotalFees { get; set; } + public string? SettlementReference { get; set; } + public int TransactionNumber { get; set; } +} diff --git a/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs b/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs new file mode 100644 index 0000000..368a931 --- /dev/null +++ b/EstateReportingAPI.IntegrationTests/TransactionMixSummaryEndpointTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using EstateReportingAPI.DataTransferObjects; +using Shared.Serialisation; +using Shouldly; +using TransactionProcessor.Database.Entities; +using Xunit; + +namespace EstateReportingAPI.IntegrationTests; + +public class TransactionMixSummaryEndpointTests : ControllerTestsBase +{ + private const string BaseRoute = "api/transactions"; + + public TransactionMixSummaryEndpointTests(ITestOutputHelper testOutputHelper) + { + this.TestOutputHelper = testOutputHelper; + } + + protected override async Task ClearStandingData() + { + } + + protected override async Task SetupStandingData() + { + Stopwatch sw = Stopwatch.StartNew(); + this.TestOutputHelper.WriteLine("Setting up standing data"); + + await this.helper.AddEstate("Test Estate", "Ref1"); + await this.helper.AddOperator("Test Estate", "Safaricom"); + await this.helper.AddOperator("Test Estate", "Voucher"); + await this.helper.AddMerchant("Test Estate", "Test Merchant 1", 100, DateTime.MinValue, DateTime.MinValue, default, default); + + List<(string productName, int productType, decimal? value)> productList = new() + { + ("200 KES Topup", 0, 200.00m), + ("100 KES Topup", 0, 100.00m) + }; + + await this.helper.AddContractWithProducts("Test Estate", "Safaricom Contract", "Safaricom", productList); + await this.helper.AddResponseCode(0, "Success"); + await this.helper.AddResponseCode(1000, "Unknown Device"); + + this.merchantsList = this.context.Merchants.Select(m => m).ToList(); + this.contractList = this.context.Contracts.Join(this.context.Operators, c => c.OperatorId, o => o.OperatorId, (c, o) => new { c.ContractId, c.Description, o.OperatorId, o.Name }).ToList().Select(x => (x.ContractId, x.Description, x.OperatorId, x.Name)).ToList(); + + var query1 = this.context.Contracts.GroupJoin(this.context.ContractProducts, c => c.ContractId, cp => cp.ContractId, (c, productGroup) => new { c.ContractId, Products = productGroup.Select(p => new { p.ContractProductReportingId, p.ContractProductId, p.ProductName, p.Value }).OrderBy(p => p.ContractProductId).Select(p => new { p.ContractProductId, p.ProductName, p.Value, p.ContractProductReportingId }).ToList() }).ToList(); + this.contractProducts = query1.ToDictionary(item => item.ContractId, item => item.Products.Select(i => (i.ContractProductId, i.ProductName, i.Value, i.ContractProductReportingId)).ToList()); + this.operatorsList = this.context.Operators.ToList(); + } + + [Fact] + public async Task TransactionMixSummary_ProductBreakdown_ReturnsGroupedResults() + { + var merchant = this.merchantsList.Single(); + var contract = this.contractList.Single(); + var product = this.contractProducts.Single(x => x.Key == contract.contractId).Value.First(); + DateTime startDate = DateTime.Now.Date.AddDays(-2); + DateTime endDate = DateTime.Now.Date; + + var transactions = new List(); + for (int i = 0; i < 3; i++) + { + transactions.Add(await this.helper.BuildTransactionX(DateTime.Now.Date, merchant.MerchantId, contract.operatorId, contract.contractId, product.productId, i == 2 ? "1000" : "0000", product.productValue)); + } + + await this.helper.AddTransactionsX(transactions); + + var request = new TransactionMixSummaryRequest + { + MerchantReportingId = 1, + StartDate = startDate, + EndDate = endDate, + Breakdown = TransactionMixBreakdown.Product, + Measure = TransactionMixMeasure.Count, + TopN = 5 + }; + + String payload = StringSerialiser.Serialise(request); + var result = await this.CreateAndSendHttpRequestMessage($"{BaseRoute}/transactionmixsummary", payload, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ShouldNotBeNull(); + result.Data.TotalCount.ShouldBe(3); + result.Data.Groups.Count.ShouldBe(1); + result.Data.Groups[0].TransactionCount.ShouldBe(3); + } + + [Fact] + public async Task TransactionMixSummary_InvalidDateRange_ReturnsBadRequest() + { + var request = new TransactionMixSummaryRequest + { + MerchantReportingId = 1, + StartDate = DateTime.Now.Date, + EndDate = DateTime.Now.Date.AddDays(-1), + Breakdown = TransactionMixBreakdown.Operator, + Measure = TransactionMixMeasure.Value, + TopN = 5 + }; + + String payload = StringSerialiser.Serialise(request); + HttpRequestMessage requestMessage = new(HttpMethod.Post, $"{BaseRoute}/transactionmixsummary"); + requestMessage.Headers.Add("estateId", this.TestId.ToString()); + requestMessage.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Test"); + requestMessage.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await this.Client.SendAsync(requestMessage, CancellationToken.None); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } +} diff --git a/EstateReportingAPI.Models/TransactionMixSummaryRequest.cs b/EstateReportingAPI.Models/TransactionMixSummaryRequest.cs new file mode 100644 index 0000000..606a11d --- /dev/null +++ b/EstateReportingAPI.Models/TransactionMixSummaryRequest.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; + +namespace EstateReportingAPI.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TransactionMixBreakdown +{ + Product, + TransactionType, + Operator, + Status +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TransactionMixMeasure +{ + Count, + Value +} + +public class TransactionMixSummaryRequest +{ + public int? MerchantReportingId { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public TransactionMixBreakdown Breakdown { get; set; } + public TransactionMixMeasure Measure { get; set; } + public int TopN { get; set; } = 5; +} diff --git a/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs b/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs new file mode 100644 index 0000000..fbc6c91 --- /dev/null +++ b/EstateReportingAPI.Models/TransactionMixSummaryResponse.cs @@ -0,0 +1,42 @@ +namespace EstateReportingAPI.Models; + +public class TransactionMixSummaryResponse +{ + public DateTime FromDate { get; set; } + public DateTime ToDate { get; set; } + public TransactionMixBreakdown Breakdown { get; set; } + public TransactionMixMeasure Measure { get; set; } + public int TotalCount { get; set; } + public decimal TotalValue { get; set; } + public List Groups { get; set; } = []; + public List Transactions { get; set; } = []; +} + +public class TransactionMixSummaryGroup +{ + public string? GroupKey { get; set; } + public string? GroupName { get; set; } + public int TransactionCount { get; set; } + public decimal TransactionValue { get; set; } +} + +public class TransactionMixSummaryTransaction +{ + public Guid Id { get; set; } + public DateTime DateTime { get; set; } + public string? Merchant { get; set; } + public Guid MerchantId { get; set; } + public int MerchantReportingId { get; set; } + public string? Operator { get; set; } + public Guid OperatorId { get; set; } + public int OperatorReportingId { get; set; } + public string? Product { get; set; } + public Guid ProductId { get; set; } + public int ProductReportingId { get; set; } + public string? Type { get; set; } + public string? Status { get; set; } + public decimal Value { get; set; } + public decimal TotalFees { get; set; } + public string? SettlementReference { get; set; } + public int TransactionNumber { get; set; } +} diff --git a/EstateReportingAPI/Endpoints/TransactionEndpoints.cs b/EstateReportingAPI/Endpoints/TransactionEndpoints.cs index 4ecc736..58d2647 100644 --- a/EstateReportingAPI/Endpoints/TransactionEndpoints.cs +++ b/EstateReportingAPI/Endpoints/TransactionEndpoints.cs @@ -30,6 +30,8 @@ public static void MapTransactionEndpoints(this IEndpointRouteBuilder app) .WithStandardProduces(); group.MapGet("productperformancereport", TransactionHandler.ProductPerformanceReport) .WithStandardProduces(); + group.MapPost("transactionmixsummary", TransactionHandler.TransactionMixSummary) + .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 7ec3e19..49bdcfc 100644 --- a/EstateReportingAPI/Handlers/TransactionHandler.cs +++ b/EstateReportingAPI/Handlers/TransactionHandler.cs @@ -185,6 +185,68 @@ TransactionSummaryByOperatorResponse SuccessFactory(Models.TransactionSummaryByO return ResponseFactory.FromResult(result, SuccessFactory); } + public static async Task TransactionMixSummary([FromHeader] Guid estateId, + [FromBody] TransactionMixSummaryRequest request, + IMediator mediator, + CancellationToken cancellationToken) + { + if (request.StartDate > request.EndDate) + return Results.BadRequest("StartDate must be less than or equal to EndDate."); + + Models.TransactionMixSummaryRequest queryRequest = new() + { + MerchantReportingId = request.MerchantReportingId, + StartDate = request.StartDate, + EndDate = request.EndDate, + Breakdown = (Models.TransactionMixBreakdown)request.Breakdown, + Measure = (Models.TransactionMixMeasure)request.Measure, + TopN = request.TopN + }; + + var query = new TransactionQueries.TransactionMixSummaryQuery(estateId, queryRequest); + var result = await mediator.Send(query, cancellationToken); + + TransactionMixSummaryResponse SuccessFactory(Models.TransactionMixSummaryResponse r) => + new TransactionMixSummaryResponse + { + FromDate = r.FromDate, + ToDate = r.ToDate, + Breakdown = (TransactionMixBreakdown)r.Breakdown, + Measure = (TransactionMixMeasure)r.Measure, + TotalCount = r.TotalCount, + TotalValue = r.TotalValue, + Groups = r.Groups.Select(g => new TransactionMixSummaryGroup + { + GroupKey = g.GroupKey, + GroupName = g.GroupName, + TransactionCount = g.TransactionCount, + TransactionValue = g.TransactionValue + }).ToList(), + Transactions = r.Transactions.Select(t => new TransactionMixSummaryTransaction + { + Id = t.Id, + DateTime = t.DateTime, + Merchant = t.Merchant, + MerchantId = t.MerchantId, + MerchantReportingId = t.MerchantReportingId, + Operator = t.Operator, + OperatorId = t.OperatorId, + OperatorReportingId = t.OperatorReportingId, + Product = t.Product, + ProductId = t.ProductId, + ProductReportingId = t.ProductReportingId, + Type = t.Type, + Status = t.Status, + Value = t.Value, + TotalFees = t.TotalFees, + SettlementReference = t.SettlementReference, + TransactionNumber = t.TransactionNumber + }).ToList() + }; + + return ResponseFactory.FromResult(result, SuccessFactory); + } + public static async Task TodaysSalesByHour([FromHeader] Guid estateId, [FromQuery] DateTime comparisonDate, IMediator mediator, diff --git a/docs/superpowers/plans/2026-07-04-transaction-mix-report.md b/docs/superpowers/plans/2026-07-04-transaction-mix-report.md new file mode 100644 index 0000000..4f309da --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-transaction-mix-report.md @@ -0,0 +1,143 @@ +# Transaction Mix 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 merchant-scoped transaction mix reporting endpoint that can break down activity by product, transaction type, operator, and status over a date range. + +**Architecture:** Extend the existing `api/transactions` reporting surface so the new endpoint follows the same Minimal API, MediatR, and `ReportingManager` flow as the current transaction reports. Keep the request/response shapes mirrored in `Models` and `DataTrasferObjects`, and implement the aggregation in `ReportingManager` so the endpoint stays thin. + +**Tech Stack:** ASP.NET Core Minimal APIs, MediatR, Entity Framework Core, xUnit integration tests. + +--- + +### Task 1: Add the new transaction mix contract + +**Files:** +- Create: `EstateReportingAPI.Models/TransactionMixSummaryRequest.cs` +- Create: `EstateReportingAPI.Models/TransactionMixSummaryResponse.cs` +- Create: `EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryRequest.cs` +- Create: `EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryResponse.cs` +- Create: `EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryGroup.cs` +- Create: `EstateReportingAPI.DataTrasferObjects/TransactionMixSummaryTransaction.cs` + +- [ ] **Step 1: Add the failing model references** + +```csharp +public enum TransactionMixBreakdown +{ + Product, + TransactionType, + Operator, + Status +} + +public enum TransactionMixMeasure +{ + Count, + Value +} +``` + +- [ ] **Step 2: Run a build that fails until the new types exist** + +Run: `dotnet build` +Expected: compile errors for the missing transaction mix types + +- [ ] **Step 3: Add the minimal DTO/model implementations** + +```csharp +public sealed class TransactionMixSummaryRequest +{ + public int? MerchantReportingId { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + public TransactionMixBreakdown Breakdown { get; set; } + public TransactionMixMeasure Measure { get; set; } + public int TopN { get; set; } +} +``` + +- [ ] **Step 4: Rebuild to verify the contract compiles** + +Run: `dotnet build` +Expected: success + +### Task 2: Wire the endpoint and query handler + +**Files:** +- Modify: `EstateReportingAPI/Endpoints/TransactionEndpoints.cs` +- Modify: `EstateReportingAPI/Handlers/TransactionHandler.cs` +- Modify: `EstateReportingAPI.BusinessLogic/Queries/TransactionQueries.cs` +- Modify: `EstateReportingAPI.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs` + +- [ ] **Step 1: Add a failing endpoint test case** + +Use the integration test suite to call `POST /api/transactions/transactionmixsummary` and assert the route is missing or unhandled before implementation. + +- [ ] **Step 2: Add the endpoint and MediatR query** + +```csharp +group.MapPost("transactionmixsummary", TransactionHandler.TransactionMixSummary) + .WithStandardProduces(); + +public record TransactionMixSummaryQuery(Guid EstateId, TransactionMixSummaryRequest Request) + : IRequest>; +``` + +- [ ] **Step 3: Add the request handler forwarding method** + +```csharp +public async Task> Handle(TransactionMixSummaryQuery request, CancellationToken cancellationToken) +{ + return await this.Manager.GetTransactionMixSummary(request, cancellationToken); +} +``` + +- [ ] **Step 4: Rebuild and confirm the new route compiles** + +Run: `dotnet build` +Expected: success + +### Task 3: Implement the aggregation in `ReportingManager` + +**Files:** +- Modify: `EstateReportingAPI.BusinessLogic/ReportingManager.cs` + +- [ ] **Step 1: Add a failing integration path** + +Add a test that seeds transactions and expects grouped counts/values by at least one breakdown dimension. + +- [ ] **Step 2: Implement the report query and projection** + +Use the existing `Transactions` set, filter by date range and merchant, then group by the selected breakdown with count/value totals and drill-down rows. + +- [ ] **Step 3: Return empty collections and validation failures cleanly** + +Reject invalid date ranges and unsupported measure/breakdown combinations; return empty collections when the query yields no rows. + +- [ ] **Step 4: Rebuild and run the targeted reporting tests** + +Run: `dotnet test EstateReportingAPI.IntegrationTests/EstateReportingAPI.IntegrationTests.csproj --filter TransactionsEndpoint` +Expected: pass + +### Task 4: Cover the endpoint with integration tests + +**Files:** +- Modify: `EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs` + +- [ ] **Step 1: Add success coverage** + +Verify product, operator, transaction type, and status breakdowns return the expected totals. + +- [ ] **Step 2: Add top-N and empty-result coverage** + +Verify the response respects `TopN` and returns empty collections when there is no matching data. + +- [ ] **Step 3: Add validation coverage** + +Verify invalid date ranges are rejected. + +- [ ] **Step 4: Run the full integration test slice** + +Run: `dotnet test EstateReportingAPI.IntegrationTests/EstateReportingAPI.IntegrationTests.csproj --filter TransactionsEndpoint` +Expected: pass