diff --git a/TransactionProcessor.BusinessLogic.Tests/OperatorInterfaces/AgencyBankingProxyTests.cs b/TransactionProcessor.BusinessLogic.Tests/OperatorInterfaces/AgencyBankingProxyTests.cs index 66b45259..bd66d9dc 100644 --- a/TransactionProcessor.BusinessLogic.Tests/OperatorInterfaces/AgencyBankingProxyTests.cs +++ b/TransactionProcessor.BusinessLogic.Tests/OperatorInterfaces/AgencyBankingProxyTests.cs @@ -6,6 +6,7 @@ using SimpleResults; using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Text.Json; @@ -531,6 +532,123 @@ public async Task AgencyBankingProxy_ProcessSaleMessage_WithdrawalSuccess_WithMi requestJson.RootElement.GetProperty("transactionId").GetString().ShouldBe(TestData.TransactionId.ToString()); } + [Fact] + public async Task AgencyBankingProxy_ProcessSaleMessage_MiniStatementFailure_ReturnsInvalidResult() + { + HttpResponseMessage responseMessage = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("mini statement rejected") + }; + + HttpRequestMessage capturedRequest = null; + HttpClient httpClient = SetupMockHttpClient(responseMessage, request => capturedRequest = request); + AgencyBankingConfiguration configuration = CreateConfiguration(); + IOperatorProxy proxy = new AgencyBankingProxy(httpClient, configuration); + + Dictionary metadata = new Dictionary + { + { "AgencyBankingMessageType", "ministatement" }, + { "AgencyBankingAccountNumber", "123456789" } + }; + + var result = await proxy.ProcessSaleMessage(TestData.TransactionId, + TestData.OperatorId, + TestData.Merchant, + TestData.TransactionDateTime, + TestData.TransactionReference, + metadata, + CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + result.Status.ShouldBe(ResultStatus.Invalid); + result.Message.ShouldBe("Failed to process mini-statement mini statement rejected"); + + capturedRequest.ShouldNotBeNull(); + capturedRequest.Method.ShouldBe(HttpMethod.Post); + capturedRequest.RequestUri.ShouldBe(new Uri("http://localhost/transactions/mini-statement")); + + String requestBody = await capturedRequest.Content.ReadAsStringAsync(); + using JsonDocument requestJson = JsonDocument.Parse(requestBody); + requestJson.RootElement.GetProperty("agentId").GetString().ShouldBe(TestData.Merchant.MerchantId.ToString()); + requestJson.RootElement.GetProperty("accountNumber").GetString().ShouldBe("123456789"); + requestJson.RootElement.GetProperty("count").GetInt32().ShouldBe(5); + } + + [Fact] + public async Task AgencyBankingProxy_ProcessSaleMessage_MiniStatementSuccess_ReturnsMappedOperatorResponse() + { + HttpResponseMessage responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "responseCode": "0000", + "responseMessage": "Mini statement successful", + "transactions": [ + { + "transactionDate": "2026-07-21T00:00:00", + "transactionType": "D", + "amount": 25.00 + }, + { + "transactionDate": "2026-07-21T00:01:00", + "transactionType": "C", + "amount": 10.50 + } + ] + } + """) + }; + + HttpRequestMessage capturedRequest = null; + HttpClient httpClient = SetupMockHttpClient(responseMessage, request => capturedRequest = request); + AgencyBankingConfiguration configuration = CreateConfiguration(); + IOperatorProxy proxy = new AgencyBankingProxy(httpClient, configuration); + + Dictionary metadata = new Dictionary + { + { "agencybankingmessagetype", "ministatement" }, + { "agencybankingaccountnumber", "123456789" } + }; + + var result = await proxy.ProcessSaleMessage(TestData.TransactionId, + TestData.OperatorId, + TestData.Merchant, + TestData.TransactionDateTime, + TestData.TransactionReference, + metadata, + CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Data.ShouldNotBeNull(); + result.Data.IsSuccessful.ShouldBeTrue(); + result.Data.ResponseCode.ShouldBe("0000"); + result.Data.ResponseMessage.ShouldBe("SUCCESS"); + result.Data.AdditionalTransactionResponseMetadata.ShouldContainKey("StatementLines"); + + String statementLinesJson = result.Data.AdditionalTransactionResponseMetadata["StatementLines"]; + using JsonDocument statementLinesDocument = JsonDocument.Parse(statementLinesJson); + statementLinesDocument.RootElement.ValueKind.ShouldBe(JsonValueKind.Array); + statementLinesDocument.RootElement.GetArrayLength().ShouldBe(2); + + JsonElement firstLine = statementLinesDocument.RootElement[0]; + firstLine.EnumerateObject().Any(property => property.Value.ValueKind == JsonValueKind.String && property.Value.GetString() == "D").ShouldBeTrue(); + firstLine.EnumerateObject().Any(property => property.Value.ValueKind == JsonValueKind.Number && property.Value.GetDecimal() == 25.00m).ShouldBeTrue(); + + JsonElement secondLine = statementLinesDocument.RootElement[1]; + secondLine.EnumerateObject().Any(property => property.Value.ValueKind == JsonValueKind.String && property.Value.GetString() == "C").ShouldBeTrue(); + secondLine.EnumerateObject().Any(property => property.Value.ValueKind == JsonValueKind.Number && property.Value.GetDecimal() == 10.50m).ShouldBeTrue(); + + capturedRequest.ShouldNotBeNull(); + capturedRequest.Method.ShouldBe(HttpMethod.Post); + capturedRequest.RequestUri.ShouldBe(new Uri("http://localhost/transactions/mini-statement")); + + String requestBody = await capturedRequest.Content.ReadAsStringAsync(); + using JsonDocument requestJson = JsonDocument.Parse(requestBody); + requestJson.RootElement.GetProperty("agentId").GetString().ShouldBe(TestData.Merchant.MerchantId.ToString()); + requestJson.RootElement.GetProperty("accountNumber").GetString().ShouldBe("123456789"); + requestJson.RootElement.GetProperty("count").GetInt32().ShouldBe(5); + } + private static AgencyBankingConfiguration CreateConfiguration() { return new AgencyBankingConfiguration diff --git a/TransactionProcessor.BusinessLogic/OperatorInterfaces/AgencyBanking/AgencyBankingProxy.cs b/TransactionProcessor.BusinessLogic/OperatorInterfaces/AgencyBanking/AgencyBankingProxy.cs index 73d4591e..0cd85b00 100644 --- a/TransactionProcessor.BusinessLogic/OperatorInterfaces/AgencyBanking/AgencyBankingProxy.cs +++ b/TransactionProcessor.BusinessLogic/OperatorInterfaces/AgencyBanking/AgencyBankingProxy.cs @@ -3,6 +3,7 @@ using SimpleResults; using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Text; using System.Threading; @@ -66,10 +67,10 @@ public async Task> ProcessSaleMessage(Guid transactionI "balanceenquiry" => await ProcessBalanceEnquiry(transactionId, merchant.MerchantId.ToString(), accountNumber, cancellationToken), "deposit" => await ProcessDeposit(transactionId, merchant.MerchantId.ToString(), accountNumber, additionalTransactionMetadata, cancellationToken), "withdrawal" => await ProcessWithdrawal(transactionId, merchant.MerchantId.ToString(), accountNumber, additionalTransactionMetadata, cancellationToken), + "ministatement" => await ProcessMiniStatement(merchant.MerchantId.ToString(), accountNumber, cancellationToken), _ => Result.Invalid($"AgencyBankingMessageType - Message Type {messageType} is not supported for this transaction type") }; - - + if (result.Status == ResultStatus.Invalid) { Logger.LogWarning($"AgencyBanking sale request rejected. TransactionId=[{transactionId}] unsupported message type [{messageType}]"); } @@ -130,6 +131,42 @@ private async Task> ProcessWithdrawal(Guid transactionI return Result.Success(operatorResponse); } + private async Task> ProcessMiniStatement(String agentId, + String accountNumber, + CancellationToken cancellationToken) { + HttpRequestMessage request = new(HttpMethod.Post, $"{this.Configuration.Url}/transactions/mini-statement"); + + MiniStatementRequest body = new() { AgentId = agentId, AccountNumber = accountNumber }; + + String requestBody = StringSerialiser.Serialise(body, SerialiserOptions); + request.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); + + Logger.LogWarning($"AgencyBanking mini-statement request starting. AgentId=[{agentId}], AccountNumber=[{accountNumber}], RequestUri=[{request.RequestUri}], RequestBody=[{requestBody}]"); + + HttpResponseMessage response = await this.HttpClient.SendAsync(request, cancellationToken); + String responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + + Logger.LogWarning($"AgencyBanking mini-statement response received. AgentId=[{agentId}], AccountNumber=[{accountNumber}], StatusCode=[{response.StatusCode}], ContentLength=[{responseContent?.Length ?? 0}]"); + + if (response.IsSuccessStatusCode == false) { + Logger.LogWarning($"AgencyBanking mini-statement failed. AgentId=[{agentId}], AccountNumber=[{accountNumber}], StatusCode=[{response.StatusCode}], ResponseBody=[{responseContent}]"); + return Result.Invalid($"Failed to process mini-statement {responseContent}"); + } + + MiniStatementResponse miniStatementResponse = StringSerialiser.Deserialise(responseContent, SerialiserOptions); + Logger.LogWarning($"AgencyBanking mini-statement completed. AgentId=[{agentId}], AccountNumber=[{accountNumber}], ResponseCode=[{miniStatementResponse.ResponseCode}]"); + + // Transform the mini-statement response into an operator response + List statementLines = miniStatementResponse.Transactions.Select(line => new OperatorStatementLine { Amount = line.Amount, TransactionDate = line.TransactionDate, TransactionType = line.TransactionType }).ToList(); + + + OperatorResponse operatorResponse = new() { IsSuccessful = true, ResponseCode = "0000", ResponseMessage = "SUCCESS", AdditionalTransactionResponseMetadata = new Dictionary() }; + + operatorResponse.AdditionalTransactionResponseMetadata.Add("StatementLines", StringSerialiser.Serialise(statementLines)); + + return Result.Success(operatorResponse); + } + private async Task> ProcessDeposit(Guid transactionId, String agentId, String accountNumber, diff --git a/TransactionProcessor.BusinessLogic/OperatorInterfaces/AgencyBanking/BalanceEnquiryResponse.cs b/TransactionProcessor.BusinessLogic/OperatorInterfaces/AgencyBanking/BalanceEnquiryResponse.cs index 17bce0aa..1d035326 100644 --- a/TransactionProcessor.BusinessLogic/OperatorInterfaces/AgencyBanking/BalanceEnquiryResponse.cs +++ b/TransactionProcessor.BusinessLogic/OperatorInterfaces/AgencyBanking/BalanceEnquiryResponse.cs @@ -63,4 +63,31 @@ public class TransactionResult public string TransactionId { get; set; } = ""; } + + public class MiniStatementRequest + { + public string AgentId { get; set; } + + public string AccountNumber { get; set; } + + public int Count { get; set; } = 5; + } + + public class MiniStatementResponse + { + public string ResponseCode { get; set; } + + public string ResponseMessage { get; set; } + + public List Transactions { get; set; } + } + + public class MiniStatementItem + { + public DateTime TransactionDate { get; set; } + + public string TransactionType { get; set; } + + public decimal Amount { get; set; } + } } diff --git a/TransactionProcessor.BusinessLogic/OperatorInterfaces/OperatorResponse.cs b/TransactionProcessor.BusinessLogic/OperatorInterfaces/OperatorResponse.cs index fac2a70c..2b063f00 100644 --- a/TransactionProcessor.BusinessLogic/OperatorInterfaces/OperatorResponse.cs +++ b/TransactionProcessor.BusinessLogic/OperatorInterfaces/OperatorResponse.cs @@ -26,4 +26,13 @@ public class OperatorResponse #endregion } + + public class OperatorStatementLine + { + public DateTime TransactionDate { get; set; } + + public string TransactionType { get; set; } + + public decimal Amount { get; set; } + } } \ No newline at end of file diff --git a/TransactionProcessor.IntegrationTests/Features/SaleTransactionFeature.feature b/TransactionProcessor.IntegrationTests/Features/SaleTransactionFeature.feature index f4047aad..f9c33005 100644 --- a/TransactionProcessor.IntegrationTests/Features/SaleTransactionFeature.feature +++ b/TransactionProcessor.IntegrationTests/Features/SaleTransactionFeature.feature @@ -89,7 +89,8 @@ Background: | Test Estate 1 | PataPawa PrePay | PataPawa PrePay Contract | Pre Pay Bill Pay | Bill Pay (Pre) | | BillPayment | | Test Estate 1 | AgencyBanking | AgencyBanking Contract | Balance Enquiry | Balance Enquiry | | MobileTopup | | Test Estate 1 | AgencyBanking | AgencyBanking Contract | Deposit | Deposit | | MobileTopup | - | Test Estate 1 | AgencyBanking | AgencyBanking Contract | Withdrawal | Withdrawal | | MobileTopup | + | Test Estate 1 | AgencyBanking | AgencyBanking Contract | Withdrawal | Withdrawal | | MobileTopup | + | Test Estate 1 | AgencyBanking | AgencyBanking Contract | MiniStatement | Mini Statement | | MobileTopup | When I add the following Transaction Fees | EstateName | OperatorName | ContractDescription | ProductName | CalculationType | FeeDescription | Value | @@ -180,7 +181,8 @@ Scenario: Sale Transactions | Today | 11 | Sale | 2 | Test Merchant 1 | 123456780 | Test Estate 1 | PataPawa PrePay | 25.00 | | | PataPawa PrePay Contract | Pre Pay Bill Pay | test@recipient.co.uk | | vend | 00000001 | Customer 1 | 00000001 | | Today | 12 | Sale | 1 | Test Merchant 1 | 123456780 | Test Estate 1 | AgencyBanking | 0.00 | | | AgencyBanking Contract | Balance Enquiry | | 12345678 | balanceenquiry | 12345678 | | | | Today | 13 | Sale | 1 | Test Merchant 1 | 123456780 | Test Estate 1 | AgencyBanking | 100.00 | | | AgencyBanking Contract | Deposit | | 12345678 | deposit | 12345678 | | | - | Today | 14 | Sale | 1 | Test Merchant 1 | 123456780 | Test Estate 1 | AgencyBanking | 9.00 | | | AgencyBanking Contract | Withdrawal | | 12345678 | withdrawal | 12345678 | | | + | Today | 14 | Sale | 1 | Test Merchant 1 | 123456780 | Test Estate 1 | AgencyBanking | 9.00 | | | AgencyBanking Contract | Withdrawal | | 12345678 | withdrawal | 12345678 | | | + | Today | 15 | Sale | 1 | Test Merchant 1 | 123456780 | Test Estate 1 | AgencyBanking | 0.00 | | | AgencyBanking Contract | MiniStatement | | 12345678 | ministatement | 12345678 | | | | Today | 100 | Sale | 1 | Test Merchant 1 | 123456781 | Test Estate 1 | Safaricom | 100.00 | 123456789 | testcustomer@customer.co.uk | Safaricom Contract | Variable Topup | | | | | | | | Today | 101 | Sale | 1 | Test Merchant 1 | 123456780 | InvalidEstate | Safaricom | 100.00 | 123456789 | testcustomer@customer.co.uk | Safaricom Contract | Variable Topup | | | | | | | | Today | 102 | Sale | 1 | InvalidMerchant | 123456780 | Test Estate 1 | Safaricom | 100.00 | 123456789 | testcustomer@customer.co.uk | Safaricom Contract | Variable Topup | | | | | | | @@ -206,6 +208,7 @@ Scenario: Sale Transactions | Test Estate 1 | Test Merchant 1 | 12 | 0000 | SUCCESS | | Test Estate 1 | Test Merchant 1 | 13 | 0000 | SUCCESS | | Test Estate 1 | Test Merchant 1 | 14 | 0000 | SUCCESS | + | Test Estate 1 | Test Merchant 1 | 15 | 0000 | SUCCESS | | Test Estate 1 | Test Merchant 1 | 100 | 1000 | Device Identifier 123456781 not valid for Merchant Test Merchant 1 | | InvalidEstate | Test Merchant 1 | 101 | 1001 | Estate Id [79902550-64df-4491-b0c1-4e78943928a3] is not a valid estate | | Test Estate 1 | InvalidMerchant | 102 | 1002 | Merchant Id [d59320fa-4c3e-4900-a999-483f6a10c69a] is not a valid merchant for estate [Test Estate 1] | diff --git a/TransactionProcessor.IntegrationTests/Features/SaleTransactionFeature.feature.cs b/TransactionProcessor.IntegrationTests/Features/SaleTransactionFeature.feature.cs index ea4cd942..fe82c384 100644 --- a/TransactionProcessor.IntegrationTests/Features/SaleTransactionFeature.feature.cs +++ b/TransactionProcessor.IntegrationTests/Features/SaleTransactionFeature.feature.cs @@ -413,6 +413,14 @@ await testRunner.GivenAsync("I have a token to access the estate management and "Withdrawal", "", "MobileTopup"}); + table15.AddRow(new string[] { + "Test Estate 1", + "AgencyBanking", + "AgencyBanking Contract", + "MiniStatement", + "Mini Statement", + "", + "MobileTopup"}); #line 84 await testRunner.WhenAsync("I create the following Products", ((string)(null)), table15, "When "); #line hidden @@ -448,7 +456,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "Percentage", "Merchant Commission", "0.50"}); -#line 94 +#line 95 await testRunner.WhenAsync("I add the following Transaction Fees", ((string)(null)), table16, "When "); #line hidden global::Reqnroll.Table table17 = new global::Reqnroll.Table(new string[] { @@ -506,7 +514,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "testcontact4@merchant4.co.uk", "Test Estate 1", "true"}); -#line 100 +#line 101 await testRunner.GivenAsync("I create the following merchants", ((string)(null)), table17, "Given "); #line hidden global::Reqnroll.Table table18 = new global::Reqnroll.Table(new string[] { @@ -635,7 +643,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "00000004", "10000004", "Test Estate 1"}); -#line 107 +#line 108 await testRunner.GivenAsync("I have assigned the following operator to the merchants", ((string)(null)), table18, "Given "); #line hidden global::Reqnroll.Table table19 = new global::Reqnroll.Table(new string[] { @@ -658,7 +666,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "123456783", "Test Merchant 4", "Test Estate 1"}); -#line 130 +#line 131 await testRunner.GivenAsync("I have assigned the following devices to the merchants", ((string)(null)), table19, "Given "); #line hidden global::Reqnroll.Table table20 = new global::Reqnroll.Table(new string[] { @@ -691,7 +699,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "Today", "Test Merchant 4", "Test Estate 1"}); -#line 137 +#line 138 await testRunner.GivenAsync("I make the following manual merchant deposits", ((string)(null)), table20, "Given "); #line hidden global::Reqnroll.Table table21 = new global::Reqnroll.Table(new string[] { @@ -766,7 +774,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "Test Estate 1", "Test Merchant 4", "PataPawa PrePay Contract"}); -#line 144 +#line 145 await testRunner.WhenAsync("I add the following contracts to the following merchants", ((string)(null)), table21, "When "); #line hidden } @@ -790,7 +798,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Sale Transactions", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 166 +#line 167 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -1103,6 +1111,26 @@ await testRunner.GivenAsync("I have a token to access the estate management and "12345678", "", ""}); + table22.AddRow(new string[] { + "Today", + "15", + "Sale", + "1", + "Test Merchant 1", + "123456780", + "Test Estate 1", + "AgencyBanking", + "0.00", + "", + "", + "AgencyBanking Contract", + "MiniStatement", + "", + "12345678", + "ministatement", + "12345678", + "", + ""}); table22.AddRow(new string[] { "Today", "100", @@ -1263,7 +1291,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "", "", ""}); -#line 168 +#line 169 await testRunner.WhenAsync("I perform the following transactions", ((string)(null)), table22, "When "); #line hidden global::Reqnroll.Table table23 = new global::Reqnroll.Table(new string[] { @@ -1356,6 +1384,12 @@ await testRunner.GivenAsync("I have a token to access the estate management and "14", "0000", "SUCCESS"}); + table23.AddRow(new string[] { + "Test Estate 1", + "Test Merchant 1", + "15", + "0000", + "SUCCESS"}); table23.AddRow(new string[] { "Test Estate 1", "Test Merchant 1", @@ -1410,7 +1444,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "1009", "Merchant [Test Merchant 4] does not have enough credit available [100.00] to perf" + "orm transaction amount [300.00]"}); -#line 193 +#line 195 await testRunner.ThenAsync("transaction response should contain the following information", ((string)(null)), table23, "Then "); #line hidden global::Reqnroll.Table table24 = new global::Reqnroll.Table(new string[] { @@ -1525,7 +1559,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "0.00", "0.00", "36.10"}); -#line 218 +#line 221 await testRunner.ThenAsync("the following entries appear in the merchants balance history for estate \'Test Es" + "tate 1\' and merchant \'Test Merchant 1\'", ((string)(null)), table24, "Then "); #line hidden @@ -1577,7 +1611,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "0.00", "0.00", "20.00"}); -#line 234 +#line 237 await testRunner.ThenAsync("the following entries appear in the merchants balance history for estate \'Test Es" + "tate 1\' and merchant \'Test Merchant 2\'", ((string)(null)), table25, "Then "); #line hidden @@ -1629,7 +1663,7 @@ await testRunner.GivenAsync("I have a token to access the estate management and "0.00", "0.00", "20.00"}); -#line 242 +#line 245 await testRunner.ThenAsync("the following entries appear in the merchants balance history for estate \'Test Es" + "tate 1\' and merchant \'Test Merchant 3\'", ((string)(null)), table26, "Then "); #line hidden