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 @@ -14,4 +14,5 @@ public record TransactionSummaryByMerchantQuery(Guid EstateId, TransactionSummar
public record TransactionSummaryByOperatorQuery(Guid EstateId, TransactionSummaryByOperatorRequest Request) : IRequest<Result<TransactionSummaryByOperatorResponse>>;
public record ProductPerformanceQuery(Guid EstateId, DateTime StartDate, DateTime EndDate) : IRequest<Result<ProductPerformanceResponse>>;
public record TodaysSalesByHour(Guid estateId, DateTime comparisonDate) : IRequest<Result<List<Models.TodaysSalesByHour>>>;
public record MerchantDailyPerformanceSummaryQuery(Guid EstateId, MerchantDailyPerformanceSummaryRequest Request) : IRequest<Result<MerchantDailyPerformanceSummaryResponse>>;
}
189 changes: 189 additions & 0 deletions EstateReportingAPI.BusinessLogic/ReportingManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ Task<Result<FileImportLog>> GetFileImportLog(FileImportLogQueries.GetFileImportL
CancellationToken cancellationToken);
Task<Result<List<FileProfileConfiguration>>> GetFileProfileConfigurationList(FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery request,
CancellationToken cancellationToken);

Task<Result<MerchantDailyPerformanceSummaryResponse>> GetMerchantDailyPerformanceSummary(TransactionQueries.MerchantDailyPerformanceSummaryQuery request,
CancellationToken cancellationToken);
}

public class ReportingManager : IReportingManager {
Expand Down Expand Up @@ -1550,6 +1553,36 @@ join o in context.Operators on c.OperatorId equals o.OperatorId
return Result.Success(result.Data);
}

public async Task<Result<MerchantDailyPerformanceSummaryResponse>> GetMerchantDailyPerformanceSummary(TransactionQueries.MerchantDailyPerformanceSummaryQuery request,
CancellationToken cancellationToken) {
using ResolvedDbContext<EstateManagementContext>? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString());
await using EstateManagementContext context = resolvedContext.Context;

DateTime startDate = request.Request.StartDate.Date;
DateTime endDate = request.Request.EndDate.Date;
int dayCount = Math.Max((endDate - startDate).Days + 1, 1);

var groupedTransactionsResult = await LoadMerchantDailyPerformanceGroups(context, request, startDate, endDate, cancellationToken);
if (groupedTransactionsResult.IsFailed)
return ResultHelpers.CreateFailure(groupedTransactionsResult);

var groupedTransactions = groupedTransactionsResult.Data;
if (groupedTransactions.Any() == false) {
return Result.Success(BuildEmptyMerchantDailyPerformanceSummaryResponse());
}

List<MetricItem> metrics = BuildMerchantDailyPerformanceMetrics(groupedTransactions, dayCount);

var recentSalesResult = await LoadMerchantDailyPerformanceRecentSales(context, request, startDate, endDate, cancellationToken);
if (recentSalesResult.IsFailed)
return ResultHelpers.CreateFailure(recentSalesResult);

return Result.Success(new MerchantDailyPerformanceSummaryResponse {
Metrics = metrics,
DrillDownTransactions = MapMerchantDailyPerformanceRecentSales(recentSalesResult.Data)
});
}

