22 changed files with 580 additions and 4 deletions
@ -0,0 +1,16 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.Application.Services; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
public interface IPaymentRequestAppService : IApplicationService |
||||
|
{ |
||||
|
Task<PaymentRequestDto> CreateAsync(PaymentRequestCreationDto input); |
||||
|
|
||||
|
Task<PaymentRequestStartResultDto> StartAsync(PaymentRequestStartDto input); |
||||
|
|
||||
|
Task<PaymentRequestDto> CompleteAsync(string token); |
||||
|
|
||||
|
Task<bool> HandleWebhookAsync(string payload); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
[Serializable] |
||||
|
public class PaymentRequestCreationDto |
||||
|
{ |
||||
|
[Required] |
||||
|
[MaxLength(PaymentRequestConsts.MaxCurrencyLength)] |
||||
|
public string Currency { get; set; } |
||||
|
|
||||
|
public string BuyerId { get; set; } |
||||
|
|
||||
|
public List<PaymentRequestProductCreationDto> Products { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
using Volo.Abp.Application.Dtos; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
[Serializable] |
||||
|
public class PaymentRequestDto : CreationAuditedEntityDto<Guid> |
||||
|
{ |
||||
|
[Required] |
||||
|
[MaxLength(PaymentRequestConsts.MaxCurrencyLength)] |
||||
|
public string Currency { get; set; } |
||||
|
|
||||
|
public string BuyerId { get; set; } |
||||
|
|
||||
|
public bool IsDeleted { get; set; } |
||||
|
|
||||
|
public PaymentRequestState State { get; set; } |
||||
|
|
||||
|
public List<PaymentRequestProductDto> Products { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
using System; |
||||
|
using Volo.Abp.Application.Dtos; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
[Serializable] |
||||
|
public class PaymentRequestProductDto : EntityDto<Guid> |
||||
|
{ |
||||
|
public Guid PaymentRequestId { get; private set; } |
||||
|
|
||||
|
public string ReferenceId { get; set; } |
||||
|
|
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
public decimal UnitPrice { get; set; } |
||||
|
|
||||
|
public int Quantity { get; set; } |
||||
|
|
||||
|
public decimal TotalPrice { get; set; } |
||||
|
|
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService |
||||
|
{ |
||||
|
[Serializable] |
||||
|
public class PaymentRequestProductCreationDto |
||||
|
{ |
||||
|
public string ReferenceId { get; set; } |
||||
|
|
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
public decimal UnitPrice { get; set; } |
||||
|
|
||||
|
public int Quantity { get; set; } |
||||
|
|
||||
|
public decimal TotalPrice { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
using System; |
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
[Serializable] |
||||
|
public class PaymentRequestStartDto |
||||
|
{ |
||||
|
public Guid PaymentRequestId { get; set; } |
||||
|
|
||||
|
[Required] |
||||
|
public string ReturnUrl { get; set; } |
||||
|
|
||||
|
public string CancelUrl { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
[Serializable] |
||||
|
public class PaymentRequestStartResultDto |
||||
|
{ |
||||
|
public string CheckoutLink { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,47 @@ |
|||||
|
namespace EShopOnAbp.PaymentService.PayPal |
||||
|
{ |
||||
|
public static class PayPalConsts |
||||
|
{ |
||||
|
public const string OrderIdPropertyName = "OrderId"; |
||||
|
|
||||
|
public static class OrderStatus |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The order was created with the specified context.
|
||||
|
/// </summary>
|
||||
|
public const string Created = "CREATED"; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The order was saved and persisted. The order status continues to be in progress until a capture is made with final_capture = true for all purchase units within the order.
|
||||
|
/// </summary>
|
||||
|
public const string Saved = "SAVED"; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The customer approved the payment through the PayPal wallet or another form of guest or unbranded payment. For example, a card, bank account, or so on.
|
||||
|
/// </summary>
|
||||
|
public const string Approved = "APPROVED"; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// All purchase units in the order are voided.
|
||||
|
/// </summary>
|
||||
|
public const string Voided = "VOIDED"; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The payment was authorized or the authorized payment was captured for the order.
|
||||
|
/// </summary>
|
||||
|
public const string Completed = "COMPLETED"; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The order requires an action from the payer (e.g. 3DS authentication). Redirect the payer to the "rel":"payer-action" HATEOAS link returned as part of the response prior to authorizing or capturing the order.
|
||||
|
/// </summary>
|
||||
|
public const string PlayerActionRequired = "PAYER_ACTION_REQUIRED"; |
||||
|
} |
||||
|
|
||||
|
public static class Environment |
||||
|
{ |
||||
|
public const string Sandbox = "Sandbox"; |
||||
|
|
||||
|
public const string Live = "Live"; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PayPal |
||||
|
{ |
||||
|
public class PayPalOptions |
||||
|
{ |
||||
|
public string ClientId { get; set; } |
||||
|
|
||||
|
public string Secret { get; set; } |
||||
|
|
||||
|
public string Locale { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// "Sandbox" or "Live". Default value is "Sandbox"
|
||||
|
/// </summary>
|
||||
|
public string Environment { get; set; } = PayPalConsts.Environment.Sandbox; |
||||
|
|
||||
|
public bool Recommended { get; set; } |
||||
|
|
||||
|
public List<string> ExtraInfos { get; set; } = new(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,156 @@ |
|||||
|
using EShopOnAbp.PaymentService.PayPal; |
||||
|
using Newtonsoft.Json.Linq; |
||||
|
using PayPalCheckoutSdk.Core; |
||||
|
using PayPalCheckoutSdk.Orders; |
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
public class PaymentRequestAppService : PaymentServiceAppService, IPaymentRequestAppService |
||||
|
{ |
||||
|
protected IPaymentRequestRepository PaymentRequestRepository { get; } |
||||
|
protected PayPalHttpClient PayPalHttpClient { get; } |
||||
|
|
||||
|
public PaymentRequestAppService( |
||||
|
IPaymentRequestRepository paymentRequestRepository, |
||||
|
PayPalHttpClient payPalHttpClient) |
||||
|
{ |
||||
|
PaymentRequestRepository = paymentRequestRepository; |
||||
|
PayPalHttpClient = payPalHttpClient; |
||||
|
} |
||||
|
|
||||
|
public async Task<PaymentRequestDto> CreateAsync(PaymentRequestCreationDto input) |
||||
|
{ |
||||
|
var paymentRequest = new PaymentRequest(GuidGenerator.Create(), input.Currency, input.BuyerId); |
||||
|
|
||||
|
foreach (var paymentRequestProduct in input.Products |
||||
|
.Select(s => new PaymentRequestProduct( |
||||
|
paymentRequest.Id, |
||||
|
s.Name, |
||||
|
s.UnitPrice, |
||||
|
s.Quantity, |
||||
|
s.TotalPrice, |
||||
|
s.ReferenceId))) |
||||
|
{ |
||||
|
paymentRequest.Products.Add(paymentRequestProduct); |
||||
|
} |
||||
|
|
||||
|
return ObjectMapper.Map<PaymentRequest, PaymentRequestDto>(paymentRequest); |
||||
|
} |
||||
|
|
||||
|
public async Task<PaymentRequestStartResultDto> StartAsync(PaymentRequestStartDto input) |
||||
|
{ |
||||
|
var paymentRequest = await PaymentRequestRepository.GetAsync(input.PaymentRequestId); |
||||
|
|
||||
|
var totalCheckoutPrice = paymentRequest.Products.Sum(s => s.TotalPrice); |
||||
|
|
||||
|
var order = new OrderRequest |
||||
|
{ |
||||
|
CheckoutPaymentIntent = "CAPTURE", |
||||
|
ApplicationContext = new ApplicationContext |
||||
|
{ |
||||
|
ReturnUrl = input.ReturnUrl, |
||||
|
CancelUrl = input.CancelUrl, |
||||
|
}, |
||||
|
PurchaseUnits = new List<PurchaseUnitRequest> |
||||
|
{ |
||||
|
new PurchaseUnitRequest |
||||
|
{ |
||||
|
AmountWithBreakdown = new AmountWithBreakdown |
||||
|
{ |
||||
|
AmountBreakdown = new AmountBreakdown |
||||
|
{ |
||||
|
ItemTotal = new Money |
||||
|
{ |
||||
|
CurrencyCode = paymentRequest.Currency, |
||||
|
Value = totalCheckoutPrice.ToString(".00") |
||||
|
} |
||||
|
}, |
||||
|
CurrencyCode = paymentRequest.Currency, |
||||
|
Value = totalCheckoutPrice.ToString(".00"), |
||||
|
}, |
||||
|
Items = paymentRequest.Products.Select(p => new Item |
||||
|
{ |
||||
|
Quantity = p.Quantity.ToString(), |
||||
|
Name = p.Name, |
||||
|
UnitAmount = new Money |
||||
|
{ |
||||
|
CurrencyCode = paymentRequest.Currency, |
||||
|
Value = p.UnitPrice.ToString(".00") |
||||
|
} |
||||
|
}).ToList(), |
||||
|
ReferenceId = paymentRequest.Id.ToString() |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
var request = new OrdersCreateRequest(); |
||||
|
request.Prefer("return=representation"); |
||||
|
request.RequestBody(order); |
||||
|
|
||||
|
var result = (await PayPalHttpClient.Execute(request)).Result<Order>(); |
||||
|
|
||||
|
return new PaymentRequestStartResultDto |
||||
|
{ |
||||
|
CheckoutLink = result.Links.First(x => x.Rel == "approve").Href |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async Task<PaymentRequestDto> CompleteAsync(string token) |
||||
|
{ |
||||
|
var request = new OrdersCaptureRequest(token); |
||||
|
request.RequestBody(new OrderActionRequest()); |
||||
|
|
||||
|
var order = (await PayPalHttpClient.Execute(request)).Result<Order>(); |
||||
|
|
||||
|
var paymentRequest = await UpdatePaymentRequestStateAsync(order); |
||||
|
|
||||
|
return ObjectMapper.Map<PaymentRequest, PaymentRequestDto>(paymentRequest); |
||||
|
} |
||||
|
|
||||
|
public async Task<bool> HandleWebhookAsync(string payload) |
||||
|
{ |
||||
|
var jObject = JObject.Parse(payload); |
||||
|
|
||||
|
var order = jObject["resource"].ToObject<Order>(); |
||||
|
|
||||
|
var request = new OrdersGetRequest(order.Id); |
||||
|
|
||||
|
// Ensure order object comes from PayPal
|
||||
|
var response = await PayPalHttpClient.Execute(request); |
||||
|
order = response.Result<Order>(); |
||||
|
|
||||
|
await UpdatePaymentRequestStateAsync(order); |
||||
|
|
||||
|
// PayPal doesn't accept Http 204 (NoContent) result and tries to execute webhook again.
|
||||
|
// So with following value, API returns Http 200 (OK) result.
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
private async Task<PaymentRequest> UpdatePaymentRequestStateAsync(Order order) |
||||
|
{ |
||||
|
var paymentRequestId = Guid.Parse(order.PurchaseUnits.First().ReferenceId); |
||||
|
|
||||
|
var paymentRequest = await PaymentRequestRepository.GetAsync(paymentRequestId); |
||||
|
|
||||
|
if (order.Status == PayPalConsts.OrderStatus.Completed || order.Status == PayPalConsts.OrderStatus.Approved) |
||||
|
{ |
||||
|
paymentRequest.SetAsCompleted(); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
paymentRequest.SetAsFailed(order.Status); |
||||
|
} |
||||
|
|
||||
|
paymentRequest.ExtraProperties[PayPalConsts.OrderIdPropertyName] = order.Id; |
||||
|
paymentRequest.ExtraProperties[nameof(order.Status)] = order.Status; |
||||
|
|
||||
|
await PaymentRequestRepository.UpdateAsync(paymentRequest); |
||||
|
|
||||
|
return paymentRequest; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
using System; |
||||
|
using Volo.Abp.Data; |
||||
|
using Volo.Abp.Domain.Entities.Events.Distributed; |
||||
|
using Volo.Abp.EventBus; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService |
||||
|
{ |
||||
|
[Serializable] |
||||
|
[EventName("Payment.Completed")] |
||||
|
public class PaymentRequestCompletedEto: EtoBase, IHasExtraProperties |
||||
|
{ |
||||
|
public Guid PaymentRequestId { get; set; } |
||||
|
public ExtraPropertyDictionary ExtraProperties { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
public static class PaymentRequestConsts |
||||
|
{ |
||||
|
public const int MaxCurrencyLength = 3; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
using System; |
||||
|
using Volo.Abp.Data; |
||||
|
using Volo.Abp.Domain.Entities.Events.Distributed; |
||||
|
using Volo.Abp.EventBus; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService |
||||
|
{ |
||||
|
[Serializable] |
||||
|
[EventName("Payment.Completed")] |
||||
|
public class PaymentRequestFailedEto : EtoBase, IHasExtraProperties |
||||
|
{ |
||||
|
public Guid PaymentRequestId { get; set; } |
||||
|
public string FailReason { get; set; } |
||||
|
public ExtraPropertyDictionary ExtraProperties { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
public enum PaymentRequestState |
||||
|
{ |
||||
|
Waiting = 0, |
||||
|
Completed, |
||||
|
Failed |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
using System; |
||||
|
using Volo.Abp.Domain.Repositories; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
public interface IPaymentRequestRepository : IBasicRepository<PaymentRequest, Guid> |
||||
|
{ |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,73 @@ |
|||||
|
using JetBrains.Annotations; |
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using Volo.Abp; |
||||
|
using Volo.Abp.Domain.Entities.Auditing; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
public class PaymentRequest : CreationAuditedAggregateRoot<Guid>, ISoftDelete |
||||
|
{ |
||||
|
[NotNull] |
||||
|
public string Currency { get; protected set; } |
||||
|
|
||||
|
[CanBeNull] |
||||
|
public string BuyerId { get; protected set; } |
||||
|
|
||||
|
public PaymentRequestState State { get; protected set; } |
||||
|
|
||||
|
[CanBeNull] |
||||
|
public string FailReason { get; protected set; } |
||||
|
|
||||
|
public bool IsDeleted { get; set; } |
||||
|
|
||||
|
public ICollection<PaymentRequestProduct> Products { get; } = new List<PaymentRequestProduct>(); |
||||
|
|
||||
|
private PaymentRequest() |
||||
|
{ |
||||
|
|
||||
|
} |
||||
|
|
||||
|
public PaymentRequest(Guid id, [NotNull] string currency, [CanBeNull] string buyerId = null) |
||||
|
{ |
||||
|
Id = id; |
||||
|
Currency = Check.NotNullOrWhiteSpace(currency, nameof(currency), maxLength: PaymentRequestConsts.MaxCurrencyLength); |
||||
|
BuyerId = buyerId; |
||||
|
} |
||||
|
|
||||
|
public virtual void SetAsCompleted() |
||||
|
{ |
||||
|
if (State == PaymentRequestState.Completed) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
State = PaymentRequestState.Completed; |
||||
|
FailReason = null; |
||||
|
|
||||
|
AddDistributedEvent(new PaymentRequestCompletedEto |
||||
|
{ |
||||
|
PaymentRequestId = Id, |
||||
|
ExtraProperties = ExtraProperties |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public virtual void SetAsFailed(string failReason) |
||||
|
{ |
||||
|
if (State != PaymentRequestState.Failed) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
State = PaymentRequestState.Failed; |
||||
|
FailReason = failReason; |
||||
|
|
||||
|
AddDistributedEvent(new PaymentRequestFailedEto |
||||
|
{ |
||||
|
PaymentRequestId = Id, |
||||
|
FailReason = failReason, |
||||
|
ExtraProperties = ExtraProperties |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
using JetBrains.Annotations; |
||||
|
using System; |
||||
|
using Volo.Abp.Domain.Entities; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
public class PaymentRequestProduct : Entity<Guid> |
||||
|
{ |
||||
|
public Guid PaymentRequestId { get; private set; } |
||||
|
|
||||
|
public string ReferenceId { get; set; } |
||||
|
|
||||
|
public string Name { get; set; } |
||||
|
|
||||
|
public decimal UnitPrice { get; set; } |
||||
|
|
||||
|
public int Quantity { get; set; } |
||||
|
|
||||
|
public decimal TotalPrice { get; set; } |
||||
|
|
||||
|
public PaymentRequestProduct( |
||||
|
Guid paymentRequestId, |
||||
|
[NotNull] string name, |
||||
|
decimal unitPrice, |
||||
|
int quantity, |
||||
|
decimal totalPrice, |
||||
|
[CanBeNull] string referenceId = null) |
||||
|
{ |
||||
|
PaymentRequestId = paymentRequestId; |
||||
|
Name = name; |
||||
|
UnitPrice = unitPrice; |
||||
|
Quantity = quantity; |
||||
|
TotalPrice = totalPrice; |
||||
|
ReferenceId = referenceId; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
using EShopOnAbp.PaymentService.EntityFrameworkCore; |
||||
|
using System; |
||||
|
using Volo.Abp.Domain.Repositories.EntityFrameworkCore; |
||||
|
using Volo.Abp.EntityFrameworkCore; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
public class PaymentRequestEfCoreRepository : EfCoreRepository<IPaymentServiceDbContext, PaymentRequest, Guid>, IPaymentRequestRepository |
||||
|
{ |
||||
|
public PaymentRequestEfCoreRepository(IDbContextProvider<IPaymentServiceDbContext> dbContextProvider) |
||||
|
: base(dbContextProvider) |
||||
|
{ |
||||
|
|
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
using EShopOnAbp.PaymentService.EntityFrameworkCore; |
||||
|
using Shouldly; |
||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace EShopOnAbp.PaymentService.PaymentRequests |
||||
|
{ |
||||
|
public class PaymentRequestRepository_Tests : PaymentServiceEntityFrameworkCoreTestBase |
||||
|
{ |
||||
|
private readonly IPaymentRequestRepository _paymentRequestRepository; |
||||
|
public PaymentRequestRepository_Tests() |
||||
|
{ |
||||
|
_paymentRequestRepository = GetRequiredService<IPaymentRequestRepository>(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Insert_Payment_Request() |
||||
|
{ |
||||
|
var id = Guid.NewGuid(); |
||||
|
var paymentRequest = new PaymentRequest(id, "USD"); |
||||
|
|
||||
|
await _paymentRequestRepository.InsertAsync(paymentRequest, autoSave: true); |
||||
|
|
||||
|
var inserted = await _paymentRequestRepository.GetAsync(id); |
||||
|
|
||||
|
inserted.Id.ShouldNotBe(Guid.Empty); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue