From 546790b6ab6624fee95e2bb5b7b0fb5c057aa8b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:10:23 +0000 Subject: [PATCH 1/5] Initial plan From f5e8e4cd302a0fc0ead1078e7752eb35a506a642 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:13:43 +0000 Subject: [PATCH 2/5] refactor: extract GetMerchants helpers Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../ReportingManager.cs | 104 ++++++++++-------- 1 file changed, 57 insertions(+), 47 deletions(-) diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index a5896a2..3f237c6 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -937,30 +937,7 @@ public async Task>> GetMerchants(MerchantQueries.GetMercha using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; - - var merchantWithAddresses = context.Merchants.Where(m => m.EstateId == request.EstateId).GroupJoin(context.MerchantAddresses, m => m.MerchantId, a => a.MerchantId, (m, - addresses) => new { Merchant = m, Address = addresses.First() }).AsQueryable(); - - // Now apply the other filters from request.QueryOptions - if (String.IsNullOrEmpty(request.QueryOptions.Name) == false) { - merchantWithAddresses = merchantWithAddresses.Where(m => m.Merchant.Name.Contains(request.QueryOptions.Name)).AsQueryable(); - } - - if (String.IsNullOrEmpty(request.QueryOptions.Reference) == false) { - merchantWithAddresses = merchantWithAddresses.Where(m => m.Merchant.Reference == request.QueryOptions.Reference).AsQueryable(); - } - - if (request.QueryOptions.SettlementSchedule > 0) { - merchantWithAddresses = merchantWithAddresses.Where(m => m.Merchant.SettlementSchedule == request.QueryOptions.SettlementSchedule).AsQueryable(); - } - - if (String.IsNullOrEmpty(request.QueryOptions.Region) == false) { - merchantWithAddresses = merchantWithAddresses.Where(m => m.Address.Region.Contains(request.QueryOptions.Region)).AsQueryable(); - } - - if (String.IsNullOrEmpty(request.QueryOptions.PostCode) == false) { - merchantWithAddresses = merchantWithAddresses.Where(m => m.Address.PostalCode == request.QueryOptions.PostCode).AsQueryable(); - } + var merchantWithAddresses = ApplyMerchantFilters(BuildMerchantWithAddressQuery(context, request.EstateId), request.QueryOptions); var queryResults = await ExecuteQuerySafeToList(merchantWithAddresses, cancellationToken, "Error retrieving merchants"); @@ -976,29 +953,7 @@ public async Task>> GetMerchants(MerchantQueries.GetMercha if (balanceQueryResults.IsFailed) return ResultHelpers.CreateFailure(balanceQueryResults); - // Ok now enumerate the results - List response = new(); - foreach (var queryResult in merchants) { - response.Add(new Merchant - { - Balance = balanceQueryResults.Data.Single(b => b.MerchantId == queryResult.Merchant.MerchantId).Balance, - CreatedDateTime = queryResult.Merchant.CreatedDateTime, - Name = queryResult.Merchant.Name, - Region = queryResult.Address.Region, - Reference = queryResult.Merchant.Reference, - PostCode = queryResult.Address.PostalCode, - SettlementSchedule = queryResult.Merchant.SettlementSchedule, - MerchantId = queryResult.Merchant.MerchantId, - AddressId = queryResult.Address.AddressId, - AddressLine1 = queryResult.Address.AddressLine1, - AddressLine2 = queryResult.Address.AddressLine2, - Town = queryResult.Address.Town, - Country = queryResult.Address.Country, - MerchantReportingId = queryResult.Merchant.MerchantReportingId - }); - } - - return Result.Success(response); + return Result.Success(BuildMerchantResponse(merchants, balanceQueryResults.Data)); } public async Task> GetMerchant(MerchantQueries.GetMerchantQuery request, CancellationToken cancellationToken) { @@ -1052,6 +1007,56 @@ private static IQueryable BuildMerchantQuery(EstateManagementConte .Where(m => m.MerchantId == merchantId); } + private static IQueryable BuildMerchantWithAddressQuery(EstateManagementContext context, + Guid estateId) { + return context.Merchants + .Where(m => m.EstateId == estateId) + .GroupJoin(context.MerchantAddresses, + m => m.MerchantId, + a => a.MerchantId, + (m, addresses) => new MerchantWithAddressData { Merchant = m, Address = addresses.First() }); + } + + private static IQueryable ApplyMerchantFilters(IQueryable query, + MerchantQueries.MerchantQueryOptions queryOptions) { + if (String.IsNullOrEmpty(queryOptions.Name) == false) + query = query.Where(m => m.Merchant.Name.Contains(queryOptions.Name)); + + if (String.IsNullOrEmpty(queryOptions.Reference) == false) + query = query.Where(m => m.Merchant.Reference == queryOptions.Reference); + + if (queryOptions.SettlementSchedule > 0) + query = query.Where(m => m.Merchant.SettlementSchedule == queryOptions.SettlementSchedule); + + if (String.IsNullOrEmpty(queryOptions.Region) == false) + query = query.Where(m => m.Address.Region.Contains(queryOptions.Region)); + + if (String.IsNullOrEmpty(queryOptions.PostCode) == false) + query = query.Where(m => m.Address.PostalCode == queryOptions.PostCode); + + return query; + } + + private static List BuildMerchantResponse(List merchants, + List balances) { + return merchants.Select(merchant => new Merchant { + Balance = balances.Single(b => b.MerchantId == merchant.Merchant.MerchantId).Balance, + CreatedDateTime = merchant.Merchant.CreatedDateTime, + Name = merchant.Merchant.Name, + Region = merchant.Address.Region, + Reference = merchant.Merchant.Reference, + PostCode = merchant.Address.PostalCode, + SettlementSchedule = merchant.Merchant.SettlementSchedule, + MerchantId = merchant.Merchant.MerchantId, + AddressId = merchant.Address.AddressId, + AddressLine1 = merchant.Address.AddressLine1, + AddressLine2 = merchant.Address.AddressLine2, + Town = merchant.Address.Town, + Country = merchant.Address.Country, + MerchantReportingId = merchant.Merchant.MerchantReportingId + }).ToList(); + } + public async Task>> GetMerchantOperators(MerchantQueries.GetMerchantOperatorsQuery request, CancellationToken cancellationToken) { using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); @@ -1428,6 +1433,11 @@ private sealed class MerchantTransactionGroupProjection { public int DeclinedCount { get; init; } } + private sealed class MerchantWithAddressData { + public TransactionProcessor.Database.Entities.Merchant Merchant { get; init; } + public MerchantAddress Address { get; init; } + } + private sealed class MerchantTransactionFinalProjection { public Guid MerchantId { get; init; } public int MerchantReportingId { get; init; } From 8f2b292247520e2918ca3b776c50c490c9de417f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:18:09 +0000 Subject: [PATCH 3/5] perf: preserve balance lookup semantics Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- EstateReportingAPI.BusinessLogic/ReportingManager.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index 3f237c6..61724f8 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -1039,8 +1039,16 @@ private static IQueryable ApplyMerchantFilters(IQueryab private static List BuildMerchantResponse(List merchants, List balances) { + Dictionary balanceLookup = new(); + foreach (var balance in balances) { + if (balanceLookup.TryAdd(balance.MerchantId, balance.Balance) == false) + throw new InvalidOperationException("Sequence contains more than one matching element"); + } + return merchants.Select(merchant => new Merchant { - Balance = balances.Single(b => b.MerchantId == merchant.Merchant.MerchantId).Balance, + Balance = balanceLookup.TryGetValue(merchant.Merchant.MerchantId, out var balance) + ? balance + : throw new InvalidOperationException("Sequence contains no matching element"), CreatedDateTime = merchant.Merchant.CreatedDateTime, Name = merchant.Merchant.Name, Region = merchant.Address.Region, From 7c35b41ad606b517cf9cd172430ffa5bbc8bc520 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:39:07 +0000 Subject: [PATCH 4/5] refactor: move merchant projection into model factory Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../DatabaseModels.cs | 5 ++ .../ModelFactory.cs | 20 +++++++ .../ReportingManager.cs | 52 ++++++++----------- 3 files changed, 46 insertions(+), 31 deletions(-) diff --git a/EstateReportingAPI.BusinessLogic/DatabaseModels.cs b/EstateReportingAPI.BusinessLogic/DatabaseModels.cs index 11ff3f3..dad3d6d 100644 --- a/EstateReportingAPI.BusinessLogic/DatabaseModels.cs +++ b/EstateReportingAPI.BusinessLogic/DatabaseModels.cs @@ -29,3 +29,8 @@ internal sealed class MerchantContactData { public string? EmailAddress { get; init; } public string? PhoneNumber { get; init; } } + +internal sealed class MerchantWithAddressData { + public required TransactionProcessor.Database.Entities.Merchant Merchant { get; init; } + public required TransactionProcessor.Database.Entities.MerchantAddress MerchantAddress { get; init; } +} diff --git a/EstateReportingAPI.BusinessLogic/ModelFactory.cs b/EstateReportingAPI.BusinessLogic/ModelFactory.cs index 888007f..dc1db27 100644 --- a/EstateReportingAPI.BusinessLogic/ModelFactory.cs +++ b/EstateReportingAPI.BusinessLogic/ModelFactory.cs @@ -26,4 +26,24 @@ internal static Merchant ConvertFrom(MerchantData merchant, ContactPhone = merchant.ContactInfo.PhoneNumber }; } + + internal static Merchant ConvertFrom(MerchantWithAddressData merchant, + decimal balance) { + return new Merchant { + Balance = balance, + CreatedDateTime = merchant.Merchant.CreatedDateTime, + Name = merchant.Merchant.Name, + Region = merchant.MerchantAddress.Region, + Reference = merchant.Merchant.Reference, + PostCode = merchant.MerchantAddress.PostalCode, + SettlementSchedule = merchant.Merchant.SettlementSchedule, + MerchantId = merchant.Merchant.MerchantId, + AddressId = merchant.MerchantAddress.AddressId, + AddressLine1 = merchant.MerchantAddress.AddressLine1, + AddressLine2 = merchant.MerchantAddress.AddressLine2, + Town = merchant.MerchantAddress.Town, + Country = merchant.MerchantAddress.Country, + MerchantReportingId = merchant.Merchant.MerchantReportingId + }; + } } diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index 61724f8..a8d4c37 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -953,7 +953,12 @@ public async Task>> GetMerchants(MerchantQueries.GetMercha if (balanceQueryResults.IsFailed) return ResultHelpers.CreateFailure(balanceQueryResults); - return Result.Success(BuildMerchantResponse(merchants, balanceQueryResults.Data)); + Dictionary balanceLookup = BuildMerchantBalanceLookup(balanceQueryResults.Data); + List response = merchants.Select(merchant => + ModelFactory.ConvertFrom(merchant, GetMerchantBalance(balanceLookup, merchant.Merchant.MerchantId))) + .ToList(); + + return Result.Success(response); } public async Task> GetMerchant(MerchantQueries.GetMerchantQuery request, CancellationToken cancellationToken) { @@ -1014,7 +1019,7 @@ private static IQueryable BuildMerchantWithAddressQuery .GroupJoin(context.MerchantAddresses, m => m.MerchantId, a => a.MerchantId, - (m, addresses) => new MerchantWithAddressData { Merchant = m, Address = addresses.First() }); + (m, addresses) => new MerchantWithAddressData { Merchant = m, MerchantAddress = addresses.First() }); } private static IQueryable ApplyMerchantFilters(IQueryable query, @@ -1029,44 +1034,34 @@ private static IQueryable ApplyMerchantFilters(IQueryab query = query.Where(m => m.Merchant.SettlementSchedule == queryOptions.SettlementSchedule); if (String.IsNullOrEmpty(queryOptions.Region) == false) - query = query.Where(m => m.Address.Region.Contains(queryOptions.Region)); + query = query.Where(m => m.MerchantAddress.Region.Contains(queryOptions.Region)); if (String.IsNullOrEmpty(queryOptions.PostCode) == false) - query = query.Where(m => m.Address.PostalCode == queryOptions.PostCode); + query = query.Where(m => m.MerchantAddress.PostalCode == queryOptions.PostCode); return query; } - private static List BuildMerchantResponse(List merchants, - List balances) { + private static Dictionary BuildMerchantBalanceLookup(List balances) { Dictionary balanceLookup = new(); foreach (var balance in balances) { if (balanceLookup.TryAdd(balance.MerchantId, balance.Balance) == false) - throw new InvalidOperationException("Sequence contains more than one matching element"); + throw new InvalidOperationException($"Duplicate balance entry found for merchant {balance.MerchantId}"); } - return merchants.Select(merchant => new Merchant { - Balance = balanceLookup.TryGetValue(merchant.Merchant.MerchantId, out var balance) - ? balance - : throw new InvalidOperationException("Sequence contains no matching element"), - CreatedDateTime = merchant.Merchant.CreatedDateTime, - Name = merchant.Merchant.Name, - Region = merchant.Address.Region, - Reference = merchant.Merchant.Reference, - PostCode = merchant.Address.PostalCode, - SettlementSchedule = merchant.Merchant.SettlementSchedule, - MerchantId = merchant.Merchant.MerchantId, - AddressId = merchant.Address.AddressId, - AddressLine1 = merchant.Address.AddressLine1, - AddressLine2 = merchant.Address.AddressLine2, - Town = merchant.Address.Town, - Country = merchant.Address.Country, - MerchantReportingId = merchant.Merchant.MerchantReportingId - }).ToList(); + return balanceLookup; + } + + private static decimal GetMerchantBalance(Dictionary balanceLookup, + Guid merchantId) { + if (balanceLookup.TryGetValue(merchantId, out var balance)) + return balance; + + throw new InvalidOperationException($"No balance entry found for merchant {merchantId}"); } public async Task>> GetMerchantOperators(MerchantQueries.GetMerchantOperatorsQuery request, - CancellationToken cancellationToken) { + CancellationToken cancellationToken) { using ResolvedDbContext? resolvedContext = this.Resolver.Resolve(EstateManagementDatabaseName, request.EstateId.ToString()); await using EstateManagementContext context = resolvedContext.Context; @@ -1441,11 +1436,6 @@ private sealed class MerchantTransactionGroupProjection { public int DeclinedCount { get; init; } } - private sealed class MerchantWithAddressData { - public TransactionProcessor.Database.Entities.Merchant Merchant { get; init; } - public MerchantAddress Address { get; init; } - } - private sealed class MerchantTransactionFinalProjection { public Guid MerchantId { get; init; } public int MerchantReportingId { get; init; } From d902e56945f39ad9f3181fd629092551d1a32f6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:51:35 +0000 Subject: [PATCH 5/5] fix: resolve merchant balance projection build error Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- EstateReportingAPI.BusinessLogic/ReportingManager.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/EstateReportingAPI.BusinessLogic/ReportingManager.cs b/EstateReportingAPI.BusinessLogic/ReportingManager.cs index a8d4c37..173af30 100644 --- a/EstateReportingAPI.BusinessLogic/ReportingManager.cs +++ b/EstateReportingAPI.BusinessLogic/ReportingManager.cs @@ -20,6 +20,7 @@ namespace EstateReportingAPI.BusinessLogic; using static Microsoft.EntityFrameworkCore.DbLoggerCategory; using Calendar = Models.Calendar; using Merchant = Models.Merchant; +using MerchantBalanceProjectionState = TransactionProcessor.ProjectionEngine.Database.Database.Entities.MerchantBalanceProjectionState; public interface IReportingManager {