private async Task<DatabaseProjections.SettlementGroupProjection> GetSettlementSummary(IQueryable<DatabaseProjections.ComparisonSettlementTransactionProjection> query,
CancellationToken cancellationToken) {
// Get the settleed fees summary
Expand Down Expand Up @@ -1711,6 +1744,162 @@ private sealed class MerchantTransactionFinalProjection {
public decimal AuthorisedPercentage { get; init; }
}

private sealed class MerchantDailyPerformanceGroupProjection {
public int ContractProductReportingId { get; init; }
public string? ProductName { get; init; }
public bool IsAuthorised { get; init; }
public int SalesCount { get; init; }
public decimal SalesValue { get; init; }
}

private sealed class MerchantDailyPerformanceRecentSaleProjection {
public string Reference { get; init; }
public string? Product { get; init; }
public string Status { get; init; }
public decimal Amount { get; init; }
public DateTime TransactionDateTime { get; init; }
}

private static MerchantDailyPerformanceSummaryResponse BuildEmptyMerchantDailyPerformanceSummaryResponse() {
return new MerchantDailyPerformanceSummaryResponse {
Metrics = BuildMerchantDailyPerformanceBaseMetrics()
};
}

private static List<MetricItem> BuildMerchantDailyPerformanceBaseMetrics() {
return new List<MetricItem> {
new() { Title = "Total Sales Count", Value = 0, Description = "All sales transactions in the range", Category = 0 },
new() { Title = "Total Sales Value", Value = 0, Description = "All sales value in the range", Category = 1 },
new() { Title = "Successful Sales Count", Value = 0, Description = "Authorised sales count in the range", Category = 2 },
new() { Title = "Successful Sales Value", Value = 0, Description = "Authorised sales value in the range", Category = 3 },
new() { Title = "Failed Sales Count", Value = 0, Description = "Declined sales count in the range", Category = 4 },
new() { Title = "Failed Sales Value", Value = 0, Description = "Declined sales value in the range", Category = 5 },
new() { Title = "Average Sales Count", Value = 0, Description = "Average sales count per day in the range", Category = 6 },
new() { Title = "Average Sales Value", Value = 0, Description = "Average value per sale in the range", Category = 7 }
};
}

private static List<MetricItem> BuildMerchantDailyPerformanceMetrics(List<MerchantDailyPerformanceGroupProjection> groupedTransactions,
int dayCount) {
int totalSalesCount = groupedTransactions.Sum(x => x.SalesCount);
decimal totalSalesValue = groupedTransactions.Sum(x => x.SalesValue);
int successfulSalesCount = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesCount);
decimal successfulSalesValue = groupedTransactions.Where(x => x.IsAuthorised).Sum(x => x.SalesValue);
int failedSalesCount = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesCount);
decimal failedSalesValue = groupedTransactions.Where(x => !x.IsAuthorised).Sum(x => x.SalesValue);
decimal averageSalesCount = (decimal)totalSalesCount / dayCount;
decimal averageSalesValue = totalSalesCount == 0 ? 0m : totalSalesValue / totalSalesCount;

List<MetricItem> metrics = new() {
new() { Title = "Total Sales Count", Value = totalSalesCount, Description = "All sales transactions in the range", Category = 0 },
new() { Title = "Total Sales Value", Value = totalSalesValue, Description = "All sales value in the range", Category = 1 },
new() { Title = "Successful Sales Count", Value = successfulSalesCount, Description = "Authorised sales count in the range", Category = 2 },
new() { Title = "Successful Sales Value", Value = successfulSalesValue, Description = "Authorised sales value in the range", Category = 3 },
new() { Title = "Failed Sales Count", Value = failedSalesCount, Description = "Declined sales count in the range", Category = 4 },
new() { Title = "Failed Sales Value", Value = failedSalesValue, Description = "Declined sales value in the range", Category = 5 },
new() { Title = "Average Sales Count", Value = averageSalesCount, Description = "Average sales count per day in the range", Category = 6 },
new() { Title = "Average Sales Value", Value = averageSalesValue, Description = "Average value per sale in the range", Category = 7 }
};

MetricItem? topProductMetric = BuildTopProductMetric(groupedTransactions);
if (topProductMetric != null)
metrics.Add(topProductMetric);

return metrics;
}

private static MetricItem? BuildTopProductMetric(List<MerchantDailyPerformanceGroupProjection> groupedTransactions) {
var topProduct = groupedTransactions
.GroupBy(x => new { x.ContractProductReportingId, x.ProductName })
.Select(g => new {
g.Key.ContractProductReportingId,
g.Key.ProductName,
SalesCount = g.Sum(x => x.SalesCount),
SalesValue = g.Sum(x => x.SalesValue)
})
.OrderByDescending(x => x.SalesCount)
.ThenBy(x => x.ProductName)
.FirstOrDefault();

if (topProduct == null)
return null;

return new MetricItem {
Title = "Top Product Sales Count",
Value = topProduct.SalesCount,
Description = topProduct.ProductName ?? "Unknown product",
Category = 8
};
}

