Skip to content

Commit 5d9d805

Browse files
authored
Support partial refund feature coming up in commerce 15.3.0 (#9)
2 parents e21ff51 + ad96441 commit 5d9d805

11 files changed

+163
-34
lines changed

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
<ItemGroup>
1313
<PackageVersion Include="Flurl.Http" Version="[4.0.2, 5)" />
1414
<PackageVersion Include="System.Runtime.Caching" Version="[9.0.0, 10)" />
15-
<PackageVersion Include="Umbraco.Commerce.Core" Version="[15.0.0, 15.999.999)" />
15+
<PackageVersion Include="Umbraco.Commerce.Core" Version="[15.3.0, 15.999.999)" />
1616
</ItemGroup>
1717
</Project>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
3+
namespace Umbraco.Commerce.PaymentProviders.PayPal.Api.Exceptions
4+
{
5+
public class PayPalPaymentProviderGeneralException : Exception
6+
{
7+
public PayPalPaymentProviderGeneralException(string message) : base(message)
8+
{
9+
}
10+
11+
public PayPalPaymentProviderGeneralException(string message, Exception innerException) : base(message, innerException)
12+
{
13+
}
14+
15+
public PayPalPaymentProviderGeneralException()
16+
{
17+
}
18+
}
19+
}
Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
namespace Umbraco.Commerce.PaymentProviders.PayPal.Api.Models
1+
using Umbraco.Commerce.Extensions;
2+
3+
namespace Umbraco.Commerce.PaymentProviders.PayPal.Api.Models
24
{
35
public class LivePayPalClientConfig : PayPalClientConfig
46
{
7+
public LivePayPalClientConfig(string clientId, string secret, string webhookId)
8+
{
9+
clientId.MustNotBeNullOrWhiteSpace(nameof(clientId));
10+
secret.MustNotBeNullOrWhiteSpace(nameof(secret));
11+
webhookId.MustNotBeNullOrWhiteSpace(nameof(webhookId));
12+
13+
ClientId = clientId;
14+
Secret = secret;
15+
WebhookId = webhookId;
16+
}
17+
518
public override string BaseUrl => PayPalClient.LiveApiUrl;
619
}
720
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Umbraco.Commerce.PaymentProviders.PayPal.Api.Models
2+
{
3+
public class PaypalClientRefundRequest
4+
{
5+
public required string PaymentId { get; set; }
6+
public required PayPalAmount Amount { get; set; }
7+
}
8+
}
Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
namespace Umbraco.Commerce.PaymentProviders.PayPal.Api.Models
1+
using Umbraco.Commerce.Extensions;
2+
3+
namespace Umbraco.Commerce.PaymentProviders.PayPal.Api.Models
24
{
35
public class SandboxPayPalClientConfig : PayPalClientConfig
46
{
7+
public SandboxPayPalClientConfig(string clientId, string secret, string webhookId)
8+
{
9+
clientId.MustNotBeNullOrWhiteSpace(nameof(clientId));
10+
secret.MustNotBeNullOrWhiteSpace(nameof(secret));
11+
webhookId.MustNotBeNullOrWhiteSpace(nameof(webhookId));
12+
13+
ClientId = clientId;
14+
Secret = secret;
15+
WebhookId = webhookId;
16+
}
17+
518
public override string BaseUrl => PayPalClient.SandboxApiUrl;
619
}
720
}

src/Umbraco.Commerce.PaymentProviders.PayPal/Api/PayPalClient.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ public async Task<PayPalCapturePayment> CapturePaymentAsync(string paymentId, Ca
116116
.ConfigureAwait(false);
117117
}
118118

119+
/// <summary>
120+
/// Send a full refund request.
121+
/// </summary>
122+
/// <param name="paymentId"></param>
123+
/// <param name="cancellationToken"></param>
124+
/// <returns></returns>
119125
public async Task<PayPalRefundPayment> RefundPaymentAsync(string paymentId, CancellationToken cancellationToken = default)
120126
{
121127
return await RequestAsync(
@@ -127,6 +133,30 @@ public async Task<PayPalRefundPayment> RefundPaymentAsync(string paymentId, Canc
127133
.ConfigureAwait(false);
128134
}
129135

136+
/// <summary>
137+
/// Send a refund request with additional information, such as an amount object for a partial refund.
138+
/// </summary>
139+
/// <param name="refundRequest"></param>
140+
/// <param name="cancellationToken"></param>
141+
/// <returns></returns>
142+
public async Task<PayPalRefundPayment> RefundPaymentAsync(PaypalClientRefundRequest refundRequest, CancellationToken cancellationToken = default)
143+
{
144+
ArgumentNullException.ThrowIfNull(refundRequest);
145+
146+
return await RequestAsync(
147+
$"/v2/payments/captures/{refundRequest.PaymentId}/refund",
148+
async (req, ct) => await req
149+
.PostJsonAsync(
150+
new
151+
{
152+
amount = refundRequest.Amount,
153+
},
154+
cancellationToken: ct)
155+
.ReceiveJson<PayPalRefundPayment>().ConfigureAwait(false),
156+
cancellationToken)
157+
.ConfigureAwait(false);
158+
}
159+
130160
public async Task CancelPaymentAsync(string paymentId, CancellationToken cancellationToken = default)
131161
{
132162
await RequestAsync(

src/Umbraco.Commerce.PaymentProviders.PayPal/PayPalCheckoutOneTimePaymentProvider.cs

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public PayPalCheckoutOneTimePaymentProvider(
3333
public override bool CanCapturePayments => true;
3434
public override bool CanCancelPayments => true;
3535
public override bool CanRefundPayments => true;
36+
public override bool CanPartiallyRefundPayments => true;
3637

3738
// Don't finalize at continue as we will finalize async via webhook
3839
public override bool FinalizeAtContinueUrl => false;
@@ -87,7 +88,7 @@ public override async Task<PaymentFormResult> GenerateFormAsync(PaymentProviderC
8788
// Ensure currency has valid ISO 4217 code
8889
if (!Iso4217.CurrencyCodes.ContainsKey(currencyCode))
8990
{
90-
throw new Exception("Currency must be a valid ISO 4217 currency code: " + currency.Name);
91+
throw new PayPalPaymentProviderGeneralException("Currency must be a valid ISO 4217 currency code: " + currency.Name);
9192
}
9293

9394
// Create the order
@@ -146,8 +147,8 @@ public override async Task<CallbackResult> ProcessCallbackAsync(PaymentProviderC
146147
{
147148
var metaData = new Dictionary<string, string>();
148149

149-
PayPalOrder payPalOrder = null;
150-
PayPalPayment payPalPayment = null;
150+
PayPalOrder? payPalOrder = null;
151+
PayPalPayment? payPalPayment = null;
151152

152153
if (payPalWebhookEvent.EventType.StartsWith("CHECKOUT.ORDER.", StringComparison.InvariantCultureIgnoreCase))
153154
{
@@ -223,14 +224,27 @@ public override async Task<CallbackResult> ProcessCallbackAsync(PaymentProviderC
223224
else if (payPalWebhookEvent.ResourceType == PayPalWebhookEvent.ResourceTypes.Payment.REFUND)
224225
{
225226
payPalPayment = payPalWebhookEvent.Resource.Deserialize<PayPalRefundPayment>();
227+
switch (payPalPayment?.Status)
228+
{
229+
case PayPalRefundPayment.Statuses.COMPLETED:
230+
return CallbackResult.Empty;
231+
232+
case PayPalRefundPayment.Statuses.PENDING:
233+
case PayPalRefundPayment.Statuses.CANCELLED:
234+
_logger.Warn($"Refund request for order '{ctx.Order.TransactionInfo.TransactionId}' has been issued but the status is '{payPalPayment.Status}'. PayPal resource id: '{payPalPayment.Id}'.");
235+
return CallbackResult.Empty;
236+
237+
default:
238+
throw new PayPalPaymentProviderGeneralException($"Refund request for order '{ctx.Order.TransactionInfo.TransactionId}' failed. PayPal resource id: '{payPalPayment?.Id}'.");
239+
}
226240
}
227241
}
228242

229243
return CallbackResult.Ok(
230244
new TransactionInfo
231245
{
232246
AmountAuthorized = decimal.Parse(payPalPayment?.Amount.Value ?? "0.00", CultureInfo.InvariantCulture),
233-
TransactionId = payPalPayment?.Id ?? ctx.Order.TransactionInfo.TransactionId ?? "",
247+
TransactionId = payPalPayment?.Id ?? ctx.Order.TransactionInfo.TransactionId ?? string.Empty,
234248
PaymentStatus = payPalOrder != null
235249
? GetPaymentStatus(payPalOrder)
236250
: GetPaymentStatus(payPalPayment)
@@ -307,24 +321,63 @@ public override async Task<ApiResult> CapturePaymentAsync(PaymentProviderContext
307321
return ApiResult.Empty;
308322
}
309323

310-
public override async Task<ApiResult> RefundPaymentAsync(PaymentProviderContext<PayPalCheckoutOneTimeSettings> ctx, CancellationToken cancellationToken = default)
324+
[Obsolete("Will be removed in v17. Use the overload that takes an order refund request")]
325+
public override async Task<ApiResult?> RefundPaymentAsync(PaymentProviderContext<PayPalCheckoutOneTimeSettings> context, CancellationToken cancellationToken = default)
326+
{
327+
ArgumentNullException.ThrowIfNull(context);
328+
329+
StoreReadOnly store = await Context.Services.StoreService.GetStoreAsync(context.Order.StoreId);
330+
Amount refundAmount = store.CanRefundTransactionFee ? context.Order.TransactionInfo.AmountAuthorized + context.Order.TransactionInfo.TransactionFee : context.Order.TransactionInfo.AmountAuthorized;
331+
return await RefundPaymentAsync(
332+
context,
333+
new PaymentProviderOrderRefundRequest
334+
{
335+
RefundAmount = refundAmount,
336+
Orderlines = context.Order.OrderLines.Select(x => new PaymentProviderOrderlineRefundRequest
337+
{
338+
OrderLineId = x.OrderId,
339+
Quantity = x.Quantity,
340+
}),
341+
},
342+
cancellationToken);
343+
}
344+
345+
public override async Task<ApiResult?> RefundPaymentAsync(PaymentProviderContext<PayPalCheckoutOneTimeSettings> context, PaymentProviderOrderRefundRequest refundRequest, CancellationToken cancellationToken = default)
311346
{
347+
ArgumentNullException.ThrowIfNull(context);
348+
ArgumentNullException.ThrowIfNull(refundRequest);
349+
312350
try
313351
{
314-
if (ctx.Order.TransactionInfo.PaymentStatus == PaymentStatus.Captured)
352+
if (context.Order.TransactionInfo.PaymentStatus
353+
is PaymentStatus.Captured or PaymentStatus.PartiallyRefunded)
315354
{
316-
var clientConfig = GetPayPalClientConfig(ctx.Settings);
317-
var client = new PayPalClient(clientConfig);
318-
319-
var payPalPayment = await client.RefundPaymentAsync(ctx.Order.TransactionInfo.TransactionId, cancellationToken).ConfigureAwait(false);
355+
// Get currency information
356+
CurrencyReadOnly currency = await Context.Services.CurrencyService.GetCurrencyAsync(context.Order.CurrencyId);
357+
string currencyCode = currency.Code.ToUpperInvariant();
358+
359+
PayPalClientConfig clientConfig = GetPayPalClientConfig(context.Settings);
360+
PayPalClient client = new(clientConfig);
361+
PayPalRefundPayment payPalPayment = await client.RefundPaymentAsync(
362+
new PaypalClientRefundRequest
363+
{
364+
PaymentId = context.Order.TransactionInfo.TransactionId,
365+
Amount = new PayPalAmount
366+
{
367+
Value = refundRequest.RefundAmount.ToString("0.00", CultureInfo.InvariantCulture),
368+
CurrencyCode = currencyCode,
369+
}
370+
},
371+
cancellationToken).ConfigureAwait(false);
320372

321373
return new ApiResult()
322374
{
323375
TransactionInfo = new TransactionInfoUpdate()
324376
{
325-
TransactionId = payPalPayment.Id,
377+
// Need to keep the paypal capture resource id after a partial refund in order to do more refunds later on
378+
TransactionId = context.Order.TransactionInfo.TransactionId,
326379
PaymentStatus = GetPaymentStatus(payPalPayment)
327-
}
380+
},
328381
};
329382
}
330383
}

src/Umbraco.Commerce.PaymentProviders.PayPal/PayPalPaymentProviderBase.cs

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
using System.Linq;
2-
using Umbraco.Commerce.Core.Models;
2+
using System.Threading;
3+
using System.Threading.Tasks;
34
using Umbraco.Commerce.Core.Api;
5+
using Umbraco.Commerce.Core.Models;
46
using Umbraco.Commerce.Core.PaymentProviders;
57
using Umbraco.Commerce.PaymentProviders.PayPal.Api;
68
using Umbraco.Commerce.PaymentProviders.PayPal.Api.Models;
7-
using System.Threading.Tasks;
8-
using System.Threading;
99

1010
namespace Umbraco.Commerce.PaymentProviders.PayPal
1111
{
@@ -99,8 +99,9 @@ protected PaymentStatus GetPaymentStatus(PayPalPayment payment)
9999
case PayPalCapturePayment.Statuses.DECLINED:
100100
return PaymentStatus.Error;
101101
case PayPalCapturePayment.Statuses.REFUNDED:
102-
case PayPalCapturePayment.Statuses.PARTIALLY_REFUNDED:
103102
return PaymentStatus.Refunded;
103+
case PayPalCapturePayment.Statuses.PARTIALLY_REFUNDED:
104+
return PaymentStatus.PartiallyRefunded;
104105
}
105106
}
106107
else if (payment is PayPalAuthorizationPayment authPayment)
@@ -140,21 +141,11 @@ protected PayPalClientConfig GetPayPalClientConfig(PayPalSettingsBase settings)
140141
{
141142
if (!settings.SandboxMode)
142143
{
143-
return new LivePayPalClientConfig
144-
{
145-
ClientId = settings.LiveClientId,
146-
Secret = settings.LiveSecret,
147-
WebhookId = settings.LiveWebhookId
148-
};
144+
return new LivePayPalClientConfig(settings.LiveClientId, settings.LiveSecret, settings.LiveWebhookId);
149145
}
150146
else
151147
{
152-
return new SandboxPayPalClientConfig
153-
{
154-
ClientId = settings.SandboxClientId,
155-
Secret = settings.SandboxSecret,
156-
WebhookId = settings.SandboxWebhookId
157-
};
148+
return new SandboxPayPalClientConfig(settings.SandboxClientId, settings.SandboxSecret, settings.SandboxWebhookId);
158149
}
159150
}
160151
}

src/Umbraco.Commerce.PaymentProviders.PayPal/Umbraco.Commerce.PaymentProviders.PayPal.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
<Title>Umbraco Commerce PayPal Payment Provider</Title>
44
<Description>PayPal payment provider for Umbraco Commerce</Description>
55
<StaticWebAssetBasePath>App_Plugins/UmbracoCommercePaypalPaymentProvider</StaticWebAssetBasePath>
6+
<NoWarn>IDE0022;CA2007;IDE0058;IDE0290;</NoWarn>
7+
<Nullable>enable</Nullable>
68
</PropertyGroup>
79

810
<ItemGroup>

src/Umbraco.Commerce.PaymentProviders.PayPal/wwwroot/lang/en.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ export default {
4040
'paypalCheckoutOnetimeSettingsCaptureDescription': 'Flag indicating whether to immediately capture the payment, or whether to just authorize the payment for later (manual) capture',
4141

4242
// metadata
43-
'paypalCheckoutOnetimeMetadataPayPalOrderIdLabel': "PayPal Order ID",
43+
'paypalCheckoutOnetimeMetaDataPayPalOrderIdLabel': "PayPal Order ID",
4444
},
45-
};
45+
};

src/Umbraco.Commerce.PaymentProviders.PayPal/wwwroot/umbraco-package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@
1313
"js": "/App_Plugins/UmbracoCommercePaypalPaymentProvider/lang/en.js"
1414
}
1515
],
16-
"version": ""
16+
"version": "15.1.0"
1717
}

0 commit comments

Comments
 (0)