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