private async Task<Result<List<MerchantDailyPerformanceGroupProjection>>> LoadMerchantDailyPerformanceGroups(EstateManagementContext context,
TransactionQueries.MerchantDailyPerformanceSummaryQuery request,
DateTime startDate,
DateTime endDate,
CancellationToken cancellationToken) {
var query =
from t in context.Transactions
join m in context.Merchants on t.MerchantId equals m.MerchantId
join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId }
where t.TransactionType == "Sale"
&& t.TransactionDate >= startDate
&& t.TransactionDate <= endDate
&& m.MerchantReportingId == request.Request.MerchantReportingId
group t by new
{
cp.ContractProductReportingId,
cp.ProductName,
t.IsAuthorised
}
into g
select new MerchantDailyPerformanceGroupProjection
{
ContractProductReportingId = g.Key.ContractProductReportingId,
ProductName = g.Key.ProductName,
IsAuthorised = g.Key.IsAuthorised,
SalesCount = g.Count(),
SalesValue = g.Sum(x => x.TransactionAmount)
};

return await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving merchant daily performance summary");
}

private async Task<Result<List<MerchantDailyPerformanceRecentSaleProjection>>> LoadMerchantDailyPerformanceRecentSales(EstateManagementContext context,
TransactionQueries.MerchantDailyPerformanceSummaryQuery request,
DateTime startDate,
DateTime endDate,
CancellationToken cancellationToken) {
var query =
(from t in context.Transactions
join m in context.Merchants on t.MerchantId equals m.MerchantId
join cp in context.ContractProducts on new { t.ContractProductId, t.ContractId } equals new { cp.ContractProductId, cp.ContractId }
where t.TransactionType == "Sale"
&& t.TransactionDate >= startDate
&& t.TransactionDate <= endDate
&& m.MerchantReportingId == request.Request.MerchantReportingId
orderby t.TransactionDateTime descending
select new MerchantDailyPerformanceRecentSaleProjection
{
Reference = t.TransactionNumber,
Product = cp.ProductName,
Status = t.IsAuthorised ? "Successful" : "Failed",
Amount = t.TransactionAmount,
TransactionDateTime = t.TransactionDateTime
}).Take(5);

return await ExecuteQuerySafeToList(query, cancellationToken, "Error retrieving merchant recent sales");
}

private static List<DrillDownTransaction> MapMerchantDailyPerformanceRecentSales(List<MerchantDailyPerformanceRecentSaleProjection> recentSales) {
return recentSales.Select(x => new DrillDownTransaction {
Reference = x.Reference,
Product = x.Product,
Status = x.Status,
Amount = x.Amount,
TransactionDateTime = x.TransactionDateTime
}).ToList();
}

