From 172b9d32bccbb42b7cb4a83d5c21ef0a9e15814f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Armando=20Rodr=C3=ADguez?=
<127134616+armando-rodriguez-cko@users.noreply.github.com>
Date: Mon, 1 Sep 2025 12:50:11 +0200
Subject: [PATCH] Add support for payment reversals with ReverseAPaymentRequest
and ReverseAPaymentResponse classes
---
.../ReverseAPaymentRequest.cs | 29 ++
.../ReverseAPaymentResponse.cs | 27 ++
src/CheckoutSdk/OAuthScope.cs | 1 +
src/CheckoutSdk/Payments/IPaymentsClient.cs | 8 +
src/CheckoutSdk/Payments/PaymentsClient.cs | 14 +
.../HandleReversalsClientTest.cs | 250 ++++++++++++++++++
.../HandleReversalsIntegrationTest.cs | 230 ++++++++++++++++
.../RequestApmPaymentsIntegrationTest.cs | 1 +
test/CheckoutSdkTest/SandboxTestFixture.cs | 3 +-
9 files changed, 562 insertions(+), 1 deletion(-)
create mode 100644 src/CheckoutSdk/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/Requests/ReverseAPaymentRequest/ReverseAPaymentRequest.cs
create mode 100644 src/CheckoutSdk/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/Responses/ReverseAPaymentResponse/ReverseAPaymentResponse.cs
create mode 100644 test/CheckoutSdkTest/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/HandleReversalsClientTest.cs
create mode 100644 test/CheckoutSdkTest/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/HandleReversalsIntegrationTest.cs
diff --git a/src/CheckoutSdk/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/Requests/ReverseAPaymentRequest/ReverseAPaymentRequest.cs b/src/CheckoutSdk/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/Requests/ReverseAPaymentRequest/ReverseAPaymentRequest.cs
new file mode 100644
index 00000000..d187c32f
--- /dev/null
+++ b/src/CheckoutSdk/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/Requests/ReverseAPaymentRequest/ReverseAPaymentRequest.cs
@@ -0,0 +1,29 @@
+namespace Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Requests.ReverseAPaymentRequest
+{
+ ///
+ /// Reverse a payment
+ /// Returns funds back to the customer by automatically performing the appropriate payment action depending on the
+ /// payment's status.
+ /// For more information, see Reverse a payment.
+ ///
+ public class ReverseAPaymentRequest
+ {
+
+ ///
+ /// An internal reference to identify the payment reversal.
+ /// For American Express payment reversals, there is a 30-character limit.
+ /// [Optional]
+ /// <= 80
+ ///
+ public string Reference { get; set; }
+
+ ///
+ /// Stores additional information about the transaction with custom fields.
+ /// You can only supply primitive data types with one level of depth. Fields of type object or array are not
+ /// supported.
+ /// [Optional]
+ ///
+ public object Metadata { get; set; }
+
+ }
+}
diff --git a/src/CheckoutSdk/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/Responses/ReverseAPaymentResponse/ReverseAPaymentResponse.cs b/src/CheckoutSdk/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/Responses/ReverseAPaymentResponse/ReverseAPaymentResponse.cs
new file mode 100644
index 00000000..be0d171e
--- /dev/null
+++ b/src/CheckoutSdk/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/Responses/ReverseAPaymentResponse/ReverseAPaymentResponse.cs
@@ -0,0 +1,27 @@
+using Checkout.Common;
+
+namespace Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Responses.ReverseAPaymentResponse
+{
+ ///
+ /// Reverse a payment Response 200
+ /// Payment is already reversed
+ ///
+ public class ReverseAPaymentResponse : Resource
+ {
+
+ ///
+ /// The unique identifier for the previously completed payment action.
+ /// [Required]
+ /// ^(act)_(\w{26})$
+ /// 30 characters
+ ///
+ public string ActionId { get; set; }
+
+ ///
+ /// A unique reference for the payment reversal.
+ /// [Optional]
+ ///
+ public string Reference { get; set; }
+
+ }
+}
diff --git a/src/CheckoutSdk/OAuthScope.cs b/src/CheckoutSdk/OAuthScope.cs
index 315baf5e..1ae96aa1 100644
--- a/src/CheckoutSdk/OAuthScope.cs
+++ b/src/CheckoutSdk/OAuthScope.cs
@@ -13,6 +13,7 @@ public enum OAuthScope
[OAuthScope("gateway:payment-voids")] GatewayPaymentVoids,
[OAuthScope("gateway:payment-captures")] GatewayPaymentCaptures,
[OAuthScope("gateway:payment-refunds")] GatewayPaymentRefunds,
+ [OAuthScope("gateway:payment-cancellations")] GatewayPaymentCancellations,
[OAuthScope("fx")] Fx,
[OAuthScope("payouts:bank-details")] PayoutsBankDetails,
[OAuthScope("sessions:app")] SessionsApp,
diff --git a/src/CheckoutSdk/Payments/IPaymentsClient.cs b/src/CheckoutSdk/Payments/IPaymentsClient.cs
index efc8e9ab..44c19fe3 100644
--- a/src/CheckoutSdk/Payments/IPaymentsClient.cs
+++ b/src/CheckoutSdk/Payments/IPaymentsClient.cs
@@ -1,5 +1,7 @@
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Requests.UnreferencedRefundRequest;
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Responses;
+using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Requests.ReverseAPaymentRequest;
+using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Responses.ReverseAPaymentResponse;
using Checkout.Payments.Request;
using Checkout.Payments.Response;
using System.Threading;
@@ -53,6 +55,12 @@ Task RefundPayment(
RefundRequest refundRequest = null,
string idempotencyKey = null,
CancellationToken cancellationToken = default);
+
+ Task ReverseAPayment(
+ string paymentId,
+ ReverseAPaymentRequest reverseAPaymentRequest = null,
+ string idempotencyKey = null,
+ CancellationToken cancellationToken = default);
Task VoidPayment(
string paymentId,
diff --git a/src/CheckoutSdk/Payments/PaymentsClient.cs b/src/CheckoutSdk/Payments/PaymentsClient.cs
index 378f6c3d..9915db9b 100644
--- a/src/CheckoutSdk/Payments/PaymentsClient.cs
+++ b/src/CheckoutSdk/Payments/PaymentsClient.cs
@@ -2,6 +2,8 @@
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Responses;
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Responses.RequestAPaymentOrPayoutResponseAccepted;
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Responses.RequestAPaymentOrPayoutResponseCreated;
+using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Requests.ReverseAPaymentRequest;
+using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Responses.ReverseAPaymentResponse;
using Checkout.Payments.Request;
using Checkout.Payments.Response;
using System;
@@ -155,6 +157,18 @@ public Task RefundPayment(
idempotencyKey);
}
+ public Task ReverseAPayment(string paymentId,
+ ReverseAPaymentRequest reverseAPaymentRequest = null,
+ string idempotencyKey = null, CancellationToken cancellationToken = default)
+ {
+ CheckoutUtils.ValidateParams("paymentId", paymentId);
+ return ApiClient.Post(BuildPath(PaymentsPath, paymentId, "reversals"),
+ SdkAuthorization(),
+ reverseAPaymentRequest,
+ cancellationToken,
+ idempotencyKey);
+ }
+
public Task VoidPayment(
string paymentId,
VoidRequest voidRequest = null,
diff --git a/test/CheckoutSdkTest/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/HandleReversalsClientTest.cs b/test/CheckoutSdkTest/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/HandleReversalsClientTest.cs
new file mode 100644
index 00000000..86b69cc9
--- /dev/null
+++ b/test/CheckoutSdkTest/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/HandleReversalsClientTest.cs
@@ -0,0 +1,250 @@
+using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Requests.ReverseAPaymentRequest;
+using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Responses.ReverseAPaymentResponse;
+using Checkout.Payments;
+using Moq;
+using Shouldly;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals
+{
+ public class HandleReversalsClientTest : UnitTestFixture
+ {
+ private const string TestPaymentId = "pay_test_12345678901234567890123456";
+ private const string TestActionId = "act_test_12345678901234567890123456";
+ private const string TestReference = "test-reversal-reference";
+ private const string TestIdempotencyKey = "test-idempotency-key";
+ private const string ReversalsPath = "payments/{0}/reversals";
+
+ private readonly SdkAuthorization _authorization =
+ new SdkAuthorization(PlatformType.DefaultOAuth, ValidDefaultSk);
+
+ private readonly Mock _apiClient = new Mock();
+ private readonly Mock _sdkCredentials = new Mock(PlatformType.DefaultOAuth);
+ private readonly Mock _httpClientFactory = new Mock();
+ private readonly Mock _configuration;
+ private readonly PaymentsClient _client;
+
+ public HandleReversalsClientTest()
+ {
+ _sdkCredentials.Setup(credentials => credentials.GetSdkAuthorization(SdkAuthorizationType.SecretKeyOrOAuth))
+ .Returns(_authorization);
+
+ _configuration = new Mock(_sdkCredentials.Object, Environment.Sandbox,
+ _httpClientFactory.Object);
+
+ _client = new PaymentsClient(_apiClient.Object, _configuration.Object);
+ }
+
+ [Fact]
+ public async Task ShouldReversePayment_WhenSuccessful()
+ {
+ // Arrange
+ var request = CreateReverseAPaymentRequest();
+ var response = CreateReverseAPaymentResponse();
+
+ SetupApiClientMock(request, response);
+
+ // Act
+ var result = await _client.ReverseAPayment(TestPaymentId, request);
+
+ // Assert
+ AssertSuccessfulResponse(result, TestActionId, TestReference);
+ }
+
+ [Fact]
+ public async Task ShouldReversePaymentWithNullRequest_WhenSuccessful()
+ {
+ // Arrange
+ var response = CreateReverseAPaymentResponse(includeReference: false);
+
+ SetupApiClientMockForNullRequest(response);
+
+ // Act
+ var result = await _client.ReverseAPayment(TestPaymentId);
+
+ // Assert
+ AssertSuccessfulResponse(result, TestActionId);
+ }
+
+ [Fact]
+ public async Task ShouldThrowCheckoutArgumentException_WhenPaymentIdIsNull()
+ {
+ // Arrange
+ var request = CreateReverseAPaymentRequest();
+
+ // Act & Assert
+ await Should.ThrowAsync(async () =>
+ await _client.ReverseAPayment(null, request));
+ }
+
+ [Fact]
+ public async Task ShouldThrowCheckoutArgumentException_WhenPaymentIdIsEmpty()
+ {
+ // Arrange
+ var request = CreateReverseAPaymentRequest();
+
+ // Act & Assert
+ await Should.ThrowAsync(async () =>
+ await _client.ReverseAPayment(string.Empty, request));
+ }
+
+ [Fact]
+ public async Task ShouldReversePayment_WithIdempotencyKey_WhenSuccessful()
+ {
+ // Arrange
+ var request = CreateReverseAPaymentRequest();
+ var response = CreateReverseAPaymentResponse();
+
+ SetupApiClientMock(request, response, TestIdempotencyKey);
+
+ // Act
+ var result = await _client.ReverseAPayment(TestPaymentId, request, TestIdempotencyKey);
+
+ // Assert
+ AssertSuccessfulResponse(result, TestActionId, TestReference);
+ }
+
+ [Fact]
+ public async Task ShouldReversePayment_WithIdempotencyKeyAndNullRequest_WhenSuccessful()
+ {
+ // Arrange
+ var response = CreateReverseAPaymentResponse(includeReference: false);
+
+ SetupApiClientMockForNullRequest(response, TestIdempotencyKey);
+
+ // Act
+ var result = await _client.ReverseAPayment(TestPaymentId, null, TestIdempotencyKey);
+
+ // Assert
+ AssertSuccessfulResponse(result, TestActionId);
+ }
+
+ [Fact]
+ public async Task ShouldReversePayment_WithCustomCancellationToken_WhenSuccessful()
+ {
+ // Arrange
+ var request = CreateReverseAPaymentRequest();
+ var cancellationToken = new CancellationToken();
+ var response = CreateReverseAPaymentResponse();
+
+ SetupApiClientMock(request, response, cancellationToken: cancellationToken);
+
+ // Act
+ var result = await _client.ReverseAPayment(TestPaymentId, request, null, cancellationToken);
+
+ // Assert
+ AssertSuccessfulResponse(result, TestActionId, TestReference);
+ }
+
+ [Fact]
+ public async Task ShouldReturnSameResult_WhenSameIdempotencyKeyUsedTwice()
+ {
+ // Arrange
+ var request = CreateReverseAPaymentRequest();
+ var idempotencyKey = "test-idempotency-key-123";
+ var expectedResponse = CreateReverseAPaymentResponse();
+
+ SetupApiClientMock(request, expectedResponse, idempotencyKey);
+
+ // Act - First call
+ var firstResult = await _client.ReverseAPayment(TestPaymentId, request, idempotencyKey);
+
+ // Act - Second call with same idempotency key
+ var secondResult = await _client.ReverseAPayment(TestPaymentId, request, idempotencyKey);
+
+ // Assert - Both calls should return the same result
+ AssertIdempotentResults(firstResult, secondResult);
+
+ // Verify the API was called twice with the same parameters
+ VerifyApiClientCalledTwice(request, idempotencyKey);
+ }
+
+ private ReverseAPaymentRequest CreateReverseAPaymentRequest()
+ {
+ return new ReverseAPaymentRequest
+ {
+ Reference = TestReference,
+ Metadata = new { OrderId = "order_123", CustomField = "test_value" }
+ };
+ }
+
+ private ReverseAPaymentResponse CreateReverseAPaymentResponse(bool includeReference = true)
+ {
+ var response = new ReverseAPaymentResponse
+ {
+ ActionId = TestActionId
+ };
+
+ if (includeReference)
+ {
+ response.Reference = TestReference;
+ }
+
+ return response;
+ }
+
+ private void SetupApiClientMock(
+ ReverseAPaymentRequest request,
+ ReverseAPaymentResponse response,
+ string idempotencyKey = null,
+ CancellationToken cancellationToken = default)
+ {
+ _apiClient.Setup(apiClient =>
+ apiClient.Post(
+ string.Format(ReversalsPath, TestPaymentId),
+ _authorization,
+ request,
+ cancellationToken == default ? CancellationToken.None : cancellationToken,
+ idempotencyKey))
+ .ReturnsAsync(response);
+ }
+
+ private void SetupApiClientMockForNullRequest(
+ ReverseAPaymentResponse response,
+ string idempotencyKey = null)
+ {
+ _apiClient.Setup(apiClient =>
+ apiClient.Post(
+ string.Format(ReversalsPath, TestPaymentId),
+ _authorization,
+ It.IsAny(),
+ CancellationToken.None,
+ idempotencyKey))
+ .ReturnsAsync(response);
+ }
+
+ private static void AssertSuccessfulResponse(ReverseAPaymentResponse result, string expectedActionId, string expectedReference = null)
+ {
+ result.ShouldNotBeNull();
+ result.ActionId.ShouldBe(expectedActionId);
+
+ if (expectedReference != null)
+ {
+ result.Reference.ShouldBe(expectedReference);
+ }
+ }
+
+ private static void AssertIdempotentResults(ReverseAPaymentResponse firstResult, ReverseAPaymentResponse secondResult)
+ {
+ firstResult.ShouldNotBeNull();
+ secondResult.ShouldNotBeNull();
+ firstResult.ActionId.ShouldBe(secondResult.ActionId);
+ firstResult.Reference.ShouldBe(secondResult.Reference);
+ firstResult.ActionId.ShouldBe(TestActionId);
+ firstResult.Reference.ShouldBe(TestReference);
+ }
+
+ private void VerifyApiClientCalledTwice(ReverseAPaymentRequest request, string idempotencyKey)
+ {
+ _apiClient.Verify(apiClient =>
+ apiClient.Post(
+ string.Format(ReversalsPath, TestPaymentId),
+ _authorization,
+ request,
+ CancellationToken.None,
+ idempotencyKey), Times.Exactly(2));
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/CheckoutSdkTest/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/HandleReversalsIntegrationTest.cs b/test/CheckoutSdkTest/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/HandleReversalsIntegrationTest.cs
new file mode 100644
index 00000000..be3883ba
--- /dev/null
+++ b/test/CheckoutSdkTest/HandlePaymentsAndPayouts/Payments/POSTPaymentsIdReversals/HandleReversalsIntegrationTest.cs
@@ -0,0 +1,230 @@
+using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Requests.ReverseAPaymentRequest;
+using Checkout.Common;
+using Checkout.Payments;
+using Shouldly;
+using System;
+using System.Net;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals
+{
+ public class HandleReversalsIntegrationTest : AbstractPaymentsIntegrationTest
+ {
+ public HandleReversalsIntegrationTest() : base(PlatformType.DefaultOAuth)
+ {
+ }
+
+ [Fact]
+ public async Task ShouldReversePayment_WhenSuccessful()
+ {
+ // Arrange - Create an initial authorized payment to reverse
+ // For authorized but not captured payments, reversal performs a void
+ var paymentResponse = await MakeCardPayment(shouldCapture: false, amount: 100);
+ paymentResponse.ShouldNotBeNull();
+ paymentResponse.Status.ShouldBe(PaymentStatus.Authorized);
+
+ var reversalRequest = new ReverseAPaymentRequest
+ {
+ Reference = $"reversal-{Guid.NewGuid()}",
+ Metadata = new { TestReason = "integration_test", OrderId = paymentResponse.Reference, CouponCode = "NY2024" }
+ };
+
+ // Act - Reverse the payment (should void the authorization)
+ var reversalResponse = await DefaultApi.PaymentsClient().ReverseAPayment(paymentResponse.Id, reversalRequest);
+
+ // Assert
+ reversalResponse.ShouldNotBeNull();
+ reversalResponse.ActionId.ShouldNotBeNullOrEmpty();
+ reversalResponse.Reference.ShouldBe(reversalRequest.Reference);
+ }
+
+ [Fact]
+ public async Task ShouldReversePayment_WithNullRequest_WhenSuccessful()
+ {
+ // Arrange - Create an initial authorized payment to reverse
+ // Minimal reversal request without reference or metadata
+ var paymentResponse = await MakeCardPayment(shouldCapture: false, amount: 100);
+ paymentResponse.ShouldNotBeNull();
+ paymentResponse.Status.ShouldBe(PaymentStatus.Authorized);
+
+ // Act - Reverse the payment without a request body
+ var reversalResponse = await DefaultApi.PaymentsClient().ReverseAPayment(paymentResponse.Id);
+
+ // Assert
+ reversalResponse.ShouldNotBeNull();
+ reversalResponse.ActionId.ShouldNotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task ShouldReversePayment_WithIdempotencyKey_WhenSuccessful()
+ {
+ // Arrange - Create an initial authorized payment to reverse
+ var paymentResponse = await MakeCardPayment(shouldCapture: false, amount: 100);
+ paymentResponse.ShouldNotBeNull();
+ paymentResponse.Status.ShouldBe(PaymentStatus.Authorized);
+
+ var reversalRequest = new ReverseAPaymentRequest
+ {
+ Reference = $"reversal-{Guid.NewGuid()}",
+ Metadata = new { TestReason = "idempotency_test" }
+ };
+
+ var idempotencyKey = $"idem-{Guid.NewGuid()}";
+
+ // Act - Reverse the payment twice with the same idempotency key
+ var firstReversalResponse = await DefaultApi.PaymentsClient().ReverseAPayment(
+ paymentResponse.Id, reversalRequest, idempotencyKey);
+
+ var secondReversalResponse = await DefaultApi.PaymentsClient().ReverseAPayment(
+ paymentResponse.Id, reversalRequest, idempotencyKey);
+
+ // Assert - Both responses should be identical (idempotent behavior)
+ firstReversalResponse.ShouldNotBeNull();
+ secondReversalResponse.ShouldNotBeNull();
+ firstReversalResponse.ActionId.ShouldBe(secondReversalResponse.ActionId);
+ firstReversalResponse.Reference.ShouldBe(secondReversalResponse.Reference);
+ }
+
+ [Fact]
+ public async Task ShouldThrowException_WhenPaymentNotFound()
+ {
+ // Arrange
+ var nonExistentPaymentId = "pay_non_existent_payment_id_123456";
+ var reversalRequest = new ReverseAPaymentRequest
+ {
+ Reference = $"reversal-{Guid.NewGuid()}",
+ Metadata = new { TestReason = "test_not_found" }
+ };
+
+ // Act & Assert
+ var exception = await Should.ThrowAsync(async () =>
+ await DefaultApi.PaymentsClient().ReverseAPayment(nonExistentPaymentId, reversalRequest));
+
+ exception.ShouldNotBeNull();
+ // According to docs: 404 for not found, 403 for cannot be reversed (declined, 3DS in progress, etc.)
+ exception.HttpStatusCode.ShouldBeOneOf(HttpStatusCode.NotFound, HttpStatusCode.Forbidden);
+ }
+
+ [Fact]
+ public async Task ShouldReversePayment_AfterCapture_WhenSuccessful()
+ {
+ // Arrange - Create an authorized payment, then capture and reverse it
+ // For authorized and captured payments, reversal performs a full refund
+ var paymentResponse = await MakeCardPayment(shouldCapture: false, amount: 100);
+ paymentResponse.ShouldNotBeNull();
+ paymentResponse.Status.ShouldBe(PaymentStatus.Authorized);
+
+ // Capture the payment
+ var captureResponse = await DefaultApi.PaymentsClient().CapturePayment(paymentResponse.Id);
+ captureResponse.ShouldNotBeNull();
+
+ // Wait a moment for the capture to process
+ await Task.Delay(2000);
+
+ var reversalRequest = new ReverseAPaymentRequest
+ {
+ Reference = $"reversal-{Guid.NewGuid()}",
+ Metadata = new { TestReason = "post_capture_reversal", PartnerId = 123989 }
+ };
+
+ // Act - Reverse the captured payment (should perform a full refund)
+ var reversalResponse = await DefaultApi.PaymentsClient().ReverseAPayment(paymentResponse.Id, reversalRequest);
+
+ // Assert - The reversal should be successful
+ reversalResponse.ShouldNotBeNull();
+ reversalResponse.ActionId.ShouldNotBeNullOrEmpty();
+ reversalResponse.Reference.ShouldBe(reversalRequest.Reference);
+ }
+
+ [Fact]
+ public async Task ShouldAllowMultipleReversals_WhenSuccessful()
+ {
+ // Arrange - Create and reverse a payment, then try to reverse it again
+ var paymentResponse = await MakeCardPayment(shouldCapture: false, amount: 100);
+ paymentResponse.ShouldNotBeNull();
+ paymentResponse.Status.ShouldBe(PaymentStatus.Authorized);
+
+ // First reversal
+ var firstReversalRequest = new ReverseAPaymentRequest
+ {
+ Reference = $"reversal-1-{Guid.NewGuid()}",
+ Metadata = new { TestReason = "first_reversal", CouponCode = "TEST2024" }
+ };
+
+ var firstReversalResponse = await DefaultApi.PaymentsClient().ReverseAPayment(paymentResponse.Id, firstReversalRequest);
+ firstReversalResponse.ShouldNotBeNull();
+ firstReversalResponse.ActionId.ShouldNotBeNullOrEmpty();
+ firstReversalResponse.Reference.ShouldBe(firstReversalRequest.Reference);
+
+ // Wait for the first reversal to process
+ await Task.Delay(3000);
+
+ // Second reversal attempt
+ var secondReversalRequest = new ReverseAPaymentRequest
+ {
+ Reference = $"reversal-2-{Guid.NewGuid()}",
+ Metadata = new { TestReason = "second_reversal" }
+ };
+
+ // Act - Try to reverse an already reversed payment
+ // According to docs: for card payments, this might return 204 (No Content) or succeed
+ // For non-card payments, this performs the second action (void -> refund)
+ var secondReversalResponse = await DefaultApi.PaymentsClient().ReverseAPayment(paymentResponse.Id, secondReversalRequest);
+
+ // Assert - The second reversal should also be successful
+ // Note: The behavior depends on payment method and current state
+ secondReversalResponse.ShouldNotBeNull();
+ secondReversalResponse.ActionId.ShouldNotBeNullOrEmpty();
+
+ // For different reversal operations, action IDs should be different
+ // Unless it's a 204 response indicating already reversed
+ if (secondReversalResponse.Reference != null)
+ {
+ secondReversalResponse.Reference.ShouldBe(secondReversalRequest.Reference);
+ }
+ }
+
+ [Fact]
+ public async Task ShouldReturn204_WhenPaymentAlreadyFullyReversed()
+ {
+ // Arrange - Create a small payment and reverse it completely
+ var paymentResponse = await MakeCardPayment(shouldCapture: true, amount: 10);
+ paymentResponse.ShouldNotBeNull();
+
+ // First reversal - should complete the reversal
+ var firstReversalRequest = new ReverseAPaymentRequest
+ {
+ Reference = $"complete-reversal-{Guid.NewGuid()}",
+ Metadata = new { TestReason = "complete_reversal" }
+ };
+
+ var firstReversalResponse = await DefaultApi.PaymentsClient().ReverseAPayment(paymentResponse.Id, firstReversalRequest);
+ firstReversalResponse.ShouldNotBeNull();
+
+ // Wait for processing
+ await Task.Delay(5000);
+
+ // Second reversal attempt - according to docs, should return 204 for already reversed payment
+ var secondReversalRequest = new ReverseAPaymentRequest
+ {
+ Reference = $"duplicate-reversal-{Guid.NewGuid()}",
+ Metadata = new { TestReason = "duplicate_attempt" }
+ };
+
+ // Act & Assert - This might return 204 or succeed depending on timing and payment state
+ try
+ {
+ var secondReversalResponse = await DefaultApi.PaymentsClient().ReverseAPayment(paymentResponse.Id, secondReversalRequest);
+ // If we get here, the system allowed the second reversal
+ secondReversalResponse.ShouldNotBeNull();
+ }
+ catch (CheckoutApiException ex) when (ex.HttpStatusCode == HttpStatusCode.NoContent)
+ {
+ // This is expected behavior according to documentation
+ // Status 204 indicates payment was already reversed
+ ex.HttpStatusCode.ShouldBe(HttpStatusCode.NoContent);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/CheckoutSdkTest/Payments/RequestApmPaymentsIntegrationTest.cs b/test/CheckoutSdkTest/Payments/RequestApmPaymentsIntegrationTest.cs
index fa3265b9..44a37241 100644
--- a/test/CheckoutSdkTest/Payments/RequestApmPaymentsIntegrationTest.cs
+++ b/test/CheckoutSdkTest/Payments/RequestApmPaymentsIntegrationTest.cs
@@ -648,6 +648,7 @@ private async Task ShouldMakeBizumPayment()
Source = new RequestBizumSource { MobileNumber = "+447700900986", },
Amount = 10L,
Currency = Currency.EUR,
+ Customer = GetCustomer(),
ProcessingChannelId = System.Environment.GetEnvironmentVariable("CHECKOUT_PROCESSING_CHANNEL_ID"),
SuccessUrl = "https://testing.checkout.com/sucess",
FailureUrl = "https://testing.checkout.com/failure",
diff --git a/test/CheckoutSdkTest/SandboxTestFixture.cs b/test/CheckoutSdkTest/SandboxTestFixture.cs
index 31fb1071..fcecaf6a 100644
--- a/test/CheckoutSdkTest/SandboxTestFixture.cs
+++ b/test/CheckoutSdkTest/SandboxTestFixture.cs
@@ -211,7 +211,8 @@ protected static CustomerRequest GetCustomer()
return new CustomerRequest
{
Email = GenerateRandomEmail(),
- Name = "John"
+ Name = "John",
+ Phone = GetPhone(),
};
}