From 4c7366dc761821afdc4236275f7ca12e2fecee32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:45:04 +0000 Subject: [PATCH 1/3] Initial plan From 7911a4d63630c1f4f8ac0d8443758bc08bca17d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:47:35 +0000 Subject: [PATCH 2/3] Refactor merchant withdrawal flow Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Services/MerchantDomainService.cs | 88 +++++++++++-------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs b/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs index c1af5881..e1e33018 100644 --- a/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs +++ b/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs @@ -345,48 +345,17 @@ public async Task MakeMerchantWithdrawal(MerchantCommands.MakeMerchantWi try { - Result estateResult = await DomainServiceHelper.GetAggregateOrFailure(ct => this.AggregateService.Get(command.EstateId, ct), command.EstateId, cancellationToken); - if (estateResult.IsFailed) - return ResultHelpers.CreateFailure(estateResult); - - Result merchantResult = await DomainServiceHelper.GetAggregateOrFailure(ct => this.AggregateService.Get(command.MerchantId, ct), command.MerchantId, cancellationToken); - if (merchantResult.IsFailed) - return ResultHelpers.CreateFailure(merchantResult); - - EstateAggregate estateAggregate = estateResult.Data; - MerchantAggregate merchantAggregate = merchantResult.Data; - - Result validateResult = - this.ValidateEstateAndMerchant(estateAggregate, merchantAggregate); - if (validateResult.IsFailed) - return ResultHelpers.CreateFailure(validateResult); - - Result getDepositListResult = await DomainServiceHelper.GetAggregateOrFailure(ct => this.AggregateService.GetLatest(command.MerchantId, ct), command.MerchantId, cancellationToken); + Result getDepositListResult = await this.GetMerchantDepositListForWithdrawal(command, cancellationToken); if (getDepositListResult.IsFailed) return ResultHelpers.CreateFailure(getDepositListResult); - - MerchantDepositListAggregate merchantDepositListAggregate = getDepositListResult.Data; - if (merchantDepositListAggregate.IsCreated == false) - { - return Result.Invalid($"Merchant [{command.MerchantId}] has not made any deposits yet"); - } - - // Now we need to check the merchants balance to ensure they have funds to withdraw - Result getBalanceResult = await this.EventStoreContext.GetPartitionStateFromProjection("MerchantBalanceProjection", $"MerchantBalance-{command.MerchantId:N}", cancellationToken); - if (getBalanceResult.IsFailed) - { - return Result.Invalid($"Failed to get Merchant Balance."); - } - - MerchantBalanceProjectionState1 projectionState = JsonConvert.DeserializeObject(getBalanceResult.Data); - if (command.RequestDto.Amount > projectionState.merchant.balance) - { - return Result.Invalid($"Not enough credit available for withdrawal of [{command.RequestDto.Amount}]. Balance is {projectionState.merchant.balance}"); - } + Result validateBalanceResult = await this.ValidateWithdrawalBalance(command, cancellationToken); + if (validateBalanceResult.IsFailed) + return validateBalanceResult; // If we are here we have enough credit to withdraw PositiveMoney amount = PositiveMoney.Create(Money.Create(command.RequestDto.Amount)); + MerchantDepositListAggregate merchantDepositListAggregate = getDepositListResult.Data; Result stateResult = merchantDepositListAggregate.MakeWithdrawal(command.RequestDto.WithdrawalDateTime, amount); if (stateResult.IsFailed) @@ -716,6 +685,53 @@ private Result ValidateEstateAndMerchant(EstateAggregate estateAggregate, return Result.Success(); } + private async Task> GetMerchantDepositListForWithdrawal(MerchantCommands.MakeMerchantWithdrawalCommand command, + CancellationToken cancellationToken) + { + Result estateResult = await DomainServiceHelper.GetAggregateOrFailure(ct => this.AggregateService.Get(command.EstateId, ct), command.EstateId, cancellationToken); + if (estateResult.IsFailed) + return ResultHelpers.CreateFailure(estateResult); + + Result merchantResult = await DomainServiceHelper.GetAggregateOrFailure(ct => this.AggregateService.Get(command.MerchantId, ct), command.MerchantId, cancellationToken); + if (merchantResult.IsFailed) + return ResultHelpers.CreateFailure(merchantResult); + + Result validateResult = this.ValidateEstateAndMerchant(estateResult.Data, merchantResult.Data); + if (validateResult.IsFailed) + return ResultHelpers.CreateFailure(validateResult); + + Result getDepositListResult = await DomainServiceHelper.GetAggregateOrFailure(ct => this.AggregateService.GetLatest(command.MerchantId, ct), command.MerchantId, cancellationToken); + if (getDepositListResult.IsFailed) + return ResultHelpers.CreateFailure(getDepositListResult); + + MerchantDepositListAggregate merchantDepositListAggregate = getDepositListResult.Data; + if (merchantDepositListAggregate.IsCreated == false) + { + return Result.Invalid($"Merchant [{command.MerchantId}] has not made any deposits yet"); + } + + return Result.Success(merchantDepositListAggregate); + } + + private async Task ValidateWithdrawalBalance(MerchantCommands.MakeMerchantWithdrawalCommand command, + CancellationToken cancellationToken) + { + Result getBalanceResult = await this.EventStoreContext.GetPartitionStateFromProjection("MerchantBalanceProjection", $"MerchantBalance-{command.MerchantId:N}", cancellationToken); + if (getBalanceResult.IsFailed) + { + return Result.Invalid($"Failed to get Merchant Balance."); + } + + MerchantBalanceProjectionState1 projectionState = JsonConvert.DeserializeObject(getBalanceResult.Data); + + if (command.RequestDto.Amount > projectionState.merchant.balance) + { + return Result.Invalid($"Not enough credit available for withdrawal of [{command.RequestDto.Amount}]. Balance is {projectionState.merchant.balance}"); + } + + return Result.Success(); + } + public async Task SwapMerchantDevice(MerchantCommands.SwapMerchantDeviceCommand command, CancellationToken cancellationToken) { From 76776cc64624daf6db7568cfe302cc98af355dcf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:53:01 +0000 Subject: [PATCH 3/3] Harden withdrawal balance validation Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Services/MerchantDomainServiceTests.cs | 21 ++++++++++++++++++- .../Services/MerchantDomainService.cs | 4 ++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/TransactionProcessor.BusinessLogic.Tests/Services/MerchantDomainServiceTests.cs b/TransactionProcessor.BusinessLogic.Tests/Services/MerchantDomainServiceTests.cs index f79ec732..b091392c 100644 --- a/TransactionProcessor.BusinessLogic.Tests/Services/MerchantDomainServiceTests.cs +++ b/TransactionProcessor.BusinessLogic.Tests/Services/MerchantDomainServiceTests.cs @@ -722,6 +722,25 @@ public async Task MerchantDomainService_MakeMerchantWithdrawal_NotEnoughFundsToW result.IsFailed.ShouldBeTrue(); } + [Fact] + public async Task MerchantDomainService_MakeMerchantWithdrawal_InvalidBalanceProjection_ResultIsFailed() { + this.AggregateService.Setup(e => e.Get(It.IsAny(), It.IsAny())) + .ReturnsAsync(TestData.Aggregates.CreatedEstateAggregate()); + + this.AggregateService.Setup(m => m.Get(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(TestData.Aggregates.CreatedMerchantAggregate())); + + this.AggregateService + .Setup(m => m.GetLatest(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(TestData.Aggregates.CreatedMerchantDepositListAggregate())); + + this.EventStoreContext.Setup(e => e.GetPartitionStateFromProjection(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success("null")); + + var result = await this.DomainService.MakeMerchantWithdrawal(TestData.Commands.MakeMerchantWithdrawalCommand, CancellationToken.None); + result.IsFailed.ShouldBeTrue(); + } + [Fact] public async Task MerchantDomainService_AddContractToMerchant_ContractAdded() { this.AggregateService.Setup(e => e.Get(It.IsAny(), It.IsAny())) @@ -1909,4 +1928,4 @@ public async Task MerchantDomainService_RemoveContractFromMerchant_ExceptionThro var result = await this.DomainService.RemoveContractFromMerchant(TestData.Commands.RemoveMerchantContractCommand, CancellationToken.None); result.IsFailed.ShouldBeTrue(); } -} \ No newline at end of file +} diff --git a/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs b/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs index e1e33018..9afa84e8 100644 --- a/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs +++ b/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs @@ -723,6 +723,10 @@ private async Task ValidateWithdrawalBalance(MerchantCommands.MakeMercha } MerchantBalanceProjectionState1 projectionState = JsonConvert.DeserializeObject(getBalanceResult.Data); + if (projectionState?.merchant == null) + { + return Result.Invalid("Merchant Balance data is missing or invalid."); + } if (command.RequestDto.Amount > projectionState.merchant.balance) {