private sealed class TransactionDetailQueryResult {
public Guid TransactionId { get; init; }
public DateTime TransactionDateTime { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public class TransactionRequestHandler : IRequestHandler<TransactionQueries.Toda
IRequestHandler<TransactionQueries.TransactionSummaryByMerchantQuery, Result<TransactionSummaryByMerchantResponse>>,
IRequestHandler<TransactionQueries.TransactionSummaryByOperatorQuery, Result<TransactionSummaryByOperatorResponse>>,
IRequestHandler<TransactionQueries.ProductPerformanceQuery, Result<ProductPerformanceResponse>>,
IRequestHandler<TransactionQueries.TodaysSalesByHour, Result<List<TodaysSalesByHour>>>
IRequestHandler<TransactionQueries.TodaysSalesByHour, Result<List<TodaysSalesByHour>>>,
IRequestHandler<TransactionQueries.MerchantDailyPerformanceSummaryQuery, Result<MerchantDailyPerformanceSummaryResponse>>

{
private readonly IReportingManager Manager;
Expand Down Expand Up @@ -56,4 +57,9 @@ public async Task<Result<List<TodaysSalesByHour>>> Handle(TransactionQueries.Tod
CancellationToken cancellationToken) {
return await this.Manager.GetTodaysSalesByHour(request, cancellationToken);
}

public async Task<Result<MerchantDailyPerformanceSummaryResponse>> Handle(TransactionQueries.MerchantDailyPerformanceSummaryQuery request,
CancellationToken cancellationToken) {
return await this.Manager.GetMerchantDailyPerformanceSummary(request, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace EstateReportingAPI.DataTransferObjects
{
public class MerchantDailyPerformanceSummaryRequest
{
public Int32 MerchantReportingId { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
}

public sealed class MerchantDailyPerformanceSummaryResponse
{
public List<MetricItem> Metrics { get; set; } = [];

public List<DrillDownTransaction> DrillDownTransactions { get; set; } = [];
}

public sealed class MetricItem
{
public string Title { get; set; }

public Decimal Value { get; set; }

public string Description { get; set; }

public int Category { get; set; }
}

public sealed class DrillDownTransaction
{
public string Reference { get; set; }

public string Product { get; set; }

public string Status { get; set; }

public Decimal Amount { get; set; }

public DateTime TransactionDateTime { get; set; }
}
}
2 changes: 1 addition & 1 deletion EstateReportingAPI.IntegrationTests/ControllerTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,4 @@ public override async Task CreateSubscriptions(){

return sqlContainer.Item2.GetMappedPublicPort(1433);
}
}
}
63 changes: 62 additions & 1 deletion EstateReportingAPI.IntegrationTests/TransactionsEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1071,4 +1071,65 @@ public async Task TransactionsEndpoint_TodaysSalesByHour_SummaryDataReturned()

}
}
}

[Fact]
public async Task TransactionsEndpoint_MerchantDailyPerformanceSummary_ReturnsMetricsAndLastFiveSales()
{
TransactionProcessor.Database.Entities.Merchant merchant = this.merchantsList.Single(m => m.Name == "Test Merchant 1");
var contract = this.contractList.Single(c => c.contractName == "Safaricom Contract");
var product = this.contractProducts.Single(cp => cp.Key == contract.contractId).Value.First();

DateTime transactionDate = DateTime.Now.Date;
List<Transaction> transactions = new();

for (int i = 1; i <= 6; i++)
{
DateTime transactionDateTime = transactionDate.AddHours(9 + i);
string responseCode = i % 2 == 0 ? "1009" : "0000";

Transaction transaction = await this.helper.BuildTransactionX(
transactionDateTime,
merchant.MerchantId,
contract.operatorId,
contract.contractId,
product.productId,
responseCode,
product.productValue,
i);

transactions.Add(transaction);
}

await this.helper.AddTransactionsX(transactions);

MerchantDailyPerformanceSummaryRequest request = new()
{
MerchantReportingId = merchant.MerchantReportingId,
StartDate = transactionDate,
EndDate = transactionDate
};

var result = await this.CreateAndSendHttpRequestMessage<MerchantDailyPerformanceSummaryResponse>(
$"{this.BaseRoute}/merchantdailyperformancesummary",
StringSerialiser.Serialise(request),
CancellationToken.None);

result.IsSuccess.ShouldBeTrue();

MerchantDailyPerformanceSummaryResponse response = result.Data;
response.ShouldNotBeNull();
response.Metrics.ShouldNotBeNull();
response.DrillDownTransactions.ShouldNotBeNull();

response.Metrics.Count.ShouldBe(9);
response.Metrics.Single(m => m.Title == "Total Sales Count").Value.ShouldBe(6);
response.Metrics.Single(m => m.Title == "Successful Sales Count").Value.ShouldBe(3);
response.Metrics.Single(m => m.Title == "Failed Sales Count").Value.ShouldBe(3);
response.Metrics.Single(m => m.Title == "Top Product Sales Count").Value.ShouldBe(6);
response.Metrics.Single(m => m.Title == "Top Product Sales Count").Description.ShouldBe(product.productName);

response.DrillDownTransactions.Count.ShouldBe(5);
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" });
}
}
Loading
Loading