From 9ee1e9397d07acfee0ad391fb0cf13cec396f7c1 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Mon, 6 Jul 2026 10:37:55 +0100 Subject: [PATCH] transaction mix report --- .../ReportRequestHandlerTests.cs | 50 ++ .../ServicesTests/ReportsServiceTests.cs | 102 ++++ .../Reports/ReportsPageViewModelTests.cs | 16 +- .../TransactionMixPageViewModelTests.cs | 65 +++ .../Models/TransactionMixRequest.cs | 32 ++ .../Models/TransactionMixSummaryModel.cs | 86 ++++ .../RequestHandlers/ReportRequestHandler.cs | 21 +- .../Requests/ReportQueries.cs | 6 + .../Services/ReportsService.cs | 107 +++- .../UIServices/INavigationService.cs | 2 + .../Reports/ReportsPageViewModel.cs | 10 +- .../Reports/TransactionMixPageViewModel.cs | 367 +++++++++++++ .../Features/Reporting.feature | 103 ++++ .../Features/Reporting.feature.cs | 481 ++++++++++++++++++ .../Pages/ReportsPage.cs | 8 + .../Pages/TransactionMixPage.cs | 25 + .../Steps/ReportsSteps.cs | 23 +- TransactionProcessor.Mobile/App.xaml.cs | 1 + .../Extensions/MauiAppBuilderExtensions.cs | 2 + .../Pages/Reports/TransactionMixPage.xaml | 254 +++++++++ .../Pages/Reports/TransactionMixPage.xaml.cs | 20 + .../TransactionProcessor.Mobile.csproj | 3 + .../UIServices/ShellNavigationService.cs | 5 + 23 files changed, 1783 insertions(+), 6 deletions(-) create mode 100644 TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/ReportsServiceTests.cs create mode 100644 TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/Reports/TransactionMixPageViewModelTests.cs create mode 100644 TransactionProcessor.Mobile.BusinessLogic/Models/TransactionMixRequest.cs create mode 100644 TransactionProcessor.Mobile.BusinessLogic/Models/TransactionMixSummaryModel.cs create mode 100644 TransactionProcessor.Mobile.BusinessLogic/ViewModels/Reports/TransactionMixPageViewModel.cs create mode 100644 TransactionProcessor.Mobile.UITests/Features/Reporting.feature create mode 100644 TransactionProcessor.Mobile.UITests/Features/Reporting.feature.cs create mode 100644 TransactionProcessor.Mobile.UITests/Pages/TransactionMixPage.cs create mode 100644 TransactionProcessor.Mobile/Pages/Reports/TransactionMixPage.xaml create mode 100644 TransactionProcessor.Mobile/Pages/Reports/TransactionMixPage.xaml.cs diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/RequestHandlerTests/ReportRequestHandlerTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/RequestHandlerTests/ReportRequestHandlerTests.cs index 55609806..1e8fa168 100644 --- a/TransactionProcessor.Mobile.BusinessLogic.Tests/RequestHandlerTests/ReportRequestHandlerTests.cs +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/RequestHandlerTests/ReportRequestHandlerTests.cs @@ -46,4 +46,54 @@ public async Task GetDailyPerformanceSummaryQuery_ReturnsMockedSummaryForToday() It.IsAny()), Times.Once); } + + [Fact] + public async Task GetTransactionMixSummaryQuery_ReturnsRequestedBreakdown() + { + Mock reportsService = new(); + Mock applicationCache = new(); + MerchantDetailsModel merchantDetails = new() + { + MerchantReportingId = 12345 + }; + + applicationCache.Setup(a => a.GetMerchantDetails()).Returns(merchantDetails); + reportsService.Setup(r => r.GetTransactionMixSummary( + merchantDetails.MerchantReportingId, + It.IsAny(), + It.IsAny(), + TransactionMixBreakdown.Product, + TransactionMixMeasure.Value, + 5, + It.IsAny())) + .ReturnsAsync(Result.Success(TransactionMixSummaryModel.CreateMock( + merchantReportingId: merchantDetails.MerchantReportingId, + breakdown: TransactionMixBreakdown.Product, + measure: TransactionMixMeasure.Value))); + + ReportRequestHandler handler = new(reportsService.Object, applicationCache.Object); + + Result result = await handler.Handle( + new ReportQueries.GetTransactionMixSummaryQuery( + StartDate: new DateTime(2026, 7, 1), + EndDate: new DateTime(2026, 7, 31), + Breakdown: TransactionMixBreakdown.Product, + Measure: TransactionMixMeasure.Value, + TopN: 5), + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Breakdown.ShouldBe(TransactionMixBreakdown.Product); + result.Data.Measure.ShouldBe(TransactionMixMeasure.Value); + result.Data.Items.ShouldNotBeEmpty(); + reportsService.Verify(r => r.GetTransactionMixSummary( + merchantDetails.MerchantReportingId, + new DateTime(2026, 7, 1), + new DateTime(2026, 7, 31), + TransactionMixBreakdown.Product, + TransactionMixMeasure.Value, + 5, + It.IsAny()), + Times.Once); + } } diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/ReportsServiceTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/ReportsServiceTests.cs new file mode 100644 index 00000000..a51c1009 --- /dev/null +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/ServicesTests/ReportsServiceTests.cs @@ -0,0 +1,102 @@ +using Moq; +using RichardSzalay.MockHttp; +using Shouldly; +using SimpleResults; +using System.Net; +using System.Text; +using TransactionProcessor.Mobile.BusinessLogic.Logging; +using TransactionProcessor.Mobile.BusinessLogic.Models; +using TransactionProcessor.Mobile.BusinessLogic.Serialisation; +using TransactionProcessor.Mobile.BusinessLogic.Services; +using TransactionProcessor.Mobile.BusinessLogic.UIServices; + +namespace TransactionProcessor.Mobile.BusinessLogic.Tests.ServicesTests; + +public class ReportsServiceTests +{ + private readonly MockHttpMessageHandler MockHttpMessageHandler; + private readonly Mock ApplicationCache; + private readonly Mock ApplicationInfoService; + private readonly IReportsService ReportsService; + + public ReportsServiceTests() + { + this.MockHttpMessageHandler = new MockHttpMessageHandler(); + this.ApplicationCache = new Mock(); + this.ApplicationInfoService = new Mock(); + this.ApplicationCache.Setup(s => s.GetAccessToken()).Returns(new TokenResponseModel + { + AccessToken = "token" + }); + this.ApplicationInfoService.Setup(s => s.VersionString).Returns("1.2.3"); + this.ReportsService = new ReportsService(_ => "http://localhost", + this.MockHttpMessageHandler.ToHttpClient(), + this.ApplicationCache.Object, + obj => StringSerialiser.Serialise(obj), + (json, type) => StringSerialiser.DeserializeObject(json, type), + this.ApplicationInfoService.Object); + Logger.Initialise(new NullLogger()); + StringSerialiser.Initialise((IStringSerialiser)new SystemTextJsonSerializer(SystemTextJsonSerializer.GetDefaultJsonSerializerOptions())); + } + + [Fact] + public async Task GetTransactionMixSummary_SendsRequestAndMapsResponse() + { + MerchantTransactionMixSummaryResponseDto response = new() + { + FromDate = new DateTime(2026, 7, 1), + ToDate = new DateTime(2026, 7, 31), + Breakdown = TransactionMixBreakdown.Product, + Measure = TransactionMixMeasure.Value, + TotalCount = 3, + TotalValue = 3110.00m, + Items = + [ + new TransactionMixSummaryItemDto { Key = "custom", Label = "Custom", Count = 2, Value = 1250.00m }, + new TransactionMixSummaryItemDto { Key = "bill-pay-post", Label = "Bill Pay (Post)", Count = 1, Value = 1900.00m }, + ], + DrillDownTransactions = + [ + new TransactionMixDrillDownTransactionDto + { + Reference = "TXN-10001", + TransactionType = "Mobile Topup", + Product = "Custom", + Operator = "Safaricom", + Status = "Success", + Amount = 100.00m, + TransactionDateTime = new DateTime(2026, 7, 6, 9, 30, 0) + } + ] + }; + + String requestPayload = string.Empty; + this.MockHttpMessageHandler.When(HttpMethod.Post, "http://localhost/api/reporting/transactionmixsummary?applicationVersion=1.2.3") + .Respond(req => + { + requestPayload = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(StringSerialiser.Serialise(response), Encoding.UTF8, "application/json") + }; + }); + + Result result = await this.ReportsService.GetTransactionMixSummary(12345, + new DateTime(2026, 7, 1), + new DateTime(2026, 7, 31), + TransactionMixBreakdown.Product, + TransactionMixMeasure.Value, + 5, + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.Breakdown.ShouldBe(TransactionMixBreakdown.Product); + result.Data.Measure.ShouldBe(TransactionMixMeasure.Value); + result.Data.TotalCount.ShouldBe(3); + result.Data.TotalValue.ShouldBe(3110.00m); + result.Data.Items.Count.ShouldBe(2); + result.Data.DrillDownTransactions.Count.ShouldBe(1); + requestPayload.ShouldContain("\"merchant_reporting_id\": 12345"); + requestPayload.ShouldContain("\"top_n\": 5"); + } +} diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/Reports/ReportsPageViewModelTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/Reports/ReportsPageViewModelTests.cs index 9a458252..5e8569f6 100644 --- a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/Reports/ReportsPageViewModelTests.cs +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/Reports/ReportsPageViewModelTests.cs @@ -66,7 +66,21 @@ public async Task ReportsPageViewModel_OptionSelectedCommand_Execute_IsExecuted( public async Task ReportsPageViewModel_Initialise_IsInitialised() { await this.ViewModel.Initialise(CancellationToken.None); - this.ViewModel.ReportsMenuOptions.Count.ShouldBe(1); + this.ViewModel.ReportsMenuOptions.Count.ShouldBe(2); + } + + [Fact] + public async Task ReportsPageViewModel_TransactionMixCommand_Execute_IsExecuted() + { + ItemSelected itemSelected = new ItemSelected + { + SelectedItem = new ListViewItem { Title = "Transaction Mix" }, + SelectedItemIndex = 1, + }; + + this.ViewModel.OptionSelectedCommand.Execute(itemSelected); + + this.NavigationService.Verify(v => v.GoToTransactionMixSummaryPage(), Times.Once); } [Fact] diff --git a/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/Reports/TransactionMixPageViewModelTests.cs b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/Reports/TransactionMixPageViewModelTests.cs new file mode 100644 index 00000000..8a029727 --- /dev/null +++ b/TransactionProcessor.Mobile.BusinessLogic.Tests/ViewModelTests/Reports/TransactionMixPageViewModelTests.cs @@ -0,0 +1,65 @@ +using MediatR; +using Moq; +using Shouldly; +using SimpleResults; +using TransactionProcessor.Mobile.BusinessLogic.Common; +using TransactionProcessor.Mobile.BusinessLogic.Models; +using TransactionProcessor.Mobile.BusinessLogic.Requests; +using TransactionProcessor.Mobile.BusinessLogic.Services; +using TransactionProcessor.Mobile.BusinessLogic.UIServices; +using TransactionProcessor.Mobile.BusinessLogic.ViewModels.Reports; + +namespace TransactionProcessor.Mobile.BusinessLogic.Tests.ViewModelTests.Reports; + +public class TransactionMixPageViewModelTests +{ + private readonly Mock Mediator; + private readonly Mock NavigationService; + private readonly Mock ApplicationCache; + private readonly Mock DialogService; + private readonly Mock DeviceService; + private readonly Mock NavigationParameterService; + private readonly TransactionMixPageViewModel ViewModel; + + public TransactionMixPageViewModelTests() + { + this.Mediator = new Mock(); + this.Mediator + .Setup(m => m.Send(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(TransactionMixSummaryModel.CreateMock( + merchantReportingId: 12345, + breakdown: TransactionMixBreakdown.TransactionType, + measure: TransactionMixMeasure.Count))); + + this.NavigationService = new Mock(); + this.ApplicationCache = new Mock(); + this.DialogService = new Mock(); + this.DeviceService = new Mock(); + this.NavigationParameterService = new Mock(); + + this.ViewModel = new TransactionMixPageViewModel(this.Mediator.Object, + this.NavigationService.Object, + this.ApplicationCache.Object, + this.DialogService.Object, + this.DeviceService.Object, + this.NavigationParameterService.Object); + } + + [Fact] + public async Task Initialise_LoadsDefaultTransactionMixSummary() + { + await this.ViewModel.Initialise(CancellationToken.None); + + this.ViewModel.Title.ShouldBe("Transaction Mix"); + this.ViewModel.SelectedBreakdown.ShouldBe(TransactionMixBreakdown.TransactionType); + this.ViewModel.SelectedMeasure.ShouldBe(TransactionMixMeasure.Count); + this.ViewModel.Summary.ShouldNotBeNull(); + this.ViewModel.Items.Count.ShouldBeGreaterThan(0); + this.ViewModel.TopItems.Count.ShouldBeGreaterThan(0); + this.ViewModel.HasChartData.ShouldBeTrue(); + this.ViewModel.ChartSeries.Length.ShouldBe(1); + this.ViewModel.ChartYAxes.Count.ShouldBe(1); + this.ViewModel.ChartXAxes.Count.ShouldBe(1); + this.ViewModel.IsLoading.ShouldBeFalse(); + } +} diff --git a/TransactionProcessor.Mobile.BusinessLogic/Models/TransactionMixRequest.cs b/TransactionProcessor.Mobile.BusinessLogic/Models/TransactionMixRequest.cs new file mode 100644 index 00000000..a07fc671 --- /dev/null +++ b/TransactionProcessor.Mobile.BusinessLogic/Models/TransactionMixRequest.cs @@ -0,0 +1,32 @@ +namespace TransactionProcessor.Mobile.BusinessLogic.Models; + +public enum TransactionMixBreakdown +{ + NotSet = 0, + TransactionType = 1, + Product = 2, + Operator = 3, + Status = 4, +} + +public enum TransactionMixMeasure +{ + NotSet = 0, + Count = 1, + Value = 2, +} + +public sealed class MerchantTransactionMixSummaryRequest +{ + public int MerchantReportingId { get; set; } + + public DateTime StartDate { get; set; } + + public DateTime EndDate { get; set; } + + public TransactionMixBreakdown Breakdown { get; set; } + + public TransactionMixMeasure Measure { get; set; } + + public int TopN { get; set; } = 5; +} diff --git a/TransactionProcessor.Mobile.BusinessLogic/Models/TransactionMixSummaryModel.cs b/TransactionProcessor.Mobile.BusinessLogic/Models/TransactionMixSummaryModel.cs new file mode 100644 index 00000000..769e1025 --- /dev/null +++ b/TransactionProcessor.Mobile.BusinessLogic/Models/TransactionMixSummaryModel.cs @@ -0,0 +1,86 @@ +namespace TransactionProcessor.Mobile.BusinessLogic.Models; + +public sealed record TransactionMixSummaryModel +{ + public DateTime FromDate { get; set; } + + public DateTime ToDate { get; set; } + + public TransactionMixBreakdown Breakdown { get; set; } + + public TransactionMixMeasure Measure { get; set; } + + public decimal TotalCount { get; set; } + + public decimal TotalValue { get; set; } + + public List Items { get; set; } = []; + + public List DrillDownTransactions { get; set; } = []; + + public static TransactionMixSummaryModel CreateMock(int merchantReportingId, + TransactionMixBreakdown breakdown, + TransactionMixMeasure measure) + { + DateTime today = DateTime.Today; + + List items = breakdown switch + { + TransactionMixBreakdown.Product => new List + { + new("custom", "Custom", 12, 1250.00m), + new("bill-pay-post", "Bill Pay (Post)", 8, 1900.00m), + new("10-kes", "10 KES", 6, 60.00m), + }, + TransactionMixBreakdown.Operator => new List + { + new("safaricom", "Safaricom", 18, 1800.00m), + new("voucher", "Voucher", 6, 60.00m), + new("patapawa-postpay", "PataPawa PostPay", 4, 1500.00m), + }, + TransactionMixBreakdown.Status => new List + { + new("success", "Success", 22, 3110.00m), + new("failed", "Failed", 6, 0.00m), + }, + _ => new List + { + new("mobile-topup", "Mobile Topup", 14, 1250.00m), + new("bill-payment", "Bill Payment", 10, 1900.00m), + new("voucher-issue", "Voucher Issue", 4, 60.00m), + } + }; + + List transactions = new() + { + new("TXN-10001", "Mobile Topup", "Custom", "Safaricom", "Success", 100.00m, today.AddHours(-1)), + new("TXN-10002", "Bill Payment", "Bill Pay (Post)", "PataPawa PostPay", "Success", 250.00m, today.AddHours(-2)), + new("TXN-10003", "Voucher Issue", "10 KES", "Voucher", "Failed", 0.00m, today.AddHours(-3)), + }; + + return new TransactionMixSummaryModel + { + Breakdown = breakdown, + DrillDownTransactions = transactions, + FromDate = today.AddDays(-7), + ToDate = today, + Items = items, + Measure = measure, + TotalCount = 28, + TotalValue = 3110.00m, + }; + } +} + +public sealed record TransactionMixSummaryItemModel(string Key, + string Label, + decimal Count, + decimal Value); + +public sealed record TransactionMixDrillDownTransactionModel(string Reference, + string TransactionType, + string Product, + string Operator, + string Status, + decimal Amount, + DateTime TransactionDateTime); diff --git a/TransactionProcessor.Mobile.BusinessLogic/RequestHandlers/ReportRequestHandler.cs b/TransactionProcessor.Mobile.BusinessLogic/RequestHandlers/ReportRequestHandler.cs index 201261d7..da78b505 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/RequestHandlers/ReportRequestHandler.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/RequestHandlers/ReportRequestHandler.cs @@ -6,7 +6,8 @@ namespace TransactionProcessor.Mobile.BusinessLogic.RequestHandlers; -public sealed class ReportRequestHandler : IRequestHandler> { +public sealed class ReportRequestHandler : IRequestHandler>, + IRequestHandler> { private readonly IReportsService ReportsService; private readonly IApplicationCache ApplicationCache; @@ -37,4 +38,22 @@ public async Task> Handle(ReportQueries.Get return await this.ReportsService.GetDailyPerformanceSummary(request.Period, merchant.MerchantReportingId, dates.startDate, dates.endDate, cancellationToken); } + + public async Task> Handle(ReportQueries.GetTransactionMixSummaryQuery request, + CancellationToken cancellationToken) + { + MerchantDetailsModel merchant = this.ApplicationCache.GetMerchantDetails(); + if (merchant == null) + { + return Result.Failure("Merchant details are not available."); + } + + return await this.ReportsService.GetTransactionMixSummary(merchant.MerchantReportingId, + request.StartDate, + request.EndDate, + request.Breakdown, + request.Measure, + request.TopN, + cancellationToken); + } } diff --git a/TransactionProcessor.Mobile.BusinessLogic/Requests/ReportQueries.cs b/TransactionProcessor.Mobile.BusinessLogic/Requests/ReportQueries.cs index 78573963..21ea2493 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/Requests/ReportQueries.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/Requests/ReportQueries.cs @@ -7,4 +7,10 @@ namespace TransactionProcessor.Mobile.BusinessLogic.Requests; public record ReportQueries { public record GetDailyPerformanceSummaryQuery(PerformanceSummaryPeriod Period) : IRequest>; + + public record GetTransactionMixSummaryQuery(DateTime StartDate, + DateTime EndDate, + TransactionMixBreakdown Breakdown, + TransactionMixMeasure Measure, + int TopN) : IRequest>; } diff --git a/TransactionProcessor.Mobile.BusinessLogic/Services/ReportsService.cs b/TransactionProcessor.Mobile.BusinessLogic/Services/ReportsService.cs index be46913c..aa1dd8ee 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/Services/ReportsService.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/Services/ReportsService.cs @@ -13,6 +13,13 @@ namespace TransactionProcessor.Mobile.BusinessLogic.Services { public interface IReportsService { Task> GetDailyPerformanceSummary(PerformanceSummaryPeriod period, int merchantReportingId, DateTime startDate, DateTime endDate, CancellationToken cancellationToken); + Task> GetTransactionMixSummary(int merchantReportingId, + DateTime startDate, + DateTime endDate, + TransactionMixBreakdown breakdown, + TransactionMixMeasure measure, + int topN, + CancellationToken cancellationToken); } public class ReportsService : ClientProxyBase.ClientProxyBase, IReportsService { @@ -73,10 +80,61 @@ public async Task> GetDailyPerformanceSumma ToDate = endDate, FromDate = startDate }; - + return Result.Success(model); } + + public async Task> GetTransactionMixSummary(int merchantReportingId, + DateTime startDate, + DateTime endDate, + TransactionMixBreakdown breakdown, + TransactionMixMeasure measure, + int topN, + CancellationToken cancellationToken) + { + TokenResponseModel accessToken = this.ApplicationCache.GetAccessToken(); + + String requestUri = this.BuildRequestUrl($"/api/reporting/transactionmixsummary?applicationVersion={this.ApplicationInfoService.VersionString}"); + + MerchantTransactionMixSummaryRequest requestBody = new() + { + MerchantReportingId = merchantReportingId, + StartDate = startDate, + EndDate = endDate, + Breakdown = breakdown, + Measure = measure, + TopN = topN + }; + + Result? responseDataResult = await this.Post(requestUri, requestBody, accessToken.AccessToken, cancellationToken); + + if (responseDataResult.IsFailed) + { + Logger.LogInformation($"GetTransactionMixSummary failed {responseDataResult.Status}"); + return ResultHelpers.CreateFailure(responseDataResult); + } + + TransactionMixSummaryModel model = new() + { + FromDate = responseDataResult.Data.FromDate, + ToDate = responseDataResult.Data.ToDate, + Breakdown = responseDataResult.Data.Breakdown, + Measure = responseDataResult.Data.Measure, + TotalCount = responseDataResult.Data.TotalCount, + TotalValue = responseDataResult.Data.TotalValue, + Items = responseDataResult.Data.Items.Select(i => new TransactionMixSummaryItemModel(i.Key, i.Label, i.Count, i.Value)).ToList(), + DrillDownTransactions = responseDataResult.Data.DrillDownTransactions.Select(t => new TransactionMixDrillDownTransactionModel(t.Reference, + t.TransactionType, + t.Product, + t.Operator, + t.Status, + t.Amount, + t.TransactionDateTime)).ToList() + }; + + return Result.Success(model); + } } public class MerchantDailyPerformanceSummaryRequest @@ -118,4 +176,51 @@ public class DrillDownTransaction public DateTime TransactionDateTime { get; set; } } + + public class MerchantTransactionMixSummaryResponseDto + { + public DateTime FromDate { get; set; } + + public DateTime ToDate { get; set; } + + public TransactionMixBreakdown Breakdown { get; set; } + + public TransactionMixMeasure Measure { get; set; } + + public decimal TotalCount { get; set; } + + public decimal TotalValue { get; set; } + + public List Items { get; set; } = []; + + public List DrillDownTransactions { get; set; } = []; + } + + public class TransactionMixSummaryItemDto + { + public string Key { get; set; } + + public string Label { get; set; } + + public decimal Count { get; set; } + + public decimal Value { get; set; } + } + + public class TransactionMixDrillDownTransactionDto + { + public string Reference { get; set; } + + public string TransactionType { get; set; } + + public string Product { get; set; } + + public string Operator { get; set; } + + public string Status { get; set; } + + public decimal Amount { get; set; } + + public DateTime TransactionDateTime { get; set; } + } } diff --git a/TransactionProcessor.Mobile.BusinessLogic/UIServices/INavigationService.cs b/TransactionProcessor.Mobile.BusinessLogic/UIServices/INavigationService.cs index 9f5f8e73..90a55924 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/UIServices/INavigationService.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/UIServices/INavigationService.cs @@ -64,6 +64,8 @@ Task GoToBillPaymentPayBillPage(ProductDetails productDetails, Task GoToDailyPerformanceSummaryPage(); + Task GoToTransactionMixSummaryPage(); + Task GoToTransactions(); #endregion diff --git a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/Reports/ReportsPageViewModel.cs b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/Reports/ReportsPageViewModel.cs index e3017a32..bf04c7d5 100644 --- a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/Reports/ReportsPageViewModel.cs +++ b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/Reports/ReportsPageViewModel.cs @@ -46,8 +46,11 @@ public override async Task Initialise(CancellationToken cancellationToken) this.ReportsMenuOptions = new List { new ListViewItem { Title = "Daily Performance Summary" - }, - }; + }, + new ListViewItem { + Title = "Transaction Mix" + }, + }; await base.Initialise(cancellationToken); } @@ -60,6 +63,7 @@ private async Task OptionSelected(ItemSelected arg) Task navigationTask = selectedOption switch { ReportsOptions.DailyPerformanceSummary => this.NavigationService.GoToDailyPerformanceSummaryPage(), + ReportsOptions.TransactionMix => this.NavigationService.GoToTransactionMixSummaryPage(), _ => Task.Factory.StartNew(() => Logger.LogWarning($"Unsupported option selected {selectedOption}")) }; @@ -73,6 +77,8 @@ private async Task OptionSelected(ItemSelected arg) public enum ReportsOptions { DailyPerformanceSummary = 0, + + TransactionMix = 1, } #endregion diff --git a/TransactionProcessor.Mobile.BusinessLogic/ViewModels/Reports/TransactionMixPageViewModel.cs b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/Reports/TransactionMixPageViewModel.cs new file mode 100644 index 00000000..7d1756a7 --- /dev/null +++ b/TransactionProcessor.Mobile.BusinessLogic/ViewModels/Reports/TransactionMixPageViewModel.cs @@ -0,0 +1,367 @@ +using CommunityToolkit.Mvvm.Input; +using LiveChartsCore; +using LiveChartsCore.Kernel.Sketches; +using LiveChartsCore.Measure; +using LiveChartsCore.SkiaSharpView; +using MediatR; +using SimpleResults; +using TransactionProcessor.Mobile.BusinessLogic.Common; +using TransactionProcessor.Mobile.BusinessLogic.Models; +using TransactionProcessor.Mobile.BusinessLogic.Requests; +using TransactionProcessor.Mobile.BusinessLogic.Services; +using TransactionProcessor.Mobile.BusinessLogic.UIServices; + +namespace TransactionProcessor.Mobile.BusinessLogic.ViewModels.Reports; + +public sealed partial class TransactionMixPageViewModel : ExtendedBaseViewModel +{ + private readonly IMediator Mediator; + private TransactionMixBreakdown selectedBreakdown; + private TransactionMixMeasure selectedMeasure; + private DateTime startDate; + private DateTime endDate; + private bool isLoading; + private string? errorMessage; + private TransactionMixSummaryModel? summary; + private TransactionMixSummaryItemModel? selectedItem; + private int topN; + private TransactionMixBreakdownOption? selectedBreakdownOption; + private TransactionMixMeasureOption? selectedMeasureOption; + private ISeries[] chartSeries = []; + private List chartXAxes = []; + private List chartYAxes = []; + + public TransactionMixPageViewModel(IMediator mediator, + INavigationService navigationService, + IApplicationCache applicationCache, + IDialogService dialogService, + IDeviceService deviceService, + INavigationParameterService navigationParameterService) : base(applicationCache, dialogService, navigationService, deviceService, navigationParameterService) + { + this.Mediator = mediator; + this.Title = "Transaction Mix"; + this.SelectedBreakdown = TransactionMixBreakdown.TransactionType; + this.SelectedMeasure = TransactionMixMeasure.Count; + this.StartDate = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); + this.EndDate = DateTime.Today; + this.TopN = 5; + this.BreakdownOptions = Enum.GetValues() + .Where(option => option != TransactionMixBreakdown.NotSet) + .Select(option => new TransactionMixBreakdownOption(option, option.ToDisplayText())) + .ToList(); + this.MeasureOptions = Enum.GetValues() + .Where(option => option != TransactionMixMeasure.NotSet) + .Select(option => new TransactionMixMeasureOption(option, option.ToDisplayText())) + .ToList(); + this.SelectedBreakdownOption = this.BreakdownOptions.First(); + this.SelectedMeasureOption = this.MeasureOptions.First(); + } + + public List BreakdownOptions { get; } + + public List MeasureOptions { get; } + + public TransactionMixBreakdown SelectedBreakdown + { + get => this.selectedBreakdown; + set => SetProperty(ref this.selectedBreakdown, value); + } + + public TransactionMixBreakdownOption? SelectedBreakdownOption + { + get => this.selectedBreakdownOption; + set + { + if (SetProperty(ref this.selectedBreakdownOption, value) && value is not null) + { + this.SelectedBreakdown = value.Breakdown; + } + } + } + + public TransactionMixMeasure SelectedMeasure + { + get => this.selectedMeasure; + set => SetProperty(ref this.selectedMeasure, value); + } + + public TransactionMixMeasureOption? SelectedMeasureOption + { + get => this.selectedMeasureOption; + set + { + if (SetProperty(ref this.selectedMeasureOption, value) && value is not null) + { + this.SelectedMeasure = value.Measure; + } + } + } + + public DateTime StartDate + { + get => this.startDate; + set => SetProperty(ref this.startDate, value); + } + + public DateTime EndDate + { + get => this.endDate; + set => SetProperty(ref this.endDate, value); + } + + public int TopN + { + get => this.topN; + set => SetProperty(ref this.topN, value); + } + + public bool IsLoading + { + get => this.isLoading; + set => SetProperty(ref this.isLoading, value); + } + + public string? ErrorMessage + { + get => this.errorMessage; + set => SetProperty(ref this.errorMessage, value); + } + + public TransactionMixSummaryModel? Summary + { + get => this.summary; + private set => SetProperty(ref this.summary, value); + } + + public TransactionMixSummaryItemModel? SelectedItem + { + get => this.selectedItem; + set + { + if (SetProperty(ref this.selectedItem, value)) + { + this.OnPropertyChanged(nameof(this.SelectedItemTransactions)); + this.OnPropertyChanged(nameof(this.SelectedItemLabel)); + } + } + } + + public IReadOnlyList Items => this.Summary?.Items ?? []; + + public IReadOnlyList TopItems => this.Items.Take(this.TopN).ToList(); + + public IReadOnlyList DrillDownTransactions => this.Summary?.DrillDownTransactions ?? []; + + public ISeries[] ChartSeries + { + get => this.chartSeries; + private set => SetProperty(ref this.chartSeries, value); + } + + public List ChartXAxes + { + get => this.chartXAxes; + private set => SetProperty(ref this.chartXAxes, value); + } + + public List ChartYAxes + { + get => this.chartYAxes; + private set => SetProperty(ref this.chartYAxes, value); + } + + public bool HasChartData => this.ChartSeries.Length > 0; + + public IReadOnlyList SelectedItemTransactions + { + get + { + if (this.Summary is null) + { + return []; + } + + return this.DrillDownTransactions + .Where(this.MatchesSelectedItem) + .Take(this.TopN) + .ToList(); + } + } + + public string SelectedItemLabel => this.SelectedItem?.Label ?? "All transactions"; + + public bool HasSummaryData => this.Items.Count > 0; + + public bool HasError => string.IsNullOrWhiteSpace(this.ErrorMessage) == false; + + public bool HasEmptyState => this.IsLoading == false && this.HasError == false && this.HasSummaryData == false; + + public override async Task Initialise(CancellationToken cancellationToken) + { + await base.Initialise(cancellationToken); + await this.LoadSummaryAsync(cancellationToken); + } + + [RelayCommand] + private async Task ApplyFilters() + { + await this.LoadSummaryAsync(CancellationToken.None); + } + + [RelayCommand] + private void SelectItem(TransactionMixSummaryItemModel item) + { + this.SelectedItem = item; + this.OnPropertyChanged(nameof(this.SelectedItemTransactions)); + } + + private async Task LoadSummaryAsync(CancellationToken cancellationToken) + { + this.IsLoading = true; + this.ErrorMessage = null; + + try + { + Result result = await this.Mediator.Send(new ReportQueries.GetTransactionMixSummaryQuery( + this.StartDate.Date, + this.EndDate.Date, + this.SelectedBreakdown, + this.SelectedMeasure, + this.TopN), + cancellationToken); + if (result.IsFailed) + { + this.Summary = null; + this.SelectedItem = null; + this.ChartSeries = []; + this.ChartXAxes = []; + this.ChartYAxes = []; + this.ErrorMessage = "Unable to load transaction mix."; + return; + } + + this.Summary = result.Data; + this.SelectedItem = null; + this.UpdateChart(); + this.OnPropertyChanged(nameof(this.Items)); + this.OnPropertyChanged(nameof(this.TopItems)); + this.OnPropertyChanged(nameof(this.DrillDownTransactions)); + this.OnPropertyChanged(nameof(this.SelectedItemTransactions)); + this.OnPropertyChanged(nameof(this.HasChartData)); + this.OnPropertyChanged(nameof(this.HasSummaryData)); + this.OnPropertyChanged(nameof(this.HasEmptyState)); + } + catch + { + this.Summary = null; + this.SelectedItem = null; + this.ChartSeries = []; + this.ChartXAxes = []; + this.ChartYAxes = []; + this.ErrorMessage = "Unable to load transaction mix."; + } + finally + { + this.IsLoading = false; + this.OnPropertyChanged(nameof(this.Items)); + this.OnPropertyChanged(nameof(this.TopItems)); + this.OnPropertyChanged(nameof(this.DrillDownTransactions)); + this.OnPropertyChanged(nameof(this.SelectedItemTransactions)); + this.OnPropertyChanged(nameof(this.SelectedItemLabel)); + this.OnPropertyChanged(nameof(this.HasChartData)); + this.OnPropertyChanged(nameof(this.HasSummaryData)); + this.OnPropertyChanged(nameof(this.HasError)); + this.OnPropertyChanged(nameof(this.HasEmptyState)); + } + } + + private bool MatchesSelectedItem(TransactionMixDrillDownTransactionModel transaction) + { + if (this.SelectedItem is null) + { + return true; + } + + return this.SelectedBreakdown switch + { + TransactionMixBreakdown.TransactionType => string.Equals(transaction.TransactionType, this.SelectedItem.Label, StringComparison.OrdinalIgnoreCase), + TransactionMixBreakdown.Product => string.Equals(transaction.Product, this.SelectedItem.Label, StringComparison.OrdinalIgnoreCase), + TransactionMixBreakdown.Operator => string.Equals(transaction.Operator, this.SelectedItem.Label, StringComparison.OrdinalIgnoreCase), + TransactionMixBreakdown.Status => string.Equals(transaction.Status, this.SelectedItem.Label, StringComparison.OrdinalIgnoreCase), + _ => true, + }; + } + + private void UpdateChart() + { + IReadOnlyList topItems = this.TopItems; + if (topItems.Count == 0) + { + this.ChartSeries = []; + this.ChartXAxes = []; + this.ChartYAxes = []; + return; + } + + List labels = topItems.Select(item => item.Label).ToList(); + List values = topItems.Select(item => this.SelectedMeasure == TransactionMixMeasure.Value ? (double)item.Value : (double)item.Count).ToList(); + + this.ChartSeries = new ISeries[] + { + new RowSeries + { + Values = values, + Name = this.SelectedMeasure.ToDisplayText(), + Stroke = null, + Fill = null, + } + }; + + this.ChartYAxes = new List + { + new Axis + { + Labels = labels, + LabelsRotation = 0, + Name = this.SelectedBreakdown.ToDisplayText(), + } + }; + + this.ChartXAxes = new List + { + new Axis + { + Labeler = value => this.SelectedMeasure == TransactionMixMeasure.Value ? value.ToString("N2") : value.ToString("N0"), + Name = this.SelectedMeasure.ToDisplayText(), + } + }; + + this.OnPropertyChanged(nameof(this.ChartSeries)); + this.OnPropertyChanged(nameof(this.ChartXAxes)); + this.OnPropertyChanged(nameof(this.ChartYAxes)); + this.OnPropertyChanged(nameof(this.HasChartData)); + } +} + +public sealed record TransactionMixBreakdownOption(TransactionMixBreakdown Breakdown, string DisplayText); + +public sealed record TransactionMixMeasureOption(TransactionMixMeasure Measure, string DisplayText); + +internal static class TransactionMixDisplayExtensions +{ + public static string ToDisplayText(this TransactionMixBreakdown breakdown) => breakdown switch + { + TransactionMixBreakdown.TransactionType => "Transaction Type", + TransactionMixBreakdown.Product => "Product", + TransactionMixBreakdown.Operator => "Operator", + TransactionMixBreakdown.Status => "Status", + _ => breakdown.ToString(), + }; + + public static string ToDisplayText(this TransactionMixMeasure measure) => measure switch + { + TransactionMixMeasure.Count => "Count", + TransactionMixMeasure.Value => "Value", + _ => measure.ToString(), + }; +} diff --git a/TransactionProcessor.Mobile.UITests/Features/Reporting.feature b/TransactionProcessor.Mobile.UITests/Features/Reporting.feature new file mode 100644 index 00000000..89a5f9a1 --- /dev/null +++ b/TransactionProcessor.Mobile.UITests/Features/Reporting.feature @@ -0,0 +1,103 @@ +@background @login @toolbar @profile @base @sharedapp @shared @transactions @reports +Feature: Reporting + +Background: + + Given the following security roles exist + | Role Name | + | Merchant | + + Given I create the following api scopes + | Name | DisplayName | Description | + | transactionProcessor | Transaction Processor REST Scope | A scope for Transaction Processor REST | + | transactionProcessorACL | Transaction Processor ACL REST Scope | A scope for Transaction Processor ACL REST | + + Given the following api resources exist + | Name | DisplayName | Secret | Scopes | UserClaims | + | transactionProcessor | Transaction Processor REST | Secret1 | transactionProcessor | merchantId, estateId, role | + | transactionProcessorACL | Transaction Processor ACL REST | Secret1 | transactionProcessorACL | merchantId, estateId, role | + + Given the following clients exist + | ClientId | ClientName | Secret | Scopes | GrantTypes | + | serviceClient | Service Client | Secret1 | transactionProcessor,transactionProcessorACL | client_credentials | + | mobileAppClient | Mobile App Client | Secret1 | transactionProcessorACL,transactionProcessor | password | + + Given I have a token to access the estate management and transaction processor acl resources + | ClientId | + | serviceClient | + + Given I have created the following estates + | EstateName | + | Test Estate 1 | + + Given I have created the following operators + | EstateName | OperatorName | RequireCustomMerchantNumber | RequireCustomTerminalNumber | + | Test Estate 1 | Safaricom | True | True | + + And I have assigned the following operators to the estates + | EstateName | OperatorName | + | Test Estate 1 | Safaricom | + + Given I create a contract with the following values + | EstateName | OperatorName | ContractDescription | + | Test Estate 1 | Safaricom | Safaricom Contract | + + When I create the following Products + | EstateName | OperatorName | ContractDescription | ProductName | DisplayText | Value | ProductType | + | Test Estate 1 | Safaricom | Safaricom Contract | Variable Topup | Custom | | MobileTopup | + + When I add the following Transaction Fees + | EstateName | OperatorName | ContractDescription | ProductName | CalculationType | FeeDescription | Value | + | Test Estate 1 | Safaricom | Safaricom Contract | Variable Topup | Fixed | Merchant Commission | 2.50 | + + Given I create the following merchants + | MerchantName | AddressLine1 | AddressLine2 | AddressLine3 | AddressLine4 | Town | Region | PostalCode | Country | ContactName | EmailAddress | EstateName | + | Test Merchant 1 | test address line 1 | test address line 2 | test address line 3 | test address line 4 | TestTown | Test Region | TE57 1NG | United Kingdom | Test Contact 1 | testcontact1@merchant1.co.uk | Test Estate 1 | + + Given I have assigned the following operator to the merchants + | OperatorName | MerchantName | MerchantNumber | TerminalNumber | EstateName | + | Safaricom | Test Merchant 1 | 00000001 | 10000001 | Test Estate 1 | + + Given I have assigned the following devices to the merchants + | MerchantName | EstateName | + | Test Merchant 1 | Test Estate 1 | + + When I add the following contracts to the following merchants + | EstateName | MerchantName | ContractDescription | + | Test Estate 1 | Test Merchant 1 | Safaricom Contract | + + Given I have created the following security users + | EmailAddress | Password | GivenName | FamilyName | EstateName | MerchantName | + | user1 | 123456 | TestMerchant | User1 | Test Estate 1 | Test Merchant 1 | + + Given I have created a config for my device + +@PRTest +Scenario: View Transaction Mix Report + Given I am on the Login Screen + And my device is registered + When I enter 'user1' as the Email Address + And I enter '123456' as the Password + And I tap on Login + Then the Merchant Home Page is displayed + When I tap on Transactions + Then the Transaction Page is displayed + When I tap on the Mobile Topup button + Then the Transaction Select Mobile Topup Operator Page is displayed + When I tap on the 'Safaricom' button + Then the Select Product Page is displayed + When I tap on the 'Custom' product button + Then the Enter Topup Details Page is displayed + When I enter '07777777775' as the Customer Mobile Number + And I enter 10.00 as the Topup Amount + And I tap on Perform Topup + Then the Mobile Topup Successful Page is displayed + And I tap on Complete + Then the Transaction Page is displayed + When I click on the back button + Then the Merchant Home Page is displayed + When I tap on Reports + Then the Reports Page is displayed + When I tap on the Transaction Mix Button + Then the Transaction Mix Report is displayed + And the Transaction Mix Chart is displayed diff --git a/TransactionProcessor.Mobile.UITests/Features/Reporting.feature.cs b/TransactionProcessor.Mobile.UITests/Features/Reporting.feature.cs new file mode 100644 index 00000000..24b3f895 --- /dev/null +++ b/TransactionProcessor.Mobile.UITests/Features/Reporting.feature.cs @@ -0,0 +1,481 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace TransactionProcessor.Mobile.UITests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::NUnit.Framework.TestFixtureAttribute()] + [global::NUnit.Framework.DescriptionAttribute("Reporting")] + [global::NUnit.Framework.FixtureLifeCycleAttribute(global::NUnit.Framework.LifeCycle.InstancePerTestCase)] + [global::NUnit.Framework.CategoryAttribute("background")] + [global::NUnit.Framework.CategoryAttribute("login")] + [global::NUnit.Framework.CategoryAttribute("toolbar")] + [global::NUnit.Framework.CategoryAttribute("profile")] + [global::NUnit.Framework.CategoryAttribute("base")] + [global::NUnit.Framework.CategoryAttribute("sharedapp")] + [global::NUnit.Framework.CategoryAttribute("shared")] + [global::NUnit.Framework.CategoryAttribute("transactions")] + [global::NUnit.Framework.CategoryAttribute("reports")] + public partial class ReportingFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private static string[] featureTags = new string[] { + "background", + "login", + "toolbar", + "profile", + "base", + "sharedapp", + "shared", + "transactions", + "reports"}; + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "Reporting", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "Reporting.feature" +#line hidden + + [global::NUnit.Framework.OneTimeSetUpAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync() + { + } + + [global::NUnit.Framework.OneTimeTearDownAttribute()] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::NUnit.Framework.SetUpAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::NUnit.Framework.TearDownAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(global::NUnit.Framework.TestContext.CurrentContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + public virtual async global::System.Threading.Tasks.Task FeatureBackgroundAsync() + { +#line 4 +#line hidden + global::Reqnroll.Table table1 = new global::Reqnroll.Table(new string[] { + "Role Name"}); + table1.AddRow(new string[] { + "Merchant"}); +#line 6 + await testRunner.GivenAsync("the following security roles exist", ((string)(null)), table1, "Given "); +#line hidden + global::Reqnroll.Table table2 = new global::Reqnroll.Table(new string[] { + "Name", + "DisplayName", + "Description"}); + table2.AddRow(new string[] { + "transactionProcessor", + "Transaction Processor REST Scope", + "A scope for Transaction Processor REST"}); + table2.AddRow(new string[] { + "transactionProcessorACL", + "Transaction Processor ACL REST Scope", + "A scope for Transaction Processor ACL REST"}); +#line 10 + await testRunner.GivenAsync("I create the following api scopes", ((string)(null)), table2, "Given "); +#line hidden + global::Reqnroll.Table table3 = new global::Reqnroll.Table(new string[] { + "Name", + "DisplayName", + "Secret", + "Scopes", + "UserClaims"}); + table3.AddRow(new string[] { + "transactionProcessor", + "Transaction Processor REST", + "Secret1", + "transactionProcessor", + "merchantId, estateId, role"}); + table3.AddRow(new string[] { + "transactionProcessorACL", + "Transaction Processor ACL REST", + "Secret1", + "transactionProcessorACL", + "merchantId, estateId, role"}); +#line 15 + await testRunner.GivenAsync("the following api resources exist", ((string)(null)), table3, "Given "); +#line hidden + global::Reqnroll.Table table4 = new global::Reqnroll.Table(new string[] { + "ClientId", + "ClientName", + "Secret", + "Scopes", + "GrantTypes"}); + table4.AddRow(new string[] { + "serviceClient", + "Service Client", + "Secret1", + "transactionProcessor,transactionProcessorACL", + "client_credentials"}); + table4.AddRow(new string[] { + "mobileAppClient", + "Mobile App Client", + "Secret1", + "transactionProcessorACL,transactionProcessor", + "password"}); +#line 20 + await testRunner.GivenAsync("the following clients exist", ((string)(null)), table4, "Given "); +#line hidden + global::Reqnroll.Table table5 = new global::Reqnroll.Table(new string[] { + "ClientId"}); + table5.AddRow(new string[] { + "serviceClient"}); +#line 25 + await testRunner.GivenAsync("I have a token to access the estate management and transaction processor acl reso" + + "urces", ((string)(null)), table5, "Given "); +#line hidden + global::Reqnroll.Table table6 = new global::Reqnroll.Table(new string[] { + "EstateName"}); + table6.AddRow(new string[] { + "Test Estate 1"}); +#line 29 + await testRunner.GivenAsync("I have created the following estates", ((string)(null)), table6, "Given "); +#line hidden + global::Reqnroll.Table table7 = new global::Reqnroll.Table(new string[] { + "EstateName", + "OperatorName", + "RequireCustomMerchantNumber", + "RequireCustomTerminalNumber"}); + table7.AddRow(new string[] { + "Test Estate 1", + "Safaricom", + "True", + "True"}); +#line 33 + await testRunner.GivenAsync("I have created the following operators", ((string)(null)), table7, "Given "); +#line hidden + global::Reqnroll.Table table8 = new global::Reqnroll.Table(new string[] { + "EstateName", + "OperatorName"}); + table8.AddRow(new string[] { + "Test Estate 1", + "Safaricom"}); +#line 37 + await testRunner.AndAsync("I have assigned the following operators to the estates", ((string)(null)), table8, "And "); +#line hidden + global::Reqnroll.Table table9 = new global::Reqnroll.Table(new string[] { + "EstateName", + "OperatorName", + "ContractDescription"}); + table9.AddRow(new string[] { + "Test Estate 1", + "Safaricom", + "Safaricom Contract"}); +#line 41 + await testRunner.GivenAsync("I create a contract with the following values", ((string)(null)), table9, "Given "); +#line hidden + global::Reqnroll.Table table10 = new global::Reqnroll.Table(new string[] { + "EstateName", + "OperatorName", + "ContractDescription", + "ProductName", + "DisplayText", + "Value", + "ProductType"}); + table10.AddRow(new string[] { + "Test Estate 1", + "Safaricom", + "Safaricom Contract", + "Variable Topup", + "Custom", + "", + "MobileTopup"}); +#line 45 + await testRunner.WhenAsync("I create the following Products", ((string)(null)), table10, "When "); +#line hidden + global::Reqnroll.Table table11 = new global::Reqnroll.Table(new string[] { + "EstateName", + "OperatorName", + "ContractDescription", + "ProductName", + "CalculationType", + "FeeDescription", + "Value"}); + table11.AddRow(new string[] { + "Test Estate 1", + "Safaricom", + "Safaricom Contract", + "Variable Topup", + "Fixed", + "Merchant Commission", + "2.50"}); +#line 49 + await testRunner.WhenAsync("I add the following Transaction Fees", ((string)(null)), table11, "When "); +#line hidden + global::Reqnroll.Table table12 = new global::Reqnroll.Table(new string[] { + "MerchantName", + "AddressLine1", + "AddressLine2", + "AddressLine3", + "AddressLine4", + "Town", + "Region", + "PostalCode", + "Country", + "ContactName", + "EmailAddress", + "EstateName"}); + table12.AddRow(new string[] { + "Test Merchant 1", + "test address line 1", + "test address line 2", + "test address line 3", + "test address line 4", + "TestTown", + "Test Region", + "TE57 1NG", + "United Kingdom", + "Test Contact 1", + "testcontact1@merchant1.co.uk", + "Test Estate 1"}); +#line 53 + await testRunner.GivenAsync("I create the following merchants", ((string)(null)), table12, "Given "); +#line hidden + global::Reqnroll.Table table13 = new global::Reqnroll.Table(new string[] { + "OperatorName", + "MerchantName", + "MerchantNumber", + "TerminalNumber", + "EstateName"}); + table13.AddRow(new string[] { + "Safaricom", + "Test Merchant 1", + "00000001", + "10000001", + "Test Estate 1"}); +#line 57 + await testRunner.GivenAsync("I have assigned the following operator to the merchants", ((string)(null)), table13, "Given "); +#line hidden + global::Reqnroll.Table table14 = new global::Reqnroll.Table(new string[] { + "MerchantName", + "EstateName"}); + table14.AddRow(new string[] { + "Test Merchant 1", + "Test Estate 1"}); +#line 61 + await testRunner.GivenAsync("I have assigned the following devices to the merchants", ((string)(null)), table14, "Given "); +#line hidden + global::Reqnroll.Table table15 = new global::Reqnroll.Table(new string[] { + "EstateName", + "MerchantName", + "ContractDescription"}); + table15.AddRow(new string[] { + "Test Estate 1", + "Test Merchant 1", + "Safaricom Contract"}); +#line 65 + await testRunner.WhenAsync("I add the following contracts to the following merchants", ((string)(null)), table15, "When "); +#line hidden + global::Reqnroll.Table table16 = new global::Reqnroll.Table(new string[] { + "EmailAddress", + "Password", + "GivenName", + "FamilyName", + "EstateName", + "MerchantName"}); + table16.AddRow(new string[] { + "user1", + "123456", + "TestMerchant", + "User1", + "Test Estate 1", + "Test Merchant 1"}); +#line 69 + await testRunner.GivenAsync("I have created the following security users", ((string)(null)), table16, "Given "); +#line hidden +#line 73 + await testRunner.GivenAsync("I have created a config for my device", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/Reporting.feature.ndjson", 3); + } + + [global::NUnit.Framework.TestAttribute()] + [global::NUnit.Framework.DescriptionAttribute("View Transaction Mix Report")] + [global::NUnit.Framework.CategoryAttribute("PRTest")] + public async global::System.Threading.Tasks.Task ViewTransactionMixReport() + { + string[] tagsOfScenario = new string[] { + "PRTest"}; + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("View Transaction Mix Report", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 76 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 +await this.FeatureBackgroundAsync(); +#line hidden +#line 77 + await testRunner.GivenAsync("I am on the Login Screen", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 78 + await testRunner.AndAsync("my device is registered", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 79 + await testRunner.WhenAsync("I enter \'user1\' as the Email Address", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 80 + await testRunner.AndAsync("I enter \'123456\' as the Password", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 81 + await testRunner.AndAsync("I tap on Login", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 82 + await testRunner.ThenAsync("the Merchant Home Page is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 83 + await testRunner.WhenAsync("I tap on Transactions", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 84 + await testRunner.ThenAsync("the Transaction Page is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 85 + await testRunner.WhenAsync("I tap on the Mobile Topup button", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 86 + await testRunner.ThenAsync("the Transaction Select Mobile Topup Operator Page is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 87 + await testRunner.WhenAsync("I tap on the \'Safaricom\' button", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 88 + await testRunner.ThenAsync("the Select Product Page is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 89 + await testRunner.WhenAsync("I tap on the \'Custom\' product button", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 90 + await testRunner.ThenAsync("the Enter Topup Details Page is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 91 + await testRunner.WhenAsync("I enter \'07777777775\' as the Customer Mobile Number", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 92 + await testRunner.AndAsync("I enter 10.00 as the Topup Amount", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 93 + await testRunner.AndAsync("I tap on Perform Topup", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 94 + await testRunner.ThenAsync("the Mobile Topup Successful Page is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 95 + await testRunner.AndAsync("I tap on Complete", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 96 + await testRunner.ThenAsync("the Transaction Page is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 97 + await testRunner.WhenAsync("I click on the back button", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 98 + await testRunner.ThenAsync("the Merchant Home Page is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 99 + await testRunner.WhenAsync("I tap on Reports", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 100 + await testRunner.ThenAsync("the Reports Page is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 101 + await testRunner.WhenAsync("I tap on the Transaction Mix Button", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 102 + await testRunner.ThenAsync("the Transaction Mix Report is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 103 + await testRunner.AndAsync("the Transaction Mix Chart is displayed", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/TransactionProcessor.Mobile.UITests/Pages/ReportsPage.cs b/TransactionProcessor.Mobile.UITests/Pages/ReportsPage.cs index 6fd94080..007d5bbf 100644 --- a/TransactionProcessor.Mobile.UITests/Pages/ReportsPage.cs +++ b/TransactionProcessor.Mobile.UITests/Pages/ReportsPage.cs @@ -19,12 +19,14 @@ protected override String Trait } private readonly String DailyPerformanceSummaryButton; + private readonly String TransactionMixButton; public ReportsPage(TestingContext testingContext) : base(testingContext) { this.DailyPerformanceSummaryButton = "DailyPerformanceSummaryButton"; + this.TransactionMixButton = "TransactionMixButton"; } public async Task ClickDailyPerformanceSummaryButton() @@ -33,6 +35,12 @@ public async Task ClickDailyPerformanceSummaryButton() element.Click(); } + public async Task ClickTransactionMixButton() + { + IWebElement element = await this.WaitForElementByAccessibilityId(this.TransactionMixButton); + element.Click(); + } + } public class SupportPage : BasePage2{ diff --git a/TransactionProcessor.Mobile.UITests/Pages/TransactionMixPage.cs b/TransactionProcessor.Mobile.UITests/Pages/TransactionMixPage.cs new file mode 100644 index 00000000..0bab6086 --- /dev/null +++ b/TransactionProcessor.Mobile.UITests/Pages/TransactionMixPage.cs @@ -0,0 +1,25 @@ +using TransactionProcessor.Mobile.UITests.Common; + +namespace TransactionProcessor.Mobile.UITests.Pages; + +public class TransactionMixPage : BasePage2 +{ + protected override string Trait => "TransactionMix"; + + private readonly string SummarySection; + + public TransactionMixPage(TestingContext testingContext) : base(testingContext) + { + this.SummarySection = "TransactionMixSummary"; + } + + public async Task AssertSummaryVisible() + { + await this.WaitForElementByAccessibilityId(this.SummarySection); + } + + public async Task AssertChartVisible() + { + await this.WaitForElementByAccessibilityId("TransactionMixChart"); + } +} diff --git a/TransactionProcessor.Mobile.UITests/Steps/ReportsSteps.cs b/TransactionProcessor.Mobile.UITests/Steps/ReportsSteps.cs index 9324e2d8..29800334 100644 --- a/TransactionProcessor.Mobile.UITests/Steps/ReportsSteps.cs +++ b/TransactionProcessor.Mobile.UITests/Steps/ReportsSteps.cs @@ -8,10 +8,12 @@ namespace TransactionProcessor.Mobile.UITests.Steps; public class ReportsSteps{ private readonly ReportsPage ReportsPage; private readonly DailyPerformanceSummaryPage DailyPerformanceSummaryPage; + private readonly TransactionMixPage TransactionMixPage; - public ReportsSteps(ReportsPage reportsPage, DailyPerformanceSummaryPage dailyPerformanceSummaryPage){ + public ReportsSteps(ReportsPage reportsPage, DailyPerformanceSummaryPage dailyPerformanceSummaryPage, TransactionMixPage transactionMixPage){ this.ReportsPage = reportsPage; this.DailyPerformanceSummaryPage = dailyPerformanceSummaryPage; + this.TransactionMixPage = transactionMixPage; } [Then(@"the Reports Page is displayed")] @@ -26,10 +28,29 @@ public async Task WhenITapOnTheDailyPerformanceSummaryButton() await this.ReportsPage.ClickDailyPerformanceSummaryButton(); } + [When(@"I tap on the Transaction Mix Button")] + public async Task WhenITapOnTheTransactionMixButton() + { + await this.ReportsPage.ClickTransactionMixButton(); + } + [Then(@"the Daily Performance Summary Report is displayed")] public async Task ThenTheDailyPerformanceSummaryReportIsDisplayed() { await this.DailyPerformanceSummaryPage.AssertOnPage(); } + [Then(@"the Transaction Mix Report is displayed")] + public async Task ThenTheTransactionMixReportIsDisplayed() + { + await this.TransactionMixPage.AssertOnPage(); + await this.TransactionMixPage.AssertSummaryVisible(); + } + + [Then(@"the Transaction Mix Chart is displayed")] + public async Task ThenTheTransactionMixChartIsDisplayed() + { + await this.TransactionMixPage.AssertChartVisible(); + } + } diff --git a/TransactionProcessor.Mobile/App.xaml.cs b/TransactionProcessor.Mobile/App.xaml.cs index 76caa6aa..ebd00fa8 100644 --- a/TransactionProcessor.Mobile/App.xaml.cs +++ b/TransactionProcessor.Mobile/App.xaml.cs @@ -178,6 +178,7 @@ public App(IApplicationThemeService applicationThemeService) RegisterRouteOnce(nameof(ViewLogsPage), typeof(ViewLogsPage)); RegisterRouteOnce(nameof(DailyPerformanceSummaryPage), typeof(DailyPerformanceSummaryPage)); + RegisterRouteOnce(nameof(TransactionMixPage), typeof(TransactionMixPage)); RegisterRouteOnce(nameof(MyAccountAddressesPage), typeof(MyAccountAddressesPage)); RegisterRouteOnce(nameof(MyAccountContactPage), typeof(MyAccountContactPage)); diff --git a/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs b/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs index 247100a1..1a54a568 100644 --- a/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs +++ b/TransactionProcessor.Mobile/Extensions/MauiAppBuilderExtensions.cs @@ -228,6 +228,7 @@ public static MauiAppBuilder ConfigureViewModels(this MauiAppBuilder builder) { builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); return builder; } @@ -269,6 +270,7 @@ public static MauiAppBuilder ConfigurePages(this MauiAppBuilder builder) { builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); return builder; } diff --git a/TransactionProcessor.Mobile/Pages/Reports/TransactionMixPage.xaml b/TransactionProcessor.Mobile/Pages/Reports/TransactionMixPage.xaml new file mode 100644 index 00000000..1701640c --- /dev/null +++ b/TransactionProcessor.Mobile/Pages/Reports/TransactionMixPage.xaml @@ -0,0 +1,254 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +