From ff4d66f6f1c4bfd0a14be669cddb080d0e53eb56 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 7 Aug 2025 18:24:40 +0100 Subject: [PATCH] Aggregate added for merchant balance and all updates to balance added in --- .../MerchantBalanceAggregateTests.cs | 138 ++++++++++++++ .../MerchantBalanceAggregate.cs | 177 ++++++++++++++++++ .../TransactionAggregate.cs | 3 +- .../Mediator/MediatorTests.cs | 6 + .../Services/MerchantDomainServiceTests.cs | 54 ++++++ ...actionProcessor.BusinessLogic.Tests.csproj | 2 +- .../TransactionDomainEventHandler.cs | 48 ++++- .../MerchantBalanceRequestHandler.cs | 59 ++++++ .../TransactionRequestHandler.cs | 5 +- .../Requests/MerchantCommands.cs | 9 + .../Services/MerchantDomainService.cs | 142 +++++++++++++- .../Services/TransactionDomainService.cs | 6 +- .../TransactionProcessor.Database.csproj | 2 +- .../MerchantBalanceDomainEvents.cs | 21 +++ .../ProcessSaleTransactionResponse.cs | 38 +--- TransactionProcessor.Models/Transaction.cs | 76 +------- TransactionProcessor.Testing/TestData.cs | 20 ++ .../Bootstrapper/MediatorRegistry.cs | 10 + .../Bootstrapper/RepositoryRegistry.cs | 1 + .../Controllers/MerchantController.cs | 46 ++++- .../Controllers/TransactionController.cs | 44 ++++- 21 files changed, 778 insertions(+), 129 deletions(-) create mode 100644 TransactionProcessor.Aggregates.Tests/MerchantBalanceAggregateTests.cs create mode 100644 TransactionProcessor.Aggregates/MerchantBalanceAggregate.cs create mode 100644 TransactionProcessor.BusinessLogic/RequestHandlers/MerchantBalanceRequestHandler.cs create mode 100644 TransactionProcessor.DomainEvents/MerchantBalanceDomainEvents.cs diff --git a/TransactionProcessor.Aggregates.Tests/MerchantBalanceAggregateTests.cs b/TransactionProcessor.Aggregates.Tests/MerchantBalanceAggregateTests.cs new file mode 100644 index 00000000..f322ff01 --- /dev/null +++ b/TransactionProcessor.Aggregates.Tests/MerchantBalanceAggregateTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Shouldly; +using TransactionProcessor.Testing; + +namespace TransactionProcessor.Aggregates.Tests +{ + public class MerchantBalanceAggregateTests + { + [Fact] + public void MerchantBalanceAggregate_RecordCompletedTransaction_MerchantNotCreated_ErrorThrown() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.EmptyMerchantAggregate(); + Should.Throw(() => { + aggregate.RecordCompletedTransaction(merchantAggregate, TestData.TransactionId, TestData.TransactionAmount, TestData.TransactionDateTime, true); + }); + } + + [Fact] + public void MerchantBalanceAggregate_RecordDeposit_MerchantNotCreated_ErrorThrown() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.EmptyMerchantAggregate(); + Should.Throw(() => { + aggregate.RecordMerchantDeposit(merchantAggregate, TestData.DepositId, TestData.DepositAmount.Value, TestData.DepositDateTime); + }); + } + + [Fact] + public void MerchantBalanceAggregate_RecordMerchantWithdrawal_MerchantNotCreated_ErrorThrown() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.EmptyMerchantAggregate(); + Should.Throw(() => { + aggregate.RecordMerchantWithdrawal(merchantAggregate, TestData.WithdrawalId, TestData.WithdrawalAmount.Value, TestData.WithdrawalDateTime); + }); + } + + [Fact] + public void MerchantBalanceAggregate_RecordSettledFee_MerchantNotCreated_ErrorThrown() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.EmptyMerchantAggregate(); + Should.Throw(() => { + aggregate.RecordSettledFee(merchantAggregate, TestData.SettledFeeId1, TestData.SettledFeeAmount1, TestData.SettledFeeDateTime1); + }); + } + + [Fact] + public void MerchantBalanceAggregate_RecordCompletedTransaction_TransactionIsRecorded() { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.CreatedMerchantAggregate(); + aggregate.RecordCompletedTransaction(merchantAggregate, TestData.TransactionId, TestData.TransactionAmount, TestData.TransactionDateTime, true); + aggregate.Balance.ShouldBe(TestData.TransactionAmount * -1); + aggregate.AuthorisedSales.Count.ShouldBe(1); + aggregate.AuthorisedSales.Value.ShouldBe(TestData.TransactionAmount); + aggregate.AuthorisedSales.LastActivity.ShouldBe(TestData.TransactionDateTime); + } + + [Fact] + public void MerchantBalanceAggregate_RecordCompletedTransaction_MultipleTransactions_TransactionIsRecorded() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.CreatedMerchantAggregate(); + aggregate.RecordCompletedTransaction(merchantAggregate, TestData.TransactionId, TestData.TransactionAmount, TestData.TransactionDateTime, true); + aggregate.RecordCompletedTransaction(merchantAggregate, TestData.TransactionId1, TestData.TransactionAmount, TestData.TransactionDateTime, true); + aggregate.Balance.ShouldBe((TestData.TransactionAmount * 2) * -1); + aggregate.AuthorisedSales.Count.ShouldBe(2); + aggregate.AuthorisedSales.Value.ShouldBe(TestData.TransactionAmount * 2); + aggregate.AuthorisedSales.LastActivity.ShouldBe(TestData.TransactionDateTime); + } + + [Fact] + public void MerchantBalanceAggregate_RecordCompletedTransaction_DuplicateTransactionIsIgnored() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.CreatedMerchantAggregate(); + aggregate.RecordCompletedTransaction(merchantAggregate, TestData.TransactionId, TestData.TransactionAmount, TestData.TransactionDateTime, true); + aggregate.RecordCompletedTransaction(merchantAggregate, TestData.TransactionId, TestData.TransactionAmount, TestData.TransactionDateTime, true); + aggregate.Balance.ShouldBe(TestData.TransactionAmount * -1); + aggregate.AuthorisedSales.Count.ShouldBe(1); + aggregate.AuthorisedSales.Value.ShouldBe(TestData.TransactionAmount); + aggregate.AuthorisedSales.LastActivity.ShouldBe(TestData.TransactionDateTime); + } + + [Fact] + public void MerchantBalanceAggregate_RecordCompletedTransaction_TransactionDeclined_TransactionIsRecorded() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.CreatedMerchantAggregate(); + aggregate.RecordCompletedTransaction(merchantAggregate, TestData.TransactionId, TestData.TransactionAmount, TestData.TransactionDateTime, false); + aggregate.Balance.ShouldBe(0); + aggregate.DeclinedSales.Count.ShouldBe(1); + aggregate.DeclinedSales.Value.ShouldBe(TestData.TransactionAmount); + aggregate.DeclinedSales.LastActivity.ShouldBe(TestData.TransactionDateTime); + } + + [Fact] + public void MerchantBalanceAggregate_RecordDeposit_DepositIsRecorded() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.CreatedMerchantAggregate(); + aggregate.RecordMerchantDeposit(merchantAggregate, TestData.DepositId, TestData.DepositAmount.Value, TestData.DepositDateTime); + aggregate.Balance.ShouldBe(TestData.DepositAmount.Value); + aggregate.Deposits.Count.ShouldBe(1); + aggregate.Deposits.Value.ShouldBe(TestData.DepositAmount.Value); + aggregate.Deposits.LastActivity.ShouldBe(TestData.DepositDateTime); + } + + [Fact] + public void MerchantBalanceAggregate_RecordWithdrawal_WithdrawalIsRecorded() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.CreatedMerchantAggregate(); + aggregate.RecordMerchantWithdrawal(merchantAggregate, TestData.WithdrawalId, TestData.WithdrawalAmount.Value, TestData.WithdrawalDateTime); + aggregate.Balance.ShouldBe(TestData.WithdrawalAmount.Value * -1); + aggregate.Withdrawals.Count.ShouldBe(1); + aggregate.Withdrawals.Value.ShouldBe(TestData.WithdrawalAmount.Value); + aggregate.Withdrawals.LastActivity.ShouldBe(TestData.WithdrawalDateTime); + } + + [Fact] + public void MerchantBalanceAggregate_RecordSettledFee_SettledFeeIsRecorded() + { + MerchantBalanceAggregate aggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + MerchantAggregate merchantAggregate = TestData.Aggregates.CreatedMerchantAggregate(); + aggregate.RecordSettledFee(merchantAggregate, TestData.SettledFeeId1, TestData.SettledFeeAmount1, TestData.SettledFeeDateTime1); + aggregate.Balance.ShouldBe(TestData.SettledFeeAmount1); + aggregate.Fees.Count.ShouldBe(1); + aggregate.Fees.Value.ShouldBe(TestData.SettledFeeAmount1); + aggregate.Fees.LastActivity.ShouldBe(TestData.SettledFeeDateTime1); + } + } +} diff --git a/TransactionProcessor.Aggregates/MerchantBalanceAggregate.cs b/TransactionProcessor.Aggregates/MerchantBalanceAggregate.cs new file mode 100644 index 00000000..7cfb655d --- /dev/null +++ b/TransactionProcessor.Aggregates/MerchantBalanceAggregate.cs @@ -0,0 +1,177 @@ +using Shared.DomainDrivenDesign.EventSourcing; +using Shared.EventStore.Aggregate; +using Shared.General; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using TransactionProcessor.DomainEvents; +using TransactionProcessor.Models.Merchant; + +namespace TransactionProcessor.Aggregates +{ + public static class MerchantBalanceAggregateExtensions + { + public static void PlayEvent(this MerchantBalanceAggregate aggregate, MerchantBalanceDomainEvents.AuthorisedTransactionRecordedEvent domainEvent) + { + aggregate.Balance -= domainEvent.Amount; + aggregate.AuthorisedSales = aggregate.AuthorisedSales with { Count = aggregate.AuthorisedSales.Count + 1, Value = aggregate.AuthorisedSales.Value + domainEvent.Amount, LastActivity = domainEvent.DateTime }; + } + + public static void PlayEvent(this MerchantBalanceAggregate aggregate, MerchantBalanceDomainEvents.DeclinedTransactionRecordedEvent domainEvent) + { + aggregate.DeclinedSales= aggregate.AuthorisedSales with { Count = aggregate.DeclinedSales.Count + 1, Value = aggregate.DeclinedSales.Value + domainEvent.Amount, LastActivity = domainEvent.DateTime }; + } + + public static void PlayEvent(this MerchantBalanceAggregate aggregate, MerchantBalanceDomainEvents.MerchantDepositRecordedEvent domainEvent) + { + aggregate.Balance += domainEvent.Amount; + aggregate.Deposits = aggregate.Deposits with { Count = aggregate.Deposits.Count + 1, Value = aggregate.Deposits.Value + domainEvent.Amount, LastActivity = domainEvent.DateTime }; + } + + public static void PlayEvent(this MerchantBalanceAggregate aggregate, MerchantBalanceDomainEvents.MerchantWithdrawalRecordedEvent domainEvent) + { + aggregate.Balance -= domainEvent.Amount; + aggregate.Withdrawals = aggregate.Withdrawals with { Count = aggregate.Withdrawals.Count + 1, Value = aggregate.Withdrawals.Value + domainEvent.Amount, LastActivity = domainEvent.DateTime }; + } + + public static void PlayEvent(this MerchantBalanceAggregate aggregate, MerchantBalanceDomainEvents.SettledFeeRecordedEvent domainEvent) + { + aggregate.Balance += domainEvent.Amount; + aggregate.Fees = aggregate.Fees with { Count = aggregate.Fees.Count + 1, Value = aggregate.Fees.Value + domainEvent.Amount, LastActivity = domainEvent.DateTime }; + } + + public static void PlayEvent(this MerchantBalanceAggregate aggregate, MerchantBalanceDomainEvents.MerchantBalanceInitialisedEvent domainEvent) + { + aggregate.IsInitialised = true; + aggregate.EstateId = domainEvent.EstateId; + aggregate.Balance = 0; + } + + private static void EnsureMerchantHasBeenCreated(this MerchantBalanceAggregate aggregate, + MerchantAggregate merchantAggregate) { + if (merchantAggregate.IsCreated == false) { + throw new InvalidOperationException("Merchant has not been created"); + } + } + + private static void EnsureMerchantBalanceHasBeenInitialised(this MerchantBalanceAggregate aggregate, + MerchantAggregate merchantAggregate, + DateTime dateTime) + { + if (aggregate.IsInitialised == false) { + Merchant merchant = merchantAggregate.GetMerchant(); + MerchantBalanceDomainEvents.MerchantBalanceInitialisedEvent merchantBalanceInitialisedEvent = new MerchantBalanceDomainEvents.MerchantBalanceInitialisedEvent(merchant.MerchantId, merchant.EstateId,dateTime); + aggregate.ApplyAndAppend(merchantBalanceInitialisedEvent); + } + } + + + public static void RecordCompletedTransaction(this MerchantBalanceAggregate aggregate, + MerchantAggregate merchantAggregate, + Guid transactionId, + Decimal transactionAmount, + DateTime transactionDateTime, + Boolean isAuthorised) { + aggregate.EnsureMerchantHasBeenCreated(merchantAggregate); + aggregate.EnsureMerchantBalanceHasBeenInitialised(merchantAggregate, transactionDateTime); + DomainEvent domainEvent = isAuthorised switch { + true => new MerchantBalanceDomainEvents.AuthorisedTransactionRecordedEvent(aggregate.AggregateId, aggregate.EstateId, transactionId, transactionAmount, transactionDateTime), + _ => new MerchantBalanceDomainEvents.DeclinedTransactionRecordedEvent(aggregate.AggregateId, aggregate.EstateId, transactionId, transactionAmount, transactionDateTime) + }; + aggregate.ApplyAndAppend(domainEvent); + } + + public static void RecordMerchantDeposit(this MerchantBalanceAggregate aggregate, + MerchantAggregate merchantAggregate, + Guid depositId, + Decimal depositAmount, + DateTime depositDateTime) { + aggregate.EnsureMerchantHasBeenCreated(merchantAggregate); + aggregate.EnsureMerchantBalanceHasBeenInitialised(merchantAggregate, depositDateTime); + + MerchantBalanceDomainEvents.MerchantDepositRecordedEvent domainEvent = new MerchantBalanceDomainEvents.MerchantDepositRecordedEvent(aggregate.AggregateId, aggregate.EstateId, depositId, depositAmount, depositDateTime); + aggregate.ApplyAndAppend(domainEvent); + } + + public static void RecordMerchantWithdrawal(this MerchantBalanceAggregate aggregate, + MerchantAggregate merchantAggregate, + Guid withdrawalId, + Decimal withdrawalAmount, + DateTime withdrawalDateTime) { + aggregate.EnsureMerchantHasBeenCreated(merchantAggregate); + aggregate.EnsureMerchantBalanceHasBeenInitialised(merchantAggregate, withdrawalDateTime); + + MerchantBalanceDomainEvents.MerchantWithdrawalRecordedEvent domainEvent = new MerchantBalanceDomainEvents.MerchantWithdrawalRecordedEvent(aggregate.AggregateId, aggregate.EstateId, withdrawalId, withdrawalAmount, withdrawalDateTime); + aggregate.ApplyAndAppend(domainEvent); + } + + public static void RecordSettledFee(this MerchantBalanceAggregate aggregate, + MerchantAggregate merchantAggregate, + Guid feeId, + Decimal feeAmount, + DateTime feeDateTime) { + aggregate.EnsureMerchantHasBeenCreated(merchantAggregate); + aggregate.EnsureMerchantBalanceHasBeenInitialised(merchantAggregate, feeDateTime); + + MerchantBalanceDomainEvents.SettledFeeRecordedEvent domainEvent = new MerchantBalanceDomainEvents.SettledFeeRecordedEvent(aggregate.AggregateId, aggregate.EstateId, feeId, feeAmount, feeDateTime); + aggregate.ApplyAndAppend(domainEvent); + } + } + + public record ActivityType(Int32 Count, Decimal Value, DateTime LastActivity); + + public record MerchantBalanceAggregate : Aggregate { + + public Boolean IsInitialised { get; internal set; } + public Guid EstateId { get; internal set; } + public Decimal Balance { get; internal set; } + public ActivityType Deposits { get; internal set; } + public ActivityType Withdrawals { get; internal set; } + public ActivityType AuthorisedSales { get; internal set; } + public ActivityType DeclinedSales { get; internal set; } + public ActivityType Fees { get; internal set; } + + [ExcludeFromCodeCoverage] + public MerchantBalanceAggregate() + { + // Nothing here + this.AuthorisedSales = new ActivityType(0, 0, DateTime.MinValue); + this.DeclinedSales = new ActivityType(0, 0, DateTime.MinValue); + this.Deposits = new ActivityType(0, 0, DateTime.MinValue); + this.Withdrawals = new ActivityType(0, 0, DateTime.MinValue); + this.Fees = new ActivityType(0, 0, DateTime.MinValue); + } + + private MerchantBalanceAggregate(Guid aggregateId) + { + Guard.ThrowIfInvalidGuid(aggregateId, "Aggregate Id cannot be an Empty Guid"); + + this.AggregateId = aggregateId; + this.AuthorisedSales = new ActivityType(0, 0, DateTime.MinValue); + this.DeclinedSales = new ActivityType(0, 0, DateTime.MinValue); + this.Deposits = new ActivityType(0, 0, DateTime.MinValue); + this.Withdrawals = new ActivityType(0, 0, DateTime.MinValue); + this.Fees = new ActivityType(0, 0, DateTime.MinValue); + } + + public static MerchantBalanceAggregate Create(Guid aggregateId) + { + return new MerchantBalanceAggregate(aggregateId); + } + + public override void PlayEvent(IDomainEvent domainEvent) => MerchantBalanceAggregateExtensions.PlayEvent(this, (dynamic)domainEvent); + + [ExcludeFromCodeCoverage] + protected override Object GetMetadata() + { + return new + { + EstateId = Guid.NewGuid() // TODO: Populate + }; + } + } +} diff --git a/TransactionProcessor.Aggregates/TransactionAggregate.cs b/TransactionProcessor.Aggregates/TransactionAggregate.cs index 8daa408c..5cfa5520 100644 --- a/TransactionProcessor.Aggregates/TransactionAggregate.cs +++ b/TransactionProcessor.Aggregates/TransactionAggregate.cs @@ -73,7 +73,8 @@ public static TransactionProcessor.Models.Transaction GetTransaction(this Transa AdditionalRequestMetadata = aggregate.AdditionalTransactionRequestMetadata, AdditionalResponseMetadata = aggregate.AdditionalTransactionResponseMetadata, ResponseCode = aggregate.ResponseCode, - IsComplete = aggregate.IsCompleted + IsComplete = aggregate.IsCompleted, + IsAuthorised = aggregate.IsAuthorised || aggregate.IsLocallyAuthorised }; } diff --git a/TransactionProcessor.BusinessLogic.Tests/Mediator/MediatorTests.cs b/TransactionProcessor.BusinessLogic.Tests/Mediator/MediatorTests.cs index 77dcf143..55fb4e9c 100644 --- a/TransactionProcessor.BusinessLogic.Tests/Mediator/MediatorTests.cs +++ b/TransactionProcessor.BusinessLogic.Tests/Mediator/MediatorTests.cs @@ -107,6 +107,12 @@ public MediatorTests() this.Requests.Add(TestData.Queries.GetVoucherByVoucherCodeQuery); this.Requests.Add(TestData.Queries.GetVoucherByTransactionIdQuery); + // Merchant Balance Commands + this.Requests.Add(TestData.Commands.RecordDepositCommand); + this.Requests.Add(TestData.Commands.RecordWithdrawalCommand); + this.Requests.Add(TestData.Commands.RecordAuthorisedSaleCommand); + this.Requests.Add(TestData.Commands.RecordDeclinedSaleCommand); + this.Requests.Add(TestData.Commands.RecordSettledFeeCommand); } [Fact] diff --git a/TransactionProcessor.BusinessLogic.Tests/Services/MerchantDomainServiceTests.cs b/TransactionProcessor.BusinessLogic.Tests/Services/MerchantDomainServiceTests.cs index da874fac..238324e3 100644 --- a/TransactionProcessor.BusinessLogic.Tests/Services/MerchantDomainServiceTests.cs +++ b/TransactionProcessor.BusinessLogic.Tests/Services/MerchantDomainServiceTests.cs @@ -1058,4 +1058,58 @@ public async Task MerchantDomainService_RemoveContractFromMerchant_ValidationFai var result = await this.DomainService.RemoveContractFromMerchant(TestData.Commands.RemoveMerchantContractCommand, CancellationToken.None); result.IsFailed.ShouldBeTrue(); } + + [Fact] + public async Task MerchantDomainService_RecordDepositAgainstBalance_DepositIsMade() + { + 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.EmptyMerchantBalanceAggregate())); + this.AggregateService + .Setup(m => m.Save(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success); + + Result result = await this.DomainService.RecordDepositAgainstBalance(TestData.Commands.RecordDepositCommand, CancellationToken.None); + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantDomainService_RecordDepositAgainstBalance_Failed_DepositIsNotMade() + { + 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.Failure()); + + Result result = await this.DomainService.RecordDepositAgainstBalance(TestData.Commands.RecordDepositCommand, CancellationToken.None); + result.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantDomainService_RecordWithdrawalAgainstBalance_WithdrawalIsMade() + { + 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.EmptyMerchantBalanceAggregate())); + this.AggregateService + .Setup(m => m.Save(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success); + + Result result = await this.DomainService.RecordWithdrawalAgainstBalance(TestData.Commands.RecordWithdrawalCommand, CancellationToken.None); + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task MerchantDomainService_RecordWithdrawalAgainstBalance_Failed_WithdrawalIsNotMade() + { + 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.Failure()); + + Result result = await this.DomainService.RecordWithdrawalAgainstBalance(TestData.Commands.RecordWithdrawalCommand, CancellationToken.None); + result.IsFailed.ShouldBeTrue(); + } } \ No newline at end of file diff --git a/TransactionProcessor.BusinessLogic.Tests/TransactionProcessor.BusinessLogic.Tests.csproj b/TransactionProcessor.BusinessLogic.Tests/TransactionProcessor.BusinessLogic.Tests.csproj index 880f67dd..09cd1fd3 100644 --- a/TransactionProcessor.BusinessLogic.Tests/TransactionProcessor.BusinessLogic.Tests.csproj +++ b/TransactionProcessor.BusinessLogic.Tests/TransactionProcessor.BusinessLogic.Tests.csproj @@ -2,7 +2,7 @@ net9.0 - Full + None false diff --git a/TransactionProcessor.BusinessLogic/EventHandling/TransactionDomainEventHandler.cs b/TransactionProcessor.BusinessLogic/EventHandling/TransactionDomainEventHandler.cs index 9f88ce12..055b9360 100644 --- a/TransactionProcessor.BusinessLogic/EventHandling/TransactionDomainEventHandler.cs +++ b/TransactionProcessor.BusinessLogic/EventHandling/TransactionDomainEventHandler.cs @@ -1,9 +1,9 @@ using MediatR; +using Prometheus; +using Shared.EventStore.Aggregate; using SimpleResults; using System; using System.Diagnostics; -using Prometheus; -using Shared.EventStore.Aggregate; using TransactionProcessor.BusinessLogic.Requests; using TransactionProcessor.DomainEvents; using TransactionProcessor.Models.Contract; @@ -15,10 +15,13 @@ namespace TransactionProcessor.BusinessLogic.EventHandling using Shared.DomainDrivenDesign.EventSourcing; using Shared.EventStore.EventHandling; using Shared.Logger; + using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using TransactionProcessor.BusinessLogic.Common; using TransactionProcessor.BusinessLogic.Services; + using TransactionProcessor.DataTransferObjects; + using TransactionProcessor.Models; using static TransactionProcessor.BusinessLogic.Requests.SettlementCommands; using static TransactionProcessor.BusinessLogic.Requests.TransactionCommands; @@ -109,7 +112,26 @@ private async Task HandleSpecificDomainEvent(TransactionDomainEvents.Mer private async Task HandleSpecificDomainEvent(TransactionDomainEvents.SettledMerchantFeeAddedToTransactionEvent domainEvent, CancellationToken cancellationToken) { AddSettledFeeToSettlementCommand command = new AddSettledFeeToSettlementCommand(domainEvent.SettledDateTime.Date, domainEvent.MerchantId, domainEvent.EstateId, domainEvent.FeeId, domainEvent.TransactionId); - return await this.Mediator.Send(command, cancellationToken); + var result = await this.Mediator.Send(command, cancellationToken); + if (result.IsFailed) { + return result; + } + + // Kick off a background command to do the balance processing + // TODO: maybe add in some retry logic here or offload to another process to retry? + FireAndForgetHelper.Run( + async () => { + MerchantBalanceCommands.RecordSettledFeeCommand balanceCommand = new MerchantBalanceCommands.RecordSettledFeeCommand(domainEvent.EstateId, domainEvent.MerchantId, domainEvent.FeeId, domainEvent.CalculatedValue, domainEvent.SettledDateTime); + Result balanceResult = await this.Mediator.Send(balanceCommand, cancellationToken); + if (balanceResult.IsFailed) + { + throw new Exception($"Failed to record balance for transaction {domainEvent.TransactionId} fee {domainEvent.FeeId} with error: {balanceResult.Message}"); + } + }, + $"Balance Recording for Transaction {domainEvent.TransactionId} Fee {domainEvent.FeeId}" + ); + + return result; } private async Task HandleSpecificDomainEvent(SettlementDomainEvents.MerchantFeeSettledEvent domainEvent, @@ -134,4 +156,24 @@ private async Task HandleSpecificDomainEvent(TransactionDomainEvents.Cus #endregion } + + [ExcludeFromCodeCoverage] + public static class FireAndForgetHelper + { + public static void Run(Func action, string contextDescription = "background task") + { + _ = Task.Run(async () => + { + try + { + await action(); + } + catch (Exception ex) + { + Exception e = new Exception($"Unhandled exception in {contextDescription}", ex); + Logger.LogError(e); + } + }); + } + } } \ No newline at end of file diff --git a/TransactionProcessor.BusinessLogic/RequestHandlers/MerchantBalanceRequestHandler.cs b/TransactionProcessor.BusinessLogic/RequestHandlers/MerchantBalanceRequestHandler.cs new file mode 100644 index 00000000..14f7048e --- /dev/null +++ b/TransactionProcessor.BusinessLogic/RequestHandlers/MerchantBalanceRequestHandler.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Newtonsoft.Json; +using Shared.EventStore.EventStore; +using SimpleResults; +using TransactionProcessor.BusinessLogic.Manager; +using TransactionProcessor.BusinessLogic.Requests; +using TransactionProcessor.BusinessLogic.Services; +using TransactionProcessor.Models.Contract; +using TransactionProcessor.ProjectionEngine.Models; +using TransactionProcessor.ProjectionEngine.Repository; +using TransactionProcessor.ProjectionEngine.State; +using Merchant = TransactionProcessor.Models.Merchant.Merchant; + +namespace TransactionProcessor.BusinessLogic.RequestHandlers; + +public class MerchantBalanceRequestHandler : + IRequestHandler, + IRequestHandler, + IRequestHandler, + IRequestHandler, + IRequestHandler +{ + private readonly IMerchantDomainService MerchantDomainService; + + public MerchantBalanceRequestHandler(IMerchantDomainService merchantDomainService) { + this.MerchantDomainService = merchantDomainService; + } + + public async Task Handle(MerchantBalanceCommands.RecordDepositCommand command, + CancellationToken cancellationToken) + { + return await this.MerchantDomainService.RecordDepositAgainstBalance(command, cancellationToken); + } + + public async Task Handle(MerchantBalanceCommands.RecordWithdrawalCommand command, + CancellationToken cancellationToken) + { + return await this.MerchantDomainService.RecordWithdrawalAgainstBalance(command, cancellationToken); + } + + public async Task Handle(MerchantBalanceCommands.RecordAuthorisedSaleCommand request, + CancellationToken cancellationToken) { + return await this.MerchantDomainService.RecordTransactionAgainstBalance(request, cancellationToken); + } + + public async Task Handle(MerchantBalanceCommands.RecordDeclinedSaleCommand request, + CancellationToken cancellationToken) { + return await this.MerchantDomainService.RecordTransactionAgainstBalance(request, cancellationToken); + } + + public async Task Handle(MerchantBalanceCommands.RecordSettledFeeCommand request, + CancellationToken cancellationToken) { + return await this.MerchantDomainService.RecordSettledFeeAgainstBalance(request, cancellationToken); + } +} \ No newline at end of file diff --git a/TransactionProcessor.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs b/TransactionProcessor.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs index 9212dda8..575da717 100644 --- a/TransactionProcessor.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs +++ b/TransactionProcessor.BusinessLogic/RequestHandlers/TransactionRequestHandler.cs @@ -23,6 +23,8 @@ public class TransactionRequestHandler : IRequestHandler private readonly ITransactionDomainService TransactionDomainService; + private readonly IMerchantDomainService MerchantDomainService; + #endregion #region Constructors @@ -31,8 +33,7 @@ public class TransactionRequestHandler : IRequestHandler class. /// /// The transaction domain service. - public TransactionRequestHandler(ITransactionDomainService transactionDomainService) - { + public TransactionRequestHandler(ITransactionDomainService transactionDomainService) { this.TransactionDomainService = transactionDomainService; } diff --git a/TransactionProcessor.BusinessLogic/Requests/MerchantCommands.cs b/TransactionProcessor.BusinessLogic/Requests/MerchantCommands.cs index 510e6e49..60e2e75e 100644 --- a/TransactionProcessor.BusinessLogic/Requests/MerchantCommands.cs +++ b/TransactionProcessor.BusinessLogic/Requests/MerchantCommands.cs @@ -6,6 +6,15 @@ namespace TransactionProcessor.BusinessLogic.Requests { + [ExcludeFromCodeCoverage] + public class MerchantBalanceCommands { + public record RecordDepositCommand(Guid EstateId, Guid MerchantId, Guid DepositId, Decimal DepositAmount, DateTime DepositDateTime) : IRequest; + public record RecordWithdrawalCommand(Guid EstateId, Guid MerchantId, Guid WithdrawalId, Decimal WithdrawalAmount, DateTime WithdrawalDateTime) : IRequest; + public record RecordAuthorisedSaleCommand(Guid EstateId, Guid MerchantId,Guid TransactionId, Decimal TransactionAmount, DateTime TransactionDateTime) : IRequest; + public record RecordDeclinedSaleCommand(Guid EstateId, Guid MerchantId, Guid TransactionId, Decimal TransactionAmount, DateTime TransactionDateTime) : IRequest; + public record RecordSettledFeeCommand(Guid EstateId, Guid MerchantId, Guid FeeId, Decimal FeeAmount, DateTime FeeDateTime) : IRequest; + } + [ExcludeFromCodeCoverage] public class MerchantCommands{ diff --git a/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs b/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs index ab5dc20d..ffee1934 100644 --- a/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs +++ b/TransactionProcessor.BusinessLogic/Services/MerchantDomainService.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Newtonsoft.Json; +using Newtonsoft.Json; using SecurityService.Client; using SecurityService.DataTransferObjects; using Shared.DomainDrivenDesign.EventSourcing; @@ -13,11 +8,21 @@ using Shared.Results; using Shared.ValueObjects; using SimpleResults; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; using TransactionProcessor.Aggregates; +using TransactionProcessor.BusinessLogic.Common; using TransactionProcessor.BusinessLogic.Requests; +using TransactionProcessor.Models; using TransactionProcessor.Models.Estate; using TransactionProcessor.Models.Merchant; using TransactionProcessor.ProjectionEngine.State; +using static Microsoft.EntityFrameworkCore.DbLoggerCategory.Database; namespace TransactionProcessor.BusinessLogic.Services { @@ -40,6 +45,15 @@ public interface IMerchantDomainService Task RemoveOperatorFromMerchant(MerchantCommands.RemoveOperatorFromMerchantCommand command, CancellationToken cancellationToken); Task RemoveContractFromMerchant(MerchantCommands.RemoveMerchantContractCommand command, CancellationToken cancellationToken); + Task RecordDepositAgainstBalance(MerchantBalanceCommands.RecordDepositCommand command, + CancellationToken cancellationToken); + Task RecordWithdrawalAgainstBalance(MerchantBalanceCommands.RecordWithdrawalCommand command, + CancellationToken cancellationToken); + + Task RecordTransactionAgainstBalance(MerchantBalanceCommands.RecordAuthorisedSaleCommand command, CancellationToken cancellationToken); + Task RecordTransactionAgainstBalance(MerchantBalanceCommands.RecordDeclinedSaleCommand command, CancellationToken cancellationToken); + Task RecordSettledFeeAgainstBalance(MerchantBalanceCommands.RecordSettledFeeCommand command, CancellationToken cancellationToken); + #endregion } @@ -101,6 +115,41 @@ private async Task ApplyUpdates(Func<(EstateAggregate estateAggregate, M } } + private async Task ApplyUpdates(Func<(MerchantAggregate merchantAggregate, MerchantBalanceAggregate merchantBalanceAggregate), Task> action, Guid estateId, Guid merchantId, CancellationToken cancellationToken, Boolean isNotFoundError = true) + { + try + { + Result getMerchantResult = await this.AggregateService.Get(merchantId, cancellationToken); + if (getMerchantResult.IsFailed) + { + return ResultHelpers.CreateFailure(getMerchantResult); + } + MerchantAggregate merchantAggregate = getMerchantResult.Data; + + Result getMerchantBalanceResult = await this.AggregateService.GetLatest(merchantId, cancellationToken); + Result merchantBalanceAggregateResult = + DomainServiceHelper.HandleGetAggregateResult(getMerchantBalanceResult, merchantId, isNotFoundError); + if (merchantBalanceAggregateResult.IsFailed) + return ResultHelpers.CreateFailure(merchantBalanceAggregateResult); + + MerchantBalanceAggregate merchantBalanceAggregate = merchantBalanceAggregateResult.Data; + + Result result = await action((merchantAggregate, merchantBalanceAggregate)); + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + Result saveResult = await this.AggregateService.Save(merchantBalanceAggregate, cancellationToken); + if (saveResult.IsFailed) + return ResultHelpers.CreateFailure(saveResult); + + return Result.Success(); + } + catch (Exception ex) + { + return Result.Failure(ex.GetExceptionMessages()); + } + } + public async Task AddDeviceToMerchant(MerchantCommands.AddMerchantDeviceCommand command, CancellationToken cancellationToken) { Result result = await ApplyUpdates( @@ -582,7 +631,86 @@ public async Task RemoveContractFromMerchant(MerchantCommands.RemoveMerc aggregates.merchantAggregate.RemoveContract(command.ContractId); return Result.Success(); - }, command.EstateId, command.MerchantId, cancellationToken); + }, command.EstateId, command.MerchantId, cancellationToken, false); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + return Result.Success(); + } + + public async Task RecordDepositAgainstBalance(MerchantBalanceCommands.RecordDepositCommand command, + CancellationToken cancellationToken) { + Result result = await ApplyUpdates( + async ((MerchantAggregate merchantAggregate, MerchantBalanceAggregate merchantBalanceAggregate) aggregates) => { + aggregates.merchantBalanceAggregate.RecordMerchantDeposit(aggregates.merchantAggregate, command.DepositId, command.DepositAmount, command.DepositDateTime); + + return Result.Success(); + }, command.EstateId, command.MerchantId, cancellationToken, false); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + return Result.Success(); + } + + public async Task RecordSettledFeeAgainstBalance(MerchantBalanceCommands.RecordSettledFeeCommand command, + CancellationToken cancellationToken) + { + Result result = await ApplyUpdates( + async ((MerchantAggregate merchantAggregate, MerchantBalanceAggregate merchantBalanceAggregate) aggregates) => { + aggregates.merchantBalanceAggregate.RecordSettledFee(aggregates.merchantAggregate, command.FeeId, command.FeeAmount, command.FeeDateTime); + + return Result.Success(); + }, command.EstateId, command.MerchantId, cancellationToken, false); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + return Result.Success(); + } + + public async Task RecordWithdrawalAgainstBalance(MerchantBalanceCommands.RecordWithdrawalCommand command, + CancellationToken cancellationToken) { + Result result = await ApplyUpdates( + async ((MerchantAggregate merchantAggregate, MerchantBalanceAggregate merchantBalanceAggregate) aggregates) => { + + aggregates.merchantBalanceAggregate.RecordMerchantWithdrawal(aggregates.merchantAggregate, command.WithdrawalId, command.WithdrawalAmount, command.WithdrawalDateTime); + + return Result.Success(); + }, command.EstateId, command.MerchantId, cancellationToken, false); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + return Result.Success(); + } + + public async Task RecordTransactionAgainstBalance(MerchantBalanceCommands.RecordAuthorisedSaleCommand command, CancellationToken cancellationToken) + { + Result result = await ApplyUpdates( + async ((MerchantAggregate merchantAggregate, MerchantBalanceAggregate merchantBalanceAggregate) aggregates) => { + + // Record the sale against the balance + aggregates.merchantBalanceAggregate.RecordCompletedTransaction(aggregates.merchantAggregate, command.TransactionId, command.TransactionAmount, command.TransactionDateTime, true); + return Result.Success(); + }, command.EstateId, command.MerchantId, cancellationToken, false); + + if (result.IsFailed) + return ResultHelpers.CreateFailure(result); + + return Result.Success(); + } + + public async Task RecordTransactionAgainstBalance(MerchantBalanceCommands.RecordDeclinedSaleCommand command, CancellationToken cancellationToken) + { + Result result = await ApplyUpdates( + async ((MerchantAggregate merchantAggregate, MerchantBalanceAggregate merchantBalanceAggregate) aggregates) => { + + // Record the sale against the balance + aggregates.merchantBalanceAggregate.RecordCompletedTransaction(aggregates.merchantAggregate, command.TransactionId, command.TransactionAmount, command.TransactionDateTime, false); + return Result.Success(); + }, command.EstateId, command.MerchantId, cancellationToken, false); if (result.IsFailed) return ResultHelpers.CreateFailure(result); diff --git a/TransactionProcessor.BusinessLogic/Services/TransactionDomainService.cs b/TransactionProcessor.BusinessLogic/Services/TransactionDomainService.cs index 7d9d6eb0..09d5fe76 100644 --- a/TransactionProcessor.BusinessLogic/Services/TransactionDomainService.cs +++ b/TransactionProcessor.BusinessLogic/Services/TransactionDomainService.cs @@ -353,14 +353,16 @@ public async Task> ProcessSaleTransaction // Get the model from the aggregate Models.Transaction transaction = transactionAggregate.GetTransaction(); - + return Result.Success(new ProcessSaleTransactionResponse { ResponseMessage = transaction.ResponseMessage, ResponseCode = transaction.ResponseCode, EstateId = command.EstateId, MerchantId = command.MerchantId, AdditionalTransactionMetadata = transaction.AdditionalResponseMetadata, - TransactionId = command.TransactionId + TransactionId = command.TransactionId, + TransactionIsAuthorised = transaction.IsAuthorised, + TransactionIsComplete = transaction.IsComplete }); }, command.TransactionId, cancellationToken, false); diff --git a/TransactionProcessor.Database/TransactionProcessor.Database.csproj b/TransactionProcessor.Database/TransactionProcessor.Database.csproj index b7c9a936..c7d733c0 100644 --- a/TransactionProcessor.Database/TransactionProcessor.Database.csproj +++ b/TransactionProcessor.Database/TransactionProcessor.Database.csproj @@ -4,7 +4,7 @@ net9.0 enable enable - Full + None diff --git a/TransactionProcessor.DomainEvents/MerchantBalanceDomainEvents.cs b/TransactionProcessor.DomainEvents/MerchantBalanceDomainEvents.cs new file mode 100644 index 00000000..6a054c8b --- /dev/null +++ b/TransactionProcessor.DomainEvents/MerchantBalanceDomainEvents.cs @@ -0,0 +1,21 @@ +using Shared.DomainDrivenDesign.EventSourcing; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TransactionProcessor.DomainEvents +{ + [ExcludeFromCodeCoverage] + public class MerchantBalanceDomainEvents + { + public record MerchantBalanceInitialisedEvent(Guid MerchantId, Guid EstateId,DateTime DateTime) : DomainEvent(MerchantId, Guid.NewGuid()); + public record AuthorisedTransactionRecordedEvent(Guid MerchantId, Guid EstateId, Guid TransactionId, Decimal Amount, DateTime DateTime) : DomainEvent(MerchantId, TransactionId); + public record DeclinedTransactionRecordedEvent(Guid MerchantId, Guid EstateId, Guid TransactionId, Decimal Amount, DateTime DateTime) : DomainEvent(MerchantId, TransactionId); + public record MerchantDepositRecordedEvent(Guid MerchantId, Guid EstateId, Guid DepositId, Decimal Amount, DateTime DateTime) : DomainEvent(MerchantId, DepositId); + public record MerchantWithdrawalRecordedEvent(Guid MerchantId, Guid EstateId, Guid WithdrawalId, Decimal Amount,DateTime DateTime) : DomainEvent(MerchantId, WithdrawalId); + public record SettledFeeRecordedEvent(Guid MerchantId, Guid EstateId, Guid FeeId, Decimal Amount, DateTime DateTime) : DomainEvent(MerchantId, FeeId); + } +} diff --git a/TransactionProcessor.Models/ProcessSaleTransactionResponse.cs b/TransactionProcessor.Models/ProcessSaleTransactionResponse.cs index c9e4df1d..4d9022db 100644 --- a/TransactionProcessor.Models/ProcessSaleTransactionResponse.cs +++ b/TransactionProcessor.Models/ProcessSaleTransactionResponse.cs @@ -10,56 +10,22 @@ public class ProcessSaleTransactionResponse { #region Properties - /// - /// Gets or sets the response code. - /// - /// - /// The response code. - /// public String ResponseCode { get; set; } - /// - /// Gets or sets the response message. - /// - /// - /// The response message. - /// public String ResponseMessage { get; set; } - /// - /// Gets or sets the estate identifier. - /// - /// - /// The estate identifier. - /// public Guid EstateId { get; set; } - /// - /// Gets or sets the merchant identifier. - /// - /// - /// The merchant identifier. - /// public Guid MerchantId { get; set; } - /// - /// Gets or sets the additional transaction metadata. - /// - /// - /// The additional transaction metadata. - /// public Dictionary AdditionalTransactionMetadata { get; set; } - /// - /// Gets or sets the transaction identifier. - /// - /// - /// The transaction identifier. - /// public Guid TransactionId { get; set; } public Boolean TransactionIsComplete { get; set; } + public Boolean TransactionIsAuthorised { get; set; } + #endregion } diff --git a/TransactionProcessor.Models/Transaction.cs b/TransactionProcessor.Models/Transaction.cs index ea8572f2..a9ee66d6 100644 --- a/TransactionProcessor.Models/Transaction.cs +++ b/TransactionProcessor.Models/Transaction.cs @@ -12,102 +12,32 @@ public class Transaction { #region Properties - public Boolean IsComplete { get; set; } + public Boolean IsAuthorised { get; set; } - /// - /// Gets or sets the additional request metadata. - /// - /// - /// The additional request metadata. - /// + public Boolean IsComplete { get; set; } + public Dictionary AdditionalRequestMetadata { get; set; } - /// - /// Gets or sets the additional response metadata. - /// - /// - /// The additional response metadata. - /// public Dictionary AdditionalResponseMetadata { get; set; } - /// - /// Gets or sets the response code. - /// - /// - /// The response code. - /// public String ResponseCode { get; set; } - /// - /// Gets or sets the authorisation code. - /// - /// - /// The authorisation code. - /// public String AuthorisationCode { get; set; } - /// - /// Gets or sets the merchant identifier. - /// - /// - /// The merchant identifier. - /// public Guid MerchantId { get; set; } - /// - /// Gets or sets the operator identifier. - /// - /// - /// The operator identifier. - /// public Guid OperatorId { get; set; } - /// - /// Gets or sets the operator transaction identifier. - /// - /// - /// The operator transaction identifier. - /// public String OperatorTransactionId { get; set; } - /// - /// Gets or sets the response message. - /// - /// - /// The response message. - /// public String ResponseMessage { get; set; } - /// - /// Gets or sets the transaction amount. - /// - /// - /// The transaction amount. - /// public Decimal TransactionAmount { get; set; } - /// - /// Gets or sets the transaction date time. - /// - /// - /// The transaction date time. - /// public DateTime TransactionDateTime { get; set; } - /// - /// Gets or sets the transaction number. - /// - /// - /// The transaction number. - /// public String TransactionNumber { get; set; } - /// - /// Gets or sets the transaction reference. - /// - /// - /// The transaction reference. - /// public String TransactionReference { get; set; } #endregion diff --git a/TransactionProcessor.Testing/TestData.cs b/TransactionProcessor.Testing/TestData.cs index f9e03913..914dd456 100644 --- a/TransactionProcessor.Testing/TestData.cs +++ b/TransactionProcessor.Testing/TestData.cs @@ -2229,6 +2229,19 @@ public static ContractCommands.AddTransactionFeeForProductToContractCommand TestData.MerchantId, TestData.MakeMerchantWithdrawalRequest); + public static MerchantBalanceCommands.RecordDepositCommand RecordDepositCommand => new(TestData.EstateId, + TestData.MerchantId, + TestData.DepositId, + TestData.DepositAmount.Value, + TestData.DepositDateTime); + + public static MerchantBalanceCommands.RecordWithdrawalCommand RecordWithdrawalCommand => new(TestData.EstateId,TestData.MerchantId,TestData.WithdrawalId, TestData.WithdrawalAmount.Value, TestData.WithdrawalDateTime); + + public static MerchantBalanceCommands.RecordAuthorisedSaleCommand RecordAuthorisedSaleCommand => new(TestData.EstateId, TestData.MerchantId, TestData.TransactionId, TestData.TransactionAmount, TestData.TransactionDateTime); + + public static MerchantBalanceCommands.RecordDeclinedSaleCommand RecordDeclinedSaleCommand => new(TestData.EstateId, TestData.MerchantId, TestData.TransactionId, TestData.TransactionAmount, TestData.TransactionDateTime); + public static MerchantBalanceCommands.RecordDeclinedSaleCommand RecordSettledFeeCommand => new(TestData.EstateId, TestData.MerchantId, SettledFeeId1, CalculatedFeeValue, SettledFeeDateTime1); + public static MerchantCommands.MakeMerchantDepositCommand MakeMerchantDepositCommand => new(TestData.EstateId, TestData.MerchantId, @@ -2404,6 +2417,13 @@ public static MerchantAggregate MerchantAggregateWithDeletedOperator(SettlementS return merchantAggregate; } + public static MerchantBalanceAggregate EmptyMerchantBalanceAggregate() + { + MerchantBalanceAggregate merchantBalanceAggregate = MerchantBalanceAggregate.Create(TestData.MerchantId); + + return merchantBalanceAggregate; + } + public static MerchantAggregate CreatedMerchantAggregate() { MerchantAggregate merchantAggregate = MerchantAggregate.Create(TestData.MerchantId); diff --git a/TransactionProcessor/Bootstrapper/MediatorRegistry.cs b/TransactionProcessor/Bootstrapper/MediatorRegistry.cs index e907048b..e42cf2f3 100644 --- a/TransactionProcessor/Bootstrapper/MediatorRegistry.cs +++ b/TransactionProcessor/Bootstrapper/MediatorRegistry.cs @@ -37,6 +37,16 @@ public MediatorRegistry() this.RegisterOperatorRequestHandler(); this.RegisterContractRequestHandler(); this.RegisterMerchantStatementRequestHandler(); + this.RegisterMerchantBalanceRequestHandler(); + } + + private void RegisterMerchantBalanceRequestHandler() + { + this.AddSingleton, MerchantBalanceRequestHandler>(); + this.AddSingleton, MerchantBalanceRequestHandler>(); + this.AddSingleton, MerchantBalanceRequestHandler>(); + this.AddSingleton, MerchantBalanceRequestHandler>(); + this.AddSingleton, MerchantBalanceRequestHandler>(); } private void RegisterMerchantStatementRequestHandler() { diff --git a/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs b/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs index 854185b8..853dcfb7 100644 --- a/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs +++ b/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs @@ -85,6 +85,7 @@ public RepositoryRegistry() this.AddSingleton, AggregateRepository>(); this.AddSingleton, AggregateRepository>(); this.AddSingleton, AggregateRepository>(); + this.AddSingleton, AggregateRepository>(); this.AddSingleton, MerchantBalanceStateRepository>(); this.AddSingleton, VoucherStateRepository>(); diff --git a/TransactionProcessor/Controllers/MerchantController.cs b/TransactionProcessor/Controllers/MerchantController.cs index 6c58cf46..a8f30396 100644 --- a/TransactionProcessor/Controllers/MerchantController.cs +++ b/TransactionProcessor/Controllers/MerchantController.cs @@ -3,6 +3,7 @@ using Shared.Results; using Shared.Results.Web; using SimpleResults; +using System.Security.Cryptography; using TransactionProcessor.BusinessLogic.Requests; using TransactionProcessor.DataTransferObjects.Requests.Merchant; using TransactionProcessor.DataTransferObjects.Responses.Merchant; @@ -19,6 +20,7 @@ namespace TransactionProcessor.Controllers; using ProjectionEngine.Repository; using ProjectionEngine.State; using Shared.DomainDrivenDesign.EventSourcing; +using Shared.EventStore.Aggregate; using Shared.EventStore.EventStore; using Shared.Exceptions; using Shared.General; @@ -26,7 +28,9 @@ namespace TransactionProcessor.Controllers; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Security.Claims; +using System.Text; using System.Threading; using System.Threading.Tasks; @@ -424,15 +428,42 @@ public async Task MakeDeposit([FromRoute] Guid estateId, } // This will always be a manual deposit as auto ones come in via another route - MerchantCommands.MakeMerchantDepositCommand command = new(estateId, merchantId, DataTransferObjects.Requests.Merchant.MerchantDepositSource.Manual, makeMerchantDepositRequest); + MerchantCommands.MakeMerchantDepositCommand command = new(estateId, merchantId, MerchantDepositSource.Manual, makeMerchantDepositRequest); // Route the command Result result = await Mediator.Send(command, cancellationToken); + if (result.IsFailed) + { + return result.ToActionResultX(); + } + + // Now we need to record the deposit against the balance + String depositData = $"{makeMerchantDepositRequest.DepositDateTime:yyyyMMdd hh:mm:ss.fff}-{makeMerchantDepositRequest.Reference}-{makeMerchantDepositRequest.Amount:N2}-{MerchantDepositSource.Manual}"; + Guid depositId = GenerateGuidFromString(depositData); + MerchantBalanceCommands.RecordDepositCommand recordDepositCommand = new(estateId, merchantId, depositId, makeMerchantDepositRequest.Amount, makeMerchantDepositRequest.DepositDateTime); + + // Route the command + result = await Mediator.Send(recordDepositCommand, cancellationToken); + // return the result return result.ToActionResultX(); } + public static Guid GenerateGuidFromString(String input) + { + using (SHA256 sha256Hash = SHA256.Create()) + { + //Generate hash from the key + Byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(input)); + + Byte[] j = bytes.Skip(Math.Max(0, bytes.Count() - 16)).ToArray(); //Take last 16 + + //Create our Guid. + return new Guid(j); + } + } + [HttpPost] [Route("{merchantId}/withdrawals")] //[SwaggerResponse(201, "Created", typeof(MakeMerchantDepositResponse))] @@ -453,6 +484,19 @@ public async Task MakeWithdrawal([FromRoute] Guid estateId, // Route the command Result result = await Mediator.Send(command, cancellationToken); + if (result.IsFailed) + { + return result.ToActionResultX(); + } + + // Now we need to record the deposit against the balance + String depositData = $"{makeMerchantWithdrawalRequest.WithdrawalDateTime:yyyyMMdd hh:mm:ss.fff}-{makeMerchantWithdrawalRequest.Amount:N2}"; + Guid withdrawalId = GenerateGuidFromString(depositData); + MerchantBalanceCommands.RecordWithdrawalCommand recordWithdrawalCommand = new(estateId, merchantId, withdrawalId, makeMerchantWithdrawalRequest.Amount, makeMerchantWithdrawalRequest.WithdrawalDateTime); + + // Route the command + result = await Mediator.Send(recordWithdrawalCommand, cancellationToken); + // return the result return result.ToActionResultX(); } diff --git a/TransactionProcessor/Controllers/TransactionController.cs b/TransactionProcessor/Controllers/TransactionController.cs index 4aa1adff..e423b3a9 100644 --- a/TransactionProcessor/Controllers/TransactionController.cs +++ b/TransactionProcessor/Controllers/TransactionController.cs @@ -1,4 +1,5 @@ -using Shared.Results; +using Shared.Logger; +using Shared.Results; using Shared.Results.Web; using SimpleResults; @@ -22,6 +23,7 @@ namespace TransactionProcessor.Controllers using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; + using TransactionProcessor.BusinessLogic.Common; /// /// @@ -178,10 +180,29 @@ private async Task> ProcessSpecificMessage(SaleTransac saleTransactionRequest.TransactionSource.GetValueOrDefault(1), transactionReceivedDateTime); - var result= await this.Mediator.Send(command, cancellationToken); + Result result= await this.Mediator.Send(command, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); + // Kick off a background command to do the balance processing + // TODO: maybe add in some retry logic here or offload to another process to retry? + FireAndForgetHelper.Run( + async () => { + ProcessSaleTransactionResponse response = result.Data; + Decimal? transactionAmount = command.AdditionalTransactionMetadata.ExtractFieldFromMetadata("Amount"); + IRequest balanceCommand = response.TransactionIsAuthorised switch { + true => new MerchantBalanceCommands.RecordAuthorisedSaleCommand(response.EstateId, response.MerchantId, response.TransactionId, transactionAmount.GetValueOrDefault(), saleTransactionRequest.TransactionDateTime), + _ => new MerchantBalanceCommands.RecordDeclinedSaleCommand(response.EstateId, response.MerchantId, response.TransactionId, transactionAmount.GetValueOrDefault(), saleTransactionRequest.TransactionDateTime) + }; + Result balanceResult = await this.Mediator.Send(balanceCommand, cancellationToken); + if (balanceResult.IsFailed) + { + throw new Exception(($"Failed to record balance for transaction {response.TransactionId} with error: {balanceResult.Message}")); + } + }, + $"Balance Recording for Transaction {transactionId}" + ); + return ModelFactory.ConvertFrom(result.Data); } @@ -222,4 +243,23 @@ private async Task> ProcessSpecificMessage(Reconciliat #endregion } + + [ExcludeFromCodeCoverage] + public static class FireAndForgetHelper + { + public static void Run(Func action, string contextDescription = "background task") + { + _ = Task.Run(async () => + { + try + { + await action(); + } + catch (Exception ex) { + Exception e = new Exception($"Unhandled exception in {contextDescription}", ex); + Logger.LogError(e); + } + }); + } + } } \ No newline at end of file