diff --git a/.codacy.yml b/.codacy.yml
new file mode 100644
index 0000000..ccf59eb
--- /dev/null
+++ b/.codacy.yml
@@ -0,0 +1,16 @@
+exclude_paths:
+ # Test projects and test infrastructure
+ - EstateReportingAPI.BusinessLogic.UnitTests/**
+ - EstateReportingAPI.IntegrationTests/**
+ - EstateReportingAPI.IntegrationUnitTests/**
+ - EstateReportingAPI.Tests/**
+ - EstateReportingAPI.Testing/**
+ - "**/*Tests/**"
+ - "**/*Test/**"
+
+ # Build, IDE, coverage, and test-result artifacts
+ - "**/bin/**"
+ - "**/obj/**"
+ - .vs/**
+ - coverage/**
+ - TestResults/**
diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj b/EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj
index 65e82f3..9d4b49a 100644
--- a/EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj
+++ b/EstateReportingAPI.BusinessLogic.UnitTests/EstateReportingAPI.BusinessLogic.UnitTests.csproj
@@ -6,7 +6,7 @@
true
enable
enable
-
+ None
false
true
diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs
new file mode 100644
index 0000000..8fcdc42
--- /dev/null
+++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerExtensionsTests.cs
@@ -0,0 +1,212 @@
+using EstateReportingAPI.BusinessLogic;
+using EstateReportingAPI.Models;
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+using TransactionProcessor.Database.Contexts;
+using TransactionProcessor.Database.Entities;
+using TransactionProcessor.Database.Entities.Summary;
+using Db = TransactionProcessor.Database.Entities;
+
+namespace EstateReportingAPI.BusinessLogic.UnitTests;
+
+public sealed class ReportingManagerExtensionsTests
+{
+ [Fact]
+ public void TodayTransactionFilters_HandleEmptyAndSelectedValues()
+ {
+ IQueryable query = new[] {
+ new TodayTransaction { MerchantReportingId = 20, OperatorReportingId = 10, ContractProductReportingId = 40 },
+ new TodayTransaction { MerchantReportingId = 21, OperatorReportingId = 11, ContractProductReportingId = 41 }
+ }.AsQueryable();
+
+ query.ApplyMerchantFilter((List)null!).Count().ShouldBe(2);
+ query.ApplyMerchantFilter([]).Count().ShouldBe(2);
+ query.ApplyMerchantFilter(20).Single().MerchantReportingId.ShouldBe(20);
+ query.ApplyProductFilter([40]).Single().ContractProductReportingId.ShouldBe(40);
+ query.ApplyProductFilter([]).Count().ShouldBe(2);
+ query.ApplyOperatorFilter([10]).Single().OperatorReportingId.ShouldBe(10);
+ query.ApplyOperatorFilter((List)null!).Count().ShouldBe(2);
+ query.ApplyOperatorFilter(11).Single().OperatorReportingId.ShouldBe(11);
+ query.ApplyOperatorFilter(0).Count().ShouldBe(2);
+ }
+
+ [Fact]
+ public void TransactionHistoryFilters_HandleEmptyAndSelectedValues()
+ {
+ IQueryable query = new[] {
+ new TransactionHistory { MerchantReportingId = 20, OperatorReportingId = 10, ContractProductReportingId = 40 },
+ new TransactionHistory { MerchantReportingId = 21, OperatorReportingId = 11, ContractProductReportingId = 41 }
+ }.AsQueryable();
+
+ query.ApplyMerchantFilter((List)null!).Count().ShouldBe(2);
+ query.ApplyMerchantFilter([]).Count().ShouldBe(2);
+ query.ApplyMerchantFilter(20).Single().MerchantReportingId.ShouldBe(20);
+ query.ApplyProductFilter([40]).Single().ContractProductReportingId.ShouldBe(40);
+ query.ApplyProductFilter([]).Count().ShouldBe(2);
+ query.ApplyOperatorFilter([10]).Single().OperatorReportingId.ShouldBe(10);
+ query.ApplyOperatorFilter((List)null!).Count().ShouldBe(2);
+ query.ApplyOperatorFilter(11).Single().OperatorReportingId.ShouldBe(11);
+ query.ApplyOperatorFilter(0).Count().ShouldBe(2);
+ }
+
+ [Fact]
+ public void SettlementFilters_HandleZeroAndSelectedValues()
+ {
+ var todayRows = new[] {
+ new DatabaseProjections.TodaySettlementTransactionProjection { Txn = new TodayTransaction { MerchantReportingId = 20, OperatorReportingId = 10 } },
+ new DatabaseProjections.TodaySettlementTransactionProjection { Txn = new TodayTransaction { MerchantReportingId = 21, OperatorReportingId = 11 } }
+ }.AsQueryable();
+ var comparisonRows = new[] {
+ new DatabaseProjections.ComparisonSettlementTransactionProjection { Txn = new TransactionHistory { MerchantReportingId = 20, OperatorReportingId = 10 } },
+ new DatabaseProjections.ComparisonSettlementTransactionProjection { Txn = new TransactionHistory { MerchantReportingId = 21, OperatorReportingId = 11 } }
+ }.AsQueryable();
+
+ todayRows.ApplyMerchantFilter(0).Count().ShouldBe(2);
+ todayRows.ApplyMerchantFilter(20).Single().Txn.MerchantReportingId.ShouldBe(20);
+ todayRows.ApplyOperatorFilter(0).Count().ShouldBe(2);
+ todayRows.ApplyOperatorFilter(10).Single().Txn.OperatorReportingId.ShouldBe(10);
+ comparisonRows.ApplyMerchantFilter(0).Count().ShouldBe(2);
+ comparisonRows.ApplyMerchantFilter(20).Single().Txn.MerchantReportingId.ShouldBe(20);
+ comparisonRows.ApplyOperatorFilter(0).Count().ShouldBe(2);
+ comparisonRows.ApplyOperatorFilter(10).Single().Txn.OperatorReportingId.ShouldBe(10);
+
+ new[] { new TodayTransaction { MerchantReportingId = 20 } }.AsQueryable()
+ .ApplyMerchantFilter(0).Count().ShouldBe(1);
+ new[] { new TransactionHistory { MerchantReportingId = 20 } }.AsQueryable()
+ .ApplyMerchantFilter(0).Count().ShouldBe(1);
+ }
+
+ [Fact]
+ public void TransactionSearchFiltersApplyAllPredicates()
+ {
+ var matching = new DatabaseProjections.TransactionSearchProjection {
+ Transaction = new Transaction {
+ TransactionAmount = 10m,
+ AuthorisationCode = "AUTH",
+ ResponseCode = "0000",
+ TransactionNumber = "TX-1"
+ },
+ Merchant = new Db.Merchant { MerchantReportingId = 20 },
+ Operator = new Db.Operator { OperatorReportingId = 10 }
+ };
+ var other = new DatabaseProjections.TransactionSearchProjection {
+ Transaction = new Transaction {
+ TransactionAmount = 90m,
+ AuthorisationCode = "OTHER",
+ ResponseCode = "1000",
+ TransactionNumber = "TX-2"
+ },
+ Merchant = new Db.Merchant { MerchantReportingId = 21 },
+ Operator = new Db.Operator { OperatorReportingId = 11 }
+ };
+
+ var request = new TransactionSearchRequest {
+ Operators = [10],
+ Merchants = [20],
+ ValueRange = new ValueRange { StartValue = 5m, EndValue = 15m },
+ AuthCode = "AUTH",
+ ResponseCode = "0000",
+ TransactionNumber = "TX-1"
+ };
+
+ new[] { matching, other }.AsQueryable().ApplyFilters(request).Single().ShouldBe(matching);
+ new[] { matching, other }.AsQueryable().ApplyFilters(new TransactionSearchRequest()).Count().ShouldBe(2);
+ }
+
+ [Fact]
+ public void PaginationAndSortingCoverAllDirections()
+ {
+ var records = new[] {
+ SearchProjection("Merchant B", "Operator A", 20m),
+ SearchProjection("Merchant A", "Operator B", 10m),
+ SearchProjection("Merchant C", "Operator C", 30m)
+ }.AsQueryable();
+
+ records.ApplyPagination(new PagingRequest(2, 1)).Single().Transaction.TransactionAmount.ShouldBe(10m);
+ records.ApplyPagination(new PagingRequest(1, 2)).Count().ShouldBe(2);
+
+ records.ApplySorting(null!).Count().ShouldBe(3);
+ records.ApplySorting(new SortingRequest(SortField.MerchantName, EstateReportingAPI.Models.SortDirection.Ascending)).First().Merchant.Name.ShouldBe("Merchant A");
+ records.ApplySorting(new SortingRequest(SortField.MerchantName, EstateReportingAPI.Models.SortDirection.Descending)).First().Merchant.Name.ShouldBe("Merchant C");
+ records.ApplySorting(new SortingRequest(SortField.OperatorName, EstateReportingAPI.Models.SortDirection.Ascending)).First().Operator.Name.ShouldBe("Operator A");
+ records.ApplySorting(new SortingRequest(SortField.OperatorName, EstateReportingAPI.Models.SortDirection.Descending)).First().Operator.Name.ShouldBe("Operator C");
+ records.ApplySorting(new SortingRequest(SortField.TransactionAmount, EstateReportingAPI.Models.SortDirection.Ascending)).First().Transaction.TransactionAmount.ShouldBe(10m);
+ records.ApplySorting(new SortingRequest(SortField.TransactionAmount, EstateReportingAPI.Models.SortDirection.Descending)).First().Transaction.TransactionAmount.ShouldBe(30m);
+ records.ApplySorting(new SortingRequest((SortField)999, (EstateReportingAPI.Models.SortDirection)999)).ShouldBe(records);
+ }
+
+ [Fact]
+ public void SettlementFiltersAndFeeGroupingsFilterAndAggregateRows()
+ {
+ using EstateManagementContext context = CreateContext();
+ Guid merchantId = Guid.NewGuid();
+ Guid operatorId = Guid.NewGuid();
+ Guid contractId = Guid.NewGuid();
+ Guid productId = Guid.NewGuid();
+ context.Merchants.Add(new Db.Merchant { MerchantId = merchantId, MerchantReportingId = 20, Name = "Merchant 1" });
+ context.Operators.Add(new Db.Operator { OperatorId = operatorId, OperatorReportingId = 10, Name = "Operator 1" });
+ context.Contracts.Add(new Db.Contract { ContractId = contractId, OperatorId = operatorId, Description = "Contract 1" });
+ context.ContractProducts.Add(new Db.ContractProduct { ContractProductId = productId, ContractId = contractId, ContractProductReportingId = 40, ProductName = "Product 1", DisplayText = "Product 1" });
+ context.SaveChanges();
+
+ Guid transactionId = Guid.NewGuid();
+ context.Transactions.Add(new Transaction {
+ TransactionId = transactionId,
+ MerchantId = merchantId,
+ OperatorId = operatorId,
+ ContractId = contractId,
+ ContractProductId = productId,
+ TransactionType = "Sale",
+ TransactionDate = new DateTime(2026, 9, 1),
+ TransactionDateTime = new DateTime(2026, 9, 1),
+ TransactionTime = TimeSpan.Zero,
+ TransactionAmount = 10m,
+ IsAuthorised = true,
+ IsCompleted = true,
+ ResponseCode = "0000",
+ TransactionNumber = "TX-1"
+ });
+ context.MerchantSettlementFees.Add(new MerchantSettlementFee {
+ MerchantId = merchantId,
+ TransactionId = transactionId,
+ CalculatedValue = 1.5m,
+ FeeValue = 1.5m
+ });
+ context.SaveChanges();
+
+ var feeRows = from t in context.Transactions
+ join f in context.MerchantSettlementFees on t.TransactionId equals f.TransactionId
+ select new DatabaseProjections.FeeTransactionProjection { Txn = t, Fee = f };
+
+ feeRows.ApplyMerchantFilter(context, [20]).Single().ShouldNotBeNull();
+ feeRows.ApplyOperatorFilter(context, [10]).Single().ShouldNotBeNull();
+ feeRows.ApplyProductFilter(context, [40]).Single().ShouldNotBeNull();
+ feeRows.ApplyMerchantFilter(context, []).Count().ShouldBe(1);
+ feeRows.ApplyOperatorFilter(context, []).Count().ShouldBe(1);
+ feeRows.ApplyProductFilter(context, []).Count().ShouldBe(1);
+
+ feeRows.ApplyMerchantGrouping(context).Single().ShouldSatisfyAllConditions(
+ row => row.DimensionName.ShouldBe("Merchant 1"),
+ row => row.FeesValue.ShouldBe(1.5m),
+ row => row.FeesCount.ShouldBe(1));
+ feeRows.ApplyOperatorGrouping(context).Single().DimensionName.ShouldBe("Operator 1");
+ feeRows.ApplyProductGrouping(context).Single().DimensionName.ShouldBe("Operator 1 - Product 1");
+ }
+
+ private static EstateManagementContext CreateContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"extension-tests-{Guid.NewGuid()}")
+ .Options;
+ return new EstateManagementContext(options);
+ }
+
+ private static DatabaseProjections.TransactionSearchProjection SearchProjection(string merchantName, string operatorName, decimal amount)
+ {
+ return new DatabaseProjections.TransactionSearchProjection {
+ Transaction = new Transaction { TransactionAmount = amount },
+ Merchant = new Db.Merchant { Name = merchantName },
+ Operator = new Db.Operator { Name = operatorName }
+ };
+ }
+}
diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs
new file mode 100644
index 0000000..93d3410
--- /dev/null
+++ b/EstateReportingAPI.BusinessLogic.UnitTests/ReportingManagerReportTests.cs
@@ -0,0 +1,1151 @@
+using EstateReportingAPI.BusinessLogic.Queries;
+using EstateReportingAPI.Models;
+using Imposter.Abstractions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Shared.EntityFramework;
+using TransactionProcessor.Database.Contexts;
+using Db = TransactionProcessor.Database.Entities;
+using BalanceState = TransactionProcessor.ProjectionEngine.Database.Database.Entities.MerchantBalanceProjectionState;
+
+namespace EstateReportingAPI.BusinessLogic.UnitTests;
+
+public sealed class ReportingManagerReportTests
+{
+ private static readonly Guid EstateId = Guid.Parse("F64241E7-F778-4F77-8A64-099CB51BF4CE");
+
+ [Fact]
+ public async Task GetCalendarComparisonDates_ReturnsCalendarRows()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddCalendar(new DateTime(2026, 9, 1));
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetCalendarComparisonDates(
+ new CalendarQueries.GetComparisonDatesQuery(EstateId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Count.ShouldBe(1);
+ result.Data[0].Date.ShouldBe(new DateTime(2026, 9, 1));
+ }
+
+ [Fact]
+ public async Task GetCalendarDates_ReturnsCalendarRows()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddCalendar(new DateTime(2026, 9, 1));
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetCalendarDates(
+ new CalendarQueries.GetAllDatesQuery(EstateId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetContract_ReturnsContractWithProducts()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddContractFees();
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetContract(
+ new ContractQueries.GetContractQuery(EstateId, database.ContractId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.ContractId.ShouldBe(database.ContractId);
+ result.Data.Products.Count.ShouldBe(1);
+ result.Data.Products[0].TransactionFees.Count.ShouldBe(2);
+ result.Data.Products[0].TransactionFees.Select(fee => fee.Description).ShouldContain("Enabled fee");
+ result.Data.Products[0].TransactionFees.Select(fee => fee.Description).ShouldContain("Disabled fee");
+ }
+
+ [Fact]
+ public async Task GetRecentContracts_ReturnsRecentContracts()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetRecentContracts(
+ new ContractQueries.GetRecentContractsQuery(EstateId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Count.ShouldBe(1);
+ result.Data[0].ContractId.ShouldBe(database.ContractId);
+ }
+
+ [Fact]
+ public async Task GetEstate_ReturnsEstateAndRelatedData()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddEstateUser();
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetEstate(
+ new EstateQueries.GetEstateQuery(EstateId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.EstateId.ShouldBe(EstateId);
+ result.Data.Merchants.Count.ShouldBe(2);
+ result.Data.Contracts.Count.ShouldBe(1);
+ result.Data.Users.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetEstateOperators_ReturnsEstateOperators()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddEstateOperator();
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetEstateOperators(
+ new EstateQueries.GetEstateOperatorsQuery(EstateId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Count.ShouldBe(1);
+ result.Data[0].OperatorId.ShouldBe(database.OperatorId);
+ }
+
+ [Fact]
+ public async Task GetRecentMerchants_ReturnsMerchantsWithBalances()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddBalance(database.MerchantId, 10m);
+ database.AddBalance(database.SecondMerchantId, 20m);
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetRecentMerchants(
+ new MerchantQueries.GetRecentMerchantsQuery(EstateId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Count.ShouldBe(2);
+ result.Data[0].Balance.ShouldBe(10m);
+ }
+
+ [Fact]
+ public async Task GetMerchantsTransactionKpis_ReturnsKpis()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddBalance(database.MerchantId, 10m);
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetMerchantsTransactionKpis(
+ new MerchantQueries.GetTransactionKpisQuery(EstateId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.MerchantsWithNoSaleInLast7Days.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetOperators_ReturnsOperators()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetOperators(
+ new OperatorQueries.GetOperatorsQuery(EstateId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Count.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetOperator_ReturnsOperator()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetOperator(
+ new OperatorQueries.GetOperatorQuery(EstateId, database.OperatorId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.OperatorId.ShouldBe(database.OperatorId);
+ }
+
+ [Fact]
+ public async Task GetFileImportLogList_WhenNoLogs_ReturnsEmptyList()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetFileImportLogList(
+ new FileImportLogQueries.GetFileImportLogListQuery(EstateId, null, DateTime.Today.AddDays(-1), DateTime.Today),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task GetFileImportLogList_WithFileDataBuildsHierarchicalResult()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ Guid logId = database.AddFileImportData();
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetFileImportLogList(
+ new FileImportLogQueries.GetFileImportLogListQuery(EstateId, null, DateTime.Today.AddDays(-1), DateTime.Today),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Count.ShouldBe(1);
+ result.Data[0].FileImportLogId.ShouldBe(logId);
+ result.Data[0].FileDetailsList.Count.ShouldBe(1);
+ result.Data[0].FileDetailsList[0].FileLines.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetFileImportLog_WhenLogDoesNotExist_ReturnsNotFound()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetFileImportLog(
+ new FileImportLogQueries.GetFileImportLogQuery(EstateId, null, Guid.NewGuid()), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task GetFileImportLog_WithFileDataBuildsHierarchicalResult()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ Guid logId = database.AddFileImportData();
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetFileImportLog(
+ new FileImportLogQueries.GetFileImportLogQuery(EstateId, null, logId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.FileImportLogId.ShouldBe(logId);
+ result.Data.FileDetailsList.Count.ShouldBe(1);
+ result.Data.FileDetailsList[0].FileLines.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetFileProfileConfigurationList_WhenNoConfigurations_ReturnsEmptyList()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetFileProfileConfigurationList(
+ new FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery(EstateId), CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task GetTransactionSummaryByMerchantReport_AverageUsesGroupedTotals()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1));
+ database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1));
+ database.AddSale(database.MerchantId, database.SecondOperatorId, 30m, new DateTime(2026, 9, 1));
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetTransactionSummaryByMerchantReport(
+ new TransactionQueries.TransactionSummaryByMerchantQuery(
+ EstateId,
+ new TransactionSummaryByMerchantRequest {
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 1)
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Merchants.Single().AverageValue.ShouldBe(30m);
+ result.Data.Summary.AverageValue.ShouldBe(20m);
+ }
+
+ [Fact]
+ public async Task GetTransactionSummaryByOperatorReport_SeparatesAuthorisedAndDeclinedTotals()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1), authorised: true);
+ database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1), authorised: false);
+ database.AddSale(database.SecondMerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1), authorised: true);
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetTransactionSummaryByOperatorReport(
+ new TransactionQueries.TransactionSummaryByOperatorQuery(
+ EstateId,
+ new TransactionSummaryByOperatorRequest {
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 1)
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ var summary = result.Data.Operators.Single();
+ summary.TotalCount.ShouldBe(3);
+ summary.TotalValue.ShouldBe(60m);
+ summary.AuthorisedCount.ShouldBe(2);
+ summary.DeclinedCount.ShouldBe(1);
+ summary.AuthorisedPercentage.ShouldBe(2m / 3m);
+ summary.AverageValue.ShouldBe(30m);
+ }
+
+ [Fact]
+ public async Task GetTransactionDetailReport_AppliesMerchantProductAndOperatorFilters()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1));
+ database.AddSale(database.SecondMerchantId, database.SecondOperatorId, 20m, new DateTime(2026, 9, 1));
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetTransactionDetailReport(
+ new TransactionQueries.TransactionDetailReportQuery(
+ EstateId,
+ new TransactionDetailReportRequest {
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 1),
+ Merchants = [20],
+ Products = [40],
+ Operators = [10]
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Summary.TransactionCount.ShouldBe(1);
+ result.Data.Summary.TotalValue.ShouldBe(10m);
+ }
+
+ [Fact]
+ public async Task GetTransactionSummaryByOperatorReport_AppliesMerchantAndOperatorFilters()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1));
+ database.AddSale(database.SecondMerchantId, database.SecondOperatorId, 20m, new DateTime(2026, 9, 1));
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetTransactionSummaryByOperatorReport(
+ new TransactionQueries.TransactionSummaryByOperatorQuery(
+ EstateId,
+ new TransactionSummaryByOperatorRequest {
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 1),
+ Merchants = [20],
+ Operators = [10]
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Operators.Single().TotalValue.ShouldBe(10m);
+ }
+
+ [Fact]
+ public async Task GetTransactionSummaryByMerchantReport_WhenNoSales_ReturnsZeroResult()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetTransactionSummaryByMerchantReport(
+ new TransactionQueries.TransactionSummaryByMerchantQuery(
+ EstateId,
+ new TransactionSummaryByMerchantRequest {
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 1)
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Merchants.ShouldBeEmpty();
+ result.Data.Summary.TotalCount.ShouldBe(0);
+ result.Data.Summary.TotalValue.ShouldBe(0m);
+ }
+
+ [Fact]
+ public async Task GetTransactionMixSummary_WhenDateRangeIsInvalid_ReturnsFailure()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetTransactionMixSummary(
+ new TransactionQueries.TransactionMixSummaryQuery(
+ EstateId,
+ new TransactionMixSummaryRequest {
+ StartDate = new DateTime(2026, 9, 2),
+ EndDate = new DateTime(2026, 9, 1),
+ Breakdown = TransactionMixBreakdown.Product,
+ Measure = TransactionMixMeasure.Count
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task GetTransactionMixSummary_WhenBreakdownIsUnsupported_ReturnsFailure()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetTransactionMixSummary(
+ new TransactionQueries.TransactionMixSummaryQuery(
+ EstateId,
+ new TransactionMixSummaryRequest {
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 1),
+ Breakdown = (TransactionMixBreakdown)999,
+ Measure = TransactionMixMeasure.Count
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task GetRecentActivityReceiptReport_NormalizesInvalidPagingAndReturnsEmptyResult()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetRecentActivityReceiptReport(
+ new TransactionQueries.GetRecentActivityReceiptReportQuery(
+ EstateId,
+ new GetRecentActivityReceiptReportRequest {
+ ReportDate = new DateTime(2026, 9, 1),
+ PageNumber = 0,
+ PageSize = 0
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.PageNumber.ShouldBe(1);
+ result.Data.PageSize.ShouldBe(10);
+ result.Data.TotalCount.ShouldBe(0);
+ result.Data.Items.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task GetRecentActivityReceiptReport_PaginatesOrderedRowsAndPreservesTotalCount()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1, 9, 0, 0));
+ database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1, 10, 0, 0));
+ database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1, 11, 0, 0));
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetRecentActivityReceiptReport(
+ new TransactionQueries.GetRecentActivityReceiptReportQuery(
+ EstateId,
+ new GetRecentActivityReceiptReportRequest {
+ ReportDate = new DateTime(2026, 9, 1),
+ PageNumber = 2,
+ PageSize = 2
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.TotalCount.ShouldBe(3);
+ result.Data.Items.Count.ShouldBe(1);
+ result.Data.Items.Single().Amount.ShouldBe(10m);
+ }
+
+ [Fact]
+ public async Task GetTransactionDetailReport_IncludesFeesAndZeroFeeForUnsettledRows()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ Guid transactionWithFee = database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1));
+ database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1));
+ database.AddSettlementFee(transactionWithFee, 0.5m);
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetTransactionDetailReport(
+ new TransactionQueries.TransactionDetailReportQuery(
+ EstateId,
+ new TransactionDetailReportRequest {
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 1)
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Summary.TransactionCount.ShouldBe(2);
+ result.Data.Summary.TotalValue.ShouldBe(30m);
+ result.Data.Summary.TotalFees.ShouldBe(0.5m);
+ result.Data.Transactions.Sum(t => t.TotalFees).ShouldBe(0.5m);
+ result.Data.Transactions.ShouldContain(t => t.TotalFees == 0m);
+ }
+
+ [Fact]
+ public async Task GetTransactionDetailReport_IncludesBothDateBoundaries()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1));
+ database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 2));
+ database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 8, 31));
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetTransactionDetailReport(
+ new TransactionQueries.TransactionDetailReportQuery(
+ EstateId,
+ new TransactionDetailReportRequest {
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 2)
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Summary.TransactionCount.ShouldBe(2);
+ result.Data.Summary.TotalValue.ShouldBe(30m);
+ }
+
+ [Fact]
+ public async Task GetTodaysSettlement_WhenNoSettlementRows_ReturnsZeroTotals()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetTodaysSettlement(
+ new SettlementQueries.TodaysSettlementQuery(EstateId, DateTime.Today.AddDays(-1)),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.TodaysSettlementCount.ShouldBe(0);
+ result.Data.TodaysPendingSettlementCount.ShouldBe(0);
+ result.Data.ComparisonSettlementCount.ShouldBe(0);
+ result.Data.ComparisonPendingSettlementCount.ShouldBe(0);
+ result.Data.TodaysSettlementValue.ShouldBe(0m);
+ result.Data.ComparisonSettlementValue.ShouldBe(0m);
+ }
+
+ [Fact]
+ public async Task GetTodaysSales_WhenReadModelsAreEmpty_ReturnsZeroTotals()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ DateTime comparisonDate = DateTime.Today.AddDays(-1);
+
+ var result = await database.Manager.GetTodaysSales(
+ new TransactionQueries.TodaysSalesQuery(EstateId, 20, 10, comparisonDate),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.TodaysSalesCount.ShouldBe(0);
+ result.Data.TodaysSalesValue.ShouldBe(0m);
+ result.Data.TodaysAverageSalesValue.ShouldBe(0m);
+ result.Data.ComparisonSalesCount.ShouldBe(0);
+ result.Data.ComparisonSalesValue.ShouldBe(0m);
+ result.Data.ComparisonAverageSalesValue.ShouldBe(0m);
+ }
+
+ [Fact]
+ public async Task GetTodaysFailedSales_WhenReadModelsAreEmpty_ReturnsZeroTotals()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ DateTime comparisonDate = DateTime.Today.AddDays(-1);
+
+ var result = await database.Manager.GetTodaysFailedSales(
+ new TransactionQueries.TodaysFailedSales(EstateId, comparisonDate, "1000"),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.TodaysSalesCount.ShouldBe(0);
+ result.Data.TodaysSalesValue.ShouldBe(0m);
+ result.Data.TodaysAverageSalesValue.ShouldBe(0m);
+ result.Data.ComparisonSalesCount.ShouldBe(0);
+ result.Data.ComparisonSalesValue.ShouldBe(0m);
+ result.Data.ComparisonAverageSalesValue.ShouldBe(0m);
+ }
+
+ [Fact]
+ public async Task GetMerchantDailyPerformanceSummary_ReturnsMetricsTopProductAndRecentSales()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1, 9, 0, 0), authorised: true);
+ database.AddSale(database.MerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1, 10, 0, 0), authorised: false);
+ database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 2, 11, 0, 0), authorised: true);
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetMerchantDailyPerformanceSummary(
+ new TransactionQueries.MerchantDailyPerformanceSummaryQuery(
+ EstateId,
+ new MerchantDailyPerformanceSummaryRequest {
+ MerchantReportingId = 20,
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 2)
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Metrics.Count.ShouldBe(9);
+ result.Data.Metrics.Single(m => m.Title == "Total Sales Count").Value.ShouldBe(3);
+ result.Data.Metrics.Single(m => m.Title == "Total Sales Value").Value.ShouldBe(60m);
+ result.Data.Metrics.Single(m => m.Title == "Successful Sales Count").Value.ShouldBe(2);
+ result.Data.Metrics.Single(m => m.Title == "Failed Sales Count").Value.ShouldBe(1);
+ result.Data.Metrics.Single(m => m.Title == "Average Sales Count").Value.ShouldBe(1.5m);
+ result.Data.Metrics.Single(m => m.Title == "Average Sales Value").Value.ShouldBe(20m);
+ result.Data.Metrics.Single(m => m.Title == "Top Product Sales Count").Value.ShouldBe(3);
+ result.Data.DrillDownTransactions.Count.ShouldBe(3);
+ result.Data.DrillDownTransactions.First().Amount.ShouldBe(30m);
+ result.Data.DrillDownTransactions.First().Status.ShouldBe("Successful");
+ }
+
+ [Fact]
+ public async Task GetMerchantDailyPerformanceSummary_WhenNoTransactions_ReturnsBaseMetrics()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetMerchantDailyPerformanceSummary(
+ new TransactionQueries.MerchantDailyPerformanceSummaryQuery(
+ EstateId,
+ new MerchantDailyPerformanceSummaryRequest {
+ MerchantReportingId = 20,
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 2)
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Metrics.Count.ShouldBe(8);
+ result.Data.Metrics.ShouldAllBe(metric => metric.Value == 0);
+ result.Data.DrillDownTransactions.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task GetMerchants_AppliesAllQueryFilters()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddBalance(database.MerchantId, 10m);
+ database.AddBalance(database.SecondMerchantId, 20m);
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetMerchants(
+ new MerchantQueries.GetMerchantsQuery(
+ EstateId,
+ new MerchantQueries.MerchantQueryOptions("Merchant 1", "M1", 2, "Region", "P1")),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Count.ShouldBe(1);
+ result.Data.Single().Name.ShouldBe("Merchant 1");
+ }
+
+ [Fact]
+ public async Task GetMerchant_MapsAddressContactOpeningHoursAndBalance()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddBalance(database.MerchantId, 123.45m);
+ database.AddMerchantRelatedData();
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetMerchant(
+ new MerchantQueries.GetMerchantQuery(EstateId, database.MerchantId),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.Name.ShouldBe("Merchant 1");
+ result.Data.Reference.ShouldBe("M1");
+ result.Data.Balance.ShouldBe(123.45m);
+ result.Data.AddressLine1.ShouldBe("Address 1");
+ result.Data.ContactName.ShouldBe("Contact 1");
+ result.Data.ContactEmail.ShouldBe("one@example.test");
+ result.Data.SettlementSchedule.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task GetProductPerformanceReport_WithSalesMapsDetailsAndSummary()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1));
+ database.AddSale(database.MerchantId, database.OperatorId, 30m, new DateTime(2026, 9, 1));
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetProductPerformanceReport(
+ new TransactionQueries.ProductPerformanceQuery(EstateId, new DateTime(2026, 9, 1), new DateTime(2026, 9, 1)),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.ProductDetails.Count.ShouldBe(1);
+ result.Data.ProductDetails.Single().ProductName.ShouldBe("Product 1");
+ result.Data.ProductDetails.Single().TransactionCount.ShouldBe(2);
+ result.Data.ProductDetails.Single().TransactionValue.ShouldBe(40m);
+ result.Data.Summary.TotalCount.ShouldBe(2);
+ result.Data.Summary.TotalValue.ShouldBe(40m);
+ result.Data.Summary.AveragePerProduct.ShouldBe(40m);
+ result.Data.Summary.TotalProducts.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task GetMerchantOperators_MapsConfiguredRows()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddMerchantRelatedData();
+ await database.SaveAsync();
+
+ var operators = await database.Manager.GetMerchantOperators(
+ new MerchantQueries.GetMerchantOperatorsQuery(EstateId, database.MerchantId), CancellationToken.None);
+
+ operators.IsSuccess.ShouldBeTrue();
+ operators.Data.Single().OperatorName.ShouldBe("Merchant Operator");
+ }
+
+ [Fact]
+ public async Task GetMerchantContracts_MapsConfiguredRows()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddMerchantRelatedData();
+ await database.SaveAsync();
+
+ var contracts = await database.Manager.GetMerchantContracts(
+ new MerchantQueries.GetMerchantContractsQuery(EstateId, database.MerchantId), CancellationToken.None);
+
+ contracts.IsSuccess.ShouldBeTrue();
+ contracts.Data.Single().ContractName.ShouldBe("Contract 1");
+ contracts.Data.Single().ContractProducts.Single().ProductName.ShouldBe("Product 1");
+ }
+
+ [Fact]
+ public async Task GetMerchantDevices_MapsConfiguredRows()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddMerchantRelatedData();
+ await database.SaveAsync();
+
+ var devices = await database.Manager.GetMerchantDevices(
+ new MerchantQueries.GetMerchantDevicesQuery(EstateId, database.MerchantId), CancellationToken.None);
+
+ devices.IsSuccess.ShouldBeTrue();
+ devices.Data.Single().DeviceIdentifier.ShouldBe("device-1");
+ }
+
+ [Fact]
+ public async Task GetMerchantOpeningHours_MapsAllDays()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddMerchantRelatedData();
+ await database.SaveAsync();
+
+ var openingHours = await database.Manager.GetMerchantOpeningHours(
+ new MerchantQueries.GetMerchantOpeningHoursQuery(EstateId, database.MerchantId), CancellationToken.None);
+
+ openingHours.IsSuccess.ShouldBeTrue();
+ openingHours.Data.Count.ShouldBe(7);
+ openingHours.Data.Single(x => x.DayOfWeek == DayOfWeek.Monday).OpeningTime.ShouldBe("08:00");
+ }
+
+ [Fact]
+ public async Task GetMerchantSchedule_ParsesClosedDays()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddMerchantRelatedData();
+ await database.SaveAsync();
+
+ var schedule = await database.Manager.GetMerchantSchedule(
+ new MerchantQueries.GetMerchantScheduleQuery(EstateId, database.MerchantId, 2026), CancellationToken.None);
+
+ schedule.IsSuccess.ShouldBeTrue();
+ schedule.Data.Year.ShouldBe(2026);
+ schedule.Data.Months.Single().ClosedDays.ShouldBe([1, 15]);
+ }
+
+ [Fact]
+ public async Task GetTodaysSalesByHour_WhenReadModelsAreEmpty_ReturnsEmptyResult()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetTodaysSalesByHour(
+ new TransactionQueries.TodaysSalesByHour(EstateId, DateTime.Today.AddDays(-1)),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task GetContracts_MapsProductsAndOnlyEnabledFees()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddContractFees();
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetContracts(
+ new ContractQueries.GetContractsQuery(EstateId),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ var contract = result.Data.Single();
+ contract.OperatorName.ShouldBe("Operator 1");
+ contract.Products.Single().ProductName.ShouldBe("Product 1");
+ contract.Products.Single().TransactionFees.Count.ShouldBe(1);
+ contract.Products.Single().TransactionFees.Single().Description.ShouldBe("Enabled fee");
+ }
+
+ [Fact]
+ public async Task GetTransactionMixSummary_GroupsByStatusAndOrdersByValue()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1), authorised: true);
+ database.AddSale(database.MerchantId, database.OperatorId, 25m, new DateTime(2026, 9, 1), authorised: false);
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetTransactionMixSummary(
+ new TransactionQueries.TransactionMixSummaryQuery(
+ EstateId,
+ new TransactionMixSummaryRequest {
+ StartDate = new DateTime(2026, 9, 1),
+ EndDate = new DateTime(2026, 9, 1),
+ Breakdown = TransactionMixBreakdown.Status,
+ Measure = TransactionMixMeasure.Value,
+ TopN = 1
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.TotalCount.ShouldBe(2);
+ result.Data.TotalValue.ShouldBe(35m);
+ result.Data.Groups.Count.ShouldBe(1);
+ result.Data.Groups.Single().GroupName.ShouldBe("Declined");
+ result.Data.Groups.Single().TransactionValue.ShouldBe(25m);
+ result.Data.Transactions.Count.ShouldBe(2);
+ result.Data.Transactions.First().Value.ShouldBe(25m);
+ }
+
+ [Fact]
+ public async Task GetRecentActivityReceiptReport_AppliesMerchantAndSearchFilters()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddSale(database.MerchantId, database.OperatorId, 10m, new DateTime(2026, 9, 1, 9, 0, 0));
+ database.AddSale(database.SecondMerchantId, database.OperatorId, 20m, new DateTime(2026, 9, 1, 10, 0, 0));
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetRecentActivityReceiptReport(
+ new TransactionQueries.GetRecentActivityReceiptReportQuery(
+ EstateId,
+ new GetRecentActivityReceiptReportRequest {
+ ReportDate = new DateTime(2026, 9, 1),
+ MerchantReportingId = 20,
+ SearchText = "Product 1"
+ }),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.TotalCount.ShouldBe(1);
+ result.Data.Items.Single().Amount.ShouldBe(10m);
+ }
+
+ [Fact]
+ public async Task GetProductPerformanceReport_WhenNoSales_ReturnsZeroSummary()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetProductPerformanceReport(
+ new TransactionQueries.ProductPerformanceQuery(EstateId, new DateTime(2026, 9, 1), new DateTime(2026, 9, 1)),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Data.ProductDetails.ShouldBeEmpty();
+ result.Data.Summary.TotalCount.ShouldBe(0);
+ result.Data.Summary.TotalValue.ShouldBe(0m);
+ }
+
+ [Fact]
+ public async Task GetMerchants_WhenBalanceIsMissing_ReturnsFailureResult()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+
+ var result = await database.Manager.GetMerchants(
+ new MerchantQueries.GetMerchantsQuery(EstateId, new MerchantQueries.MerchantQueryOptions("", "", null, "", "")),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task GetMerchants_WhenBalanceIsDuplicated_ReturnsFailureResult()
+ {
+ await using TestDatabase database = await TestDatabase.CreateAsync();
+ database.AddBalance(database.MerchantId, 10m);
+ database.AddBalance(database.MerchantId, 20m);
+ await database.SaveAsync();
+
+ var result = await database.Manager.GetMerchants(
+ new MerchantQueries.GetMerchantsQuery(EstateId, new MerchantQueries.MerchantQueryOptions("", "", null, "", "")),
+ CancellationToken.None);
+
+ result.IsSuccess.ShouldBeFalse();
+ }
+
+ private sealed class TestDatabase : IAsyncDisposable
+ {
+ private readonly ServiceProvider services;
+ private readonly EstateManagementContext seedContext;
+
+ private TestDatabase(ServiceProvider services, EstateManagementContext seedContext, ReportingManager manager,
+ Guid merchantId, Guid secondMerchantId, Guid operatorId, Guid secondOperatorId, Guid contractId, Guid productId)
+ {
+ this.services = services;
+ this.seedContext = seedContext;
+ Manager = manager;
+ MerchantId = merchantId;
+ SecondMerchantId = secondMerchantId;
+ OperatorId = operatorId;
+ SecondOperatorId = secondOperatorId;
+ ContractId = contractId;
+ ProductId = productId;
+ }
+
+ public ReportingManager Manager { get; }
+ public Guid MerchantId { get; }
+ public Guid SecondMerchantId { get; }
+ public Guid OperatorId { get; }
+ public Guid SecondOperatorId { get; }
+ public Guid ContractId { get; }
+ public Guid ProductId { get; }
+
+ public static async Task CreateAsync()
+ {
+ string databaseName = $"reporting-tests-{Guid.NewGuid()}";
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName)
+ .Options;
+ var seedContext = new EstateManagementContext(options);
+
+ Guid estateId = EstateId;
+ Guid merchantId = Guid.NewGuid();
+ Guid secondMerchantId = Guid.NewGuid();
+ Guid operatorId = Guid.NewGuid();
+ Guid secondOperatorId = Guid.NewGuid();
+ Guid contractId = Guid.NewGuid();
+ Guid productId = Guid.NewGuid();
+
+ seedContext.Estates.Add(new Db.Estate { EstateId = estateId, EstateReportingId = 1, Name = "Test Estate" });
+ seedContext.Operators.AddRange(
+ new Db.Operator { OperatorId = operatorId, EstateId = estateId, OperatorReportingId = 10, Name = "Operator 1" },
+ new Db.Operator { OperatorId = secondOperatorId, EstateId = estateId, OperatorReportingId = 11, Name = "Operator 2" });
+ seedContext.Merchants.Add(new Db.Merchant { MerchantId = merchantId, EstateId = estateId, MerchantReportingId = 20, Name = "Merchant 1", Reference = "M1", SettlementSchedule = 2 });
+ seedContext.Merchants.Add(new Db.Merchant { MerchantId = secondMerchantId, EstateId = estateId, MerchantReportingId = 21, Name = "Merchant 2", Reference = "M2", SettlementSchedule = 3 });
+ seedContext.MerchantAddresses.AddRange(
+ new Db.MerchantAddress { AddressId = Guid.NewGuid(), MerchantId = merchantId, AddressLine1 = "Address 1", Region = "Region", PostalCode = "P1" },
+ new Db.MerchantAddress { AddressId = Guid.NewGuid(), MerchantId = secondMerchantId, AddressLine1 = "Address 2", Region = "Region", PostalCode = "P2" });
+ seedContext.MerchantContacts.AddRange(
+ new Db.MerchantContact { ContactId = Guid.NewGuid(), MerchantId = merchantId, Name = "Contact 1", EmailAddress = "one@example.test" },
+ new Db.MerchantContact { ContactId = Guid.NewGuid(), MerchantId = secondMerchantId, Name = "Contact 2", EmailAddress = "two@example.test" });
+ seedContext.Contracts.Add(new Db.Contract { ContractId = contractId, EstateId = estateId, OperatorId = operatorId, ContractReportingId = 30, Description = "Contract 1" });
+ seedContext.ContractProducts.Add(new Db.ContractProduct {
+ ContractProductId = productId,
+ ContractId = contractId,
+ ContractProductReportingId = 40,
+ ProductName = "Product 1",
+ DisplayText = "Product 1",
+ ProductType = 1,
+ Value = 1m
+ });
+ await seedContext.SaveChangesAsync();
+
+ var services = new ServiceCollection()
+ .AddDbContext(builder => builder.UseInMemoryDatabase(databaseName))
+ .BuildServiceProvider();
+ var resolver = new IDbContextResolverImposter();
+ resolver.Resolve(Arg.Any(), Arg.Any())
+ .Returns(new ResolvedDbContext(services.CreateScope()));
+
+ return new TestDatabase(services, seedContext, new ReportingManager(resolver.Instance()), merchantId, secondMerchantId, operatorId,
+ secondOperatorId, contractId, productId);
+ }
+
+ public Guid AddSale(Guid merchantId, Guid operatorId, decimal amount, DateTime date, bool authorised = true)
+ {
+ Guid transactionId = Guid.NewGuid();
+ seedContext.Transactions.Add(new Db.Transaction {
+ TransactionId = transactionId,
+ MerchantId = merchantId,
+ OperatorId = operatorId,
+ ContractId = ContractId,
+ ContractProductId = ProductId,
+ TransactionType = "Sale",
+ TransactionDate = date.Date,
+ TransactionDateTime = date,
+ TransactionTime = date.TimeOfDay,
+ TransactionAmount = amount,
+ IsAuthorised = authorised,
+ IsCompleted = true,
+ ResponseCode = authorised ? "0000" : "1000",
+ TransactionNumber = "0001"
+ });
+ return transactionId;
+ }
+
+ public void AddContractFees()
+ {
+ seedContext.ContractProductTransactionFees.AddRange(
+ new Db.ContractProductTransactionFee {
+ ContractProductTransactionFeeId = Guid.NewGuid(),
+ ContractProductTransactionFeeReportingId = 50,
+ ContractProductId = ProductId,
+ Description = "Enabled fee",
+ Value = 0.5m,
+ CalculationType = 1,
+ FeeType = 1,
+ IsEnabled = true
+ },
+ new Db.ContractProductTransactionFee {
+ ContractProductTransactionFeeId = Guid.NewGuid(),
+ ContractProductTransactionFeeReportingId = 51,
+ ContractProductId = ProductId,
+ Description = "Disabled fee",
+ Value = 0.25m,
+ CalculationType = 1,
+ FeeType = 1,
+ IsEnabled = false
+ });
+ }
+
+ public void AddMerchantRelatedData()
+ {
+ seedContext.MerchantOperators.Add(new Db.MerchantOperator {
+ MerchantId = MerchantId,
+ OperatorId = OperatorId,
+ MerchantNumber = "merchant-number",
+ Name = "Merchant Operator",
+ TerminalNumber = "terminal-1",
+ IsDeleted = false
+ });
+ seedContext.MerchantContracts.Add(new Db.MerchantContract {
+ MerchantId = MerchantId,
+ ContractId = ContractId,
+ IsDeleted = false
+ });
+ seedContext.MerchantDevices.Add(new Db.MerchantDevice {
+ MerchantId = MerchantId,
+ DeviceId = Guid.NewGuid(),
+ DeviceIdentifier = "device-1",
+ CreatedDateTime = new DateTime(2026, 9, 1),
+ IsEnabled = true
+ });
+ seedContext.MerchantOpeningHours.Add(new Db.MerchantOpeningHours {
+ MerchantId = MerchantId,
+ MondayOpening = "08:00",
+ MondayClosing = "17:00",
+ TuesdayOpening = "08:00",
+ TuesdayClosing = "17:00",
+ WednesdayOpening = "08:00",
+ WednesdayClosing = "17:00",
+ ThursdayOpening = "08:00",
+ ThursdayClosing = "17:00",
+ FridayOpening = "08:00",
+ FridayClosing = "17:00",
+ SaturdayOpening = "09:00",
+ SaturdayClosing = "13:00",
+ SundayOpening = "Closed",
+ SundayClosing = "Closed"
+ });
+ Guid scheduleId = Guid.NewGuid();
+ seedContext.MerchantSchedules.Add(new Db.MerchantSchedule {
+ MerchantScheduleId = scheduleId,
+ EstateId = EstateId,
+ MerchantId = MerchantId,
+ Year = 2026
+ });
+ seedContext.MerchantScheduleMonths.Add(new Db.MerchantScheduleMonth {
+ MerchantScheduleId = scheduleId,
+ Month = 9,
+ ClosedDays = "1,15,invalid"
+ });
+ }
+
+ public void AddBalance(Guid merchantId, decimal balance)
+ {
+ seedContext.MerchantBalanceProjectionState.Add(new BalanceState {
+ EstateId = Guid.NewGuid(),
+ MerchantId = merchantId,
+ MerchantName = merchantId == MerchantId ? "Merchant 1" : "Merchant 2",
+ Balance = balance,
+ LastSale = new DateTime(2026, 9, 1),
+ Timestamp = Array.Empty()
+ });
+ }
+
+ public void AddCalendar(DateTime date)
+ {
+ seedContext.Calendar.Add(new Db.Calendar {
+ Date = date,
+ DayOfWeek = date.DayOfWeek.ToString(),
+ DayOfWeekShort = date.DayOfWeek.ToString()[..3],
+ MonthNameLong = date.ToString("MMMM"),
+ MonthNameShort = date.ToString("MMM"),
+ MonthNumber = date.Month,
+ WeekNumberString = "35",
+ YearWeekNumber = $"{date.Year}35",
+ Year = date.Year
+ });
+ }
+
+ public void AddEstateOperator()
+ {
+ seedContext.EstateOperators.Add(new Db.EstateOperator {
+ EstateId = EstateId,
+ OperatorId = OperatorId
+ });
+ }
+
+ public void AddEstateUser()
+ {
+ seedContext.EstateSecurityUsers.Add(new Db.EstateSecurityUser {
+ EstateId = EstateId,
+ SecurityUserId = Guid.NewGuid(),
+ EmailAddress = "user@example.test"
+ });
+ }
+
+ public Guid AddFileImportData()
+ {
+ Guid logId = Guid.NewGuid();
+ Guid fileId = Guid.NewGuid();
+ Guid userId = Guid.NewGuid();
+ Guid profileId = Guid.NewGuid();
+
+ seedContext.FileImportLogs.Add(new Db.FileImportLog {
+ FileImportLogId = logId,
+ EstateId = EstateId,
+ ImportLogDate = DateTime.Today,
+ ImportLogDateTime = DateTime.Today.AddHours(1)
+ });
+ seedContext.EstateSecurityUsers.Add(new Db.EstateSecurityUser {
+ EstateId = EstateId,
+ SecurityUserId = userId,
+ EmailAddress = "uploader@example.test"
+ });
+ seedContext.Files.Add(new Db.File {
+ FileId = fileId,
+ FileImportLogId = logId,
+ FileLocation = "import.csv",
+ FileProfileId = profileId,
+ FileReceivedDateTime = DateTime.Today,
+ UserId = userId,
+ MerchantId = MerchantId
+ });
+ seedContext.FileLines.Add(new Db.FileLine {
+ FileId = fileId,
+ LineNumber = 1,
+ FileLineData = "line-data",
+ Status = "OK"
+ });
+
+ return logId;
+ }
+
+ public void AddSettlementFee(Guid transactionId, decimal feeValue)
+ {
+ Guid settlementId = Guid.NewGuid();
+ seedContext.Settlements.Add(new Db.Settlement {
+ SettlementId = settlementId,
+ EstateId = EstateId,
+ MerchantId = MerchantId,
+ SettlementDate = new DateTime(2026, 9, 1),
+ ProcessingStarted = true,
+ IsCompleted = true,
+ ProcessingStartedDateTIme = new DateTime(2026, 9, 1)
+ });
+ seedContext.MerchantSettlementFees.Add(new Db.MerchantSettlementFee {
+ SettlementId = settlementId,
+ TransactionId = transactionId,
+ MerchantId = MerchantId,
+ FeeValue = feeValue,
+ CalculatedValue = feeValue,
+ IsSettled = true,
+ FeeCalculatedDateTime = new DateTime(2026, 9, 1)
+ });
+ }
+
+ public Task SaveAsync() => seedContext.SaveChangesAsync();
+
+ public async ValueTask DisposeAsync()
+ {
+ await seedContext.DisposeAsync();
+ await services.DisposeAsync();
+ }
+ }
+}
diff --git a/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs b/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs
new file mode 100644
index 0000000..1202750
--- /dev/null
+++ b/EstateReportingAPI.BusinessLogic.UnitTests/RequestHandlerTests.cs
@@ -0,0 +1,458 @@
+using EstateReportingAPI.BusinessLogic;
+using EstateReportingAPI.BusinessLogic.Queries;
+using EstateReportingAPI.BusinessLogic.RequestHandlers;
+using EstateReportingAPI.Models;
+using Imposter.Abstractions;
+using Shouldly;
+using SimpleResults;
+
+[assembly: GenerateImposter(typeof(IReportingManager))]
+
+namespace EstateReportingAPI.BusinessLogic.UnitTests;
+
+public sealed class RequestHandlerTests
+{
+ [Fact]
+ public async Task SettlementRequestHandler_ForwardsRequestAndCancellationToken()
+ {
+ var manager = new IReportingManagerImposter();
+ var expected = Result.Success(new TodaysSettlement());
+ var request = new SettlementQueries.TodaysSettlementQuery(Guid.NewGuid(), DateTime.Today);
+ var cancellationToken = new CancellationTokenSource().Token;
+ manager.GetTodaysSettlement(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(expected));
+
+ var actual = await new SettlementRequestHandler(manager.Instance()).Handle(request, cancellationToken);
+
+ actual.ShouldBe(expected);
+ }
+
+ [Fact]
+ public async Task CalendarRequestHandler_ForwardsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new CalendarQueries.GetYearsQuery(Guid.NewGuid());
+ manager.GetCalendarYears(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new CalendarRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task CalendarRequestHandler_ForwardsAllDatesRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new CalendarQueries.GetAllDatesQuery(Guid.NewGuid());
+ manager.GetCalendarDates(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new CalendarRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task CalendarRequestHandler_ForwardsComparisonDatesRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new CalendarQueries.GetComparisonDatesQuery(Guid.NewGuid());
+ manager.GetCalendarComparisonDates(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new CalendarRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task CalendarRequestHandler_WhenComparisonDatesFails_ReturnsFailure()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new CalendarQueries.GetComparisonDatesQuery(Guid.NewGuid());
+ manager.GetCalendarComparisonDates(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult>>(Result.Failure("Calendar lookup failed.")));
+
+ var actual = await new CalendarRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsFailed.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task ContractRequestHandler_ForwardsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new ContractQueries.GetContractsQuery(Guid.NewGuid());
+ manager.GetContracts(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new ContractRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task ContractRequestHandler_ForwardsRecentContractsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new ContractQueries.GetRecentContractsQuery(Guid.NewGuid());
+ manager.GetRecentContracts(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new ContractRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task ContractRequestHandler_ForwardsContractRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new ContractQueries.GetContractQuery(Guid.NewGuid(), Guid.NewGuid());
+ manager.GetContract(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new Contract())));
+
+ var actual = await new ContractRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task EstateRequestHandler_ForwardsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new EstateQueries.GetEstateQuery(Guid.NewGuid());
+ manager.GetEstate(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new Estate())));
+
+ var actual = await new EstateRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task EstateRequestHandler_ForwardsEstateOperatorsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new EstateQueries.GetEstateOperatorsQuery(Guid.NewGuid());
+ manager.GetEstateOperators(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new EstateRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task FileImportLogRequestHandler_ForwardsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new FileImportLogQueries.GetFileImportLogListQuery(Guid.NewGuid(), null, DateTime.Today, DateTime.Today);
+ manager.GetFileImportLogList(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new FileImportLogRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task FileImportLogRequestHandler_ForwardsSingleLogRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new FileImportLogQueries.GetFileImportLogQuery(Guid.NewGuid(), null, Guid.NewGuid());
+ manager.GetFileImportLog(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new FileImportLog())));
+
+ var actual = await new FileImportLogRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task FileProfileConfigurationRequestHandler_ForwardsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new FileProfileConfigurationQueries.GetFileProfileConfigurationListQuery(Guid.NewGuid());
+ manager.GetFileProfileConfigurationList(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new FileProfileConfigurationRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task MerchantRequestHandler_ForwardsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new MerchantQueries.GetMerchantQuery(Guid.NewGuid(), Guid.NewGuid());
+ manager.GetMerchant(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new Merchant())));
+
+ var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task MerchantRequestHandler_ForwardsRecentMerchantsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new MerchantQueries.GetRecentMerchantsQuery(Guid.NewGuid());
+ manager.GetRecentMerchants(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task MerchantRequestHandler_ForwardsTransactionKpisRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new MerchantQueries.GetTransactionKpisQuery(Guid.NewGuid());
+ manager.GetMerchantsTransactionKpis(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new MerchantKpi())));
+
+ var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task MerchantRequestHandler_ForwardsMerchantsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new MerchantQueries.GetMerchantsQuery(Guid.NewGuid(), new MerchantQueries.MerchantQueryOptions("", "", null, "", ""));
+ manager.GetMerchants(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task MerchantRequestHandler_ForwardsMerchantOperatorsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new MerchantQueries.GetMerchantOperatorsQuery(Guid.NewGuid(), Guid.NewGuid());
+ manager.GetMerchantOperators(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task MerchantRequestHandler_ForwardsMerchantContractsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new MerchantQueries.GetMerchantContractsQuery(Guid.NewGuid(), Guid.NewGuid());
+ manager.GetMerchantContracts(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task MerchantRequestHandler_ForwardsMerchantDevicesRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new MerchantQueries.GetMerchantDevicesQuery(Guid.NewGuid(), Guid.NewGuid());
+ manager.GetMerchantDevices(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task MerchantRequestHandler_ForwardsMerchantOpeningHoursRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new MerchantQueries.GetMerchantOpeningHoursQuery(Guid.NewGuid(), Guid.NewGuid());
+ manager.GetMerchantOpeningHours(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task MerchantRequestHandler_ForwardsMerchantScheduleRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new MerchantQueries.GetMerchantScheduleQuery(Guid.NewGuid(), Guid.NewGuid(), 2026);
+ manager.GetMerchantSchedule(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new MerchantScheduleResponse())));
+
+ var actual = await new MerchantRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task OperatorRequestHandler_ForwardsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new OperatorQueries.GetOperatorsQuery(Guid.NewGuid());
+ manager.GetOperators(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new OperatorRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task OperatorRequestHandler_ForwardsOperatorRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new OperatorQueries.GetOperatorQuery(Guid.NewGuid(), Guid.NewGuid());
+ manager.GetOperator(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new Operator())));
+
+ var actual = await new OperatorRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.ProductPerformanceQuery(Guid.NewGuid(), DateTime.Today, DateTime.Today);
+ manager.GetProductPerformanceReport(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new ProductPerformanceResponse())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsFailedSalesRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.TodaysFailedSales(Guid.NewGuid(), DateTime.Today, "1000");
+ manager.GetTodaysFailedSales(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new TodaysSales())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsTodaysSalesRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.TodaysSalesQuery(Guid.NewGuid(), 1, 2, DateTime.Today);
+ manager.GetTodaysSales(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new TodaysSales())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsTransactionDetailRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.TransactionDetailReportQuery(Guid.NewGuid(), new TransactionDetailReportRequest());
+ manager.GetTransactionDetailReport(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new TransactionDetailReportResponse())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsSummaryByMerchantRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.TransactionSummaryByMerchantQuery(Guid.NewGuid(), new TransactionSummaryByMerchantRequest());
+ manager.GetTransactionSummaryByMerchantReport(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new TransactionSummaryByMerchantResponse())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsSummaryByOperatorRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.TransactionSummaryByOperatorQuery(Guid.NewGuid(), new TransactionSummaryByOperatorRequest());
+ manager.GetTransactionSummaryByOperatorReport(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new TransactionSummaryByOperatorResponse())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsTransactionMixRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.TransactionMixSummaryQuery(Guid.NewGuid(), new TransactionMixSummaryRequest());
+ manager.GetTransactionMixSummary(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new TransactionMixSummaryResponse())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsRecentActivityRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.GetRecentActivityReceiptReportQuery(Guid.NewGuid(), new GetRecentActivityReceiptReportRequest());
+ manager.GetRecentActivityReceiptReport(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new GetRecentActivityReceiptReportResponse())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsSalesByHourRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.TodaysSalesByHour(Guid.NewGuid(), DateTime.Today);
+ manager.GetTodaysSalesByHour(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new List())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TransactionRequestHandler_ForwardsMerchantDailyPerformanceRequest()
+ {
+ var manager = new IReportingManagerImposter();
+ var request = new TransactionQueries.MerchantDailyPerformanceSummaryQuery(Guid.NewGuid(), new MerchantDailyPerformanceSummaryRequest());
+ manager.GetMerchantDailyPerformanceSummary(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Success(new MerchantDailyPerformanceSummaryResponse())));
+
+ var actual = await new TransactionRequestHandler(manager.Instance()).Handle(request, CancellationToken.None);
+
+ actual.IsSuccess.ShouldBeTrue();
+ }
+}
diff --git a/EstateReportingAPI.BusinessLogic/Queries/Archive/ArchiveCode.cs b/EstateReportingAPI.BusinessLogic/Queries/Archive/ArchiveCode.cs
deleted file mode 100644
index e4d6f2e..0000000
--- a/EstateReportingAPI.BusinessLogic/Queries/Archive/ArchiveCode.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using EstateReportingAPI.Models;
-using MediatR;
-using SimpleResults;
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Text;
-
-namespace EstateReportingAPI.BusinessLogic.Queries.Archive
-{
- public record MerchantQueries
- {
- public record GetMerchantsQuery(Guid EstateId) : IRequest>>;
-
- public record GetByLastSaleQuery(Guid EstateId, DateTime StartDateTime, DateTime EndDateTime) : IRequest>>;
- public record GetTopMerchantsBySalesValueQuery(Guid EstateId, Int32 numberOfMerchants) : IRequest>>;
- public record GetBottomMerchantsBySalesValueQuery(Guid EstateId, Int32 numberOfMerchants) : IRequest>>;
- public record GetMerchantPerformanceQuery(Guid EstateId, DateTime comparisonDate, List merchantReportingIds) : IRequest>;
- }
-
-
- [ExcludeFromCodeCoverage]
- public record OperatorQueries
- {
- public record GetOperatorsQuery(Guid EstateId) : IRequest>>;
- public record GetOperatorPerformanceQuery(Guid EstateId, DateTime comparisonDate, List operatorReportingIds) : IRequest>;
- public record GetTopOperatorsBySalesValueQuery(Guid EstateId, Int32 numberOfOperators) : IRequest>>;
- public record GetBottomOperatorsBySalesValueQuery(Guid EstateId, Int32 numberOfOperators) : IRequest>>;
- }
-
- [ExcludeFromCodeCoverage]
- public record ProductQueries
- {
- public record GetProductPerformanceQuery(Guid EstateId, DateTime comparisonDate, List productReportingIds) : IRequest>;
- public record GetTopProductsBySalesValueQuery(Guid EstateId, Int32 numberOfProducts) : IRequest>>;
- public record GetBottomProductsBySalesValueQuery(Guid EstateId, Int32 numberOfProducts) : IRequest>>;
- }
-
- [ExcludeFromCodeCoverage]
- public record ResponseCodeQueries
- {
- public record GetResponseCodesQuery(Guid EstateId) : IRequest>>;
- }
-
- [ExcludeFromCodeCoverage]
- public record SettlementQueries
- {
- public record GetTodaysSettlementQuery(Guid EstateId, Int32 MerchantReportingId, Int32 OperatorReportingId, DateTime ComparisonDate) : IRequest>;
-
- public record GetLastSettlementQuery(Guid EstateId) : IRequest>;
-
- public record GetUnsettledFeesQuery(Guid EstateId, DateTime StartDate, DateTime EndDate, List MerchantIdFilter, List OperatorIdFilter, List ProductIdFilter, GroupByOption GroupByOption) : IRequest>>;
- }
-
- [ExcludeFromCodeCoverage]
- public record TransactionQueries
- {
- public record TodaysSalesQuery(Guid estateId, Int32 merchantReportingId, Int32 operatorReportingId, DateTime comparisonDate) : IRequest>;
- public record TodaysFailedSales(Guid estateId, DateTime comparisonDate, String responseCode) : IRequest>;
-
- public record TodaysSalesCountByHour(Guid estateId, Int32 merchantReportingId, Int32 operatorReportingId, DateTime comparisonDate) : IRequest>>;
- public record TodaysSalesValueByHour(Guid estateId, Int32 merchantReportingId, Int32 operatorReportingId, DateTime comparisonDate) : IRequest>>;
-
- public record TransactionSearchQuery(Guid estateId, TransactionSearchRequest request, PagingRequest pagingRequest, Models.SortingRequest sortingRequest) : IRequest>>;
- }
-}
diff --git a/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs b/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs
index cdbb0ff..0e47a0e 100644
--- a/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs
+++ b/EstateReportingAPI.BusinessLogic/QueryTimingInterceptor.cs
@@ -11,11 +11,14 @@
using System.Collections.Generic;
using System.Data.Common;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Text;
using TransactionProcessor.Database.Contexts;
namespace EstateReportingAPI.BusinessLogic;
+// Infrastructure component currently scheduled for generalisation and relocation to Shared; exclude until that work is completed.
+[ExcludeFromCodeCoverage]
public class QueryTimingInterceptor : DbCommandInterceptor {
internal void LogIfRequired(DbCommand command,
@@ -46,6 +49,8 @@ public override async ValueTask ReaderExecutedAsync(DbCommand comm
}
+// Infrastructure component currently scheduled for generalisation and relocation to Shared; exclude until that work is completed.
+[ExcludeFromCodeCoverage]
public class DbContextResolverX : IDbContextResolver where TContext : DbContext
{
private readonly IServiceProvider _rootProvider;
diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs
index ff086b2..3a41d53 100644
--- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs
+++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs
@@ -1053,12 +1053,17 @@ public async Task>> GetMerchants(MerchantQueries.GetMercha
if (balanceQueryResults.IsFailed)
return ResultHelpers.CreateFailure(balanceQueryResults);
- Dictionary balanceLookup = BuildMerchantBalanceLookup(balanceQueryResults.Data);
- List response = merchants.Select(merchant =>
- ModelFactory.ConvertFrom(merchant, GetMerchantBalance(balanceLookup, merchant.Merchant.MerchantId)))
- .ToList();
+ try {
+ Dictionary balanceLookup = BuildMerchantBalanceLookup(balanceQueryResults.Data);
+ List response = merchants.Select(merchant =>
+ ModelFactory.ConvertFrom(merchant, GetMerchantBalance(balanceLookup, merchant.Merchant.MerchantId)))
+ .ToList();
- return Result.Success(response);
+ return Result.Success(response);
+ }
+ catch (InvalidOperationException ex) {
+ return Result.Failure(ex.Message);
+ }
}
public async Task> GetMerchant(MerchantQueries.GetMerchantQuery request, CancellationToken cancellationToken) {