Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using MediatR;

namespace VictoryCenter.BLL.Commands.Payment.Common;

public interface IPaymentCommandHandler<in TRequest, TResult> : IRequestHandler<TRequest, TResult>
where TRequest : IRequest<TResult>
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using FluentResults;
using MediatR;
using VictoryCenter.BLL.DTOs.Payment.Common;

namespace VictoryCenter.BLL.Commands.Payment.Common;

public record PaymentCommand(PaymentRequestDto Request) : IRequest<Result<PaymentResponseDto>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using FluentResults;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using VictoryCenter.BLL.Commands.Payment.Common;
using VictoryCenter.BLL.Constants;
using VictoryCenter.BLL.DTOs.Payment.Common;
using VictoryCenter.BLL.DTOs.Payment.WayForPay;
using VictoryCenter.BLL.Options.Payment;

namespace VictoryCenter.BLL.Commands.Payment.WayForPay;

public class WayForPayPaymentCommandHandler : IPaymentCommandHandler<PaymentCommand, Result<PaymentResponseDto>>
{
private readonly IOptions<WayForPayOptions> _way4PayOptions;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<WayForPayPaymentCommandHandler> _logger;

public WayForPayPaymentCommandHandler(IOptions<WayForPayOptions> way4PayOptions, IHttpClientFactory httpClientFactory, ILogger<WayForPayPaymentCommandHandler> logger)
{
_way4PayOptions = way4PayOptions;
_httpClientFactory = httpClientFactory;
_logger = logger;
}

public async Task<Result<PaymentResponseDto>> Handle(PaymentCommand request, CancellationToken cancellationToken)
{
var orderReference = Guid.CreateVersion7();
var orderDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var merchantSignature = GenerateMerchantSignature(request, orderReference, orderDate);

var purchaseRequest = new WayForPayPurchaseRequest()
{
Amount = request.Request.Amount,
Currency = request.Request.Currency,
MerchantAccount = _way4PayOptions.Value.MerchantLogin,
MerchantDomainName = _way4PayOptions.Value.MerchantDomainName,
OrderDate = orderDate,
OrderReference = orderReference.ToString(),
ProductCount = [1],
ProductName = [PaymentConstants.ProductName],
ProductPrice = [request.Request.Amount],
MerchantSignature = merchantSignature,
ReturnUrl = request.Request.ReturnUrl
};

// regular payment is going to be supported in the future
// if (request.Request.IsSubscription)
// {
// purchaseRequest.RegularBehavior = PaymentConstants.RegularPaymentBehaviour;
// purchaseRequest.RegularAmount = request.Request.Amount;
// purchaseRequest.RegularMode = PaymentConstants.RegularPaymentMode;
// purchaseRequest.RegularOn = true;
// }

var keyValues = new Dictionary<string, string>
{
["merchantAccount"] = purchaseRequest.MerchantAccount,
["merchantDomainName"] = purchaseRequest.MerchantDomainName,
["orderReference"] = purchaseRequest.OrderReference,
["orderDate"] = purchaseRequest.OrderDate.ToString(),
["amount"] = purchaseRequest.Amount.ToString(CultureInfo.InvariantCulture),
["currency"] = purchaseRequest.Currency.ToString(),
["productName[]"] = purchaseRequest.ProductName[0],
["productCount[]"] = purchaseRequest.ProductCount[0].ToString(CultureInfo.InvariantCulture),
["productPrice[]"] = purchaseRequest.ProductPrice[0].ToString(CultureInfo.InvariantCulture),
["merchantSignature"] = purchaseRequest.MerchantSignature,
};

// regular payment is going to be supported in the future
// if (purchaseRequest.RegularOn.HasValue && purchaseRequest.RegularOn.Value)
// {
// keyValues["regularOn"] = "1";
// keyValues["regularAmount"] = purchaseRequest.RegularAmount?.ToString(CultureInfo.InvariantCulture) ?? purchaseRequest.Amount.ToString(CultureInfo.InvariantCulture);
// keyValues["regularMode"] = purchaseRequest.RegularMode!;
// keyValues["regularBehavior"] = purchaseRequest.RegularBehavior!;
// keyValues["regularCount"] = PaymentConstants.RegularPaymentCount;
// }

if (!string.IsNullOrWhiteSpace(purchaseRequest.ReturnUrl))
{
keyValues["returnUrl"] = purchaseRequest.ReturnUrl;
}

var content = new FormUrlEncodedContent(keyValues);

var client = _httpClientFactory.CreateClient("Way4PayClient");

var httpRequestMessage = new HttpRequestMessage()
{
RequestUri = new Uri(_way4PayOptions.Value.ApiUrl),
Method = HttpMethod.Post,
Content = content
};

try
{
var response = await client.SendAsync(httpRequestMessage, cancellationToken);

if (response.StatusCode is HttpStatusCode.Found or HttpStatusCode.SeeOther or HttpStatusCode.Moved)
{
var paymentUrl = response.Headers.Location?.ToString();
if (!string.IsNullOrEmpty(paymentUrl))
{
return Result.Ok(new PaymentResponseDto()
{
PaymentUrl = paymentUrl
});
}
}

return Result.Fail(response.ReasonPhrase ?? PaymentConstants.PaymentRequestFailedWithStatus(response.StatusCode));
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Error occured when processing payment request: {ErrorMessage}", ex.Message);
return Result.Fail(PaymentConstants.FailedToCommunicateWithPaymentGateway(ex.Message));
}
catch (TaskCanceledException ex)
{
_logger.LogError(ex, PaymentConstants.PaymentRequestWasCancelledOrTimedOut);
return Result.Fail(PaymentConstants.PaymentRequestWasCancelledOrTimedOut);
}
}

private string GenerateMerchantSignature(PaymentCommand request, Guid orderReference, long orderDate)
{
var concatenatedValues = string.Join(
';',
_way4PayOptions.Value.MerchantLogin,
_way4PayOptions.Value.MerchantDomainName,
orderReference,
orderDate,
request.Request.Amount.ToString(CultureInfo.InvariantCulture),
request.Request.Currency,
PaymentConstants.ProductName,
1,
request.Request.Amount.ToString(CultureInfo.InvariantCulture));

var secretKeyBytes = Encoding.UTF8.GetBytes(_way4PayOptions.Value.MerchantSecretKey);
var signatureBytes = Encoding.UTF8.GetBytes(concatenatedValues);

using var hmac = new HMACMD5(secretKeyBytes);

var bytes = hmac.ComputeHash(signatureBytes);
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes)
{
sb.Append(b.ToString("x2"));
}

return sb.ToString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,9 @@ public static string PropertyMustBeInAValidFormat(string property, string? forma
? $"{property} must be in a valid format"
: $"{property} must be in a valid format of {format}";
}

public static string PropertyMustHaveALengthOfNCharacters(string property, int length)
{
return $"{property} must have a length of {length} characters";
}
}
25 changes: 25 additions & 0 deletions VictoryCenter/VictoryCenter.BLL/Constants/PaymentConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Net;

