Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(unzip:*)",
"Bash(git pull:*)",
"Bash(python3:*)",
"Bash(dotnet test)"
],
"deny": []
}
}
Original file line number Diff line number Diff line change
@@ -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; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Xml.Serialization;

namespace AppifySheets.TBC.IntegrationService.Client.SoapInfrastructure;

/// <summary>
/// Represents a SOAP fault response from TBC Bank API
/// Contains error code and message when API calls fail
/// </summary>
[XmlRoot("Fault", Namespace = "http://schemas.xmlsoap.org/soap/envelope/")]
public sealed record SoapFaultResponse : ISoapResponse
{
/// <summary>
/// The fault code indicating the type of error (e.g., "a:USER_IS_BLOCKED")
/// </summary>
[XmlElement("faultcode", Namespace = "")]
public string FaultCode { get; init; } = string.Empty;

/// <summary>
/// Human-readable description of the error
/// </summary>
[XmlElement("faultstring", Namespace = "")]
public string FaultString { get; init; } = string.Empty;

/// <summary>
/// Creates a SoapFaultResponse with the specified fault code and message
/// </summary>
public static SoapFaultResponse Create(string faultCode, string faultString) =>
new() { FaultCode = faultCode, FaultString = faultString };

/// <summary>
/// Gets a formatted error message combining fault code and string
/// </summary>
public string FormattedError => $"SOAP Fault [{FaultCode}]: {FaultString}";

/// <summary>
/// Checks if this represents a specific fault code
/// </summary>
public bool IsFaultCode(string faultCode) =>
string.Equals(FaultCode, faultCode, StringComparison.OrdinalIgnoreCase);
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,6 @@ public Task<Result<string>> CallTBCServiceAsync<TDeserializeInto>(RequestSoap<TD
return CallTBCServiceAsync(template);
}



async Task<Result<string>> CallTBCServiceAsync(PerformedActionSoapEnvelope performedActionSoapEnvelope)
{
const string url = "https://secdbi.tbconline.ge/dbi/dbiService";
Expand Down Expand Up @@ -137,7 +135,11 @@ async Task<Result<string>> CallTBCServiceAsync(PerformedActionSoapEnvelope perfo
}
catch (Exception)
{
return Result.Failure<string>(responseContent.FormatXml());
// Try to parse SOAP fault first, fallback to formatted XML
var faultParseResult = TryParseSoapFault(responseContent);
return Result.Failure<string>(faultParseResult.IsSuccess
? faultParseResult.Value.FormattedError
: responseContent.FormatXml());
}

X509Certificate2Collection GetCertificates()
Expand All @@ -147,4 +149,29 @@ X509Certificate2Collection GetCertificates()
return collection;
}
}

/// <summary>
/// Attempts to parse SOAP fault from response content
/// </summary>
static Result<SoapFaultResponse> 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<SoapFaultResponse>("No SOAP fault found in response");

return faultNode.OuterXml.XmlDeserializeFromString<SoapFaultResponse>();
}
catch (Exception ex)
{
return Result.Failure<SoapFaultResponse>($"Failed to parse SOAP fault: {ex.Message}");
}
}
}
Original file line number Diff line number Diff line change
@@ -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 = """
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Header/>
<s:Body>
<s:Fault>
<faultcode xmlns:a="http://www.mygemini.com/schemas/mygemini">a:USER_IS_BLOCKED</faultcode>
<faultstring xml:lang="en">User is blocked.</faultstring>
</s:Fault>
</s:Body>
</s:Envelope>
""";

// Act
var result = soapFaultXml.DeserializeInto<SoapFaultResponse>();

// 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 = """
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Header/>
<s:Body>
<s:Fault>
<faultcode xmlns:a="http://www.mygemini.com/schemas/mygemini">a:USER_IS_BLOCKED</faultcode>
<faultstring xml:lang="en">User is blocked.</faultstring>
</s:Fault>
</s:Body>
</s:Envelope>
""";

// 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<SoapFaultResponse>)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.");
}
}
1 change: 1 addition & 0 deletions PROJECTNAME
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TBC-IntegrationService
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dotnet add package AppifySheets.TBC.IntegrationService.Client
* <a href="https://developers.tbcbank.ge/docs/import-single-payments" target="_blank">Import Single Payment Orders</a> - Execute various types of payment transfers
* <a href="https://developers.tbcbank.ge/docs/account-movement" target="_blank">Get Account Movements</a> - Retrieve account transaction history
* <a href="https://developers.tbcbank.ge/docs/payment-order-status" target="_blank">Get Payment Order Status</a> - Check status of submitted payment orders
* <a href="https://developers.tbcbank.ge/docs/change-password" target="_blank">Change Password</a> - Change API user password
* <a href="https://developers.tbcbank.ge/docs/password-change" target="_blank">Change Password</a> - Change API user password

## Usage Examples

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.1.0
2.1.1
Loading