Skip to content

Commit 172b9d3

Browse files
Add support for payment reversals with ReverseAPaymentRequest and ReverseAPaymentResponse classes
1 parent 33ddbac commit 172b9d3

File tree

9 files changed

+562
-1
lines changed

9 files changed

+562
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Requests.ReverseAPaymentRequest
2+
{
3+
/// <summary>
4+
/// Reverse a payment
5+
/// Returns funds back to the customer by automatically performing the appropriate payment action depending on the
6+
/// payment's status.
7+
/// For more information, see Reverse a payment.
8+
/// </summary>
9+
public class ReverseAPaymentRequest
10+
{
11+
12+
/// <summary>
13+
/// An internal reference to identify the payment reversal.
14+
/// For American Express payment reversals, there is a 30-character limit.
15+
/// [Optional]
16+
/// &lt;= 80
17+
/// </summary>
18+
public string Reference { get; set; }
19+
20+
/// <summary>
21+
/// Stores additional information about the transaction with custom fields.
22+
/// You can only supply primitive data types with one level of depth. Fields of type object or array are not
23+
/// supported.
24+
/// [Optional]
25+
/// </summary>
26+
public object Metadata { get; set; }
27+
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using Checkout.Common;
2+
3+
namespace Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Responses.ReverseAPaymentResponse
4+
{
5+
/// <summary>
6+
/// Reverse a payment Response 200
7+
/// Payment is already reversed
8+
/// </summary>
9+
public class ReverseAPaymentResponse : Resource
10+
{
11+
12+
/// <summary>
13+
/// The unique identifier for the previously completed payment action.
14+
/// [Required]
15+
/// ^(act)_(\w{26})$
16+
/// 30 characters
17+
/// </summary>
18+
public string ActionId { get; set; }
19+
20+
/// <summary>
21+
/// A unique reference for the payment reversal.
22+
/// [Optional]
23+
/// </summary>
24+
public string Reference { get; set; }
25+
26+
}
27+
}

src/CheckoutSdk/OAuthScope.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public enum OAuthScope
1313
[OAuthScope("gateway:payment-voids")] GatewayPaymentVoids,
1414
[OAuthScope("gateway:payment-captures")] GatewayPaymentCaptures,
1515
[OAuthScope("gateway:payment-refunds")] GatewayPaymentRefunds,
16+
[OAuthScope("gateway:payment-cancellations")] GatewayPaymentCancellations,
1617
[OAuthScope("fx")] Fx,
1718
[OAuthScope("payouts:bank-details")] PayoutsBankDetails,
1819
[OAuthScope("sessions:app")] SessionsApp,

src/CheckoutSdk/Payments/IPaymentsClient.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Requests.UnreferencedRefundRequest;
22
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Responses;
3+
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Requests.ReverseAPaymentRequest;
4+
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Responses.ReverseAPaymentResponse;
35
using Checkout.Payments.Request;
46
using Checkout.Payments.Response;
57
using System.Threading;
@@ -53,6 +55,12 @@ Task<RefundResponse> RefundPayment(
5355
RefundRequest refundRequest = null,
5456
string idempotencyKey = null,
5557
CancellationToken cancellationToken = default);
58+
59+
Task<ReverseAPaymentResponse> ReverseAPayment(
60+
string paymentId,
61+
ReverseAPaymentRequest reverseAPaymentRequest = null,
62+
string idempotencyKey = null,
63+
CancellationToken cancellationToken = default);
5664

5765
Task<VoidResponse> VoidPayment(
5866
string paymentId,

src/CheckoutSdk/Payments/PaymentsClient.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Responses;
33
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Responses.RequestAPaymentOrPayoutResponseAccepted;
44
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPayments.Responses.RequestAPaymentOrPayoutResponseCreated;
5+
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Requests.ReverseAPaymentRequest;
6+
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Responses.ReverseAPaymentResponse;
57
using Checkout.Payments.Request;
68
using Checkout.Payments.Response;
79
using System;
@@ -155,6 +157,18 @@ public Task<RefundResponse> RefundPayment(
155157
idempotencyKey);
156158
}
157159

160+
public Task<ReverseAPaymentResponse> ReverseAPayment(string paymentId,
161+
ReverseAPaymentRequest reverseAPaymentRequest = null,
162+
string idempotencyKey = null, CancellationToken cancellationToken = default)
163+
{
164+
CheckoutUtils.ValidateParams("paymentId", paymentId);
165+
return ApiClient.Post<ReverseAPaymentResponse>(BuildPath(PaymentsPath, paymentId, "reversals"),
166+
SdkAuthorization(),
167+
reverseAPaymentRequest,
168+
cancellationToken,
169+
idempotencyKey);
170+
}
171+
158172
public Task<VoidResponse> VoidPayment(
159173
string paymentId,
160174
VoidRequest voidRequest = null,
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Requests.ReverseAPaymentRequest;
2+
using Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals.Responses.ReverseAPaymentResponse;
3+
using Checkout.Payments;
4+
using Moq;
5+
using Shouldly;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Xunit;
9+
10+
namespace Checkout.HandlePaymentsAndPayouts.Payments.POSTPaymentsIdReversals
11+
{
12+
public class HandleReversalsClientTest : UnitTestFixture
13+
{
14+
private const string TestPaymentId = "pay_test_12345678901234567890123456";
15+
private const string TestActionId = "act_test_12345678901234567890123456";
16+
private const string TestReference = "test-reversal-reference";
17+
private const string TestIdempotencyKey = "test-idempotency-key";
18+
private const string ReversalsPath = "payments/{0}/reversals";
19+
20+
private readonly SdkAuthorization _authorization =
21+
new SdkAuthorization(PlatformType.DefaultOAuth, ValidDefaultSk);
22+
23+
private readonly Mock<IApiClient> _apiClient = new Mock<IApiClient>();
24+
private readonly Mock<SdkCredentials> _sdkCredentials = new Mock<SdkCredentials>(PlatformType.DefaultOAuth);
25+
private readonly Mock<IHttpClientFactory> _httpClientFactory = new Mock<IHttpClientFactory>();
26+
private readonly Mock<CheckoutConfiguration> _configuration;
27+
private readonly PaymentsClient _client;
28+
29+
public HandleReversalsClientTest()
30+
{
31+
_sdkCredentials.Setup(credentials => credentials.GetSdkAuthorization(SdkAuthorizationType.SecretKeyOrOAuth))
32+
.Returns(_authorization);
33+
34+
_configuration = new Mock<CheckoutConfiguration>(_sdkCredentials.Object, Environment.Sandbox,
35+
_httpClientFactory.Object);
36+
37+
_client = new PaymentsClient(_apiClient.Object, _configuration.Object);
38+
}
39+
40+
[Fact]
41+
public async Task ShouldReversePayment_WhenSuccessful()
42+
{
43+
// Arrange
44+
var request = CreateReverseAPaymentRequest();
45+
var response = CreateReverseAPaymentResponse();
46+
47+
SetupApiClientMock(request, response);
48+
49+
// Act
50+
var result = await _client.ReverseAPayment(TestPaymentId, request);
51+
52+
// Assert
53+
AssertSuccessfulResponse(result, TestActionId, TestReference);
54+
}
55+
56+
[Fact]
57+
public async Task ShouldReversePaymentWithNullRequest_WhenSuccessful()
58+
{
59+
// Arrange
60+
var response = CreateReverseAPaymentResponse(includeReference: false);
61+
62+
SetupApiClientMockForNullRequest(response);
63+
64+
// Act
65+
var result = await _client.ReverseAPayment(TestPaymentId);
66+
67+
// Assert
68+
AssertSuccessfulResponse(result, TestActionId);
69+
}
70+
71+
[Fact]
72+
public async Task ShouldThrowCheckoutArgumentException_WhenPaymentIdIsNull()
73+
{
74+
// Arrange
75+
var request = CreateReverseAPaymentRequest();
76+
77+
// Act & Assert
78+
await Should.ThrowAsync<CheckoutArgumentException>(async () =>
79+
await _client.ReverseAPayment(null, request));
80+
}
81+
82+
[Fact]
83+
public async Task ShouldThrowCheckoutArgumentException_WhenPaymentIdIsEmpty()
84+
{
85+
// Arrange
86+
var request = CreateReverseAPaymentRequest();
87+
88+
// Act & Assert
89+
await Should.ThrowAsync<CheckoutArgumentException>(async () =>
90+
await _client.ReverseAPayment(string.Empty, request));
91+
}
92+
93+
[Fact]
94+
public async Task ShouldReversePayment_WithIdempotencyKey_WhenSuccessful()
95+
{
96+
// Arrange
97+
var request = CreateReverseAPaymentRequest();
98+
var response = CreateReverseAPaymentResponse();
99+
100+
SetupApiClientMock(request, response, TestIdempotencyKey);
101+
102+
// Act
103+
var result = await _client.ReverseAPayment(TestPaymentId, request, TestIdempotencyKey);
104+
105+
// Assert
106+
AssertSuccessfulResponse(result, TestActionId, TestReference);
107+
}
108+
109+
[Fact]
110+
public async Task ShouldReversePayment_WithIdempotencyKeyAndNullRequest_WhenSuccessful()
111+
{
112+
// Arrange
113+
var response = CreateReverseAPaymentResponse(includeReference: false);
114+
115+
SetupApiClientMockForNullRequest(response, TestIdempotencyKey);
116+
117+
// Act
118+
var result = await _client.ReverseAPayment(TestPaymentId, null, TestIdempotencyKey);
119+
120+
// Assert
121+
AssertSuccessfulResponse(result, TestActionId);
122+
}
123+
124+
[Fact]
125+
public async Task ShouldReversePayment_WithCustomCancellationToken_WhenSuccessful()
126+
{
127+
// Arrange
128+
var request = CreateReverseAPaymentRequest();
129+
var cancellationToken = new CancellationToken();
130+
var response = CreateReverseAPaymentResponse();
131+
132+
SetupApiClientMock(request, response, cancellationToken: cancellationToken);
133+
134+
// Act
135+
var result = await _client.ReverseAPayment(TestPaymentId, request, null, cancellationToken);
136+
137+
// Assert
138+
AssertSuccessfulResponse(result, TestActionId, TestReference);
139+
}
140+
141+
[Fact]
142+
public async Task ShouldReturnSameResult_WhenSameIdempotencyKeyUsedTwice()
143+
{
144+
// Arrange
145+
var request = CreateReverseAPaymentRequest();
146+
var idempotencyKey = "test-idempotency-key-123";
147+
var expectedResponse = CreateReverseAPaymentResponse();
148+
149+
SetupApiClientMock(request, expectedResponse, idempotencyKey);
150+
151+
// Act - First call
152+
var firstResult = await _client.ReverseAPayment(TestPaymentId, request, idempotencyKey);
153+
154+
// Act - Second call with same idempotency key
155+
var secondResult = await _client.ReverseAPayment(TestPaymentId, request, idempotencyKey);
156+
157+
// Assert - Both calls should return the same result
158+
AssertIdempotentResults(firstResult, secondResult);
159+
160+
// Verify the API was called twice with the same parameters
161+
VerifyApiClientCalledTwice(request, idempotencyKey);
162+
}
163+
164+
private ReverseAPaymentRequest CreateReverseAPaymentRequest()
165+
{
166+
return new ReverseAPaymentRequest
167+
{
168+
Reference = TestReference,
169+
Metadata = new { OrderId = "order_123", CustomField = "test_value" }
170+
};
171+
}
172+
173+
private ReverseAPaymentResponse CreateReverseAPaymentResponse(bool includeReference = true)
174+
{
175+
var response = new ReverseAPaymentResponse
176+
{
177+
ActionId = TestActionId
178+
};
179+
180+
if (includeReference)
181+
{
182+
response.Reference = TestReference;
183+
}
184+
185+
return response;
186+
}
187+
188+
private void SetupApiClientMock(
189+
ReverseAPaymentRequest request,
190+
ReverseAPaymentResponse response,
191+
string idempotencyKey = null,
192+
CancellationToken cancellationToken = default)
193+
{
194+
_apiClient.Setup(apiClient =>
195+
apiClient.Post<ReverseAPaymentResponse>(
196+
string.Format(ReversalsPath, TestPaymentId),
197+
_authorization,
198+
request,
199+
cancellationToken == default ? CancellationToken.None : cancellationToken,
200+
idempotencyKey))
201+
.ReturnsAsync(response);
202+
}
203+
204+
private void SetupApiClientMockForNullRequest(
205+
ReverseAPaymentResponse response,
206+
string idempotencyKey = null)
207+
{
208+
_apiClient.Setup(apiClient =>
209+
apiClient.Post<ReverseAPaymentResponse>(
210+
string.Format(ReversalsPath, TestPaymentId),
211+
_authorization,
212+
It.IsAny<ReverseAPaymentRequest>(),
213+
CancellationToken.None,
214+
idempotencyKey))
215+
.ReturnsAsync(response);
216+
}
217+
218+
private static void AssertSuccessfulResponse(ReverseAPaymentResponse result, string expectedActionId, string expectedReference = null)
219+
{
220+
result.ShouldNotBeNull();
221+
result.ActionId.ShouldBe(expectedActionId);
222+
223+
if (expectedReference != null)
224+
{
225+
result.Reference.ShouldBe(expectedReference);
226+
}
227+
}
228+
229+
private static void AssertIdempotentResults(ReverseAPaymentResponse firstResult, ReverseAPaymentResponse secondResult)
230+
{
231+
firstResult.ShouldNotBeNull();
232+
secondResult.ShouldNotBeNull();
233+
firstResult.ActionId.ShouldBe(secondResult.ActionId);
234+
firstResult.Reference.ShouldBe(secondResult.Reference);
235+
firstResult.ActionId.ShouldBe(TestActionId);
236+
firstResult.Reference.ShouldBe(TestReference);
237+
}
238+
239+
private void VerifyApiClientCalledTwice(ReverseAPaymentRequest request, string idempotencyKey)
240+
{
241+
_apiClient.Verify(apiClient =>
242+
apiClient.Post<ReverseAPaymentResponse>(
243+
string.Format(ReversalsPath, TestPaymentId),
244+
_authorization,
245+
request,
246+
CancellationToken.None,
247+
idempotencyKey), Times.Exactly(2));
248+
}
249+
}
250+
}

0 commit comments

Comments
 (0)