namespace VictoryCenter.BLL.Constants;

public static class PaymentConstants
{
public static readonly string ChosenPaymentSystemIsNotSupported = "Chosen payment system is not supported";
public static readonly string ProductName = "Донат";
public static readonly string RegularPaymentBehaviour = "preset";
public static readonly string RegularPaymentMode = "monthly";
public static readonly string RegularPaymentCount = "12";
public static readonly string PaymentUrlIsNotAvailable = "Payment URL is not available";
public static readonly string UnableToConductDonation = "Unable to conduct donation";
public static readonly string PaymentRequestWasCancelledOrTimedOut = "Payment request was cancelled or timed out";

public static string PaymentRequestFailedWithStatus(HttpStatusCode status)
{
return $"Payment request failed with status: {status}";
}

public static string FailedToCommunicateWithPaymentGateway(string errorMessage)
{
return $"Failed to communicate with payment gateway: {errorMessage}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace VictoryCenter.BLL.DTOs.Payment.Common;

public record PaymentRequestDto
{
public decimal Amount { get; init; }
public Currency Currency { get; init; }
public bool IsSubscription { get; init; }
public PaymentSystem PaymentSystem { get; init; }
public string? ReturnUrl { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace VictoryCenter.BLL.DTOs.Payment.Common;

public record PaymentResponseDto
{
public string PaymentUrl { get; init; } = null!;
}
11 changes: 11 additions & 0 deletions VictoryCenter/VictoryCenter.BLL/DTOs/Payment/Currency.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;

namespace VictoryCenter.BLL.DTOs.Payment;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Currency
{
UAH,
USD,
EUR
}
9 changes: 9 additions & 0 deletions VictoryCenter/VictoryCenter.BLL/DTOs/Payment/PaymentSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;

namespace VictoryCenter.BLL.DTOs.Payment;

[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PaymentSystem
{
WayForPay
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace VictoryCenter.BLL.DTOs.Payment.WayForPay;

public class WayForPayPurchaseRequest
{
required public string MerchantAccount { get; init; }
required public string MerchantDomainName { get; init; }
required public string MerchantSignature { get; init; }
required public string OrderReference { get; init; }
required public long OrderDate { get; init; }
required public decimal Amount { get; init; }
required public Currency Currency { get; init; }
required public string[] ProductName { get; init; }
required public decimal[] ProductPrice { get; init; }
required public int[] ProductCount { get; init; }
public string? ReturnUrl { get; set; }
public string? ServiceUrl { get; set; }
public string? RegularBehavior { get; set; }
public string? RegularMode { get; set; }
public decimal? RegularAmount { get; set; }
public bool? RegularOn { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using FluentResults;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using VictoryCenter.BLL.Commands.Payment.Common;
using VictoryCenter.BLL.Commands.Payment.WayForPay;
using VictoryCenter.BLL.DTOs.Payment;
using VictoryCenter.BLL.DTOs.Payment.Common;
using VictoryCenter.BLL.Factories.Payment.Interfaces;
using VictoryCenter.BLL.Options.Payment;

namespace VictoryCenter.BLL.Factories.Payment.Implementations;

public class WayForPayDonationFactory : IDonationFactory
{
private readonly IOptions<WayForPayOptions> _way4PayOptions;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<WayForPayPaymentCommandHandler> _logger;

public WayForPayDonationFactory(IOptions<WayForPayOptions> way4PayOptions, IHttpClientFactory httpClientFactory, ILogger<WayForPayPaymentCommandHandler> logger)
{
_way4PayOptions = way4PayOptions;
_httpClientFactory = httpClientFactory;
_logger = logger;
}

public PaymentSystem PaymentSystem => PaymentSystem.WayForPay;

public IPaymentCommandHandler<PaymentCommand, Result<PaymentResponseDto>> GetRequestHandler()
{
return new WayForPayPaymentCommandHandler(_way4PayOptions, _httpClientFactory, _logger);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using FluentResults;
using VictoryCenter.BLL.Commands.Payment.Common;
using VictoryCenter.BLL.DTOs.Payment;
using VictoryCenter.BLL.DTOs.Payment.Common;

namespace VictoryCenter.BLL.Factories.Payment.Interfaces;

public interface IDonationFactory
{
PaymentSystem PaymentSystem { get; }
IPaymentCommandHandler<PaymentCommand, Result<PaymentResponseDto>> GetRequestHandler();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using FluentResults;
using VictoryCenter.BLL.DTOs.Payment.Common;

namespace VictoryCenter.BLL.Interfaces.PaymentService;

public interface IPaymentService
{
Task<Result<PaymentResponseDto>> CreatePayment(PaymentRequestDto request, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;

namespace VictoryCenter.BLL.Options.Payment;

public class WayForPayOptions
{
public const string Position = "PaymentSystemsConfigurations:Way4Pay";

[Required]
public string MerchantLogin { get; init; } = null!;

[Required]
public string MerchantSecretKey { get; init; } = null!;

[Required]
public string MerchantDomainName { get; init; } = null!;

[Required]
public string ApiUrl { get; init; } = null!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using FluentResults;
using FluentValidation;
using VictoryCenter.BLL.Commands.Payment.Common;
using VictoryCenter.BLL.Constants;
using VictoryCenter.BLL.DTOs.Payment.Common;
using VictoryCenter.BLL.Factories.Payment.Interfaces;
using VictoryCenter.BLL.Interfaces.PaymentService;

namespace VictoryCenter.BLL.Services.PaymentService;

public class PaymentService : IPaymentService
{
private readonly IEnumerable<IDonationFactory> _donationFactories;
private readonly IValidator<PaymentRequestDto> _validator;

public PaymentService(IEnumerable<IDonationFactory> donationFactories, IValidator<PaymentRequestDto> validator)
{
_donationFactories = donationFactories;
_validator = validator;
}

public async Task<Result<PaymentResponseDto>> CreatePayment(PaymentRequestDto request, CancellationToken cancellationToken)
{
var validationResult = await _validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
{
return Result.Fail(validationResult.Errors.Select(x => x.ErrorMessage));
}

var donationFactory = _donationFactories.SingleOrDefault(df => df.PaymentSystem == request.PaymentSystem);
if (donationFactory is null)
{
return Result.Fail(PaymentConstants.ChosenPaymentSystemIsNotSupported);
}

var commandHandler = donationFactory.GetRequestHandler();

return await commandHandler.Handle(new PaymentCommand(request), cancellationToken);
}
}
Loading
Loading