Skip to content

Commit

Permalink
Add support for refunding split payments (#376)
Browse files Browse the repository at this point in the history
* #367 Add support for the RoutingReversal and ReverseRouting in RefundRequest and RefundResponse

* #367: Add unit test to verify serialization and deserialization of RoutingReversals property

* #367: Add unit test to verify serialization and deserialization of ReverseRouting property
  • Loading branch information
Viincenttt authored Jul 6, 2024
1 parent 980896d commit 9d907b4
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 22 deletions.
20 changes: 19 additions & 1 deletion src/Mollie.Api/Models/Refund/Request/RefundRequest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Mollie.Api.JsonConverters;
using System.Collections.Generic;
using Mollie.Api.JsonConverters;
using Newtonsoft.Json;

namespace Mollie.Api.Models.Refund.Request {
Expand All @@ -21,6 +22,23 @@ public record RefundRequest {
[JsonConverter(typeof(RawJsonConverter))]
public string? Metadata { get; set; }

/// <summary>
/// With Mollie Connect you can charge fees on payments that your app is processing on behalf of other Mollie merchants,
/// by providing the routing object during payment creation. When creating refunds for these routed payments, by default
/// the full amount is deducted from your balance.If you want to pull back the funds that were routed to the connected
/// merchant(s), you can set this parameter to true when issuing a full refund. For more fine-grained control and for
/// partial refunds, use the routingReversals parameter instead.
/// </summary>
public bool? ReverseRouting { get; set; }

/// <summary>
/// When creating refunds for routed payments, by default the full amount is deducted from your balance. If you want to
/// pull back funds from the connected merchant(s), you can use this parameter to specify what amount needs to be
/// reversed from which merchant(s). If you simply want to fully reverse the routed funds, you can also use the
/// reverseRouting parameter instead.
/// </summary>
public IList<RoutingReversal>? RoutingReversals { get; init; }

/// <summary>
/// Set this to true to refund a test mode payment.
/// </summary>
Expand Down
48 changes: 31 additions & 17 deletions src/Mollie.Api/Models/Refund/Response/RefundResponse.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using Mollie.Api.JsonConverters;
using Newtonsoft.Json;

Expand All @@ -15,15 +16,14 @@ public record RefundResponse {
public required string Id { get; set; }

/// <summary>
/// The amount refunded to the consumer with this refund.
/// The description of the refund that may be shown to the consumer, depending on the payment method used.
/// </summary>
public required Amount Amount { get; set; }
public string? Description { get; set; }

/// <summary>
/// The identifier referring to the settlement this payment was settled with. For example, stl_BkEjN2eBb.
/// This field is omitted if the refund is not settled (yet).
/// The amount refunded to the consumer with this refund.
/// </summary>
public string? SettlementId { get; set; }
public required Amount Amount { get; set; }

/// <summary>
/// This optional field will contain the amount that will be deducted from your account balance, converted
Expand All @@ -34,9 +34,11 @@ public record RefundResponse {
public Amount? SettlementAmount { get; set; }

/// <summary>
/// The description of the refund that may be shown to the consumer, depending on the payment method used.
/// Provide any data you like, for example a string or a JSON object. We will save the data alongside the refund. Whenever
/// you fetch the refund with our API, we’ll also include the metadata. You can use up to approximately 1kB.
/// </summary>
public string? Description { get; set; }
[JsonConverter(typeof(RawJsonConverter))]
public string? Metadata { get; set; }

/// <summary>
/// Since refunds may be delayed for certain payment methods, the refund carries a status field. See the
Expand All @@ -45,9 +47,21 @@ public record RefundResponse {
public required string Status { get; set; }

/// <summary>
/// The date and time the refund was issued, in ISO 8601 format.
/// With Mollie Connect you can charge fees on payments that your app is processing on behalf of other Mollie merchants,
/// by providing the routing object during payment creation. When creating refunds for these routed payments, by default
/// the full amount is deducted from your balance.If you want to pull back the funds that were routed to the connected
/// merchant(s), you can set this parameter to true when issuing a full refund. For more fine-grained control and for
/// partial refunds, use the routingReversals parameter instead.
/// </summary>
public DateTime? CreatedAt { get; set; }
public bool? ReverseRouting { get; set; }

/// <summary>
/// When creating refunds for routed payments, by default the full amount is deducted from your balance. If you want to
/// pull back funds from the connected merchant(s), you can use this parameter to specify what amount needs to be
/// reversed from which merchant(s). If you simply want to fully reverse the routed funds, you can also use the
/// reverseRouting parameter instead.
/// </summary>
public IList<RoutingReversal>? RoutingReversals { get; init; }

/// <summary>
/// The unique identifier of the payment this refund was created for. For example: tr_7UhSN1zuXS. The full
Expand All @@ -56,11 +70,15 @@ public record RefundResponse {
public required string PaymentId { get; set; }

/// <summary>
/// Provide any data you like, for example a string or a JSON object. We will save the data alongside the refund. Whenever
/// you fetch the refund with our API, we’ll also include the metadata. You can use up to approximately 1kB.
/// The identifier referring to the settlement this payment was settled with. For example, stl_BkEjN2eBb.
/// This field is omitted if the refund is not settled (yet).
/// </summary>
[JsonConverter(typeof(RawJsonConverter))]
public string? Metadata { get; set; }
public string? SettlementId { get; set; }

/// <summary>
/// The date and time the refund was issued, in ISO 8601 format.
/// </summary>
public DateTime? CreatedAt { get; set; }

/// <summary>
/// An object with several URL objects relevant to the refund. Every URL object will contain an href and a type field.
Expand All @@ -71,9 +89,5 @@ public record RefundResponse {
public T? GetMetadata<T>(JsonSerializerSettings? jsonSerializerSettings = null) {
return Metadata != null ? JsonConvert.DeserializeObject<T>(Metadata, jsonSerializerSettings) : default;
}

public override string ToString() {
return $"Id: {Id} - PaymentId: {PaymentId}";
}
}
}
15 changes: 15 additions & 0 deletions src/Mollie.Api/Models/Refund/RoutingReversal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Mollie.Api.Models.Payment;

namespace Mollie.Api.Models.Refund;

public record RoutingReversal {
/// <summary>
/// The amount that will be pulled back.
/// </summary>
public required Amount Amount { get; init; }

/// <summary>
/// Where the funds will be pulled back from.
/// </summary>
public required RoutingDestination Source { get; init; }
}
2 changes: 0 additions & 2 deletions tests/Mollie.Tests.Integration/Api/OrderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,10 @@ public async Task UpdateOrderAsync_OrderIsUpdated_OrderIsUpdated() {
OrderNumber = "1337",
BillingAddress = createdOrder.BillingAddress
};
orderUpdateRequest.BillingAddress!.City = "Den Haag";
OrderResponse updatedOrder = await _orderClient.UpdateOrderAsync(createdOrder.Id, orderUpdateRequest);

// Then: Make sure the order is updated
updatedOrder.OrderNumber.Should().Be(orderUpdateRequest.OrderNumber);
updatedOrder.BillingAddress!.City.Should().Be(orderUpdateRequest.BillingAddress.City);
}

[DefaultRetryFact(Skip = "Broken - Reported to Mollie: https://discordapp.com/channels/1037712581407817839/1180467187677401198/1180467187677401198")]
Expand Down
1 change: 0 additions & 1 deletion tests/Mollie.Tests.Integration/Api/PaymentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,6 @@ public async Task CanCreatePaymentWithMultiplePaymentMethods() {
[InlineData(typeof(PaymentRequest), PaymentMethod.Belfius, typeof(BelfiusPaymentResponse))]
[InlineData(typeof(KbcPaymentRequest), PaymentMethod.Kbc, typeof(KbcPaymentResponse))]
[InlineData(typeof(PaymentRequest), PaymentMethod.Eps, typeof(EpsPaymentResponse))]
[InlineData(typeof(PaymentRequest), PaymentMethod.Giropay, typeof(GiropayPaymentResponse))]
[InlineData(typeof(PaymentRequest), null, typeof(PaymentResponse))]
public async Task CanCreateSpecificPaymentType(Type paymentType, string paymentMethod, Type expectedResponseType) {
// When: we create a specific payment type with some bank transfer specific values
Expand Down
108 changes: 107 additions & 1 deletion tests/Mollie.Tests.Unit/Client/RefundClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
using FluentAssertions.Extensions;
using Mollie.Api.Models;
using Mollie.Api.Models.Order.Request;
using Mollie.Api.Models.Payment;
using Mollie.Api.Models.Refund;
using Mollie.Api.Models.Refund.Request;
using Mollie.Api.Models.Refund.Response;
using RichardSzalay.MockHttp;
using Xunit;

Expand Down Expand Up @@ -69,7 +72,7 @@ public async Task GetRefundAsync_TestModeParameterCase_QueryStringOnlyContainsTe
[InlineData(" ")]
[InlineData(null)]
public async Task CreateRefundAsync_NoPaymentIdIsGiven_ArgumentExceptionIsThrown(string paymentId) {
// Arrange
// Given: We create a refund without specifying a paymentId
var mockHttp = new MockHttpMessageHandler();
HttpClient httpClient = mockHttp.ToHttpClient();
RefundClient refundClient = new RefundClient("api-key", httpClient);
Expand All @@ -84,6 +87,109 @@ public async Task CreateRefundAsync_NoPaymentIdIsGiven_ArgumentExceptionIsThrown
exception.Message.Should().Be("Required URL argument 'paymentId' is null or empty");
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task CreateRefundAsync_WithReverseRouting_ResponseIsDeserializedInExpectedFormat(bool reverseRouting) {
// Given: We create a refund with a routing destination
const string paymentId = "tr_7UhSN1zuXS";
var refundRequest = new RefundRequest {
Amount = new Amount(Currency.EUR, 100m),
ReverseRouting = reverseRouting
};
string expectedStringValue = reverseRouting.ToString().ToLowerInvariant();
string expectedRoutingInformation = $"\"reverseRouting\": {expectedStringValue}";
string expectedJsonResponse = @$"{{
""resource"": ""refund"",
""id"": ""re_4qqhO89gsT"",
""description"": """",
""amount"": {{
""currency"": ""EUR"",
""value"": ""100.00""
}},
""reverseRouting"": ""{expectedStringValue}"",
""status"": ""pending"",
""metadata"": null,
""paymentId"": ""{paymentId}"",
""createdAt"": ""2023-03-14T17:09:02.0Z""
}}";
var mockHttp = CreateMockHttpMessageHandler(
HttpMethod.Post,
$"{BaseMollieClient.ApiEndPoint}payments/{paymentId}/refunds",
expectedJsonResponse,
expectedRoutingInformation);
HttpClient httpClient = mockHttp.ToHttpClient();
RefundClient refundClient = new("api-key", httpClient);

// When: We create the refund
RefundResponse refundResponse = await refundClient.CreatePaymentRefundAsync(paymentId, refundRequest);

// Then
mockHttp.VerifyNoOutstandingExpectation();
refundResponse.ReverseRouting.Should().Be(reverseRouting);
refundResponse.RoutingReversals.Should().BeNull();
}

[Fact]
public async Task CreateRefundAsync_WithRoutingInformation_ResponseIsDeserializedInExpectedFormat() {
// Given: We create a refund with a routing destination
const string paymentId = "tr_7UhSN1zuXS";
var refundRequest = new RefundRequest {
Amount = new Amount(Currency.EUR, 100m),
ReverseRouting = null,
RoutingReversals = new List<RoutingReversal> {
new RoutingReversal {
Amount = new Amount(Currency.EUR, 50m),
Source = new RoutingDestination {
Type = "organization",
OrganizationId = "organization-id"
}
}
}
};
string expectedRoutingInformation = $"\"routingReversals\":[{{\"amount\":{{\"currency\":\"EUR\",\"value\":\"50.00\"}},\"source\":{{\"type\":\"organization\",\"organizationId\":\"organization-id\"}}}}]}}";
string expectedJsonResponse = @$"{{
""resource"": ""refund"",
""id"": ""re_4qqhO89gsT"",
""description"": """",
""amount"": {{
""currency"": ""EUR"",
""value"": ""100.00""
}},
""routingReversals"": [
{{
""amount"": {{
""currency"": ""EUR"",
""value"": ""50.00""
}},
""source"": {{
""type"": ""organization"",
""organizationId"": ""organization-id""
}}
}}
],
""status"": ""pending"",
""metadata"": null,
""paymentId"": ""{paymentId}"",
""createdAt"": ""2023-03-14T17:09:02.0Z""
}}";
var mockHttp = CreateMockHttpMessageHandler(
HttpMethod.Post,
$"{BaseMollieClient.ApiEndPoint}payments/{paymentId}/refunds",
expectedJsonResponse,
expectedRoutingInformation);
HttpClient httpClient = mockHttp.ToHttpClient();
RefundClient refundClient = new("api-key", httpClient);

// When: We create the refund
RefundResponse refundResponse = await refundClient.CreatePaymentRefundAsync(paymentId, refundRequest);

// Then
mockHttp.VerifyNoOutstandingExpectation();
refundResponse.RoutingReversals.Should().BeEquivalentTo(refundRequest.RoutingReversals);
refundResponse.ReverseRouting.Should().BeNull();
}

[Theory]
[InlineData("")]
[InlineData(" ")]
Expand Down

0 comments on commit 9d907b4

Please sign in to comment.