diff --git a/TransactionProcessor.BusinessLogic.Tests/Services/TransactionAggregateManagerTests.cs b/TransactionProcessor.BusinessLogic.Tests/Services/TransactionAggregateManagerTests.cs index 28f58d96..c07c2ee4 100644 --- a/TransactionProcessor.BusinessLogic.Tests/Services/TransactionAggregateManagerTests.cs +++ b/TransactionProcessor.BusinessLogic.Tests/Services/TransactionAggregateManagerTests.cs @@ -8,18 +8,25 @@ namespace TransactionProcessor.BusinessLogic.Tests.Services using System.Threading; using System.Threading.Tasks; using BusinessLogic.Services; + using Microsoft.Extensions.Logging.Abstractions; using Models; using Moq; using Shared.DomainDrivenDesign.EventSourcing; using Shared.EventStore.Aggregate; using Shared.EventStore.EventStore; + using Shared.Logger; using Shouldly; using Testing; using TransactionAggregate; using Xunit; + using NullLogger = Shared.Logger.NullLogger; public class TransactionAggregateManagerTests { + public TransactionAggregateManagerTests() { + Logger.Initialise(new NullLogger()); + } + [Fact] public async Task TransactionAggregateManager_AuthoriseTransaction_TransactionAuthorised() { diff --git a/TransactionProcessor.BusinessLogic.Tests/Services/TransactionDomainServiceTests.cs b/TransactionProcessor.BusinessLogic.Tests/Services/TransactionDomainServiceTests.cs index 6e1eaf97..ef3066b5 100644 --- a/TransactionProcessor.BusinessLogic.Tests/Services/TransactionDomainServiceTests.cs +++ b/TransactionProcessor.BusinessLogic.Tests/Services/TransactionDomainServiceTests.cs @@ -14,6 +14,8 @@ namespace TransactionProcessor.BusinessLogic.Tests.Services using Models; using Moq; using OperatorInterfaces; + using ProjectionEngine.Repository; + using ProjectionEngine.State; using ReconciliationAggregate; using SecurityService.Client; using SecurityService.DataTransferObjects.Responses; @@ -39,6 +41,8 @@ public class TransactionDomainServiceTests private TransactionDomainService transactionDomainService = null; + private Mock> stateRepository; + private Mock> reconciliationAggregateRepository = null; public TransactionDomainServiceTests() { @@ -53,12 +57,13 @@ public TransactionDomainServiceTests() { operatorProxy = new Mock(); reconciliationAggregateRepository = new Mock>(); Func operatorProxyResolver = (operatorName) => { return operatorProxy.Object; }; - + stateRepository = new Mock>(); transactionDomainService = new TransactionDomainService(transactionAggregateManager.Object, estateClient.Object, securityServiceClient.Object, operatorProxyResolver, - reconciliationAggregateRepository.Object); + reconciliationAggregateRepository.Object, + stateRepository.Object); } [Fact] @@ -351,7 +356,10 @@ public async Task TransactionDomainService_ProcessSaleTransaction_SuccessfulOper TransactionId = TestData.OperatorTransactionId, ResponseCode = TestData.ResponseCode }); - + + this.stateRepository.Setup(p => p.Load(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestData.MerchantBalanceProjectionState); + ProcessSaleTransactionResponse response = await transactionDomainService.ProcessSaleTransaction(TestData.TransactionId, TestData.EstateId, TestData.MerchantId, @@ -430,7 +438,10 @@ public async Task TransactionDomainService_ProcessSaleTransaction_MetaDataCaseTe TransactionId = TestData.OperatorTransactionId, ResponseCode = TestData.ResponseCode }); - + + this.stateRepository.Setup(p => p.Load(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestData.MerchantBalanceProjectionState); + ProcessSaleTransactionResponse response = await transactionDomainService.ProcessSaleTransaction(TestData.TransactionId, TestData.EstateId, TestData.MerchantId, @@ -480,7 +491,10 @@ public async Task TransactionDomainService_ProcessSaleTransaction_MetaDataCaseTe TransactionId = TestData.OperatorTransactionId, ResponseCode = TestData.ResponseCode }); - + + this.stateRepository.Setup(p => p.Load(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestData.MerchantBalanceProjectionState); + ProcessSaleTransactionResponse response = await transactionDomainService.ProcessSaleTransaction(TestData.TransactionId, TestData.EstateId, TestData.MerchantId, @@ -525,7 +539,10 @@ public async Task TransactionDomainService_ProcessSaleTransaction_FailedOperator IsSuccessful = false, ResponseCode = TestData.DeclinedOperatorResponseCode }); - + + this.stateRepository.Setup(p => p.Load(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestData.MerchantBalanceProjectionState); + ProcessSaleTransactionResponse response = await transactionDomainService.ProcessSaleTransaction(TestData.TransactionId, TestData.EstateId, TestData.MerchantId, @@ -673,6 +690,9 @@ public async Task TransactionDomainService_ProcessSaleTransaction_NotEnoughCredi transactionAggregateManager.Setup(t => t.GetAggregate(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(TestData.GetDeclinedTransactionAggregate(TransactionResponseCode.MerchantDoesNotHaveEnoughCredit)); + this.stateRepository.Setup(p => p.Load(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestData.MerchantBalanceProjectionState); + ProcessSaleTransactionResponse response = await transactionDomainService.ProcessSaleTransaction(TestData.TransactionId, TestData.EstateId, TestData.MerchantId, @@ -849,6 +869,9 @@ public async Task TransactionDomainService_ProcessSaleTransaction_ContractId_Tra transactionAggregateManager.Setup(t => t.GetAggregate(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(TestData.GetLocallyDeclinedTransactionAggregate(expectedResponseCode)); + this.stateRepository.Setup(p => p.Load(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestData.MerchantBalanceProjectionState); + ProcessSaleTransactionResponse response = await transactionDomainService.ProcessSaleTransaction(TestData.TransactionId, TestData.EstateId, TestData.MerchantId, @@ -882,6 +905,9 @@ public async Task TransactionDomainService_ProcessSaleTransaction_ProductId_Tran transactionAggregateManager.Setup(t => t.GetAggregate(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(TestData.GetLocallyDeclinedTransactionAggregate(expectedResponseCode)); + this.stateRepository.Setup(p => p.Load(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestData.MerchantBalanceProjectionState); + ProcessSaleTransactionResponse response = await transactionDomainService.ProcessSaleTransaction(TestData.TransactionId, TestData.EstateId, TestData.MerchantId, @@ -979,7 +1005,10 @@ public async Task TransactionDomainService_ProcessSaleTransaction_ErrorInOperato It.IsAny(), It.IsAny>(), It.IsAny())).ThrowsAsync(new Exception("Comms Error")); - + + this.stateRepository.Setup(p => p.Load(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(TestData.MerchantBalanceProjectionState); + ProcessSaleTransactionResponse response = await transactionDomainService.ProcessSaleTransaction(TestData.TransactionId, TestData.EstateId, TestData.MerchantId, diff --git a/TransactionProcessor.BusinessLogic/Services/TransactionDomainService.cs b/TransactionProcessor.BusinessLogic/Services/TransactionDomainService.cs index ac07a60d..a1be5bf7 100644 --- a/TransactionProcessor.BusinessLogic/Services/TransactionDomainService.cs +++ b/TransactionProcessor.BusinessLogic/Services/TransactionDomainService.cs @@ -12,6 +12,8 @@ using EstateManagement.DataTransferObjects.Responses; using Models; using OperatorInterfaces; + using ProjectionEngine.Repository; + using ProjectionEngine.State; using ReconciliationAggregate; using SecurityService.Client; using SecurityService.DataTransferObjects.Responses; @@ -44,6 +46,8 @@ public class TransactionDomainService : ITransactionDomainService /// private readonly IAggregateRepository ReconciliationAggregateRepository; + private readonly IProjectionStateRepository MerchantBalanceStateRepository; + /// /// The security service client /// @@ -75,12 +79,14 @@ public TransactionDomainService(ITransactionAggregateManager transactionAggregat IEstateClient estateClient, ISecurityServiceClient securityServiceClient, Func operatorProxyResolver, - IAggregateRepository reconciliationAggregateRepository) { + IAggregateRepository reconciliationAggregateRepository, + IProjectionStateRepository merchantBalanceStateRepository) { this.TransactionAggregateManager = transactionAggregateManager; this.EstateClient = estateClient; this.SecurityServiceClient = securityServiceClient; this.OperatorProxyResolver = operatorProxyResolver; this.ReconciliationAggregateRepository = reconciliationAggregateRepository; + this.MerchantBalanceStateRepository = merchantBalanceStateRepository; } #endregion @@ -670,10 +676,12 @@ private async Task ProcessMessageWithOperator(MerchantResponse throw new TransactionValidationException("Transaction Amount must be greater than 0", TransactionResponseCode.InvalidSaleTransactionAmount); } + MerchantBalanceState merchantBalanceState = await this.MerchantBalanceStateRepository.Load(estateId, merchantId, cancellationToken); + // Check the merchant has enough balance to perform the sale - if (merchant.AvailableBalance < transactionAmount) { + if (merchantBalanceState.AvailableBalance < transactionAmount) { throw new - TransactionValidationException($"Merchant [{merchant.MerchantName}] does not have enough credit available [{merchant.AvailableBalance}] to perform transaction amount [{transactionAmount}]", + TransactionValidationException($"Merchant [{merchant.MerchantName}] does not have enough credit available [{merchantBalanceState.AvailableBalance}] to perform transaction amount [{transactionAmount}]", TransactionResponseCode.MerchantDoesNotHaveEnoughCredit); } } diff --git a/TransactionProcessor.BusinessLogic/TransactionProcessor.BusinessLogic.csproj b/TransactionProcessor.BusinessLogic/TransactionProcessor.BusinessLogic.csproj index b232607e..28a07daa 100644 --- a/TransactionProcessor.BusinessLogic/TransactionProcessor.BusinessLogic.csproj +++ b/TransactionProcessor.BusinessLogic/TransactionProcessor.BusinessLogic.csproj @@ -8,8 +8,8 @@ - - + + @@ -22,6 +22,7 @@ + diff --git a/TransactionProcessor.Client/ITransactionProcessorClient.cs b/TransactionProcessor.Client/ITransactionProcessorClient.cs index ab8380d5..6c94ca3b 100644 --- a/TransactionProcessor.Client/ITransactionProcessorClient.cs +++ b/TransactionProcessor.Client/ITransactionProcessorClient.cs @@ -28,6 +28,11 @@ Task ResendEmailReceipt(String accessToken, Guid transactionId, CancellationToken cancellationToken); + Task GetMerchantBalance(String accessToken, + Guid estateId, + Guid merchantId, + CancellationToken cancellationToken); + #endregion } } \ No newline at end of file diff --git a/TransactionProcessor.Client/TransactionProcessor.Client.csproj b/TransactionProcessor.Client/TransactionProcessor.Client.csproj index f9337bdd..084e1e33 100644 --- a/TransactionProcessor.Client/TransactionProcessor.Client.csproj +++ b/TransactionProcessor.Client/TransactionProcessor.Client.csproj @@ -6,7 +6,7 @@ - + diff --git a/TransactionProcessor.Client/TransactionProcessorClient.cs b/TransactionProcessor.Client/TransactionProcessorClient.cs index 1c22bea9..d0cbe11a 100644 --- a/TransactionProcessor.Client/TransactionProcessorClient.cs +++ b/TransactionProcessor.Client/TransactionProcessorClient.cs @@ -183,6 +183,38 @@ public async Task ResendEmailReceipt(String accessToken, } } + public async Task GetMerchantBalance(String accessToken, + Guid estateId, + Guid merchantId, + CancellationToken cancellationToken) { + String requestUri = $"{this.BaseAddress}/api/estates/{estateId}/merchants/{merchantId}/balance"; + MerchantBalanceResponse response = null; + try + { + // Add the access token to the client headers + this.HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + // Make the Http Call here + HttpResponseMessage httpResponse = await this.HttpClient.GetAsync(requestUri, cancellationToken); + + // Process the response + String content = await this.HandleResponse(httpResponse, cancellationToken); + + // call was successful so now deserialise the body to the response object + response = JsonConvert.DeserializeObject(content); + + } + catch (Exception ex) + { + // An exception has occurred, add some additional information to the message + Exception exception = new Exception("Error getting merchant balance.", ex); + + throw exception; + } + + return response; + } + #endregion } } \ No newline at end of file diff --git a/TransactionProcessor.DataTransferObjects/MerchantBalanceResponse.cs b/TransactionProcessor.DataTransferObjects/MerchantBalanceResponse.cs new file mode 100644 index 00000000..c947337e --- /dev/null +++ b/TransactionProcessor.DataTransferObjects/MerchantBalanceResponse.cs @@ -0,0 +1,46 @@ +namespace TransactionProcessor.DataTransferObjects +{ + using System; + using System.Diagnostics.CodeAnalysis; + using Newtonsoft.Json; + + [ExcludeFromCodeCoverage] + public class MerchantBalanceResponse + { + /// + /// Gets or sets the estate identifier. + /// + /// + /// The estate identifier. + /// + [JsonProperty("estate_id")] + public Guid EstateId { get; set; } + + /// + /// Gets or sets the merchant identifier. + /// + /// + /// The merchant identifier. + /// + [JsonProperty("merchant_id")] + public Guid MerchantId { get; set; } + + /// + /// Gets or sets the available balance. + /// + /// + /// The available balance. + /// + [JsonProperty("available_balance")] + public Decimal AvailableBalance { get; set; } + + /// + /// Gets or sets the balance. + /// + /// + /// The balance. + /// + [JsonProperty("balance")] + public Decimal Balance { get; set; } + } +} \ No newline at end of file diff --git a/TransactionProcessor.IntegrationTests/Common/DockerHelper.cs b/TransactionProcessor.IntegrationTests/Common/DockerHelper.cs index 22d5660c..6e1b1d9a 100644 --- a/TransactionProcessor.IntegrationTests/Common/DockerHelper.cs +++ b/TransactionProcessor.IntegrationTests/Common/DockerHelper.cs @@ -105,7 +105,7 @@ public DockerHelper(NlogLogger logger, #region Methods - public async Task PopulateSubscriptionServiceConfiguration(String estateName, Boolean isSecureEventStore) + public async Task PopulateSubscriptionServiceConfigurationForEstate(String estateName, Boolean isSecureEventStore) { List<(String streamName, String groupName, Int32 maxRetries)> subscriptions = new List<(String streamName, String groupName, Int32 maxRetries)>(); subscriptions.Add((estateName.Replace(" ", ""), "Reporting",2)); @@ -113,6 +113,14 @@ public async Task PopulateSubscriptionServiceConfiguration(String estateName, Bo subscriptions.Add(($"TransactionProcessorSubscriptionStream_{estateName.Replace(" ", "")}", "Transaction Processor",0)); await this.PopulateSubscriptionServiceConfiguration(this.EventStoreHttpPort, subscriptions, isSecureEventStore); } + public async Task PopulateSubscriptionServiceConfigurationGeneric(Boolean isSecureEventStore) + { + List<(String streamName, String groupName, Int32 maxRetries)> subscriptions = new List<(String streamName, String groupName, Int32 maxRetries)>(); + subscriptions.Add(($"$ce-MerchantArchive", "Transaction Processor - Ordered", 0)); + subscriptions.Add(($"$et-EstateCreatedEvent", "Transaction Processor - Ordered", 2)); + await this.PopulateSubscriptionServiceConfiguration(this.EventStoreHttpPort, subscriptions, isSecureEventStore); + } + protected override String GenerateEventStoreConnectionString() { @@ -151,7 +159,7 @@ public override async Task StartContainersForScenarioRun(String scenarioName) Boolean.TryParse(IsSecureEventStoreEnvVar, out Boolean isSecure); this.IsSecureEventStore = isSecure; } - + this.HostTraceFolder = FdOs.IsWindows() ? $"C:\\home\\txnproc\\trace\\{scenarioName}" : $"//home//txnproc//trace//{scenarioName}"; this.SqlServerDetails = (Setup.SqlServerContainerName, Setup.SqlUserName, Setup.SqlPassword); Logging.Enabled(); @@ -192,6 +200,8 @@ public override async Task StartContainersForScenarioRun(String scenarioName) String pataPawaConnectionString = $"ConnectionStrings:PataPawaReadModel=\"server={this.SqlServerDetails.sqlServerContainerName};user id=sa;password={this.SqlServerDetails.sqlServerPassword};database=PataPawaReadModel-{this.TestId:N}\""; + String transactionProcessorReadModelConnectionString = $"ConnectionStrings:TransactionProcessorReadModel=\"server={this.SqlServerDetails.sqlServerContainerName};user id=sa;password={this.SqlServerDetails.sqlServerPassword};database=TransactionProcessorReadModel\""; + IContainerService testhostContainer = this.SetupTestHostContainer("stuartferguson/testhosts:master", new List { @@ -242,9 +252,11 @@ public override async Task StartContainersForScenarioRun(String scenarioName) IContainerService transactionProcessorContainer = this.SetupTransactionProcessorContainer("transactionprocessor", new List { - testNetwork + testNetwork, + Setup.DatabaseServerNetwork }, additionalEnvironmentVariables:new List { + transactionProcessorReadModelConnectionString, insecureEventStoreEnvironmentVariable, persistentSubscriptionPollingInSeconds, internalSubscriptionServiceCacheDuration, @@ -309,8 +321,90 @@ public override async Task StartContainersForScenarioRun(String scenarioName) this.TestHostHttpClient.BaseAddress = new Uri($"http://127.0.0.1:{this.TestHostPort}"); await this.LoadEventStoreProjections(this.EventStoreHttpPort, this.IsSecureEventStore).ConfigureAwait(false); + await this.LoadAdditionalEventStoreProjections(this.EventStoreHttpPort, this.IsSecureEventStore).ConfigureAwait(false); + await this.PopulateSubscriptionServiceConfigurationGeneric(this.IsSecureEventStore).ConfigureAwait(false); + } + + private static async Task RemoveProjectionTestSetup(FileInfo file) + { + // Read the file + String[] projectionLines = await File.ReadAllLinesAsync(file.FullName); + + // Find the end of the test setup code + Int32 index = Array.IndexOf(projectionLines, "//endtestsetup"); + List projectionLinesList = projectionLines.ToList(); + + // Remove the test setup code + projectionLinesList.RemoveRange(0, index + 1); + // Rebuild the string from the lines + String projection = String.Join(Environment.NewLine, projectionLinesList); + + return projection; } + protected virtual async Task LoadAdditionalEventStoreProjections(Int32 eventStoreHttpPort, Boolean isSecureEventStore = false) + { + //Start our Continous Projections - we might decide to do this at a different stage, but now lets try here + String projectionsFolder = "additionalprojections"; + IPAddress[] ipAddresses = Dns.GetHostAddresses("127.0.0.1"); + + if (!String.IsNullOrWhiteSpace(projectionsFolder)) + { + DirectoryInfo di = new DirectoryInfo(projectionsFolder); + + if (di.Exists) + { + FileInfo[] files = di.GetFiles(); + + EventStoreProjectionManagementClient projectionClient = + new EventStoreProjectionManagementClient(this.ConfigureEventStoreSettings(eventStoreHttpPort, isSecureEventStore)); + List projectionNames = new List(); + + foreach (FileInfo file in files) + { + String projection = await DockerHelper.RemoveProjectionTestSetup(file); + String projectionName = file.Name.Replace(".js", String.Empty); + + try + { + this.Logger.LogInformation($"Creating projection [{projectionName}] from file [{file.FullName}]"); + await projectionClient.CreateContinuousAsync(projectionName, projection, trackEmittedStreams: true).ConfigureAwait(false); + + projectionNames.Add(projectionName); + } + catch (Exception e) + { + this.Logger.LogError(new Exception($"Projection [{projectionName}] error", e)); + } + } + + // Now check the create status of each + foreach (String projectionName in projectionNames) + { + try + { + ProjectionDetails projectionDetails = await projectionClient.GetStatusAsync(projectionName); + + if (projectionDetails.Status == "Running") + { + this.Logger.LogInformation($"Projection [{projectionName}] is Running"); + } + else + { + this.Logger.LogWarning($"Projection [{projectionName}] is {projectionDetails.Status}"); + } + } + catch (Exception e) + { + this.Logger.LogError(new Exception($"Error getting Projection [{projectionName}] status", e)); + } + } + } + } + + this.Logger.LogInformation("Loaded additional projections"); + } + /// /// Stops the containers for scenario run. /// diff --git a/TransactionProcessor.IntegrationTests/SaleTransaction/SaleTransactionFeature.feature.cs b/TransactionProcessor.IntegrationTests/SaleTransaction/SaleTransactionFeature.feature.cs index 024d173e..01073e73 100644 --- a/TransactionProcessor.IntegrationTests/SaleTransaction/SaleTransactionFeature.feature.cs +++ b/TransactionProcessor.IntegrationTests/SaleTransaction/SaleTransactionFeature.feature.cs @@ -1445,7 +1445,7 @@ public void SaleTransactionWithNotEnoughCreditAvailable() "Test Merchant 1", "1", "1009", - "Merchant [Test Merchant 1] does not have enough credit available [210.0] to perfo" + + "Merchant [Test Merchant 1] does not have enough credit available [230.0] to perfo" + "rm transaction amount [300.00]"}); #line 223 testRunner.Then("transaction response should contain the following information", ((string)(null)), table73, "Then "); diff --git a/TransactionProcessor.IntegrationTests/Shared/SharedSteps.cs b/TransactionProcessor.IntegrationTests/Shared/SharedSteps.cs index 504cee26..4a33cbad 100644 --- a/TransactionProcessor.IntegrationTests/Shared/SharedSteps.cs +++ b/TransactionProcessor.IntegrationTests/Shared/SharedSteps.cs @@ -29,6 +29,7 @@ namespace TransactionProcessor.IntegrationTests.Shared using TechTalk.SpecFlow; using Xunit; using ClientDetails = Common.ClientDetails; + using MerchantBalanceResponse = DataTransferObjects.MerchantBalanceResponse; [Binding] [Scope(Tag = "shared")] @@ -52,7 +53,7 @@ public async Task WhenICreateTheFollowingEstates(Table table) { // Setup the subscriptions for the estate await Retry.For(async () => { await this.TestingContext.DockerHelper - .PopulateSubscriptionServiceConfiguration(estateName, this.TestingContext.DockerHelper.IsSecureEventStore) + .PopulateSubscriptionServiceConfigurationForEstate(estateName, this.TestingContext.DockerHelper.IsSecureEventStore) .ConfigureAwait(false); }, retryFor:TimeSpan.FromMinutes(2), @@ -266,13 +267,14 @@ public async Task WhenICreateTheFollowingMerchants(Table table) { token = estateDetails.AccessToken; } - await Retry.For(async () => { - MerchantResponse merchant = await this.TestingContext.DockerHelper.EstateClient - .GetMerchant(token, estateDetails.EstateId, merchantId, CancellationToken.None) - .ConfigureAwait(false); + await Retry.For(async () => + { + MerchantResponse merchant = await this.TestingContext.DockerHelper.EstateClient + .GetMerchant(token, estateDetails.EstateId, merchantId, CancellationToken.None) + .ConfigureAwait(false); - merchant.MerchantName.ShouldBe(merchantName); - }); + merchant.MerchantName.ShouldBe(merchantName); + }); } } @@ -896,7 +898,7 @@ public async Task GivenIMakeTheFollowingManualMerchantDeposits(Table table) Guid merchantId = estateDetails.GetMerchantId(merchantName); // Get current balance - MerchantBalanceResponse previousMerchantBalance = await this.TestingContext.DockerHelper.EstateClient.GetMerchantBalance(token, estateDetails.EstateId, merchantId, CancellationToken.None); + MerchantBalanceResponse previousMerchantBalance = await this.TestingContext.DockerHelper.TransactionProcessorClient.GetMerchantBalance(token, estateDetails.EstateId, merchantId, CancellationToken.None); MakeMerchantDepositRequest makeMerchantDepositRequest = new MakeMerchantDepositRequest { @@ -917,7 +919,7 @@ await Retry.For(async () => { // Check the merchant balance MerchantBalanceResponse currentMerchantBalance = - await this.TestingContext.DockerHelper.EstateClient.GetMerchantBalance(token, + await this.TestingContext.DockerHelper.TransactionProcessorClient.GetMerchantBalance(token, estateDetails.EstateId, merchantId, CancellationToken.None); diff --git a/TransactionProcessor.IntegrationTests/TransactionProcessor.IntegrationTests.csproj b/TransactionProcessor.IntegrationTests/TransactionProcessor.IntegrationTests.csproj index ed359903..416a7822 100644 --- a/TransactionProcessor.IntegrationTests/TransactionProcessor.IntegrationTests.csproj +++ b/TransactionProcessor.IntegrationTests/TransactionProcessor.IntegrationTests.csproj @@ -7,15 +7,16 @@ - + + - + @@ -52,28 +53,10 @@ - - Always - - - Always - - - Always - - - Always - - + Always - - Always - - - Always - - + Always @@ -98,4 +81,25 @@ + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + diff --git a/TransactionProcessor.IntegrationTests/projections/continuous/MerchantBalanceCalculator.js b/TransactionProcessor.IntegrationTests/additionalprojections/MerchantBalanceCalculator.js similarity index 73% rename from TransactionProcessor.IntegrationTests/projections/continuous/MerchantBalanceCalculator.js rename to TransactionProcessor.IntegrationTests/additionalprojections/MerchantBalanceCalculator.js index edc65b85..e69f4638 100644 --- a/TransactionProcessor.IntegrationTests/projections/continuous/MerchantBalanceCalculator.js +++ b/TransactionProcessor.IntegrationTests/additionalprojections/MerchantBalanceCalculator.js @@ -1,3 +1,5 @@ + + fromCategory('MerchantArchive') .foreachStream() .when({ @@ -14,11 +16,10 @@ fromCategory('MerchantArchive') totalAuthorisedSales: 0, totalDeclinedSales: 0, totalFees: 0, - emittedEvents:1 + emittedEvents: 1 } }, - $any: function (s, e) - { + $any: function (s, e) { if (e === null || e.data === null || e.data.IsJson === false) return; @@ -61,16 +62,6 @@ var eventbus = { } } -function getStreamName(s) { - return "MerchantBalanceHistory-" + s.merchantId.replace(/-/gi, ""); -} - -function getEventTypeName() { - return 'EstateReporting.BusinessLogic.Events.' + getEventType() + ', EstateReporting.BusinessLogic.Events'; -} - -function getEventType() { return "MerchantBalanceChangedEvent"; } - function addTwoNumbers(number1, number2) { return parseFloat((number1 + number2).toFixed(4)); } @@ -128,44 +119,7 @@ var merchantCreatedEventHandler = function (s, e) { s.merchantName = e.data.merchantName; }; -var emitBalanceChangedEvent = function (aggregateId, eventId, s, changeAmount, dateTime, reference) { - - if (s.initialised === true) { - - // Emit an opening balance event - var openingBalanceEvent = { - $type: getEventTypeName(), - "merchantId": s.merchantId, - "estateId": s.estateId, - "balance": 0, - "changeAmount": 0, - "eventId": s.merchantId, - "eventCreatedDateTime": dateTime, - "reference": "Opening Balance", - "aggregateId": s.merchantId - } - emit(getStreamName(s), getEventType(), openingBalanceEvent); - s.emittedEvents++; - s.initialised = false; - } - - var balanceChangedEvent = { - $type: getEventTypeName(), - "merchantId": s.merchantId, - "estateId": s.estateId, - "balance": s.balance, - "changeAmount": changeAmount, - "eventId": eventId, - "eventCreatedDateTime": dateTime, - "reference": reference, - "aggregateId": aggregateId - } - // emit an balance changed event here - emit(getStreamName(s), getEventType(), balanceChangedEvent); - s.emittedEvents++; - return s; -}; var depositMadeEventHandler = function (s, e) { @@ -178,9 +132,6 @@ var depositMadeEventHandler = function (s, e) { } incrementBalanceFromDeposit(s, e.data.amount, e.data.depositDateTime); - - // emit an balance changed event here - s = emitBalanceChangedEvent(e.data.merchantId, e.eventId, s, e.data.amount, e.data.depositDateTime, "Merchant Deposit"); }; var transactionHasStartedEventHandler = function (s, e) { @@ -220,15 +171,10 @@ var transactionHasCompletedEventHandler = function (s, e) { if (e.data.isAuthorised) { decrementBalanceFromAuthorisedTransaction(s, amount, completedTime); - - // emit an balance changed event here - if (amount > 0) { - s = emitBalanceChangedEvent(e.data.transactionId, e.eventId, s, amount * -1, completedTime, "Transaction Completed"); - } } else { - incrementAvailableBalanceFromDeclinedTransaction(s, amount, completedTime); - } + incrementAvailableBalanceFromDeclinedTransaction(s, amount, completedTime); +} }; var merchantFeeAddedToTransactionEventHandler = function (s, e) { @@ -243,7 +189,4 @@ var merchantFeeAddedToTransactionEventHandler = function (s, e) { // increment the balance now incrementBalanceFromMerchantFee(s, e.data.calculatedValue, e.data.feeCalculatedDateTime); - - // emit an balance changed event here - s = emitBalanceChangedEvent(e.data.transactionId, e.eventId, s, e.data.calculatedValue, e.data.feeCalculatedDateTime, "Transaction Fee Processed"); -} +} \ No newline at end of file diff --git a/TransactionProcessor.IntegrationTests/nlog.config b/TransactionProcessor.IntegrationTests/nlog.config index f94fa5fb..84eca0f1 100644 --- a/TransactionProcessor.IntegrationTests/nlog.config +++ b/TransactionProcessor.IntegrationTests/nlog.config @@ -13,5 +13,6 @@ + \ No newline at end of file diff --git a/TransactionProcessor.IntegrationTests/projections/continuous/CallbackHandlerEnricher.js b/TransactionProcessor.IntegrationTests/projections/continuous/CallbackHandlerEnricher.js deleted file mode 100644 index cb2b1faa..00000000 --- a/TransactionProcessor.IntegrationTests/projections/continuous/CallbackHandlerEnricher.js +++ /dev/null @@ -1,80 +0,0 @@ -fromStreams("$ce-EstateAggregate", "$et-CallbackReceivedEvent") - .when({ - $init: function (s, e) - { - return { - estates: [], - debug: [] - } - }, - "EstateCreatedEvent": function (s, e) { - s.estates.push({ - estateId: e.data.estateId, - estateName: e.data.estateName - }); - }, - "EstateReferenceAllocatedEvent": function (s, e) { - var estateIndex = s.estates.findIndex(element => element.estateId === e.data.estateId); - s.estates[estateIndex].reference = e.data.estateReference; - }, - "CallbackReceivedEvent": function (s, e) { - // find the estate from the reference - if (s.debug === undefined) { - s.debug = []; - } - var ref = e.data.reference.split("-"); // Element 0 is estate reference, Element 1 is merchant reference - var estate = s.estates.find(element => element.reference === ref[0]); - if (estate !== undefined && estate !== null) { - var enrichedEvent = createEnrichedEvent(e, estate); - - // Emit the enriched event - emit(getStreamName(estate, e), "CallbackReceivedEnrichedEvent", enrichedEvent); - } - else { - var enrichedEvent = createEnrichedEvent(e); - // Emit the enriched event - emit(getStreamName(estate, e), "CallbackReceivedEnrichedWithNoEstateEvent", enrichedEvent); - } - } - }); - -function createEnrichedEvent(originalEvent, estate) { - var enrichedEvent = {}; - if (estate !== undefined && estate !== null) { - enrichedEvent = { - typeString: originalEvent.data.typeString, - messageFormat: originalEvent.data.messageFormat, - callbackMessage: originalEvent.data.callbackMessage, - estateId: estate.estateId, - reference: originalEvent.data.reference - }; - } - else { - enrichedEvent = { - typeString: originalEvent.data.typeString, - messageFormat: originalEvent.data.messageFormat, - callbackMessage: originalEvent.data.callbackMessage, - reference: originalEvent.data.reference - }; - } - - return enrichedEvent; -} - -function getStreamName(estate, e) { - var streamName = ""; - if (e.data.destination === "EstateManagement") { - streamName += "EstateManagementSubscriptionStream_"; - } - - // Add the estate name - if (estate !== undefined && estate !== null) { - streamName += estate.estateName.replace(/ /g, ""); - } - else { - streamName += "UnknownEstate"; - } - - return streamName; - -} diff --git a/TransactionProcessor.IntegrationTests/projections/continuous/EstateAggregator.js b/TransactionProcessor.IntegrationTests/projections/continuous/EstateAggregator.js deleted file mode 100644 index e70c9eb6..00000000 --- a/TransactionProcessor.IntegrationTests/projections/continuous/EstateAggregator.js +++ /dev/null @@ -1,46 +0,0 @@ -isEstateEvent = (e) => { return (e.data && e.data.estateId); } -isAnEstateCreatedEvent = (e) => { return compareEventTypeSafely(e.eventType, 'EstateCreatedEvent') }; - -isAMerchantFeeAddedToTransactionEvent = (e) => { return compareEventTypeSafely(e.eventType, 'MerchantFeeAddedToTransactionEvent') }; -isAServiceProviderFeeAddedToTransactionEvent = (e) => { return compareEventTypeSafely(e.eventType, 'ServiceProviderFeeAddedToTransactionEvent') }; - -compareEventTypeSafely = (sourceEventType, targetEventType) => { return (sourceEventType.toUpperCase() === targetEventType.toUpperCase()); } - -ignoreEvent = (e) => isAServiceProviderFeeAddedToTransactionEvent(e) | isAMerchantFeeAddedToTransactionEvent(e); - -isInvalidEvent = (e) => (e === null || e === undefined || e.data === undefined); - -isTruncated = function (metadata) { - if (metadata && metadata['$v']) { - var parts = metadata['$v'].split(":"); - var projectionEpoch = parts[1]; - - return (projectionEpoch < 0); - } - return false; -}; - -getStringWithNoSpaces = function(inputString) { return inputString.replace(/-/gi, "").replace(/ /g, ""); } - -fromAll() - .when({ - $init: function (s, e) { - return { estates: {} } - }, - $any: function (s, e) { - if (isTruncated(e)) return; - - if (isEstateEvent(e)) { - if (ignoreEvent(e)) return; - - if (isAnEstateCreatedEvent(e)) { - s.estates[e.data.estateId] = { - name: getStringWithNoSpaces(e.data.estateName) - }; - } - - linkTo(s.estates[e.data.estateId].name, e); - } - } - } - ); diff --git a/TransactionProcessor.IntegrationTests/projections/continuous/EstateManagementSubscriptionStreamBuilder.js b/TransactionProcessor.IntegrationTests/projections/continuous/EstateManagementSubscriptionStreamBuilder.js deleted file mode 100644 index 130bdd90..00000000 --- a/TransactionProcessor.IntegrationTests/projections/continuous/EstateManagementSubscriptionStreamBuilder.js +++ /dev/null @@ -1,63 +0,0 @@ -isEstateEvent = (e) => { return (e.data && e.data.estateId); } -isAnEstateCreatedEvent = (e) => { return compareEventTypeSafely(e.eventType, 'EstateCreatedEvent') }; -compareEventTypeSafely = (sourceEventType, targetEventType) => { return (sourceEventType.toUpperCase() === targetEventType.toUpperCase()); } -isInvalidEvent = (e) => (e === null || e === undefined || e.data === undefined); - -getSupportedEventTypes = function () { - var eventTypes = []; - - eventTypes.push('TransactionHasBeenCompletedEvent'); - eventTypes.push('MerchantFeeSettledEvent'); - eventTypes.push('StatementGeneratedEvent'); - - return eventTypes; -} - -isARequiredEvent = (e) => { - var supportedEvents = getSupportedEventTypes(); - - var index = supportedEvents.indexOf(e.eventType); - - return index !== -1; -}; - -isTruncated = function (metadata) { - if (metadata && metadata['$v']) { - var parts = metadata['$v'].split(":"); - var projectionEpoch = parts[1]; - - return (projectionEpoch < 0); - } - return false; -}; - -getStreamName = function (estateName) { - return 'EstateManagementSubscriptionStream_' + estateName; -} - -getStringWithNoSpaces = function (inputString) { return inputString.replace(/-/gi, "").replace(/ /g, ""); } - -fromAll() - .when({ - $init: function (s, e) { - return { estates: {} } - }, - $any: function (s, e) { - if (isTruncated(e)) return; - - if (isEstateEvent(e)) { - - if (isAnEstateCreatedEvent(e)) { - s.estates[e.data.estateId] = { - filteredName: e.data.estateName.replace(/-/gi, ""), - name: getStringWithNoSpaces(e.data.estateName) - }; - } - - if (isARequiredEvent(e) === false) return; - - linkTo(getStreamName(s.estates[e.data.estateId].name), e); - } - } - } - ); diff --git a/TransactionProcessor.IntegrationTests/projections/continuous/FileProcessorSubscriptionStreamBuilder.js b/TransactionProcessor.IntegrationTests/projections/continuous/FileProcessorSubscriptionStreamBuilder.js deleted file mode 100644 index bf74789b..00000000 --- a/TransactionProcessor.IntegrationTests/projections/continuous/FileProcessorSubscriptionStreamBuilder.js +++ /dev/null @@ -1,67 +0,0 @@ -isEstateEvent = (e) => { return (e.data && e.data.estateId); } -isAnEstateCreatedEvent = (e) => { return compareEventTypeSafely(e.eventType, 'EstateCreatedEvent') }; -compareEventTypeSafely = (sourceEventType, targetEventType) => { return (sourceEventType.toUpperCase() === targetEventType.toUpperCase()); } -isInvalidEvent = (e) => (e === null || e === undefined || e.data === undefined); - -getSupportedEventTypes = function () { - var eventTypes = []; - - eventTypes.push('ImportLogCreatedEvent'); - eventTypes.push('FileAddedToImportLogEvent'); - eventTypes.push('FileCreatedEvent'); - eventTypes.push('FileLineAddedEvent'); - eventTypes.push('FileLineProcessingSuccessfulEvent'); - eventTypes.push('FileLineProcessingIgnoredEvent'); - eventTypes.push('FileLineProcessingFailedEvent'); - eventTypes.push('FileProcessingCompletedEvent'); - - return eventTypes; -} - -isARequiredEvent = (e) => { - var supportedEvents = getSupportedEventTypes(); - - var index = supportedEvents.indexOf(e.eventType); - - return index !== -1 -}; - -isTruncated = function (metadata) { - if (metadata && metadata['$v']) { - var parts = metadata['$v'].split(":"); - var projectionEpoch = parts[1]; - - return (projectionEpoch < 0); - } - return false; -}; -getStreamName = function (estateName) { - return 'FileProcessorSubscriptionStream_' + estateName; -} - -getStringWithNoSpaces = function (inputString) { return inputString.replace(/-/gi, "").replace(/ /g, ""); } - -fromAll() - .when({ - $init: function (s, e) { - return { estates: {} } - }, - $any: function (s, e) { - if (isTruncated(e)) return; - - if (isEstateEvent(e)) { - - if (isAnEstateCreatedEvent(e)) { - s.estates[e.data.estateId] = { - filteredName: e.data.estateName.replace(/-/gi, ""), - name: getStringWithNoSpaces(e.data.estateName) - }; - } - - if (isARequiredEvent(e) === false) return; - - linkTo(getStreamName(s.estates[e.data.estateId].name), e); - } - } - } -); diff --git a/TransactionProcessor.IntegrationTests/projections/continuous/MerchantAggregator.js b/TransactionProcessor.IntegrationTests/projections/continuous/MerchantAggregator.js deleted file mode 100644 index f379c84b..00000000 --- a/TransactionProcessor.IntegrationTests/projections/continuous/MerchantAggregator.js +++ /dev/null @@ -1,34 +0,0 @@ -isValidEvent = function (e) { - - if (e) { - if (e.data) { - if (e.isJson) { - if (e.eventType !== "$metadata") { - return true; - } - } - } - } - - return false; -}; - -getMerchantId = function (e) { - if (e.data.merchantId === undefined) { - return null; - } - return e.data.merchantId; -}; - -fromAll() - .when({ - $any: function (s, e) { - if (isValidEvent(e)) { - var merchantId = getMerchantId(e); - if (merchantId !== null) { - var streamName = "MerchantArchive-" + merchantId.replace(/-/gi, ""); - linkTo(streamName, e); - } - } - } - }); diff --git a/TransactionProcessor.IntegrationTests/projections/continuous/TransactionEnricher.js b/TransactionProcessor.IntegrationTests/projections/continuous/TransactionEnricher.js deleted file mode 100644 index a8c21180..00000000 --- a/TransactionProcessor.IntegrationTests/projections/continuous/TransactionEnricher.js +++ /dev/null @@ -1,64 +0,0 @@ -fromCategory('TransactionAggregate') - .foreachStream() - .when({ - $any: function (s, e) { - - if (e === null || e.data === null || e.data.IsJson === false) - return; - - eventbus.dispatch(s, e); - } - }); - -var eventbus = { - dispatch: function (s, e) { - - if (e.eventType === 'MerchantFeeAddedToTransactionEvent') { - merchantFeeAddedToTransactionEventHandler(s, e); - return; - } - if (e.eventType === 'ServiceProviderFeeAddedToTransactionEvent') { - serviceProviderFeeAddedToTransactionEventHandler(s, e); - return; - } - else { - //Just add the existing event to to our stream - linkTo(getStreamName(s), e); - } - - } -} - -function merchantFeeAddedToTransactionEventHandler(s, e) { - var newEvent = { - calculatedValue: e.data.calculatedValue, - feeCalculatedDateTime: e.data.feeCalculatedDateTime, - estateId: e.data.estateId, - feeId: e.data.feeId, - feeValue: e.data.feeValue, - merchantId: e.data.merchantId, - transactionId: e.data.transactionId, - feeCalculationType: e.data.feeCalculationType, - eventId: e.eventId - } - emit(getStreamName(s), "MerchantFeeAddedToTransactionEnrichedEvent", newEvent, {}); -} - -function serviceProviderFeeAddedToTransactionEventHandler(s, e) { - var newEvent = { - calculatedValue: e.data.calculatedValue, - feeCalculatedDateTime: e.data.feeCalculatedDateTime, - estateId: e.data.estateId, - feeId: e.data.feeId, - feeValue: e.data.feeValue, - merchantId: e.data.merchantId, - transactionId: e.data.transactionId, - feeCalculationType: e.data.feeCalculationType, - eventId: e.eventId - } - emit(getStreamName(s), "ServiceProviderFeeAddedToTransactionEnrichedEvent", newEvent, { }); -} - -function getStreamName(s) { - return "TransactionEnricherResult"; -} diff --git a/TransactionProcessor.IntegrationTests/projections/continuous/TransactionProcessorSubscriptionStreamBuilder.js b/TransactionProcessor.IntegrationTests/projections/continuous/TransactionProcessorSubscriptionStreamBuilder.js deleted file mode 100644 index e7c99c25..00000000 --- a/TransactionProcessor.IntegrationTests/projections/continuous/TransactionProcessorSubscriptionStreamBuilder.js +++ /dev/null @@ -1,62 +0,0 @@ -isEstateEvent = (e) => { return (e.data && e.data.estateId); } -isAnEstateCreatedEvent = (e) => { return compareEventTypeSafely(e.eventType, 'EstateCreatedEvent') }; -compareEventTypeSafely = (sourceEventType, targetEventType) => { return (sourceEventType.toUpperCase() === targetEventType.toUpperCase()); } -isInvalidEvent = (e) => (e === null || e === undefined || e.data === undefined); - -getSupportedEventTypes = function () { - var eventTypes = []; - - eventTypes.push('CustomerEmailReceiptRequestedEvent'); - eventTypes.push('TransactionHasBeenCompletedEvent'); - eventTypes.push('MerchantFeeAddedToTransactionEvent'); - - return eventTypes; -} - -isARequiredEvent = (e) => { - var supportedEvents = getSupportedEventTypes(); - - var index = supportedEvents.indexOf(e.eventType); - - return index !== -1; -}; - -isTruncated = function (metadata) { - if (metadata && metadata['$v']) { - var parts = metadata['$v'].split(":"); - var projectionEpoch = parts[1]; - - return (projectionEpoch < 0); - } - return false; -}; -getStreamName = function (estateName) { - return 'TransactionProcessorSubscriptionStream_' + estateName; -} - -getStringWithNoSpaces = function (inputString) { return inputString.replace(/-/gi, "").replace(/ /g, ""); } - -fromAll() - .when({ - $init: function (s, e) { - return { estates: {} } - }, - $any: function (s, e) { - if (isTruncated(e)) return; - - if (isEstateEvent(e)) { - - if (isAnEstateCreatedEvent(e)) { - s.estates[e.data.estateId] = { - filteredName: e.data.estateName.replace(/-/gi, ""), - name: getStringWithNoSpaces(e.data.estateName) - }; - } - - if (isARequiredEvent(e) === false) return; - - linkTo(getStreamName(s.estates[e.data.estateId].name), e); - } - } - } -); diff --git a/TransactionProcessor.ProjectionEngine.Tests/MerchantBalanceProjectionTests.cs b/TransactionProcessor.ProjectionEngine.Tests/MerchantBalanceProjectionTests.cs new file mode 100644 index 00000000..d6b2735b --- /dev/null +++ b/TransactionProcessor.ProjectionEngine.Tests/MerchantBalanceProjectionTests.cs @@ -0,0 +1,162 @@ +namespace TransactionProcessor.ProjectionEngine.Tests; + +using EstateManagement.Merchant.DomainEvents; +using Projections; +using Shouldly; +using State; +using Transaction.DomainEvents; + +public class MerchantBalanceProjectionTests +{ + [Fact] + public async Task MerchantBalanceProjection_Handle_MerchantCreatedEvent_EventIsHandled() { + MerchantBalanceProjection projection = new MerchantBalanceProjection(); + MerchantBalanceState state = new MerchantBalanceState(); + MerchantCreatedEvent @event = TestData.MerchantCreatedEvent; + + MerchantBalanceState newState = await projection.Handle(state, @event, CancellationToken.None); + + newState.EstateId.ShouldBe(TestData.EstateId); + newState.MerchantId.ShouldBe(TestData.MerchantId); + newState.MerchantName.ShouldBe(TestData.MerchantName); + newState.AvailableBalance.ShouldBe(0); + newState.Balance.ShouldBe(0); + } + + [Fact] + public async Task MerchantBalanceProjection_Handle_ManualDepositMadeEvent_EventIsHandled() + { + MerchantBalanceProjection projection = new MerchantBalanceProjection(); + MerchantBalanceState state = new MerchantBalanceState(); + state = state with { + EstateId = TestData.EstateId, + MerchantId = TestData.MerchantId, + MerchantName = TestData.MerchantName + }; + + ManualDepositMadeEvent @event = TestData.ManualDepositMadeEvent; + + MerchantBalanceState newState = await projection.Handle(state, @event, CancellationToken.None); + + newState.AvailableBalance.ShouldBe(TestData.ManualDepositMadeEvent.Amount); + newState.Balance.ShouldBe(TestData.ManualDepositMadeEvent.Amount); + newState.DepositCount.ShouldBe(1); + newState.TotalDeposited.ShouldBe(TestData.ManualDepositMadeEvent.Amount); + } + + [Fact] + public async Task MerchantBalanceProjection_Handle_AutomaticDepositMadeEvent_EventIsHandled() + { + MerchantBalanceProjection projection = new MerchantBalanceProjection(); + MerchantBalanceState state = new MerchantBalanceState(); + state = state with + { + EstateId = TestData.EstateId, + MerchantId = TestData.MerchantId, + MerchantName = TestData.MerchantName + }; + + AutomaticDepositMadeEvent @event = TestData.AutomaticDepositMadeEvent; + + MerchantBalanceState newState = await projection.Handle(state, @event, CancellationToken.None); + + newState.AvailableBalance.ShouldBe(TestData.AutomaticDepositMadeEvent.Amount); + newState.Balance.ShouldBe(TestData.AutomaticDepositMadeEvent.Amount); + newState.DepositCount.ShouldBe(1); + newState.TotalDeposited.ShouldBe(TestData.AutomaticDepositMadeEvent.Amount); + } + + [Fact] + public async Task MerchantBalanceProjection_Handle_TransactionHasStartedEvent_EventIsHandled() + { + MerchantBalanceProjection projection = new MerchantBalanceProjection(); + MerchantBalanceState state = new MerchantBalanceState(); + state = state with + { + EstateId = TestData.EstateId, + MerchantId = TestData.MerchantId, + MerchantName = TestData.MerchantName, + AvailableBalance = 100.00m, + Balance = 100.00m + }; + + TransactionHasStartedEvent @event = TestData.GetTransactionHasStartedEvent(TestData.TransactionAmount); + + MerchantBalanceState newState = await projection.Handle(state, @event, CancellationToken.None); + + newState.AvailableBalance.ShouldBe(state.AvailableBalance - @event.TransactionAmount.GetValueOrDefault(0)); + newState.Balance.ShouldBe(state.Balance); + newState.StartedTransactionCount.ShouldBe(1); + } + + [Fact] + public async Task MerchantBalanceProjection_Handle_TransactionHasCompletedEvent_IsAuthorised_EventIsHandled() + { + MerchantBalanceProjection projection = new MerchantBalanceProjection(); + MerchantBalanceState state = new MerchantBalanceState(); + state = state with + { + EstateId = TestData.EstateId, + MerchantId = TestData.MerchantId, + MerchantName = TestData.MerchantName, + AvailableBalance = 75.00m, + Balance = 100.00m + }; + TransactionHasBeenCompletedEvent @event = TestData.GetTransactionHasBeenCompletedEvent(true, TestData.TransactionAmount); + + MerchantBalanceState newState = await projection.Handle(state, @event, CancellationToken.None); + + newState.AvailableBalance.ShouldBe(state.AvailableBalance); + newState.Balance.ShouldBe(state.Balance - @event.TransactionAmount.GetValueOrDefault(0)); + newState.AuthorisedSales.ShouldBe(@event.TransactionAmount.GetValueOrDefault(0)); + newState.SaleCount.ShouldBe(1); + newState.CompletedTransactionCount.ShouldBe(1); + } + + [Fact] + public async Task MerchantBalanceProjection_Handle_TransactionHasCompletedEvent_IsNotAuthorised_EventIsHandled() + { + MerchantBalanceProjection projection = new MerchantBalanceProjection(); + MerchantBalanceState state = new MerchantBalanceState(); + state = state with + { + EstateId = TestData.EstateId, + MerchantId = TestData.MerchantId, + MerchantName = TestData.MerchantName, + AvailableBalance = 75.00m, + Balance = 100.00m + }; + TransactionHasBeenCompletedEvent @event = TestData.GetTransactionHasBeenCompletedEvent(false, TestData.TransactionAmount); + + MerchantBalanceState newState = await projection.Handle(state, @event, CancellationToken.None); + + newState.AvailableBalance.ShouldBe(state.AvailableBalance + @event.TransactionAmount.GetValueOrDefault(0)); + newState.Balance.ShouldBe(state.Balance); + newState.DeclinedSales.ShouldBe(@event.TransactionAmount.GetValueOrDefault(0)); + newState.SaleCount.ShouldBe(1); + newState.CompletedTransactionCount.ShouldBe(1); + } + + [Fact] + public async Task MerchantBalanceProjection_Handle_MerchantFeeAddedToTransactionEvent_EventIsHandled() + { + MerchantBalanceProjection projection = new MerchantBalanceProjection(); + MerchantBalanceState state = new MerchantBalanceState(); + state = state with + { + EstateId = TestData.EstateId, + MerchantId = TestData.MerchantId, + MerchantName = TestData.MerchantName, + AvailableBalance = 75.00m, + Balance = 75.00m + }; + MerchantFeeAddedToTransactionEvent @event = TestData.GetMerchantFeeAddedToTransactionEvent(0.25m); + + MerchantBalanceState newState = await projection.Handle(state, @event, CancellationToken.None); + + newState.AvailableBalance.ShouldBe(state.AvailableBalance + @event.CalculatedValue); + newState.Balance.ShouldBe(state.Balance + @event.CalculatedValue); + newState.ValueOfFees.ShouldBe(@event.CalculatedValue); + newState.FeeCount.ShouldBe(1); + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine.Tests/TestData.cs b/TransactionProcessor.ProjectionEngine.Tests/TestData.cs new file mode 100644 index 00000000..daf8e5c8 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine.Tests/TestData.cs @@ -0,0 +1,108 @@ +namespace TransactionProcessor.ProjectionEngine.Tests +{ + using EstateManagement.Merchant.DomainEvents; + using Transaction.DomainEvents; + + public class TestData + { + public static Guid MerchantId = Guid.Parse("1FDEF549-4BDA-4DA3-823B-79684CD93F88"); + public static Guid EstateId = Guid.Parse("C81CD4E6-1F3B-431F-AA63-0ACAB7BC0CD3"); + + public static String MerchantName = "Test Merchant 1"; + + public static DateTime CreatedDateTime = new DateTime(2022, 10, 13, 1, 2, 3); + + public static DateTime ManualDepositDateTime = new DateTime(2022, 10, 13, 2, 2, 3); + + public static Guid ManualDepositId = Guid.Parse("7BDB5FD8-F944-4864-844E-EF12F7B16A4F"); + + public static String ManualDepositReference = "Manual Deposit 1"; + + public static Decimal ManualDepositAmount = 100.00m; + + public static DateTime AutomaticDepositDateTime = new DateTime(2022, 10, 13, 3, 2, 3); + + public static Guid AutomaticDepositId = Guid.Parse("520521a1f9504ec1bf1cc5a7fd4fd905"); + + public static String AutomaticDepositReference = "Automatic Deposit 1"; + + public static Decimal AutomaticDepositAmount = 200.00m; + + public static Guid TransactionId = Guid.Parse("58306666-746C-4984-B264-4ECF15749BF5"); + + public static DateTime TransactionDateTime = new DateTime(2022, 10, 13, 7, 30, 0); + + public static String TransactionNumber = "1"; + + public static String TransactionType = "Sale"; + + public static String TransactionReference = "Test Reference 1"; + + public static String DeviceIdentifier = "TestDevice1"; + + public static Decimal? TransactionAmount = 25.00m; + + public static Int32 FeeCalculationType = 1; + + public static Guid FeeId = Guid.Parse("BDE02D5A-3489-4A0A-9A69-9DCEA661B9D9"); + + public static Decimal FeeValue = 0.25m; + + public static DateTime FeeCalculatedDateTime = new DateTime(2022, 10, 13, 8, 30, 0); + + public static DateTime SettlementDueDate = new DateTime(2022, 10, 13, 8, 31, 0); + + public static DateTime SettledDateTime = new DateTime(2022, 10, 13, 8, 31, 0); + + public static String ResponseCode = "ResponseCode"; + public static String ResponseMessage = "ResponseMessage"; + + public static MerchantFeeAddedToTransactionEvent GetMerchantFeeAddedToTransactionEvent(Decimal calculatedFeeValue) => + new MerchantFeeAddedToTransactionEvent(TestData.TransactionId, + TestData.EstateId, + TestData.MerchantId, + calculatedFeeValue, + TestData.FeeCalculationType, + TestData.FeeId, + TestData.FeeValue, + TestData.FeeCalculatedDateTime, + TestData.SettlementDueDate, + TestData.SettledDateTime); + + public static TransactionHasBeenCompletedEvent GetTransactionHasBeenCompletedEvent(Boolean isAuthorised, + Decimal? amount) => + new TransactionHasBeenCompletedEvent(TestData.TransactionId, + TestData.EstateId, + TestData.MerchantId, + TestData.ResponseCode, + TestData.ResponseMessage, + isAuthorised, + TestData.TransactionDateTime, + amount); + + + + public static TransactionHasStartedEvent GetTransactionHasStartedEvent(Decimal? amount) => new TransactionHasStartedEvent(TestData.TransactionId, + TestData.EstateId, + TestData.MerchantId, + TestData.TransactionDateTime, + TestData.TransactionNumber, + TestData.TransactionType, + TestData.TransactionReference, + TestData.DeviceIdentifier, + amount); + + public static MerchantCreatedEvent MerchantCreatedEvent => + new MerchantCreatedEvent(TestData.MerchantId, TestData.EstateId, TestData.MerchantName, TestData.CreatedDateTime); + + public static ManualDepositMadeEvent ManualDepositMadeEvent => + new ManualDepositMadeEvent(TestData.MerchantId, TestData.EstateId, TestData.ManualDepositId, + TestData.ManualDepositReference, TestData.ManualDepositDateTime, + TestData.ManualDepositAmount); + + public static AutomaticDepositMadeEvent AutomaticDepositMadeEvent => + new AutomaticDepositMadeEvent(TestData.MerchantId, TestData.EstateId, TestData.AutomaticDepositId, + TestData.AutomaticDepositReference, TestData.AutomaticDepositDateTime, + TestData.AutomaticDepositAmount); + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine.Tests/TransactionProcessor.ProjectionEngine.Tests.csproj b/TransactionProcessor.ProjectionEngine.Tests/TransactionProcessor.ProjectionEngine.Tests.csproj new file mode 100644 index 00000000..94f095e7 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine.Tests/TransactionProcessor.ProjectionEngine.Tests.csproj @@ -0,0 +1,31 @@ + + + + net6.0 + enable + enable + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/TransactionProcessor.ProjectionEngine.Tests/Usings.cs b/TransactionProcessor.ProjectionEngine.Tests/Usings.cs new file mode 100644 index 00000000..8c927eb7 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Common/DomainEventHelper.cs b/TransactionProcessor.ProjectionEngine/Common/DomainEventHelper.cs new file mode 100644 index 00000000..e46d1ed5 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Common/DomainEventHelper.cs @@ -0,0 +1,65 @@ +namespace TransactionProcessor.ProjectionEngine.Common; + +using System.Reflection; +using Shared.DomainDrivenDesign.EventSourcing; + +public static class DomainEventHelper +{ + public static Boolean HasProperty(IDomainEvent domainEvent, + String propertyName) + { + PropertyInfo propertyInfo = domainEvent.GetType() + .GetProperties() + .SingleOrDefault(p => p.Name == propertyName); + + return propertyInfo != null; + } + + public static T GetPropertyIgnoreCase(IDomainEvent domainEvent, String propertyName) + { + try + { + var f = domainEvent.GetType() + .GetProperties() + .SingleOrDefault(p => String.Compare(p.Name, propertyName, StringComparison.CurrentCultureIgnoreCase) == 0); + + if (f != null) + { + return (T)f.GetValue(domainEvent); + } + } + catch + { + // ignored + } + + return default(T); + } + + public static T GetProperty(IDomainEvent domainEvent, String propertyName) + { + try + { + var f = domainEvent.GetType() + .GetProperties() + .SingleOrDefault(p => p.Name == propertyName); + + if (f != null) + { + return (T)f.GetValue(domainEvent); + } + } + catch + { + // ignored + } + + return default(T); + } + + public static Guid GetEstateId(IDomainEvent domainEvent) => DomainEventHelper.GetProperty(domainEvent, "EstateId"); + + + public static Guid GetMerchantId(IDomainEvent domainEvent) => DomainEventHelper.GetProperty(domainEvent, "MerchantId"); + +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Common/Extensions.cs b/TransactionProcessor.ProjectionEngine/Common/Extensions.cs new file mode 100644 index 00000000..0f1401cf --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Common/Extensions.cs @@ -0,0 +1,28 @@ +namespace TransactionProcessor.ProjectionEngine.Common +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Metadata.Builders; + + public static class Extensions + { + #region Methods + + /// + /// Decimals the precision. + /// + /// The property builder. + /// The precision. + /// The scale. + /// + public static PropertyBuilder DecimalPrecision(this PropertyBuilder propertyBuilder, + Int32 precision, + Int32 scale) + { + return propertyBuilder.HasColumnType($"decimal({precision},{scale})"); + } + + + + #endregion + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Database/Entities/MerchantBalanceProjectionState.cs b/TransactionProcessor.ProjectionEngine/Database/Entities/MerchantBalanceProjectionState.cs new file mode 100644 index 00000000..04bed484 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Database/Entities/MerchantBalanceProjectionState.cs @@ -0,0 +1,44 @@ +namespace TransactionProcessor.ProjectionEngine.Database.Entities; + +using System.ComponentModel.DataAnnotations; + +public class MerchantBalanceProjectionState +{ + [Timestamp] + public Byte[] Timestamp { get; set; } + public Guid EstateId { get; set; } + public Guid MerchantId { get; set; } + public String MerchantName { get; set; } + public Decimal AvailableBalance { get; set; } + public Decimal Balance { get; init; } + public Int32 DepositCount { get; set; } + public Decimal TotalDeposited { get; set; } + + public Int32 SaleCount { get; set; } + public Decimal AuthorisedSales { get; set; } + public Decimal DeclinedSales { get; set; } + + public Int32 FeeCount { get; set; } + public Decimal ValueOfFees { get; set; } + + public DateTime LastDeposit { get; set; } + public DateTime LastSale { get; set; } + public DateTime LastFee { get; set; } + + public Int32 StartedTransactionCount { get; set; } + public Int32 CompletedTransactionCount { get; set; } +} + +public class MerchantBalanceChangedEntry +{ + public Guid AggregateId { get; set; } + public Guid OriginalEventId { get; set; } + public Guid EstateId { get; set; } + public Guid MerchantId { get; set; } + public Decimal Balance { get; set; } + public Decimal ChangeAmount { get; set; } + public DateTime DateTime { get; set; } + public String Reference { get; set; } + public Guid CauseOfChangeId { get; set; } + public String DebitOrCredit { get; set; } +} diff --git a/TransactionProcessor.ProjectionEngine/Database/MySqlIgnoreDuplicatesOnInsertInterceptor.cs b/TransactionProcessor.ProjectionEngine/Database/MySqlIgnoreDuplicatesOnInsertInterceptor.cs new file mode 100644 index 00000000..d5740a06 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Database/MySqlIgnoreDuplicatesOnInsertInterceptor.cs @@ -0,0 +1,85 @@ +namespace TransactionProcessor.ProjectionEngine.Database; + +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; + +public class MySqlIgnoreDuplicatesOnInsertInterceptor : DbCommandInterceptor +{ + public override ValueTask> NonQueryExecutingAsync(DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = new CancellationToken()) + { + command.CommandText = this.SetIgnoreDuplicates(command.CommandText); + return new ValueTask>(result); + } + + public override ValueTask> ScalarExecutingAsync(DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = new CancellationToken()) + { + command.CommandText = this.SetIgnoreDuplicates(command.CommandText); + return new ValueTask>(result); + } + + public override ValueTask> ReaderExecutingAsync(DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = new CancellationToken()) + { + command.CommandText = this.SetIgnoreDuplicates(command.CommandText); + + return new ValueTask>(result); + } + + public override InterceptionResult ScalarExecuting(DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + command.CommandText = this.SetIgnoreDuplicates(command.CommandText); + return result; + } + + public override InterceptionResult NonQueryExecuting(DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + command.CommandText = this.SetIgnoreDuplicates(command.CommandText); + return result; + } + + public override InterceptionResult ReaderExecuting(DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + command.CommandText = this.SetIgnoreDuplicates(command.CommandText); + return result; + } + + private String SetIgnoreDuplicates(String commandText) + { + if (this.IsCommandInsert(commandText)) + { + if (TransactionProcessorGenericContext.IsDuplicateInsertsIgnored(this.GetTableName(commandText))) + { + // Swap the insert to ignore duplicates + return commandText.Replace("INSERT INTO", "INSERT IGNORE INTO"); + } + } + + return commandText; + } + + private Boolean IsCommandInsert(String commandText) => commandText.Contains("INSERT INTO", StringComparison.InvariantCultureIgnoreCase); + + private String GetTableName(String commandText) + { + // Extract table and check if we are ignoring duplicates + Int32 tablenameEnd = commandText.IndexOf("("); + Int32 tablenameStart = 11; + String tableName = commandText.Substring(tablenameStart, tablenameEnd - tablenameStart); + tableName = tableName.Replace("`", ""); + return tableName; + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Database/TransactionProcessorGenericContext.cs b/TransactionProcessor.ProjectionEngine/Database/TransactionProcessorGenericContext.cs new file mode 100644 index 00000000..cb2370b9 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Database/TransactionProcessorGenericContext.cs @@ -0,0 +1,76 @@ +namespace TransactionProcessor.ProjectionEngine.Database; + +using System.Linq.Expressions; +using Entities; +using Microsoft.EntityFrameworkCore; + +public abstract class TransactionProcessorGenericContext : DbContext +{ + #region Fields + + protected readonly String ConnectionString; + + protected readonly String DatabaseEngine; + + protected static List TablesToIgnoreDuplicates = new List(); + + #endregion + + #region Constructors + + protected TransactionProcessorGenericContext(String databaseEngine, + String connectionString) + { + this.DatabaseEngine = databaseEngine; + this.ConnectionString = connectionString; + } + + public TransactionProcessorGenericContext(DbContextOptions dbContextOptions) : base(dbContextOptions) + { + } + + #endregion + + #region Properties + + + + #endregion + + + + public static Boolean IsDuplicateInsertsIgnored(String tableName) => + TransactionProcessorGenericContext.TablesToIgnoreDuplicates.Contains(tableName.Trim(), StringComparer.InvariantCultureIgnoreCase); + + public virtual async Task MigrateAsync(CancellationToken cancellationToken) + { + if (this.Database.IsSqlServer() || this.Database.IsMySql()) + { + await this.Database.MigrateAsync(cancellationToken); + } + } + + protected virtual async Task SetIgnoreDuplicates(CancellationToken cancellationToken) { + TransactionProcessorGenericContext.TablesToIgnoreDuplicates = new List { + }; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity().HasKey(c => new { + c.EstateId, + c.MerchantId + }); + + modelBuilder.Entity().HasKey(c => new { + c.AggregateId, + c.OriginalEventId + }); + + base.OnModelCreating(modelBuilder); + } + + public DbSet MerchantBalanceProjectionState { get; set; } + public DbSet MerchantBalanceChangedEntry { get; set; } + + +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Database/TransactionProcessorMySqlContext.cs b/TransactionProcessor.ProjectionEngine/Database/TransactionProcessorMySqlContext.cs new file mode 100644 index 00000000..aedc8525 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Database/TransactionProcessorMySqlContext.cs @@ -0,0 +1,27 @@ +namespace TransactionProcessor.ProjectionEngine.Database; + +using Microsoft.EntityFrameworkCore; +using Shared.General; + +public class TransactionProcessorMySqlContext : TransactionProcessorGenericContext +{ + public TransactionProcessorMySqlContext() : base("MySql", ConfigurationReader.GetConnectionString("TransactionProcessorReadModel")) + { + } + + public TransactionProcessorMySqlContext(String connectionString) : base("MySql", connectionString) + { + } + + public TransactionProcessorMySqlContext(DbContextOptions dbContextOptions) : base(dbContextOptions) + { + } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + if (!string.IsNullOrWhiteSpace(this.ConnectionString)) + { + options.UseMySql(this.ConnectionString, ServerVersion.Parse("8.0.27")).AddInterceptors(new MySqlIgnoreDuplicatesOnInsertInterceptor()); + } + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Database/TransactionProcessorSqlServerContext.cs b/TransactionProcessor.ProjectionEngine/Database/TransactionProcessorSqlServerContext.cs new file mode 100644 index 00000000..cba62144 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Database/TransactionProcessorSqlServerContext.cs @@ -0,0 +1,38 @@ +namespace TransactionProcessor.ProjectionEngine.Database; + +using Microsoft.EntityFrameworkCore; +using Shared.General; + +public class TransactionProcessorSqlServerContext : TransactionProcessorGenericContext +{ + public TransactionProcessorSqlServerContext() : base("SqlServer", ConfigurationReader.GetConnectionString("TransactionProcessorReadModel")) + { + } + + public TransactionProcessorSqlServerContext(String connectionString) : base("SqlServer", connectionString) + { + } + + public TransactionProcessorSqlServerContext(DbContextOptions dbContextOptions) : base(dbContextOptions) + { + } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + if (!string.IsNullOrWhiteSpace(this.ConnectionString)) + { + options.UseSqlServer(this.ConnectionString); + } + } + + protected override async Task SetIgnoreDuplicates(CancellationToken cancellationToken) + { + base.SetIgnoreDuplicates(cancellationToken); + + TransactionProcessorGenericContext.TablesToIgnoreDuplicates = TransactionProcessorGenericContext.TablesToIgnoreDuplicates.Select(x => $"ALTER TABLE [{x}] REBUILD WITH (IGNORE_DUP_KEY = ON)").ToList(); + + String sql = string.Join(";", TransactionProcessorGenericContext.TablesToIgnoreDuplicates); + + await this.Database.ExecuteSqlRawAsync(sql, cancellationToken); + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Dispatchers/IStateDispatcher.cs b/TransactionProcessor.ProjectionEngine/Dispatchers/IStateDispatcher.cs new file mode 100644 index 00000000..c2059129 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Dispatchers/IStateDispatcher.cs @@ -0,0 +1,9 @@ +namespace TransactionProcessor.ProjectionEngine.Dispatchers; + +using Shared.DomainDrivenDesign.EventSourcing; +using State; + +public interface IStateDispatcher where TState : State +{ + Task Dispatch(TState state, IDomainEvent @event, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Dispatchers/MerchantBalanceStateDispatcher.cs b/TransactionProcessor.ProjectionEngine/Dispatchers/MerchantBalanceStateDispatcher.cs new file mode 100644 index 00000000..0742f85d --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Dispatchers/MerchantBalanceStateDispatcher.cs @@ -0,0 +1,123 @@ +namespace TransactionProcessor.ProjectionEngine.Dispatchers; + +using EstateManagement.Merchant.DomainEvents; +using Models; +using Repository; +using Shared.DomainDrivenDesign.EventSourcing; +using State; +using Transaction.DomainEvents; + +public class MerchantBalanceStateDispatcher : IStateDispatcher +{ + private readonly ITransactionProcessorReadRepository TransactionProcessorReadRepository; + + public MerchantBalanceStateDispatcher(ITransactionProcessorReadRepository transactionProcessorReadRepository) { + this.TransactionProcessorReadRepository = transactionProcessorReadRepository; + } + public async Task Dispatch(MerchantBalanceState state, + IDomainEvent @event, + CancellationToken cancellationToken) { + + MerchantBalanceChangedEntry entry = @event switch { + MerchantCreatedEvent e => this.CreateOpeningBalanceEntry(e), + ManualDepositMadeEvent e => this.CreateManualDepositBalanceEntry(state, e), + AutomaticDepositMadeEvent e => this.CreateAutomaticDepositBalanceEntry(state, e), + TransactionHasBeenCompletedEvent e => this.CreateTransactionBalanceEntry(state, e), + MerchantFeeAddedToTransactionEvent e => this.CreateTransactionFeeBalanceEntry(state, e), + _ => null + }; + + if (entry == null) + return; + + await this.TransactionProcessorReadRepository.AddMerchantBalanceChangedEntry(entry, cancellationToken); + } + + private MerchantBalanceChangedEntry CreateTransactionFeeBalanceEntry(MerchantBalanceState state, MerchantFeeAddedToTransactionEvent @event) + { + return new MerchantBalanceChangedEntry + { + MerchantId = @event.MerchantId, + EstateId = @event.EstateId, + Balance = state.Balance, + ChangeAmount = @event.CalculatedValue, + DateTime = @event.FeeCalculatedDateTime, + Reference = "Transaction Fee Processed", + AggregateId = @event.TransactionId, + OriginalEventId = @event.EventId, + DebitOrCredit = "C" + }; + } + + private MerchantBalanceChangedEntry CreateTransactionBalanceEntry(MerchantBalanceState state, TransactionHasBeenCompletedEvent @event) { + if (@event.IsAuthorised == false) + return null; + + var transactionAmount = @event.TransactionAmount.GetValueOrDefault(0); + + // Skip logons + if (transactionAmount == 0) + return null; + + if (transactionAmount < 0) + transactionAmount = transactionAmount * -1; + + return new MerchantBalanceChangedEntry + { + MerchantId = @event.MerchantId, + EstateId = @event.EstateId, + Balance = state.Balance, + ChangeAmount = transactionAmount, + DateTime = @event.CompletedDateTime.AddSeconds(2), + Reference = "Transaction Completed", + AggregateId = @event.TransactionId, + OriginalEventId = @event.EventId, + DebitOrCredit = "D" + }; + } + + private MerchantBalanceChangedEntry CreateManualDepositBalanceEntry(MerchantBalanceState state, ManualDepositMadeEvent @event) { + return new MerchantBalanceChangedEntry { + MerchantId = @event.MerchantId, + EstateId = @event.EstateId, + Balance = state.Balance, + ChangeAmount = @event.Amount, + DateTime = @event.DepositDateTime, + Reference = "Merchant Deposit", + AggregateId = @event.MerchantId, + OriginalEventId = @event.EventId, + DebitOrCredit = "C" + }; + } + + private MerchantBalanceChangedEntry CreateAutomaticDepositBalanceEntry(MerchantBalanceState state, AutomaticDepositMadeEvent @event) + { + return new MerchantBalanceChangedEntry + { + MerchantId = @event.MerchantId, + EstateId = @event.EstateId, + Balance = state.Balance, + ChangeAmount = @event.Amount, + DateTime = @event.DepositDateTime, + Reference = "Merchant Deposit", + AggregateId = @event.MerchantId, + OriginalEventId = @event.EventId, + DebitOrCredit = "C" + }; + } + + private MerchantBalanceChangedEntry CreateOpeningBalanceEntry(MerchantCreatedEvent @event) { + return new MerchantBalanceChangedEntry { + MerchantId = @event.MerchantId, + EstateId = @event.EstateId, + CauseOfChangeId = @event.MerchantId, + Balance = 0, + ChangeAmount = 0, + DateTime = @event.DateCreated.AddMonths(-1), + Reference = "Opening Balance", + AggregateId = @event.MerchantId, + OriginalEventId = @event.EventId, + DebitOrCredit = "C" + }; + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/EventHandling/EventHandler.cs b/TransactionProcessor.ProjectionEngine/EventHandling/EventHandler.cs new file mode 100644 index 00000000..0c2f402e --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/EventHandling/EventHandler.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TransactionProcessor.ProjectionEngine.EventHandling +{ + using Shared.DomainDrivenDesign.EventSourcing; + using Shared.EventStore.EventHandling; + using System.Reflection; + using Microsoft.AspNetCore.Hosting; + using Shared.General; + using State; + + public class EventHandler : IDomainEventHandler + { + private readonly Func Resolver; + + public static Dictionary StateTypes; + + public EventHandler(Func resolver) + { + this.Resolver = resolver; + List subclassTypes = Assembly.GetAssembly(typeof(State))?.GetTypes().Where(t => t.IsSubclassOf(typeof(State))).ToList(); + + if (subclassTypes != null) + { + EventHandler.StateTypes = subclassTypes.ToDictionary(x => x.Name, x => x); + } + } + + public async Task Handle(IDomainEvent domainEvent, + CancellationToken cancellationToken) { + // Lookup the event type in the config + var gimp = ConfigurationReader.GetValue("AppSettings:EventStateConfig", domainEvent.GetType().Name); + + var handler = this.Resolver(gimp); + + await handler.Handle(domainEvent, cancellationToken); + + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/EventHandling/StateProjectionEventHandlers.cs b/TransactionProcessor.ProjectionEngine/EventHandling/StateProjectionEventHandlers.cs new file mode 100644 index 00000000..6f5fed2e --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/EventHandling/StateProjectionEventHandlers.cs @@ -0,0 +1,49 @@ +namespace TransactionProcessor.ProjectionEngine.EventHandling; + +using Database; +using EstateManagement.Estate.DomainEvents; +using ProjectionHandler; +using Shared.DomainDrivenDesign.EventSourcing; +using Shared.EntityFramework; +using Shared.EventStore.EventHandling; +using State; + +public class StateProjectionEventHandler : IDomainEventHandler where TState : State +{ + #region Fields + + private readonly IProjectionHandler ProjectionHandler; + + private readonly IDbContextFactory ContextFactory; + + #endregion + + #region Constructors + + public StateProjectionEventHandler(ProjectionHandler projectionHandler, + IDbContextFactory contextFactory) { + this.ProjectionHandler = projectionHandler; + this.ContextFactory = contextFactory; + } + + #endregion + + #region Methods + + public async Task Handle(IDomainEvent domainEvent, + CancellationToken cancellationToken) { + if (domainEvent.GetType() == typeof(EstateCreatedEvent)) { + await this.MigrateDatabase((EstateCreatedEvent)domainEvent, cancellationToken); + return; + } + + await this.ProjectionHandler.Handle(domainEvent, cancellationToken); + } + + private async Task MigrateDatabase(EstateCreatedEvent domainEvent, CancellationToken cancellationToken) { + var context = await this.ContextFactory.GetContext(domainEvent.EstateId, cancellationToken); + await context.MigrateAsync(cancellationToken); + } + + #endregion +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Migrations/20221014123644_InitialDatabase.Designer.cs b/TransactionProcessor.ProjectionEngine/Migrations/20221014123644_InitialDatabase.Designer.cs new file mode 100644 index 00000000..496cec83 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/20221014123644_InitialDatabase.Designer.cs @@ -0,0 +1,58 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TransactionProcessor.ProjectionEngine.Database; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations +{ + [DbContext(typeof(TransactionProcessorSqlServerContext))] + [Migration("20221014123644_InitialDatabase")] + partial class InitialDatabase + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceProjectionState", b => + { + b.Property("EstateId") + .HasColumnType("uniqueidentifier"); + + b.Property("MerchantId") + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("Balance") + .HasColumnType("decimal(18,2)"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("EstateId", "MerchantId"); + + b.ToTable("MerchantBalanceProjectionState"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/20221014123644_InitialDatabase.cs b/TransactionProcessor.ProjectionEngine/Migrations/20221014123644_InitialDatabase.cs new file mode 100644 index 00000000..42960d4e --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/20221014123644_InitialDatabase.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations +{ + public partial class InitialDatabase : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MerchantBalanceProjectionState", + columns: table => new + { + EstateId = table.Column(type: "uniqueidentifier", nullable: false), + MerchantId = table.Column(type: "uniqueidentifier", nullable: false), + Timestamp = table.Column(type: "rowversion", rowVersion: true, nullable: false), + MerchantName = table.Column(type: "nvarchar(max)", nullable: false), + AvailableBalance = table.Column(type: "decimal(18,2)", nullable: false), + Balance = table.Column(type: "decimal(18,2)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MerchantBalanceProjectionState", x => new { x.EstateId, x.MerchantId }); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MerchantBalanceProjectionState"); + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/20221017153140_recordmoreinfoatstate.Designer.cs b/TransactionProcessor.ProjectionEngine/Migrations/20221017153140_recordmoreinfoatstate.Designer.cs new file mode 100644 index 00000000..655dcd70 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/20221017153140_recordmoreinfoatstate.Designer.cs @@ -0,0 +1,94 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TransactionProcessor.ProjectionEngine.Database; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations +{ + [DbContext(typeof(TransactionProcessorSqlServerContext))] + [Migration("20221017153140_recordmoreinfoatstate")] + partial class recordmoreinfoatstate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceProjectionState", b => + { + b.Property("EstateId") + .HasColumnType("uniqueidentifier"); + + b.Property("MerchantId") + .HasColumnType("uniqueidentifier"); + + b.Property("AuthorisedSales") + .HasColumnType("decimal(18,2)"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("Balance") + .HasColumnType("decimal(18,2)"); + + b.Property("CompletedTransactionCount") + .HasColumnType("int"); + + b.Property("DeclinedSales") + .HasColumnType("decimal(18,2)"); + + b.Property("DepositCount") + .HasColumnType("int"); + + b.Property("FeeCount") + .HasColumnType("int"); + + b.Property("LastDeposit") + .HasColumnType("datetime2"); + + b.Property("LastFee") + .HasColumnType("datetime2"); + + b.Property("LastSale") + .HasColumnType("datetime2"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SaleCount") + .HasColumnType("int"); + + b.Property("StartedTransactionCount") + .HasColumnType("int"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TotalDeposited") + .HasColumnType("decimal(18,2)"); + + b.Property("ValueOfFees") + .HasColumnType("decimal(18,2)"); + + b.HasKey("EstateId", "MerchantId"); + + b.ToTable("MerchantBalanceProjectionState"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/20221017153140_recordmoreinfoatstate.cs b/TransactionProcessor.ProjectionEngine/Migrations/20221017153140_recordmoreinfoatstate.cs new file mode 100644 index 00000000..a67a1da4 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/20221017153140_recordmoreinfoatstate.cs @@ -0,0 +1,148 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations +{ + public partial class recordmoreinfoatstate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AuthorisedSales", + table: "MerchantBalanceProjectionState", + type: "decimal(18,2)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "CompletedTransactionCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "DeclinedSales", + table: "MerchantBalanceProjectionState", + type: "decimal(18,2)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "DepositCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FeeCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LastDeposit", + table: "MerchantBalanceProjectionState", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastFee", + table: "MerchantBalanceProjectionState", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastSale", + table: "MerchantBalanceProjectionState", + type: "datetime2", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "SaleCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StartedTransactionCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "TotalDeposited", + table: "MerchantBalanceProjectionState", + type: "decimal(18,2)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "ValueOfFees", + table: "MerchantBalanceProjectionState", + type: "decimal(18,2)", + nullable: false, + defaultValue: 0m); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AuthorisedSales", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "CompletedTransactionCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "DeclinedSales", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "DepositCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "FeeCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "LastDeposit", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "LastFee", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "LastSale", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "SaleCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "StartedTransactionCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "TotalDeposited", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "ValueOfFees", + table: "MerchantBalanceProjectionState"); + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/20221018083556_storebalancehistory.Designer.cs b/TransactionProcessor.ProjectionEngine/Migrations/20221018083556_storebalancehistory.Designer.cs new file mode 100644 index 00000000..37632c9d --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/20221018083556_storebalancehistory.Designer.cs @@ -0,0 +1,133 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TransactionProcessor.ProjectionEngine.Database; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations +{ + [DbContext(typeof(TransactionProcessorSqlServerContext))] + [Migration("20221018083556_storebalancehistory")] + partial class storebalancehistory + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceChangedEntry", b => + { + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginalEventId") + .HasColumnType("uniqueidentifier"); + + b.Property("Balance") + .HasColumnType("decimal(18,2)"); + + b.Property("CauseOfChangeId") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangeAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("DateTime") + .HasColumnType("datetime2"); + + b.Property("DebitOrCredit") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EstateId") + .HasColumnType("uniqueidentifier"); + + b.Property("MerchantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reference") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("AggregateId", "OriginalEventId"); + + b.ToTable("MerchantBalanceChangedEntry"); + }); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceProjectionState", b => + { + b.Property("EstateId") + .HasColumnType("uniqueidentifier"); + + b.Property("MerchantId") + .HasColumnType("uniqueidentifier"); + + b.Property("AuthorisedSales") + .HasColumnType("decimal(18,2)"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("Balance") + .HasColumnType("decimal(18,2)"); + + b.Property("CompletedTransactionCount") + .HasColumnType("int"); + + b.Property("DeclinedSales") + .HasColumnType("decimal(18,2)"); + + b.Property("DepositCount") + .HasColumnType("int"); + + b.Property("FeeCount") + .HasColumnType("int"); + + b.Property("LastDeposit") + .HasColumnType("datetime2"); + + b.Property("LastFee") + .HasColumnType("datetime2"); + + b.Property("LastSale") + .HasColumnType("datetime2"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SaleCount") + .HasColumnType("int"); + + b.Property("StartedTransactionCount") + .HasColumnType("int"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TotalDeposited") + .HasColumnType("decimal(18,2)"); + + b.Property("ValueOfFees") + .HasColumnType("decimal(18,2)"); + + b.HasKey("EstateId", "MerchantId"); + + b.ToTable("MerchantBalanceProjectionState"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/20221018083556_storebalancehistory.cs b/TransactionProcessor.ProjectionEngine/Migrations/20221018083556_storebalancehistory.cs new file mode 100644 index 00000000..14002499 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/20221018083556_storebalancehistory.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations +{ + public partial class storebalancehistory : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MerchantBalanceChangedEntry", + columns: table => new + { + AggregateId = table.Column(type: "uniqueidentifier", nullable: false), + OriginalEventId = table.Column(type: "uniqueidentifier", nullable: false), + EstateId = table.Column(type: "uniqueidentifier", nullable: false), + MerchantId = table.Column(type: "uniqueidentifier", nullable: false), + Balance = table.Column(type: "decimal(18,2)", nullable: false), + ChangeAmount = table.Column(type: "decimal(18,2)", nullable: false), + DateTime = table.Column(type: "datetime2", nullable: false), + Reference = table.Column(type: "nvarchar(max)", nullable: false), + CauseOfChangeId = table.Column(type: "uniqueidentifier", nullable: false), + DebitOrCredit = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MerchantBalanceChangedEntry", x => new { x.AggregateId, x.OriginalEventId }); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MerchantBalanceChangedEntry"); + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221014123702_InitialDatabase.Designer.cs b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221014123702_InitialDatabase.Designer.cs new file mode 100644 index 00000000..461e0fa8 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221014123702_InitialDatabase.Designer.cs @@ -0,0 +1,54 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TransactionProcessor.ProjectionEngine.Database; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations.TransactionProcessorMySql +{ + [DbContext(typeof(TransactionProcessorMySqlContext))] + [Migration("20221014123702_InitialDatabase")] + partial class InitialDatabase + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceProjectionState", b => + { + b.Property("EstateId") + .HasColumnType("char(36)"); + + b.Property("MerchantId") + .HasColumnType("char(36)"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("Balance") + .HasColumnType("decimal(65,30)"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp(6)"); + + b.HasKey("EstateId", "MerchantId"); + + b.ToTable("MerchantBalanceProjectionState"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221014123702_InitialDatabase.cs b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221014123702_InitialDatabase.cs new file mode 100644 index 00000000..b8cb4ad9 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221014123702_InitialDatabase.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations.TransactionProcessorMySql +{ + public partial class InitialDatabase : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "MerchantBalanceProjectionState", + columns: table => new + { + EstateId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + MerchantId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Timestamp = table.Column(type: "timestamp(6)", rowVersion: true, nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.ComputedColumn), + MerchantName = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + AvailableBalance = table.Column(type: "decimal(65,30)", nullable: false), + Balance = table.Column(type: "decimal(65,30)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MerchantBalanceProjectionState", x => new { x.EstateId, x.MerchantId }); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MerchantBalanceProjectionState"); + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221017153124_recordmoreinfoatstate.Designer.cs b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221017153124_recordmoreinfoatstate.Designer.cs new file mode 100644 index 00000000..247b0d3e --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221017153124_recordmoreinfoatstate.Designer.cs @@ -0,0 +1,90 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TransactionProcessor.ProjectionEngine.Database; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations.TransactionProcessorMySql +{ + [DbContext(typeof(TransactionProcessorMySqlContext))] + [Migration("20221017153124_recordmoreinfoatstate")] + partial class recordmoreinfoatstate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceProjectionState", b => + { + b.Property("EstateId") + .HasColumnType("char(36)"); + + b.Property("MerchantId") + .HasColumnType("char(36)"); + + b.Property("AuthorisedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("Balance") + .HasColumnType("decimal(65,30)"); + + b.Property("CompletedTransactionCount") + .HasColumnType("int"); + + b.Property("DeclinedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("DepositCount") + .HasColumnType("int"); + + b.Property("FeeCount") + .HasColumnType("int"); + + b.Property("LastDeposit") + .HasColumnType("datetime(6)"); + + b.Property("LastFee") + .HasColumnType("datetime(6)"); + + b.Property("LastSale") + .HasColumnType("datetime(6)"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SaleCount") + .HasColumnType("int"); + + b.Property("StartedTransactionCount") + .HasColumnType("int"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp(6)"); + + b.Property("TotalDeposited") + .HasColumnType("decimal(65,30)"); + + b.Property("ValueOfFees") + .HasColumnType("decimal(65,30)"); + + b.HasKey("EstateId", "MerchantId"); + + b.ToTable("MerchantBalanceProjectionState"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221017153124_recordmoreinfoatstate.cs b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221017153124_recordmoreinfoatstate.cs new file mode 100644 index 00000000..80758d48 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221017153124_recordmoreinfoatstate.cs @@ -0,0 +1,148 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations.TransactionProcessorMySql +{ + public partial class recordmoreinfoatstate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AuthorisedSales", + table: "MerchantBalanceProjectionState", + type: "decimal(65,30)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "CompletedTransactionCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "DeclinedSales", + table: "MerchantBalanceProjectionState", + type: "decimal(65,30)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "DepositCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FeeCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LastDeposit", + table: "MerchantBalanceProjectionState", + type: "datetime(6)", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastFee", + table: "MerchantBalanceProjectionState", + type: "datetime(6)", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastSale", + table: "MerchantBalanceProjectionState", + type: "datetime(6)", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "SaleCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StartedTransactionCount", + table: "MerchantBalanceProjectionState", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "TotalDeposited", + table: "MerchantBalanceProjectionState", + type: "decimal(65,30)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "ValueOfFees", + table: "MerchantBalanceProjectionState", + type: "decimal(65,30)", + nullable: false, + defaultValue: 0m); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AuthorisedSales", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "CompletedTransactionCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "DeclinedSales", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "DepositCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "FeeCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "LastDeposit", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "LastFee", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "LastSale", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "SaleCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "StartedTransactionCount", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "TotalDeposited", + table: "MerchantBalanceProjectionState"); + + migrationBuilder.DropColumn( + name: "ValueOfFees", + table: "MerchantBalanceProjectionState"); + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221018083612_storebalancehistory.Designer.cs b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221018083612_storebalancehistory.Designer.cs new file mode 100644 index 00000000..a835d5b5 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221018083612_storebalancehistory.Designer.cs @@ -0,0 +1,129 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TransactionProcessor.ProjectionEngine.Database; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations.TransactionProcessorMySql +{ + [DbContext(typeof(TransactionProcessorMySqlContext))] + [Migration("20221018083612_storebalancehistory")] + partial class storebalancehistory + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceChangedEntry", b => + { + b.Property("AggregateId") + .HasColumnType("char(36)"); + + b.Property("OriginalEventId") + .HasColumnType("char(36)"); + + b.Property("Balance") + .HasColumnType("decimal(65,30)"); + + b.Property("CauseOfChangeId") + .HasColumnType("char(36)"); + + b.Property("ChangeAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("DateTime") + .HasColumnType("datetime(6)"); + + b.Property("DebitOrCredit") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstateId") + .HasColumnType("char(36)"); + + b.Property("MerchantId") + .HasColumnType("char(36)"); + + b.Property("Reference") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("AggregateId", "OriginalEventId"); + + b.ToTable("MerchantBalanceChangedEntry"); + }); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceProjectionState", b => + { + b.Property("EstateId") + .HasColumnType("char(36)"); + + b.Property("MerchantId") + .HasColumnType("char(36)"); + + b.Property("AuthorisedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("Balance") + .HasColumnType("decimal(65,30)"); + + b.Property("CompletedTransactionCount") + .HasColumnType("int"); + + b.Property("DeclinedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("DepositCount") + .HasColumnType("int"); + + b.Property("FeeCount") + .HasColumnType("int"); + + b.Property("LastDeposit") + .HasColumnType("datetime(6)"); + + b.Property("LastFee") + .HasColumnType("datetime(6)"); + + b.Property("LastSale") + .HasColumnType("datetime(6)"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SaleCount") + .HasColumnType("int"); + + b.Property("StartedTransactionCount") + .HasColumnType("int"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp(6)"); + + b.Property("TotalDeposited") + .HasColumnType("decimal(65,30)"); + + b.Property("ValueOfFees") + .HasColumnType("decimal(65,30)"); + + b.HasKey("EstateId", "MerchantId"); + + b.ToTable("MerchantBalanceProjectionState"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221018083612_storebalancehistory.cs b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221018083612_storebalancehistory.cs new file mode 100644 index 00000000..4bfc9126 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/20221018083612_storebalancehistory.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations.TransactionProcessorMySql +{ + public partial class storebalancehistory : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MerchantBalanceChangedEntry", + columns: table => new + { + AggregateId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + OriginalEventId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + EstateId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + MerchantId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Balance = table.Column(type: "decimal(65,30)", nullable: false), + ChangeAmount = table.Column(type: "decimal(65,30)", nullable: false), + DateTime = table.Column(type: "datetime(6)", nullable: false), + Reference = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CauseOfChangeId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + DebitOrCredit = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4") + }, + constraints: table => + { + table.PrimaryKey("PK_MerchantBalanceChangedEntry", x => new { x.AggregateId, x.OriginalEventId }); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MerchantBalanceChangedEntry"); + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/TransactionProcessorMySqlContextModelSnapshot.cs b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/TransactionProcessorMySqlContextModelSnapshot.cs new file mode 100644 index 00000000..40a32f2e --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorMySql/TransactionProcessorMySqlContextModelSnapshot.cs @@ -0,0 +1,127 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TransactionProcessor.ProjectionEngine.Database; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations.TransactionProcessorMySql +{ + [DbContext(typeof(TransactionProcessorMySqlContext))] + partial class TransactionProcessorMySqlContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceChangedEntry", b => + { + b.Property("AggregateId") + .HasColumnType("char(36)"); + + b.Property("OriginalEventId") + .HasColumnType("char(36)"); + + b.Property("Balance") + .HasColumnType("decimal(65,30)"); + + b.Property("CauseOfChangeId") + .HasColumnType("char(36)"); + + b.Property("ChangeAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("DateTime") + .HasColumnType("datetime(6)"); + + b.Property("DebitOrCredit") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EstateId") + .HasColumnType("char(36)"); + + b.Property("MerchantId") + .HasColumnType("char(36)"); + + b.Property("Reference") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("AggregateId", "OriginalEventId"); + + b.ToTable("MerchantBalanceChangedEntry"); + }); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceProjectionState", b => + { + b.Property("EstateId") + .HasColumnType("char(36)"); + + b.Property("MerchantId") + .HasColumnType("char(36)"); + + b.Property("AuthorisedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("Balance") + .HasColumnType("decimal(65,30)"); + + b.Property("CompletedTransactionCount") + .HasColumnType("int"); + + b.Property("DeclinedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("DepositCount") + .HasColumnType("int"); + + b.Property("FeeCount") + .HasColumnType("int"); + + b.Property("LastDeposit") + .HasColumnType("datetime(6)"); + + b.Property("LastFee") + .HasColumnType("datetime(6)"); + + b.Property("LastSale") + .HasColumnType("datetime(6)"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SaleCount") + .HasColumnType("int"); + + b.Property("StartedTransactionCount") + .HasColumnType("int"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp(6)"); + + b.Property("TotalDeposited") + .HasColumnType("decimal(65,30)"); + + b.Property("ValueOfFees") + .HasColumnType("decimal(65,30)"); + + b.HasKey("EstateId", "MerchantId"); + + b.ToTable("MerchantBalanceProjectionState"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorSqlServerContextModelSnapshot.cs b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorSqlServerContextModelSnapshot.cs new file mode 100644 index 00000000..933e326f --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Migrations/TransactionProcessorSqlServerContextModelSnapshot.cs @@ -0,0 +1,131 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TransactionProcessor.ProjectionEngine.Database; + +#nullable disable + +namespace TransactionProcessor.ProjectionEngine.Migrations +{ + [DbContext(typeof(TransactionProcessorSqlServerContext))] + partial class TransactionProcessorSqlServerContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceChangedEntry", b => + { + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginalEventId") + .HasColumnType("uniqueidentifier"); + + b.Property("Balance") + .HasColumnType("decimal(18,2)"); + + b.Property("CauseOfChangeId") + .HasColumnType("uniqueidentifier"); + + b.Property("ChangeAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("DateTime") + .HasColumnType("datetime2"); + + b.Property("DebitOrCredit") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EstateId") + .HasColumnType("uniqueidentifier"); + + b.Property("MerchantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Reference") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("AggregateId", "OriginalEventId"); + + b.ToTable("MerchantBalanceChangedEntry"); + }); + + modelBuilder.Entity("TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceProjectionState", b => + { + b.Property("EstateId") + .HasColumnType("uniqueidentifier"); + + b.Property("MerchantId") + .HasColumnType("uniqueidentifier"); + + b.Property("AuthorisedSales") + .HasColumnType("decimal(18,2)"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("Balance") + .HasColumnType("decimal(18,2)"); + + b.Property("CompletedTransactionCount") + .HasColumnType("int"); + + b.Property("DeclinedSales") + .HasColumnType("decimal(18,2)"); + + b.Property("DepositCount") + .HasColumnType("int"); + + b.Property("FeeCount") + .HasColumnType("int"); + + b.Property("LastDeposit") + .HasColumnType("datetime2"); + + b.Property("LastFee") + .HasColumnType("datetime2"); + + b.Property("LastSale") + .HasColumnType("datetime2"); + + b.Property("MerchantName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SaleCount") + .HasColumnType("int"); + + b.Property("StartedTransactionCount") + .HasColumnType("int"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TotalDeposited") + .HasColumnType("decimal(18,2)"); + + b.Property("ValueOfFees") + .HasColumnType("decimal(18,2)"); + + b.HasKey("EstateId", "MerchantId"); + + b.ToTable("MerchantBalanceProjectionState"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TransactionProcessor.ProjectionEngine/Models/MerchantBalanceChangedEntry.cs b/TransactionProcessor.ProjectionEngine/Models/MerchantBalanceChangedEntry.cs new file mode 100644 index 00000000..4f20891f --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Models/MerchantBalanceChangedEntry.cs @@ -0,0 +1,16 @@ +namespace TransactionProcessor.ProjectionEngine.Models +{ + public class MerchantBalanceChangedEntry + { + public Guid AggregateId { get; set; } + public Guid OriginalEventId { get; set; } + public Guid EstateId { get; set; } + public Guid MerchantId { get; set; } + public Decimal Balance { get; set; } + public Decimal ChangeAmount { get; set; } + public DateTime DateTime { get; set; } + public String Reference { get; set; } + public Guid CauseOfChangeId { get; set; } + public String DebitOrCredit { get; set; } + } +} diff --git a/TransactionProcessor.ProjectionEngine/ProjectionHandler/IProjectionHandler.cs b/TransactionProcessor.ProjectionEngine/ProjectionHandler/IProjectionHandler.cs new file mode 100644 index 00000000..e1d6cae3 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/ProjectionHandler/IProjectionHandler.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; +using Shared.DomainDrivenDesign.EventSourcing; +using Shared.General; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace TransactionProcessor.ProjectionEngine.ProjectionHandler +{ + public interface IProjectionHandler + { + Task Handle(IDomainEvent @event, CancellationToken cancellationToken); + } +} diff --git a/TransactionProcessor.ProjectionEngine/ProjectionHandler/ProjectionHandler.cs b/TransactionProcessor.ProjectionEngine/ProjectionHandler/ProjectionHandler.cs new file mode 100644 index 00000000..ac812026 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/ProjectionHandler/ProjectionHandler.cs @@ -0,0 +1,113 @@ +namespace TransactionProcessor.ProjectionEngine.ProjectionHandler; + +using System.Diagnostics; +using System.Text; +using Dispatchers; +using Projections; +using Repository; +using Shared.DomainDrivenDesign.EventSourcing; +using Shared.Logger; + +public class ProjectionHandler : IProjectionHandler where TState : State.State +{ + private readonly IProjectionStateRepository ProjectionStateRepository; + private readonly IProjection Projection; + private readonly IStateDispatcher StateDispatcher; + + //private readonly Boolean TraceState; + + //private readonly Boolean ProjectionTrace; + + protected ProjectionHandler() + { + + } + + public ProjectionHandler(IProjectionStateRepository projectionStateRepository, + IProjection projection, + IStateDispatcher stateDispatcher) + { + this.ProjectionStateRepository = projectionStateRepository; + this.Projection = projection; + this.StateDispatcher = stateDispatcher; + + //Boolean.TryParse(ConfigurationReader.GetValueOrDefault("TraceState", "false"), out Boolean traceState); + //Boolean.TryParse(ConfigurationReader.GetValueOrDefault("ProjectionTrace", "false"), out Boolean projectionTrace); + //TraceState = traceState; + //ProjectionTrace = projectionTrace; + } + + public async Task Handle(IDomainEvent @event, CancellationToken cancellationToken) + { + if (@event == null) return; + + if (this.Projection.ShouldIHandleEvent(@event) == false) + { + return; + } + + Stopwatch stopwatch = Stopwatch.StartNew(); + StringBuilder builder = new(); + + //Load the state from persistence + TState state = await this.ProjectionStateRepository.Load(@event, cancellationToken); + + if (state == null) + { + return; + } + + builder.Append($"{stopwatch.ElapsedMilliseconds}ms After Load|"); + + builder.Append($"{stopwatch.ElapsedMilliseconds}ms Handling {@event.EventType} for state {state.GetType().Name}|"); + + TState newState = await this.Projection.Handle(state, @event, cancellationToken); + + builder.Append($"{stopwatch.ElapsedMilliseconds}ms After Handle|"); + + if (newState != state) + { + newState = newState with + { + ChangesApplied = true + }; + + // save state + newState = await this.ProjectionStateRepository.Save(newState, @event, cancellationToken); + + //Repo might have detected a duplicate event + if (newState.ChangesApplied) + { + builder.Append($"{stopwatch.ElapsedMilliseconds}ms After Save|"); + + if (this.StateDispatcher != null) + { + //Send to anyone else interested + await this.StateDispatcher.Dispatch(newState, @event, cancellationToken); + + builder.Append($"{stopwatch.ElapsedMilliseconds}ms After Dispatch|"); + } + } + } + else + { + builder.Append($"{stopwatch.ElapsedMilliseconds}ms No Save required|"); + + //if (TraceState) + //{ + // String originalStateJson = JsonConvert.SerializeObject(state); + // String newStateJson = JsonConvert.SerializeObject(newState); + + // ProjectionHelper.WriteToLogAsWarning($"{Projection.GetType().Name} - {@event.GetType().Name} did not save. {JsonConvert.SerializeObject(@event)}. Original state {originalStateJson} - modified state {newStateJson}"); + //} + } + + stopwatch.Stop(); + + //if (ProjectionTrace) + //{ + builder.Insert(0, $"Total time: {stopwatch.ElapsedMilliseconds}ms|"); + Logger.LogWarning(builder.ToString()); + //} + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Projections/IProjection.cs b/TransactionProcessor.ProjectionEngine/Projections/IProjection.cs new file mode 100644 index 00000000..8a79bb02 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Projections/IProjection.cs @@ -0,0 +1,12 @@ +namespace TransactionProcessor.ProjectionEngine.Projections; + +using System.Diagnostics.Contracts; +using Shared.DomainDrivenDesign.EventSourcing; + +public interface IProjection where TState : State.State +{ + [Pure] + Task Handle(TState state, IDomainEvent domainEvent, CancellationToken cancellationToken); + + bool ShouldIHandleEvent(IDomainEvent domainEvent); +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Projections/MerchantBalanceProjection.cs b/TransactionProcessor.ProjectionEngine/Projections/MerchantBalanceProjection.cs new file mode 100644 index 00000000..2e8686cd --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Projections/MerchantBalanceProjection.cs @@ -0,0 +1,53 @@ +namespace TransactionProcessor.ProjectionEngine.Projections; + +using EstateManagement.Merchant.DomainEvents; +using Shared.DomainDrivenDesign.EventSourcing; +using State; +using Transaction.DomainEvents; + +public class MerchantBalanceProjection : IProjection +{ + public async Task Handle(MerchantBalanceState state, + IDomainEvent domainEvent, + CancellationToken cancellationToken) + { + Func ProcessTransactionHasBeenCompletedEvent = (thbce) => + { + if (thbce.IsAuthorised) + { + return state.DecrementBalance(thbce.TransactionAmount.GetValueOrDefault(0)).RecordAuthorisedSale(thbce.TransactionAmount.GetValueOrDefault(0)); + } + else + { + return state.IncrementAvailableBalance(thbce.TransactionAmount.GetValueOrDefault(0)).RecordDeclinedSale(thbce.TransactionAmount.GetValueOrDefault(0)); + } + }; + + MerchantBalanceState newState = domainEvent switch { + MerchantCreatedEvent mce => state.SetEstateId(mce.EstateId).SetMerchantId(mce.MerchantId).SetMerchantName(mce.MerchantName).InitialiseBalances(), + ManualDepositMadeEvent mdme => state.IncrementAvailableBalance(mdme.Amount).IncrementBalance(mdme.Amount).RecordDeposit(mdme.Amount), + AutomaticDepositMadeEvent adme => state.IncrementAvailableBalance(adme.Amount).IncrementBalance(adme.Amount).RecordDeposit(adme.Amount), + TransactionHasStartedEvent thse => state.DecrementAvailableBalance(thse.TransactionAmount.GetValueOrDefault(0)).StartTransaction(thse), + TransactionHasBeenCompletedEvent thbce => ProcessTransactionHasBeenCompletedEvent(thbce).CompleteTransaction(thbce), + MerchantFeeAddedToTransactionEvent mfatte => state.IncrementAvailableBalance(mfatte.CalculatedValue).IncrementBalance(mfatte.CalculatedValue) + .RecordMerchantFee(mfatte.CalculatedValue), + _ => state + }; + + return newState; + } + + public bool ShouldIHandleEvent(IDomainEvent domainEvent) + { + return domainEvent switch + { + MerchantCreatedEvent _ => true, + ManualDepositMadeEvent _ => true, + AutomaticDepositMadeEvent _ => true, + TransactionHasStartedEvent _ => true, + TransactionHasBeenCompletedEvent _ => true, + MerchantFeeAddedToTransactionEvent _ => true, + _ => false + }; + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Repository/IProjectionStateRepository.cs b/TransactionProcessor.ProjectionEngine/Repository/IProjectionStateRepository.cs new file mode 100644 index 00000000..6f394951 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Repository/IProjectionStateRepository.cs @@ -0,0 +1,13 @@ +namespace TransactionProcessor.ProjectionEngine.Repository; + +using Shared.DomainDrivenDesign.EventSourcing; +using State; + +public interface IProjectionStateRepository where TState : State +{ + Task Load(IDomainEvent @event, CancellationToken cancellationToken); + + Task Load(Guid estateId, Guid stateId, CancellationToken cancellationToken); + + Task Save(TState state, IDomainEvent @event, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Repository/ITransactionProcessorReadRepository.cs b/TransactionProcessor.ProjectionEngine/Repository/ITransactionProcessorReadRepository.cs new file mode 100644 index 00000000..d66a1965 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Repository/ITransactionProcessorReadRepository.cs @@ -0,0 +1,9 @@ +namespace TransactionProcessor.ProjectionEngine.Repository; + +using Dispatchers; +using Models; + +public interface ITransactionProcessorReadRepository +{ + Task AddMerchantBalanceChangedEntry(MerchantBalanceChangedEntry entry, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Repository/MerchantBalanceStateRepository.cs b/TransactionProcessor.ProjectionEngine/Repository/MerchantBalanceStateRepository.cs new file mode 100644 index 00000000..708e3acd --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Repository/MerchantBalanceStateRepository.cs @@ -0,0 +1,120 @@ +namespace TransactionProcessor.ProjectionEngine.Repository; + +using Common; +using Database; +using Database.Entities; +using Microsoft.EntityFrameworkCore; +using Shared.DomainDrivenDesign.EventSourcing; +using State; + +public class MerchantBalanceStateRepository : IProjectionStateRepository +{ + private readonly Shared.EntityFramework.IDbContextFactory ContextFactory; + + public MerchantBalanceStateRepository(Shared.EntityFramework.IDbContextFactory contextFactory) + { + this.ContextFactory = contextFactory; + } + + private async Task LoadHelper(Guid estateId, + Guid merchantId, + CancellationToken cancellationToken) + { + await using TransactionProcessorGenericContext context = await this.ContextFactory.GetContext(estateId, cancellationToken); + + MerchantBalanceProjectionState? entity = + await EntityFrameworkQueryableExtensions.SingleOrDefaultAsync(context.MerchantBalanceProjectionState.Where(m => m.MerchantId == merchantId)); + + if (entity == null) { + return new MerchantBalanceState(); + } + + // We have located a state record so we need to translate to the Model type + return new MerchantBalanceState() + { + Version = entity.Timestamp, + Balance = entity.Balance, + MerchantId = merchantId, + AvailableBalance = entity.AvailableBalance, + MerchantName = entity.MerchantName, + EstateId = entity.EstateId, + DeclinedSales = entity.DeclinedSales, + ValueOfFees = entity.ValueOfFees, + StartedTransactionCount = entity.StartedTransactionCount, + FeeCount = entity.FeeCount, + AuthorisedSales = entity.AuthorisedSales, + CompletedTransactionCount = entity.CompletedTransactionCount, + DepositCount = entity.DepositCount, + LastDeposit = entity.LastDeposit, + LastFee = entity.LastFee, + LastSale = entity.LastSale, + SaleCount = entity.SaleCount, + TotalDeposited = entity.TotalDeposited, + }; + } + + public async Task Load(IDomainEvent @event, CancellationToken cancellationToken) + { + Guid estateId = DomainEventHelper.GetEstateId(@event); + Guid merchantId = DomainEventHelper.GetMerchantId(@event); + + return await this.LoadHelper(estateId, merchantId, cancellationToken); + } + + public async Task Load(Guid estateId, + Guid stateId, + CancellationToken cancellationToken) { + return await this.LoadHelper(estateId, stateId, cancellationToken); + } + + public async Task Save(MerchantBalanceState state, IDomainEvent @event, CancellationToken cancellationToken) + { + await using TransactionProcessorGenericContext context = await this.ContextFactory.GetContext(state.EstateId, cancellationToken); + // Note: we don't want to select the state again here.... + MerchantBalanceProjectionState entity = MerchantBalanceStateRepository.CreateMerchantBalanceProjectionState(state); + + if (state.IsInitialised) + { + // handle updates here + context.MerchantBalanceProjectionState.Update(entity); + } + else + { + await context.MerchantBalanceProjectionState.AddAsync(entity, cancellationToken); + } + + await context.SaveChangesAsync(cancellationToken); + return state; + } + + private static MerchantBalanceProjectionState CreateMerchantBalanceProjectionState(MerchantBalanceState state) + { + MerchantBalanceProjectionState entity = new() + { + Balance = state.Balance, + EstateId = state.EstateId, + MerchantId = state.MerchantId, + AvailableBalance = state.AvailableBalance, + MerchantName = state.MerchantName, + Timestamp = state.Version, + DeclinedSales = state.DeclinedSales, + ValueOfFees = state.ValueOfFees, + StartedTransactionCount = state.StartedTransactionCount, + FeeCount = state.FeeCount, + AuthorisedSales = state.AuthorisedSales, + CompletedTransactionCount = state.CompletedTransactionCount, + DepositCount = state.DepositCount, + LastDeposit = state.LastDeposit, + LastFee = state.LastFee, + LastSale = state.LastSale, + SaleCount = state.SaleCount, + TotalDeposited = state.TotalDeposited, + }; + return entity; + } + + //public async Task Load(Guid organisationId, Guid storeId, CancellationToken cancellationToken) + //{ + // return await LoadHelper(organisationId, storeId, 0, cancellationToken); + //} +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/Repository/TransactionProcessorReadRepository.cs b/TransactionProcessor.ProjectionEngine/Repository/TransactionProcessorReadRepository.cs new file mode 100644 index 00000000..90492b5b --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/Repository/TransactionProcessorReadRepository.cs @@ -0,0 +1,35 @@ +namespace TransactionProcessor.ProjectionEngine.Repository; + +using Database; +using Dispatchers; +using Models; +using Shared.EntityFramework; + +public class TransactionProcessorReadRepository : ITransactionProcessorReadRepository +{ + private readonly IDbContextFactory ContextFactory; + + public TransactionProcessorReadRepository(Shared.EntityFramework.IDbContextFactory contextFactory) { + this.ContextFactory = contextFactory; + } + public async Task AddMerchantBalanceChangedEntry(MerchantBalanceChangedEntry entry, + CancellationToken cancellationToken) { + await using TransactionProcessorGenericContext context = await this.ContextFactory.GetContext(entry.EstateId, cancellationToken); + + TransactionProcessor.ProjectionEngine.Database.Entities.MerchantBalanceChangedEntry entity = new() { + Balance = entry.Balance, + ChangeAmount = entry.ChangeAmount, + Reference = entry.Reference, + AggregateId = entry.AggregateId, + OriginalEventId = entry.OriginalEventId, + DebitOrCredit = entry.DebitOrCredit, + CauseOfChangeId = entry.CauseOfChangeId, + DateTime = entry.DateTime, + EstateId = entry.EstateId, + MerchantId = @entry.MerchantId, + }; + + await context.MerchantBalanceChangedEntry.AddAsync(entity, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/State/MerchantBalanceState.cs b/TransactionProcessor.ProjectionEngine/State/MerchantBalanceState.cs new file mode 100644 index 00000000..b6a07129 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/State/MerchantBalanceState.cs @@ -0,0 +1,27 @@ +namespace TransactionProcessor.ProjectionEngine.State; + +public record MerchantBalanceState : State +{ + public Guid EstateId { get; init; } + public Guid MerchantId { get; init; } + public String MerchantName { get; init; } + public Decimal AvailableBalance { get; init; } + public Decimal Balance { get; init; } + + public Int32 DepositCount { get; init; } + public Decimal TotalDeposited { get; init; } + + public Int32 SaleCount { get; init; } + public Decimal AuthorisedSales { get; init; } + public Decimal DeclinedSales { get; init; } + + public Int32 FeeCount { get; init; } + public Decimal ValueOfFees { get; init; } + + public DateTime LastDeposit { get; init; } + public DateTime LastSale { get; init; } + public DateTime LastFee { get; init; } + + public Int32 StartedTransactionCount { get; init; } + public Int32 CompletedTransactionCount { get; init; } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/State/MerchantBalanceStateExtensions.cs b/TransactionProcessor.ProjectionEngine/State/MerchantBalanceStateExtensions.cs new file mode 100644 index 00000000..5c3ba5f2 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/State/MerchantBalanceStateExtensions.cs @@ -0,0 +1,132 @@ +namespace TransactionProcessor.ProjectionEngine.State +{ + using System.Diagnostics.Contracts; + using Transaction.DomainEvents; + + public static class MerchantBalanceStateExtensions + { + [Pure] + public static MerchantBalanceState InitialiseBalances(this MerchantBalanceState state) => + state with + { + Balance = 0, + AvailableBalance = 0 + }; + + + [Pure] + public static MerchantBalanceState SetEstateId(this MerchantBalanceState state, + Guid estateId) => + state with { + EstateId = estateId + }; + + [Pure] + public static MerchantBalanceState SetMerchantId(this MerchantBalanceState state, + Guid merchantId) => + state with + { + MerchantId = merchantId + }; + + [Pure] + public static MerchantBalanceState StartTransaction(this MerchantBalanceState state, + TransactionHasStartedEvent domainEvent) { + + if (domainEvent.TransactionType == "Logon") + return state; + + return state with { + StartedTransactionCount = state.StartedTransactionCount + 1 + }; + } + + + [Pure] + public static MerchantBalanceState CompleteTransaction(this MerchantBalanceState state, + TransactionHasBeenCompletedEvent domainEvent) { + + if (domainEvent.TransactionAmount.HasValue == false) + return state; + + return state with { + CompletedTransactionCount = state.CompletedTransactionCount + 1 + }; + } + + [Pure] + public static MerchantBalanceState SetMerchantName(this MerchantBalanceState state, + String merchantName) => + state with + { + MerchantName = merchantName + }; + + [Pure] + public static MerchantBalanceState IncrementBalance(this MerchantBalanceState state, + Decimal amount) => + state with { + Balance = state.Balance + amount + }; + + [Pure] + public static MerchantBalanceState IncrementAvailableBalance(this MerchantBalanceState state, + Decimal amount) => + state with + { + AvailableBalance = state.AvailableBalance + amount + }; + + [Pure] + public static MerchantBalanceState RecordDeposit(this MerchantBalanceState state, + Decimal depositAmount) => + state with + { + DepositCount = state.DepositCount + 1, + TotalDeposited = state.TotalDeposited+depositAmount + }; + + [Pure] + public static MerchantBalanceState RecordAuthorisedSale(this MerchantBalanceState state, + Decimal saleAmount) => + state with + { + SaleCount = state.SaleCount+1, + AuthorisedSales = state.AuthorisedSales + saleAmount + }; + + [Pure] + public static MerchantBalanceState RecordDeclinedSale(this MerchantBalanceState state, + Decimal saleAmount) => + state with + { + SaleCount = state.SaleCount+1, + DeclinedSales = state.DeclinedSales + saleAmount + }; + + [Pure] + public static MerchantBalanceState RecordMerchantFee(this MerchantBalanceState state, + Decimal feeAmount) => + state with + { + FeeCount = state.FeeCount+1, + ValueOfFees = state.ValueOfFees+ feeAmount + }; + + [Pure] + public static MerchantBalanceState DecrementAvailableBalance(this MerchantBalanceState state, + Decimal amount) => + state with + { + AvailableBalance = state.AvailableBalance - amount + }; + + [Pure] + public static MerchantBalanceState DecrementBalance(this MerchantBalanceState state, + Decimal amount) => + state with + { + Balance = state.Balance - amount + }; + } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/State/State.cs b/TransactionProcessor.ProjectionEngine/State/State.cs new file mode 100644 index 00000000..6f7fae35 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/State/State.cs @@ -0,0 +1,10 @@ +namespace TransactionProcessor.ProjectionEngine.State; + +public record State() +{ + public Byte[] Version { get; init; } + public Boolean IsInitialised => this.Version != null; + public Boolean IsNotInitialised => this.Version == null; + + public Boolean ChangesApplied { get; init; } +} \ No newline at end of file diff --git a/TransactionProcessor.ProjectionEngine/TransactionProcessor.ProjectionEngine.csproj b/TransactionProcessor.ProjectionEngine/TransactionProcessor.ProjectionEngine.csproj new file mode 100644 index 00000000..e4e41239 --- /dev/null +++ b/TransactionProcessor.ProjectionEngine/TransactionProcessor.ProjectionEngine.csproj @@ -0,0 +1,37 @@ + + + + net6.0 + enable + enable + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/TransactionProcessor.Reconciliation.DomainEvents/TransactionProcessor.Reconciliation.DomainEvents.csproj b/TransactionProcessor.Reconciliation.DomainEvents/TransactionProcessor.Reconciliation.DomainEvents.csproj index 50316526..a1a7657a 100644 --- a/TransactionProcessor.Reconciliation.DomainEvents/TransactionProcessor.Reconciliation.DomainEvents.csproj +++ b/TransactionProcessor.Reconciliation.DomainEvents/TransactionProcessor.Reconciliation.DomainEvents.csproj @@ -5,6 +5,6 @@ - + diff --git a/TransactionProcessor.ReconciliationAggregate/TransactionProcessor.ReconciliationAggregate.csproj b/TransactionProcessor.ReconciliationAggregate/TransactionProcessor.ReconciliationAggregate.csproj index dad8f642..47acf35e 100644 --- a/TransactionProcessor.ReconciliationAggregate/TransactionProcessor.ReconciliationAggregate.csproj +++ b/TransactionProcessor.ReconciliationAggregate/TransactionProcessor.ReconciliationAggregate.csproj @@ -6,7 +6,7 @@ - + diff --git a/TransactionProcessor.Settlement.DomainEvents/TransactionProcessor.Settlement.DomainEvents.csproj b/TransactionProcessor.Settlement.DomainEvents/TransactionProcessor.Settlement.DomainEvents.csproj index 9af9b461..a51477bf 100644 --- a/TransactionProcessor.Settlement.DomainEvents/TransactionProcessor.Settlement.DomainEvents.csproj +++ b/TransactionProcessor.Settlement.DomainEvents/TransactionProcessor.Settlement.DomainEvents.csproj @@ -5,7 +5,7 @@ - + diff --git a/TransactionProcessor.SettlementAggregates/TransactionProcessor.SettlementAggregates.csproj b/TransactionProcessor.SettlementAggregates/TransactionProcessor.SettlementAggregates.csproj index ecd80173..50c08e39 100644 --- a/TransactionProcessor.SettlementAggregates/TransactionProcessor.SettlementAggregates.csproj +++ b/TransactionProcessor.SettlementAggregates/TransactionProcessor.SettlementAggregates.csproj @@ -6,7 +6,7 @@ - + diff --git a/TransactionProcessor.Testing/TestData.cs b/TransactionProcessor.Testing/TestData.cs index 8870b7be..2bbfda49 100644 --- a/TransactionProcessor.Testing/TestData.cs +++ b/TransactionProcessor.Testing/TestData.cs @@ -12,6 +12,7 @@ using EstateManagement.DataTransferObjects.Responses; using Models; using PataPawaPostPay; + using ProjectionEngine.State; using ReconciliationAggregate; using SecurityService.DataTransferObjects.Responses; using SettlementAggregates; @@ -1001,8 +1002,13 @@ public static TokenResponse TokenResponse() new Dictionary() }; + public static MerchantBalanceState MerchantBalanceProjectionState => + new MerchantBalanceState { + AvailableBalance = TestData.AvailableBalance, + }; + public static ResendTransactionReceiptRequest ResendTransactionReceiptRequest => ResendTransactionReceiptRequest.Create(TestData.TransactionId, - TestData.EstateId); + TestData.EstateId); public static List ContractProductTransactionFees => new List diff --git a/TransactionProcessor.Transaction.DomainEvents/TransactionProcessor.Transaction.DomainEvents.csproj b/TransactionProcessor.Transaction.DomainEvents/TransactionProcessor.Transaction.DomainEvents.csproj index d62ed1ef..f8fdd2d1 100644 --- a/TransactionProcessor.Transaction.DomainEvents/TransactionProcessor.Transaction.DomainEvents.csproj +++ b/TransactionProcessor.Transaction.DomainEvents/TransactionProcessor.Transaction.DomainEvents.csproj @@ -5,7 +5,7 @@ - + diff --git a/TransactionProcessor.TransactionAgrgegate/TransactionProcessor.TransactionAggregate.csproj b/TransactionProcessor.TransactionAgrgegate/TransactionProcessor.TransactionAggregate.csproj index f62f2945..9209be64 100644 --- a/TransactionProcessor.TransactionAgrgegate/TransactionProcessor.TransactionAggregate.csproj +++ b/TransactionProcessor.TransactionAgrgegate/TransactionProcessor.TransactionAggregate.csproj @@ -6,7 +6,7 @@ - + diff --git a/TransactionProcessor.sln b/TransactionProcessor.sln index 0a65323c..dd2d0658 100644 --- a/TransactionProcessor.sln +++ b/TransactionProcessor.sln @@ -37,11 +37,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TransactionProcessor.Reconc EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TransactionProcessor.ReconciliationAggregate.Tests", "TransactionProcessor.ReconciliationAggregate.Tests\TransactionProcessor.ReconciliationAggregate.Tests.csproj", "{E71A7E6D-B320-40A1-BF86-C2857B472313}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessor.SettlementAggregates", "TransactionProcessor.SettlementAggregates\TransactionProcessor.SettlementAggregates.csproj", "{2FF2AE7B-21F8-4EEF-9B47-3FCDAA39885B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TransactionProcessor.SettlementAggregates", "TransactionProcessor.SettlementAggregates\TransactionProcessor.SettlementAggregates.csproj", "{2FF2AE7B-21F8-4EEF-9B47-3FCDAA39885B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessor.SettlementAggregates.Tests", "TransactionProcessor.SettlementAggregates.Tests\TransactionProcessor.SettlementAggregates.Tests.csproj", "{1FF2753E-6F90-400D-9A98-E5FE3F79518E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TransactionProcessor.SettlementAggregates.Tests", "TransactionProcessor.SettlementAggregates.Tests\TransactionProcessor.SettlementAggregates.Tests.csproj", "{1FF2753E-6F90-400D-9A98-E5FE3F79518E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessor.Settlement.DomainEvents", "TransactionProcessor.Settlement.DomainEvents\TransactionProcessor.Settlement.DomainEvents.csproj", "{1D6CF3B6-41D3-46B8-BD6B-03FD32483763}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TransactionProcessor.Settlement.DomainEvents", "TransactionProcessor.Settlement.DomainEvents\TransactionProcessor.Settlement.DomainEvents.csproj", "{1D6CF3B6-41D3-46B8-BD6B-03FD32483763}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessor.ProjectionEngine", "TransactionProcessor.ProjectionEngine\TransactionProcessor.ProjectionEngine.csproj", "{6AE953A8-6400-456B-858A-454F78A28436}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessor.ProjectionEngine.Tests", "TransactionProcessor.ProjectionEngine.Tests\TransactionProcessor.ProjectionEngine.Tests.csproj", "{7E770B14-0BF6-40B7-A36C-7AF403CE3C60}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -121,6 +125,14 @@ Global {1D6CF3B6-41D3-46B8-BD6B-03FD32483763}.Debug|Any CPU.Build.0 = Debug|Any CPU {1D6CF3B6-41D3-46B8-BD6B-03FD32483763}.Release|Any CPU.ActiveCfg = Release|Any CPU {1D6CF3B6-41D3-46B8-BD6B-03FD32483763}.Release|Any CPU.Build.0 = Release|Any CPU + {6AE953A8-6400-456B-858A-454F78A28436}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AE953A8-6400-456B-858A-454F78A28436}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AE953A8-6400-456B-858A-454F78A28436}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AE953A8-6400-456B-858A-454F78A28436}.Release|Any CPU.Build.0 = Release|Any CPU + {7E770B14-0BF6-40B7-A36C-7AF403CE3C60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E770B14-0BF6-40B7-A36C-7AF403CE3C60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E770B14-0BF6-40B7-A36C-7AF403CE3C60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E770B14-0BF6-40B7-A36C-7AF403CE3C60}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -144,6 +156,8 @@ Global {2FF2AE7B-21F8-4EEF-9B47-3FCDAA39885B} = {749ADE74-A6F0-4469-A638-8FD7E82A8667} {1FF2753E-6F90-400D-9A98-E5FE3F79518E} = {71B30DC4-AB27-4D30-8481-B4C326D074CB} {1D6CF3B6-41D3-46B8-BD6B-03FD32483763} = {749ADE74-A6F0-4469-A638-8FD7E82A8667} + {6AE953A8-6400-456B-858A-454F78A28436} = {749ADE74-A6F0-4469-A638-8FD7E82A8667} + {7E770B14-0BF6-40B7-A36C-7AF403CE3C60} = {71B30DC4-AB27-4D30-8481-B4C326D074CB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {193D13DE-424B-4D50-B674-01F9E4CC2CA9} diff --git a/TransactionProcessor/Bootstrapper/DomainEventHandlerRegistry.cs b/TransactionProcessor/Bootstrapper/DomainEventHandlerRegistry.cs index 098c2739..2575689c 100644 --- a/TransactionProcessor/Bootstrapper/DomainEventHandlerRegistry.cs +++ b/TransactionProcessor/Bootstrapper/DomainEventHandlerRegistry.cs @@ -7,6 +7,14 @@ using Lamar; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; + using ProjectionEngine; + using ProjectionEngine.Database; + using ProjectionEngine.Dispatchers; + using ProjectionEngine.EventHandling; + using ProjectionEngine.ProjectionHandler; + using ProjectionEngine.Projections; + using ProjectionEngine.State; + using Shared.EntityFramework; using Shared.EventStore.EventHandling; /// @@ -24,6 +32,7 @@ public class DomainEventHandlerRegistry : ServiceRegistry public DomainEventHandlerRegistry() { Dictionary eventHandlersConfiguration = new Dictionary(); + Dictionary eventHandlersConfigurationOrdered = new Dictionary(); if (Startup.Configuration != null) { @@ -33,9 +42,19 @@ public DomainEventHandlerRegistry() { Startup.Configuration.GetSection("AppSettings:EventHandlerConfiguration").Bind(eventHandlersConfiguration); } - } - this.AddSingleton(eventHandlersConfiguration); + //this.AddSingleton(eventHandlersConfiguration); + this.Use(eventHandlersConfiguration).Named("Concurrent"); + + section = Startup.Configuration.GetSection("AppSettings:EventHandlerConfigurationOrdered"); + + if (section != null) + { + Startup.Configuration.GetSection("AppSettings:EventHandlerConfigurationOrdered").Bind(eventHandlersConfigurationOrdered); + } + + this.Use(eventHandlersConfigurationOrdered).Named("Ordered"); + } this.AddSingleton>(container => type => { @@ -43,8 +62,22 @@ public DomainEventHandlerRegistry() return handler; }); + this.AddSingleton>(container => type => { + return container.GetService>(); + }); + + this.AddSingleton(); this.AddSingleton(); - this.AddSingleton(); + this.AddSingleton>(); + + this.AddSingleton>(); + this.AddSingleton, MerchantBalanceProjection>(); + this.AddSingleton, MerchantBalanceStateDispatcher>(); + + this.For().Use().Named("Concurrent") + .Ctor>().Is(eventHandlersConfiguration).Singleton(); + this.For().Use().Named("Ordered") + .Ctor>().Is(eventHandlersConfigurationOrdered).Singleton(); } #endregion diff --git a/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs b/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs index 299011df..97cd2bf2 100644 --- a/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs +++ b/TransactionProcessor/Bootstrapper/RepositoryRegistry.cs @@ -1,16 +1,29 @@ namespace TransactionProcessor.Bootstrapper { using System; + using System.Data.Common; using System.Diagnostics.CodeAnalysis; using System.Net.Http; using System.Net.Security; + using System.Threading.Tasks; + using System.Threading; using BusinessLogic.Services; using Lamar; + using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; + using MySqlConnector; + using ProjectionEngine; + using ProjectionEngine.Database; + using ProjectionEngine.Database.Entities; + using ProjectionEngine.Dispatchers; + using ProjectionEngine.Projections; + using ProjectionEngine.Repository; + using ProjectionEngine.State; using ReconciliationAggregate; using SettlementAggregates; using Shared.DomainDrivenDesign.EventSourcing; + using Shared.EntityFramework; using Shared.EntityFramework.ConnectionStringConfiguration; using Shared.EventStore.Aggregate; using Shared.EventStore.EventStore; @@ -18,6 +31,7 @@ using Shared.General; using Shared.Repositories; using TransactionAggregate; + using ConnectionStringType = Shared.Repositories.ConnectionStringType; /// /// @@ -71,6 +85,8 @@ public RepositoryRegistry() { this.AddEventStoreClient(Startup.EventStoreClientSettings.ConnectivitySettings.Address, CreateHttpMessageHandler); } + + this.AddSingleton(); } this.AddTransient(); @@ -82,6 +98,110 @@ public RepositoryRegistry() AggregateRepository>(); this.AddSingleton, AggregateRepository>(); + + + this.AddSingleton, MerchantBalanceStateRepository>(); + this.AddSingleton(); + this.AddSingleton, MerchantBalanceProjection>(); + + this.AddSingleton, DbContextFactory>(); + + this.AddSingleton>(cont => connectionString => + { + String databaseEngine = + ConfigurationReader.GetValue("AppSettings", "DatabaseEngine"); + + return databaseEngine switch + { + "MySql" => new TransactionProcessorMySqlContext(connectionString), + "SqlServer" => new TransactionProcessorSqlServerContext(connectionString), + _ => throw new + NotSupportedException($"Unsupported Database Engine {databaseEngine}") + }; + }); + } + + #endregion + } + + [ExcludeFromCodeCoverage] + public class ConfigurationReaderConnectionStringRepository : IConnectionStringConfigurationRepository + { + #region Methods + + /// + /// Creates the connection string. + /// + /// The external identifier. + /// Type of the connection string. + /// The connection string. + /// The cancellation token. + public async Task CreateConnectionString(String externalIdentifier, + ConnectionStringType connectionStringType, + String connectionString, + CancellationToken cancellationToken) + { + throw new NotImplementedException("This is only required to complete the interface"); + } + + /// + /// Deletes the connection string configuration. + /// + /// The external identifier. + /// Type of the connection string. + /// The cancellation token. + public async Task DeleteConnectionStringConfiguration(String externalIdentifier, + ConnectionStringType connectionStringType, + CancellationToken cancellationToken) + { + throw new NotImplementedException("This is only required to complete the interface"); + } + + /// + /// Gets the connection string. + /// + /// The external identifier. + /// Type of the connection string. + /// The cancellation token. + /// + public async Task GetConnectionString(String externalIdentifier, + ConnectionStringType connectionStringType, + CancellationToken cancellationToken) + { + String connectionString = string.Empty; + String databaseName = string.Empty; + + String databaseEngine = ConfigurationReader.GetValue("AppSettings", "DatabaseEngine"); + + switch (connectionStringType) + { + case ConnectionStringType.ReadModel: + databaseName = "TransactionProcessorReadModel" + externalIdentifier; + connectionString = ConfigurationReader.GetConnectionString("TransactionProcessorReadModel"); + break; + default: + throw new NotSupportedException($"Connection String type [{connectionStringType}] is not supported"); + } + + DbConnectionStringBuilder builder = null; + + if (databaseEngine == "MySql") + { + builder = new MySqlConnectionStringBuilder(connectionString) + { + Database = databaseName + }; + } + else + { + // Default to SQL Server + builder = new SqlConnectionStringBuilder(connectionString) + { + InitialCatalog = databaseName + }; + } + + return builder.ToString(); } #endregion diff --git a/TransactionProcessor/Controllers/MerchantController.cs b/TransactionProcessor/Controllers/MerchantController.cs new file mode 100644 index 00000000..0024b762 --- /dev/null +++ b/TransactionProcessor/Controllers/MerchantController.cs @@ -0,0 +1,80 @@ +namespace TransactionProcessor.Controllers; + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Common; +using DataTransferObjects; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ProjectionEngine.Repository; +using ProjectionEngine.State; +using Shared.Exceptions; +using Swashbuckle.AspNetCore.Annotations; + +[ExcludeFromCodeCoverage] +[Route(MerchantController.ControllerRoute)] +[ApiController] +[Authorize] +public class MerchantController : ControllerBase +{ + private readonly IProjectionStateRepository MerchantBalanceStateRepository; + + public MerchantController(IProjectionStateRepository merchantBalanceStateRepository) { + this.MerchantBalanceStateRepository = merchantBalanceStateRepository; + } + + #region Others + + /// + /// The controller name + /// + public const String ControllerName = "merchants"; + + /// + /// The controller route + /// + private const String ControllerRoute = "api/estates/{estateId}/" + MerchantController.ControllerName; + + #endregion + + /// + /// Gets the merchant balance. + /// + /// The estate identifier. + /// The merchant identifier. + /// The cancellation token. + /// + /// Merchant Balance details not found with estate Id {estateId} and merchant Id {merchantId} + /// Merchant Balance details not found with estate Id {estateId} and merchant Id {merchantId} + [HttpGet] + [Route("{merchantId}/balance")] + [SwaggerResponse(200, "Created", typeof(MerchantBalanceResponse))] + public async Task GetMerchantBalance([FromRoute] Guid estateId, + [FromRoute] Guid merchantId, + CancellationToken cancellationToken) + { + // Reject password tokens + if (ClaimsHelper.IsPasswordToken(this.User)) + { + return this.Forbid(); + } + + MerchantBalanceState merchantBalance = await this.MerchantBalanceStateRepository.Load(estateId, merchantId, cancellationToken); + + if (merchantBalance == null) + { + throw new NotFoundException($"Merchant Balance details not found with estate Id {estateId} and merchant Id {merchantId}"); + } + + MerchantBalanceResponse response= new MerchantBalanceResponse { + Balance = merchantBalance.Balance, + MerchantId = merchantId, + AvailableBalance = merchantBalance.AvailableBalance, + EstateId = estateId + }; + + return this.Ok(response); + } +} \ No newline at end of file diff --git a/TransactionProcessor/Controllers/TransactionController.cs b/TransactionProcessor/Controllers/TransactionController.cs index 03dfbcdb..2a6ddb96 100644 --- a/TransactionProcessor/Controllers/TransactionController.cs +++ b/TransactionProcessor/Controllers/TransactionController.cs @@ -4,12 +4,14 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Net; + using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using BusinessLogic.Requests; using Common; using Common.Examples; using DataTransferObjects; + using EstateManagement.DataTransferObjects.Responses; using Factories; using MediatR; using Microsoft.AspNetCore.Authorization; diff --git a/TransactionProcessor/Extensions.cs b/TransactionProcessor/Extensions.cs index ea679e4f..e9e46816 100644 --- a/TransactionProcessor/Extensions.cs +++ b/TransactionProcessor/Extensions.cs @@ -8,8 +8,10 @@ namespace TransactionProcessor using System.Threading; using EventStore.Client; using Microsoft.AspNetCore.Builder; + using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Editing; using Microsoft.Extensions.Caching.Memory; + using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Shared.EventStore.EventHandling; @@ -55,68 +57,117 @@ public static IServiceCollection AddInSecureEventStoreClient(this IServiceCollec }; static Action concurrentLog = (tt, message) => Extensions.log(tt, "CONCURRENT", message); + static Action orderedLog = (tt, message) => Extensions.log(tt, "ORDERED", message); - public static void PreWarm(this IApplicationBuilder applicationBuilder) - { + public static void PreWarm(this IApplicationBuilder applicationBuilder) { Startup.LoadTypes(); - //SubscriptionWorker worker = new SubscriptionWorker() - var internalSubscriptionService = Boolean.Parse(ConfigurationReader.GetValue("InternalSubscriptionService")); + Boolean internalSubscriptionService = Boolean.Parse(ConfigurationReader.GetValue("InternalSubscriptionService")); + + if (internalSubscriptionService) { + IConfigurationSection subscriptionConfigSection = Startup.Configuration.GetSection("AppSettings:SubscriptionConfig"); + SubscriptionConfigRoot subscriptionConfigRoot = new SubscriptionConfigRoot(); + subscriptionConfigSection.Bind(subscriptionConfigRoot); - if (internalSubscriptionService) - { String eventStoreConnectionString = ConfigurationReader.GetValue("EventStoreSettings", "ConnectionString"); - Int32 inflightMessages = Int32.Parse(ConfigurationReader.GetValue("AppSettings", "InflightMessages")); - Int32 persistentSubscriptionPollingInSeconds = Int32.Parse(ConfigurationReader.GetValue("AppSettings", "PersistentSubscriptionPollingInSeconds")); - String filter = ConfigurationReader.GetValue("AppSettings", "InternalSubscriptionServiceFilter"); - String ignore = ConfigurationReader.GetValue("AppSettings", "InternalSubscriptionServiceIgnore"); - String streamName = ConfigurationReader.GetValue("AppSettings", "InternalSubscriptionFilterOnStreamName"); Int32 cacheDuration = Int32.Parse(ConfigurationReader.GetValue("AppSettings", "InternalSubscriptionServiceCacheDuration")); ISubscriptionRepository subscriptionRepository = SubscriptionRepository.Create(eventStoreConnectionString, cacheDuration); + ((SubscriptionRepository)subscriptionRepository).Trace += (sender, + s) => Extensions.log(TraceEventType.Information, "REPOSITORY", s); - ((SubscriptionRepository)subscriptionRepository).Trace += (sender, s) => Extensions.log(TraceEventType.Information, "REPOSITORY", s); - // init our SubscriptionRepository subscriptionRepository.PreWarm(CancellationToken.None).Wait(); - var eventHandlerResolver = Startup.ServiceProvider.GetService(); - - SubscriptionWorker concurrentSubscriptions = SubscriptionWorker.CreateConcurrentSubscriptionWorker(Startup.EventStoreClientSettings, eventHandlerResolver, subscriptionRepository, inflightMessages, persistentSubscriptionPollingInSeconds); + if (subscriptionConfigRoot.Concurrent.IsEnabled) { + SubscriptionWorker concurrentSubscriptions = ConfigureConcurrentSubscriptions(subscriptionRepository, subscriptionConfigRoot.Concurrent); + concurrentSubscriptions.StartAsync(CancellationToken.None).Wait(); + } - concurrentSubscriptions.Trace += (_, args) => Extensions.concurrentLog(TraceEventType.Information, args.Message); - concurrentSubscriptions.Warning += (_, args) => Extensions.concurrentLog(TraceEventType.Warning, args.Message); - concurrentSubscriptions.Error += (_, args) => Extensions.concurrentLog(TraceEventType.Error, args.Message); + if (subscriptionConfigRoot.Ordered.IsEnabled) { + SubscriptionWorker orderedSubscriptions = ConfigureOrderedSubscriptions(subscriptionRepository, subscriptionConfigRoot.Ordered); + orderedSubscriptions.StartAsync(CancellationToken.None).Wait(); + } + } - if (!String.IsNullOrEmpty(ignore)) - { - concurrentSubscriptions = concurrentSubscriptions.IgnoreSubscriptions(ignore); + if (Startup.AutoApiLogonOperators.Any()) { + foreach (String autoApiLogonOperator in Startup.AutoApiLogonOperators) { + OperatorLogon(autoApiLogonOperator); } + } + } - if (!String.IsNullOrEmpty(filter)) - { - //NOTE: Not overly happy with this design, but; - //the idea is if we supply a filter, this overrides ignore - concurrentSubscriptions = concurrentSubscriptions.FilterSubscriptions(filter) - .IgnoreSubscriptions(null); + private static SubscriptionWorker ConfigureConcurrentSubscriptions(ISubscriptionRepository subscriptionRepository, SubscriptionConfig concurrent) { + IDomainEventHandlerResolver eventHandlerResolver = Startup.Container.GetInstance("Concurrent"); - } + Int32 inflightMessages = Int32.Parse(ConfigurationReader.GetValue("AppSettings", "InflightMessages")); + Int32 persistentSubscriptionPollingInSeconds = Int32.Parse(ConfigurationReader.GetValue("AppSettings", "PersistentSubscriptionPollingInSeconds")); - if (!String.IsNullOrEmpty(streamName)) - { - concurrentSubscriptions = concurrentSubscriptions.FilterByStreamName(streamName); - } + SubscriptionWorker concurrentSubscriptions = SubscriptionWorker.CreateConcurrentSubscriptionWorker(Startup.EventStoreClientSettings, eventHandlerResolver, subscriptionRepository, + inflightMessages, persistentSubscriptionPollingInSeconds); + + concurrentSubscriptions.Trace += (_, args) => Extensions.concurrentLog(TraceEventType.Information, args.Message); + concurrentSubscriptions.Warning += (_, args) => Extensions.concurrentLog(TraceEventType.Warning, args.Message); + concurrentSubscriptions.Error += (_, args) => Extensions.concurrentLog(TraceEventType.Error, args.Message); - concurrentSubscriptions.StartAsync(CancellationToken.None).Wait(); + if (!String.IsNullOrEmpty(concurrent.Ignore)) + { + concurrentSubscriptions = concurrentSubscriptions.IgnoreSubscriptions(concurrent.Ignore); } - if (Startup.AutoApiLogonOperators.Any()) + if (!String.IsNullOrEmpty(concurrent.Filter)) { - foreach (String autoApiLogonOperator in Startup.AutoApiLogonOperators) - { - OperatorLogon(autoApiLogonOperator); - } + //NOTE: Not overly happy with this design, but; + //the idea is if we supply a filter, this overrides ignore + concurrentSubscriptions = concurrentSubscriptions.FilterSubscriptions(concurrent.Filter); + //.IgnoreSubscriptions(null); + + } + + if (!String.IsNullOrEmpty(concurrent.StreamName)) + { + concurrentSubscriptions = concurrentSubscriptions.FilterByStreamName(concurrent.StreamName); + } + + return concurrentSubscriptions; + } + + private static SubscriptionWorker ConfigureOrderedSubscriptions(ISubscriptionRepository subscriptionRepository, SubscriptionConfig ordered) + { + IDomainEventHandlerResolver eventHandlerResolver = Startup.Container.GetInstance("Ordered"); + + Int32 persistentSubscriptionPollingInSeconds = Int32.Parse(ConfigurationReader.GetValue("AppSettings", "PersistentSubscriptionPollingInSeconds")); + + SubscriptionWorker orderedSubscriptions = + SubscriptionWorker.CreateOrderedSubscriptionWorker(Startup.EventStoreClientSettings, + eventHandlerResolver, + subscriptionRepository, + persistentSubscriptionPollingInSeconds); + + orderedSubscriptions.Trace += (_, args) => Extensions.orderedLog(TraceEventType.Information, args.Message); + orderedSubscriptions.Warning += (_, args) => Extensions.orderedLog(TraceEventType.Warning, args.Message); + orderedSubscriptions.Error += (_, args) => Extensions.orderedLog(TraceEventType.Error, args.Message); + + if (!String.IsNullOrEmpty(ordered.Ignore)) + { + orderedSubscriptions = orderedSubscriptions.IgnoreSubscriptions(ordered.Ignore); } + + if (!String.IsNullOrEmpty(ordered.Filter)) + { + //NOTE: Not overly happy with this design, but; + //the idea is if we supply a filter, this overrides ignore + orderedSubscriptions = orderedSubscriptions.FilterSubscriptions(ordered.Filter) + .IgnoreSubscriptions(null); + + } + + if (!String.IsNullOrEmpty(ordered.StreamName)) + { + orderedSubscriptions = orderedSubscriptions.FilterByStreamName(ordered.StreamName); + } + + return orderedSubscriptions; } private static void OperatorLogon(String operatorId) @@ -130,4 +181,17 @@ private static void OperatorLogon(String operatorId) Logger.LogInformation($"Auto logon for operator Id [{operatorId}] status [{logonResult.IsSuccessful}]"); } } + + public class SubscriptionConfigRoot + { + public SubscriptionConfig Ordered { get; set; } + public SubscriptionConfig Concurrent { get; set; } + } + + public class SubscriptionConfig{ + public Boolean IsEnabled { get; set; } + public String Filter { get; set; } + public String Ignore { get; set; } + public String StreamName { get; set; } + } } \ No newline at end of file diff --git a/TransactionProcessor/Startup.cs b/TransactionProcessor/Startup.cs index 5933eaf3..8e520f6d 100644 --- a/TransactionProcessor/Startup.cs +++ b/TransactionProcessor/Startup.cs @@ -6,9 +6,14 @@ namespace TransactionProcessor using System.IO; using System.Linq; using System.Net.Http; + using System.Runtime.InteropServices.ComTypes; using System.Threading; using Bootstrapper; + using EstateManagement.Estate.DomainEvents; + using EstateManagement.Merchant.DomainEvents; using EventStore.Client; + using FileProcessor.File.DomainEvents; + using FileProcessor.FileImportLog.DomainEvents; using HealthChecks.UI.Client; using Lamar; using Microsoft.AspNetCore.Builder; @@ -21,14 +26,19 @@ namespace TransactionProcessor using Microsoft.Extensions.Logging; using NLog.Extensions.Logging; using NuGet.Protocol; + using ProjectionEngine.EventHandling; + using ProjectionEngine.State; using Reconciliation.DomainEvents; using Settlement.DomainEvents; + using Shared.DomainDrivenDesign.EventSourcing; using Shared.EventStore.Aggregate; + using Shared.EventStore.EventHandling; using Shared.Extensions; using Shared.General; using Shared.Logger; using Transaction.DomainEvents; using TransactionProcessor.BusinessLogic.OperatorInterfaces; + using EventHandler = ProjectionEngine.EventHandling.EventHandler; using ILogger = Microsoft.Extensions.Logging.ILogger; /// @@ -189,6 +199,19 @@ public static void LoadTypes() { ReconciliationHasStartedEvent r = new ReconciliationHasStartedEvent(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), DateTime.Now); + EstateCreatedEvent c = new EstateCreatedEvent(Guid.NewGuid(), ""); + MerchantCreatedEvent m = new MerchantCreatedEvent(Guid.NewGuid(), Guid.NewGuid(), "", DateTime.Now); + FileCreatedEvent f = new FileCreatedEvent(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "", DateTime.Now); + FileAddedToImportLogEvent fi = new FileAddedToImportLogEvent(Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + "", + "", + DateTime.Now); + TypeProvider.LoadDomainEventsTypeDynamically(); } diff --git a/TransactionProcessor/TransactionProcessor.csproj b/TransactionProcessor/TransactionProcessor.csproj index 42f5f24d..6316cecf 100644 --- a/TransactionProcessor/TransactionProcessor.csproj +++ b/TransactionProcessor/TransactionProcessor.csproj @@ -34,8 +34,8 @@ - - + + @@ -47,6 +47,7 @@ + diff --git a/TransactionProcessor/appsettings.json b/TransactionProcessor/appsettings.json index cbd7a3dc..cb7e2daf 100644 --- a/TransactionProcessor/appsettings.json +++ b/TransactionProcessor/appsettings.json @@ -1,6 +1,22 @@ { "AppSettings": { "InternalSubscriptionServiceFilter": "Transaction Processor", + "SubscriptionConfig": { + "Ordered": { + "Filter": "Transaction Processor", + "Ignore": "", + "StreamName": "", + "IsEnabled": true + }, + "Concurrent": { + "Filter": "Transaction Processor", + "Ignore": "Ordered", + "StreamName": "", + //"InflightMessages": 1, + "IsEnabled": true + } + }, + "ClientId": "serviceClient", "ClientSecret": "d192cbc46d834d0da90e8a9d50ded543", //"SecurityService": "https://127.0.0.1:5001", @@ -14,10 +30,52 @@ "MerchantFeeAddedToTransactionEvent": [ "TransactionProcessor.BusinessLogic.EventHandling.TransactionDomainEventHandler,TransactionProcessor.BusinessLogic" ] + }, + "EventHandlerConfigurationOrdered": { + "EstateCreatedEvent": [ + "TransactionProcessor.ProjectionEngine.EventHandling.EventHandler,TransactionProcessor.ProjectionEngine" + ], + "MerchantCreatedEvent": [ + "TransactionProcessor.ProjectionEngine.EventHandling.EventHandler,TransactionProcessor.ProjectionEngine" + ], + "ManualDepositMadeEvent": [ + "TransactionProcessor.ProjectionEngine.EventHandling.EventHandler,TransactionProcessor.ProjectionEngine" + ], + "AutomaticDepositMadeEvent": [ + "TransactionProcessor.ProjectionEngine.EventHandling.EventHandler,TransactionProcessor.ProjectionEngine" + ], + "TransactionHasStartedEvent": [ + "TransactionProcessor.ProjectionEngine.EventHandling.EventHandler,TransactionProcessor.ProjectionEngine" + ], + "TransactionHasBeenCompletedEvent": [ + "TransactionProcessor.ProjectionEngine.EventHandling.EventHandler,TransactionProcessor.ProjectionEngine" + ], + "CustomerEmailReceiptRequestedEvent": [ + "TransactionProcessor.BusinessLogic.EventHandling.TransactionDomainEventHandler,TransactionProcessor.BusinessLogic" + ], + "MerchantFeeAddedToTransactionEvent": [ + "TransactionProcessor.ProjectionEngine.EventHandling.EventHandler,TransactionProcessor.ProjectionEngine" + ] + }, + + "EventStateConfig": { + "EstateCreatedEvent": "MerchantBalanceProjectionState", + "MerchantCreatedEvent": "MerchantBalanceProjectionState", + "ManualDepositMadeEvent": "MerchantBalanceProjectionState", + "AutomaticDepositMadeEvent": "MerchantBalanceProjectionState", + "TransactionHasStartedEvent": "MerchantBalanceProjectionState", + "TransactionHasBeenCompletedEvent": "MerchantBalanceProjectionState", + "MerchantFeeAddedToTransactionEvent": "MerchantBalanceProjectionState" } }, + "ConnectionStrings": { + // SQL Server + "TransactionProcessorReadModel": "server=192.168.1.133;user id=sa;password=Sc0tland;database=TransactionProcessorReadModel" + // MySql + //"TransactionProcessorReadModel": "server=127.0.0.1;userid=root;password=sp1ttal;database=TransactionProcessorReadModel;" + }, "SecurityConfiguration": { - "ApiName": "transactionProcessor", + "ApiName": "transactionProcessor" //"Authority": "https://127.0.0.1:5001" }, "OperatorConfiguration": { @@ -34,6 +92,6 @@ "Username": "testuser1", "Password": "password1", "ApiLogonRequired": true - } + } } } \ No newline at end of file