Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public record TransactionDetailReportQuery(Guid EstateId, TransactionDetailRepor
public record TransactionSummaryByMerchantQuery(Guid EstateId, TransactionSummaryByMerchantRequest Request) : IRequest<Result<TransactionSummaryByMerchantResponse>>;
public record TransactionSummaryByOperatorQuery(Guid EstateId, TransactionSummaryByOperatorRequest Request) : IRequest<Result<TransactionSummaryByOperatorResponse>>;
public record ProductPerformanceQuery(Guid EstateId, DateTime StartDate, DateTime EndDate) : IRequest<Result<ProductPerformanceResponse>>;
public record TransactionMixSummaryQuery(Guid EstateId, TransactionMixSummaryRequest Request) : IRequest<Result<TransactionMixSummaryResponse>>;
public record TodaysSalesByHour(Guid estateId, DateTime comparisonDate) : IRequest<Result<List<Models.TodaysSalesByHour>>>;
public record MerchantDailyPerformanceSummaryQuery(Guid EstateId, MerchantDailyPerformanceSummaryRequest Request) : IRequest<Result<MerchantDailyPerformanceSummaryResponse>>;
}
126 changes: 126 additions & 0 deletions EstateReportingAPI.BusinessLogic/ReportingManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Task<Result<TransactionSummaryByOperatorResponse>> GetTransactionSummaryByOperat
CancellationToken cancellationToken);
Task<Result<ProductPerformanceResponse>> GetProductPerformanceReport(TransactionQueries.ProductPerformanceQuery request,
CancellationToken cancellationToken);
Task<Result<TransactionMixSummaryResponse>> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request,
CancellationToken cancellationToken);

Task<Result<List<TodaysSalesByHour>>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request,
CancellationToken cancellationToken);
Expand Down Expand Up @@ -1241,6 +1243,56 @@ public async Task<Result<ProductPerformanceResponse>> GetProductPerformanceRepor
return Result.Success(BuildProductPerformanceResponse(queryResults));
}

public async Task<Result<TransactionMixSummaryResponse>> GetTransactionMixSummary(TransactionQueries.TransactionMixSummaryQuery request,
CancellationToken cancellationToken)
{
using ResolvedDbContext<EstateManagementContext>? 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<int> { 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<ProductPerformanceItemData> BuildProductPerformanceQuery(EstateManagementContext context,
DateTime startDate,
DateTime endDate,
Expand Down Expand Up @@ -1279,6 +1331,80 @@ private ProductPerformanceResponse BuildProductPerformanceResponse(List<ProductP
};
}

private static TransactionMixSummaryResponse BuildTransactionMixSummaryResponse(List<TransactionDetailQueryResult> 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<Result<List<TodaysSalesByHour>>> GetTodaysSalesByHour(TransactionQueries.TodaysSalesByHour request,
CancellationToken cancellationToken) {
using ResolvedDbContext<EstateManagementContext>? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.estateId.ToString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public class TransactionRequestHandler : IRequestHandler<TransactionQueries.Toda
IRequestHandler<TransactionQueries.TransactionSummaryByMerchantQuery, Result<TransactionSummaryByMerchantResponse>>,
IRequestHandler<TransactionQueries.TransactionSummaryByOperatorQuery, Result<TransactionSummaryByOperatorResponse>>,
IRequestHandler<TransactionQueries.ProductPerformanceQuery, Result<ProductPerformanceResponse>>,
IRequestHandler<TransactionQueries.TransactionMixSummaryQuery, Result<TransactionMixSummaryResponse>>,
IRequestHandler<TransactionQueries.TodaysSalesByHour, Result<List<TodaysSalesByHour>>>,
IRequestHandler<TransactionQueries.MerchantDailyPerformanceSummaryQuery, Result<MerchantDailyPerformanceSummaryResponse>>

Expand Down Expand Up @@ -53,6 +54,12 @@ public async Task<Result<ProductPerformanceResponse>> Handle(TransactionQueries.
return await this.Manager.GetProductPerformanceReport(request, cancellationToken);
}

public async Task<Result<TransactionMixSummaryResponse>> Handle(TransactionQueries.TransactionMixSummaryQuery request,
CancellationToken cancellationToken)
{
return await this.Manager.GetTransactionMixSummary(request, cancellationToken);
}

public async Task<Result<List<TodaysSalesByHour>>> Handle(TransactionQueries.TodaysSalesByHour request,
CancellationToken cancellationToken) {
return await this.Manager.GetTodaysSalesByHour(request, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<TransactionMixSummaryGroup> Groups { get; set; } = [];
public List<TransactionMixSummaryTransaction> 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; }
}
Original file line number Diff line number Diff line change
@@ -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<Transaction>();
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<TransactionMixSummaryResponse>($"{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);
}
}
Loading
Loading