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(), }; }