diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9f91dd3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(unzip:*)", + "Bash(git pull:*)", + "Bash(python3:*)", + "Bash(dotnet test)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/AppifySheets.TBC.IntegrationService.Client/SoapInfrastructure/ImportSinglePaymentOrders/TransferTypeInterfaces.cs b/AppifySheets.TBC.IntegrationService.Client/SoapInfrastructure/ImportSinglePaymentOrders/TransferTypeInterfaces.cs index 1c9f610..8f72d80 100644 --- a/AppifySheets.TBC.IntegrationService.Client/SoapInfrastructure/ImportSinglePaymentOrders/TransferTypeInterfaces.cs +++ b/AppifySheets.TBC.IntegrationService.Client/SoapInfrastructure/ImportSinglePaymentOrders/TransferTypeInterfaces.cs @@ -1,7 +1,9 @@ using AppifySheets.Immutable.BankIntegrationTypes; +using JetBrains.Annotations; namespace AppifySheets.TBC.IntegrationService.Client.SoapInfrastructure.ImportSinglePaymentOrders; +[PublicAPI] public sealed record BankTransferCommonDetails { public required BankAccount SenderAccountWithCurrency { get; init; } diff --git a/AppifySheets.TBC.IntegrationService.Client/SoapInfrastructure/SoapFaultResponse.cs b/AppifySheets.TBC.IntegrationService.Client/SoapInfrastructure/SoapFaultResponse.cs new file mode 100644 index 0000000..4ddc405 --- /dev/null +++ b/AppifySheets.TBC.IntegrationService.Client/SoapInfrastructure/SoapFaultResponse.cs @@ -0,0 +1,41 @@ +using System; +using System.Xml.Serialization; + +namespace AppifySheets.TBC.IntegrationService.Client.SoapInfrastructure; + +/// +/// Represents a SOAP fault response from TBC Bank API +/// Contains error code and message when API calls fail +/// +[XmlRoot("Fault", Namespace = "http://schemas.xmlsoap.org/soap/envelope/")] +public sealed record SoapFaultResponse : ISoapResponse +{ + /// + /// The fault code indicating the type of error (e.g., "a:USER_IS_BLOCKED") + /// + [XmlElement("faultcode", Namespace = "")] + public string FaultCode { get; init; } = string.Empty; + + /// + /// Human-readable description of the error + /// + [XmlElement("faultstring", Namespace = "")] + public string FaultString { get; init; } = string.Empty; + + /// + /// Creates a SoapFaultResponse with the specified fault code and message + /// + public static SoapFaultResponse Create(string faultCode, string faultString) => + new() { FaultCode = faultCode, FaultString = faultString }; + + /// + /// Gets a formatted error message combining fault code and string + /// + public string FormattedError => $"SOAP Fault [{FaultCode}]: {FaultString}"; + + /// + /// Checks if this represents a specific fault code + /// + public bool IsFaultCode(string faultCode) => + string.Equals(FaultCode, faultCode, StringComparison.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/AppifySheets.TBC.IntegrationService.Client/TBC_Services/TBCSoapCaller.cs b/AppifySheets.TBC.IntegrationService.Client/TBC_Services/TBCSoapCaller.cs index 74ef1b0..ca277c2 100644 --- a/AppifySheets.TBC.IntegrationService.Client/TBC_Services/TBCSoapCaller.cs +++ b/AppifySheets.TBC.IntegrationService.Client/TBC_Services/TBCSoapCaller.cs @@ -100,8 +100,6 @@ public Task> CallTBCServiceAsync(RequestSoap> CallTBCServiceAsync(PerformedActionSoapEnvelope performedActionSoapEnvelope) { const string url = "https://secdbi.tbconline.ge/dbi/dbiService"; @@ -137,7 +135,11 @@ async Task> CallTBCServiceAsync(PerformedActionSoapEnvelope perfo } catch (Exception) { - return Result.Failure(responseContent.FormatXml()); + // Try to parse SOAP fault first, fallback to formatted XML + var faultParseResult = TryParseSoapFault(responseContent); + return Result.Failure(faultParseResult.IsSuccess + ? faultParseResult.Value.FormattedError + : responseContent.FormatXml()); } X509Certificate2Collection GetCertificates() @@ -147,4 +149,29 @@ X509Certificate2Collection GetCertificates() return collection; } } + + /// + /// Attempts to parse SOAP fault from response content + /// + static Result TryParseSoapFault(string responseContent) + { + try + { + var doc = new XmlDocument(); + doc.LoadXml(responseContent); + + var nsManager = new XmlNamespaceManager(doc.NameTable); + nsManager.AddNamespace("s", "http://schemas.xmlsoap.org/soap/envelope/"); + + var faultNode = doc.SelectSingleNode("//s:Fault", nsManager); + if (faultNode == null) + return Result.Failure("No SOAP fault found in response"); + + return faultNode.OuterXml.XmlDeserializeFromString(); + } + catch (Exception ex) + { + return Result.Failure($"Failed to parse SOAP fault: {ex.Message}"); + } + } } \ No newline at end of file diff --git a/AppifySheets.TBC.IntegrationService.Tests/SoapFaultResponseTests.cs b/AppifySheets.TBC.IntegrationService.Tests/SoapFaultResponseTests.cs new file mode 100644 index 0000000..1e7ba53 --- /dev/null +++ b/AppifySheets.TBC.IntegrationService.Tests/SoapFaultResponseTests.cs @@ -0,0 +1,91 @@ +using AppifySheets.TBC.IntegrationService.Client.SoapInfrastructure; +using AppifySheets.TBC.IntegrationService.Client.TBC_Services; +using CSharpFunctionalExtensions; +using Shouldly; +using Xunit; + +namespace AppifySheets.TBC.IntegrationService.Tests; + +public class SoapFaultResponseTests +{ + [Fact] + public void Should_Deserialize_SOAP_Fault_Response() + { + // Arrange + const string soapFaultXml = """ + + + + + + a:USER_IS_BLOCKED + User is blocked. + + + + """; + + // Act + var result = soapFaultXml.DeserializeInto(); + + // Assert - deserialization should fail with SOAP fault error message + result.IsFailure.ShouldBeTrue(); + result.Error.ShouldContain("USER_IS_BLOCKED"); + result.Error.ShouldContain("User is blocked"); + } + + + [Fact] + public void Should_Create_SoapFaultResponse_With_Static_Factory() + { + // Act + var fault = SoapFaultResponse.Create("a:USER_IS_BLOCKED", "User is blocked."); + + // Assert + fault.FaultCode.ShouldBe("a:USER_IS_BLOCKED"); + fault.FaultString.ShouldBe("User is blocked."); + fault.FormattedError.ShouldBe("SOAP Fault [a:USER_IS_BLOCKED]: User is blocked."); + } + + [Fact] + public void Should_Check_Fault_Code_Case_Insensitive() + { + // Arrange + var fault = SoapFaultResponse.Create("a:USER_IS_BLOCKED", "User is blocked."); + + // Act & Assert + fault.IsFaultCode("a:user_is_blocked").ShouldBeTrue(); + fault.IsFaultCode("A:USER_IS_BLOCKED").ShouldBeTrue(); + fault.IsFaultCode("a:USER_IS_BLOCKED").ShouldBeTrue(); + fault.IsFaultCode("different_code").ShouldBeFalse(); + } + + [Fact] + public void TryParseSoapFault_Should_Parse_Valid_Fault() + { + // Arrange + const string soapFaultXml = """ + + + + + + a:USER_IS_BLOCKED + User is blocked. + + + + """; + + // Act - use reflection to call the private static method + var method = typeof(TBCSoapCaller).GetMethod("TryParseSoapFault", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + var result = (Result)method!.Invoke(null, [soapFaultXml])!; + + // Assert + result.IsSuccess.ShouldBeTrue(); + result.Value.FaultCode.ShouldBe("a:USER_IS_BLOCKED"); + result.Value.FaultString.ShouldBe("User is blocked."); + result.Value.FormattedError.ShouldBe("SOAP Fault [a:USER_IS_BLOCKED]: User is blocked."); + } +} \ No newline at end of file diff --git a/PROJECTNAME b/PROJECTNAME new file mode 100644 index 0000000..e7f6efc --- /dev/null +++ b/PROJECTNAME @@ -0,0 +1 @@ +TBC-IntegrationService \ No newline at end of file diff --git a/README.md b/README.md index 05a685a..c670d0c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ dotnet add package AppifySheets.TBC.IntegrationService.Client * Import Single Payment Orders - Execute various types of payment transfers * Get Account Movements - Retrieve account transaction history * Get Payment Order Status - Check status of submitted payment orders -* Change Password - Change API user password +* Change Password - Change API user password ## Usage Examples diff --git a/VERSION b/VERSION index 50aea0e..7c32728 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 \ No newline at end of file +2.1.1 \ No newline at end of file