Skip to content

Commit b6b2af3

Browse files
Feature/issue 236 (#282)
* feat: implement donation functionality * feat: add unit tests * feat: add integration tests * feat: add support for return url * feat: make changes according to the code rabbit suggestions * add fixes * small fixes * fix cors * fix comments * fix tests
1 parent 597644d commit b6b2af3

29 files changed

Lines changed: 1041 additions & 10 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using MediatR;
2+
3+
namespace VictoryCenter.BLL.Commands.Payment.Common;
4+
5+
public interface IPaymentCommandHandler<in TRequest, TResult> : IRequestHandler<TRequest, TResult>
6+
where TRequest : IRequest<TResult>
7+
{
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
using FluentResults;
2+
using MediatR;
3+
using VictoryCenter.BLL.DTOs.Payment.Common;
4+
5+
namespace VictoryCenter.BLL.Commands.Payment.Common;
6+
7+
public record PaymentCommand(PaymentRequestDto Request) : IRequest<Result<PaymentResponseDto>>;
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System.Globalization;
2+
using System.Net;
3+
using System.Security.Cryptography;
4+
using System.Text;
5+
using FluentResults;
6+
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Options;
8+
using VictoryCenter.BLL.Commands.Payment.Common;
9+
using VictoryCenter.BLL.Constants;
10+
using VictoryCenter.BLL.DTOs.Payment.Common;
11+
using VictoryCenter.BLL.DTOs.Payment.WayForPay;
12+
using VictoryCenter.BLL.Options.Payment;
13+
14+
namespace VictoryCenter.BLL.Commands.Payment.WayForPay;
15+
16+
public class WayForPayPaymentCommandHandler : IPaymentCommandHandler<PaymentCommand, Result<PaymentResponseDto>>
17+
{
18+
private readonly IOptions<WayForPayOptions> _way4PayOptions;
19+
private readonly IHttpClientFactory _httpClientFactory;
20+
private readonly ILogger<WayForPayPaymentCommandHandler> _logger;
21+
22+
public WayForPayPaymentCommandHandler(IOptions<WayForPayOptions> way4PayOptions, IHttpClientFactory httpClientFactory, ILogger<WayForPayPaymentCommandHandler> logger)
23+
{
24+
_way4PayOptions = way4PayOptions;
25+
_httpClientFactory = httpClientFactory;
26+
_logger = logger;
27+
}
28+
29+
public async Task<Result<PaymentResponseDto>> Handle(PaymentCommand request, CancellationToken cancellationToken)
30+
{
31+
var orderReference = Guid.CreateVersion7();
32+
var orderDate = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
33+
var merchantSignature = GenerateMerchantSignature(request, orderReference, orderDate);
34+
35+
var purchaseRequest = new WayForPayPurchaseRequest()
36+
{
37+
Amount = request.Request.Amount,
38+
Currency = request.Request.Currency,
39+
MerchantAccount = _way4PayOptions.Value.MerchantLogin,
40+
MerchantDomainName = _way4PayOptions.Value.MerchantDomainName,
41+
OrderDate = orderDate,
42+
OrderReference = orderReference.ToString(),
43+
ProductCount = [1],
44+
ProductName = [PaymentConstants.ProductName],
45+
ProductPrice = [request.Request.Amount],
46+
MerchantSignature = merchantSignature,
47+
ReturnUrl = request.Request.ReturnUrl
48+
};
49+
50+
// regular payment is going to be supported in the future
51+
// if (request.Request.IsSubscription)
52+
// {
53+
// purchaseRequest.RegularBehavior = PaymentConstants.RegularPaymentBehaviour;
54+
// purchaseRequest.RegularAmount = request.Request.Amount;
55+
// purchaseRequest.RegularMode = PaymentConstants.RegularPaymentMode;
56+
// purchaseRequest.RegularOn = true;
57+
// }
58+
59+
var keyValues = new Dictionary<string, string>
60+
{
61+
["merchantAccount"] = purchaseRequest.MerchantAccount,
62+
["merchantDomainName"] = purchaseRequest.MerchantDomainName,
63+
["orderReference"] = purchaseRequest.OrderReference,
64+
["orderDate"] = purchaseRequest.OrderDate.ToString(),
65+
["amount"] = purchaseRequest.Amount.ToString(CultureInfo.InvariantCulture),
66+
["currency"] = purchaseRequest.Currency.ToString(),
67+
["productName[]"] = purchaseRequest.ProductName[0],
68+
["productCount[]"] = purchaseRequest.ProductCount[0].ToString(CultureInfo.InvariantCulture),
69+
["productPrice[]"] = purchaseRequest.ProductPrice[0].ToString(CultureInfo.InvariantCulture),
70+
["merchantSignature"] = purchaseRequest.MerchantSignature,
71+
};
72+
73+
// regular payment is going to be supported in the future
74+
// if (purchaseRequest.RegularOn.HasValue && purchaseRequest.RegularOn.Value)
75+
// {
76+
// keyValues["regularOn"] = "1";
77+
// keyValues["regularAmount"] = purchaseRequest.RegularAmount?.ToString(CultureInfo.InvariantCulture) ?? purchaseRequest.Amount.ToString(CultureInfo.InvariantCulture);
78+
// keyValues["regularMode"] = purchaseRequest.RegularMode!;
79+
// keyValues["regularBehavior"] = purchaseRequest.RegularBehavior!;
80+
// keyValues["regularCount"] = PaymentConstants.RegularPaymentCount;
81+
// }
82+
83+
if (!string.IsNullOrWhiteSpace(purchaseRequest.ReturnUrl))
84+
{
85+
keyValues["returnUrl"] = purchaseRequest.ReturnUrl;
86+
}
87+
88+
var content = new FormUrlEncodedContent(keyValues);
89+
90+
var client = _httpClientFactory.CreateClient("Way4PayClient");
91+
92+
var httpRequestMessage = new HttpRequestMessage()
93+
{
94+
RequestUri = new Uri(_way4PayOptions.Value.ApiUrl),
95+
Method = HttpMethod.Post,
96+
Content = content
97+
};
98+
99+
try
100+
{
101+
var response = await client.SendAsync(httpRequestMessage, cancellationToken);
102+
103+
if (response.StatusCode is HttpStatusCode.Found or HttpStatusCode.SeeOther or HttpStatusCode.Moved)
104+
{
105+
var paymentUrl = response.Headers.Location?.ToString();
106+
if (!string.IsNullOrEmpty(paymentUrl))
107+
{
108+
return Result.Ok(new PaymentResponseDto()
109+
{
110+
PaymentUrl = paymentUrl
111+
});
112+
}
113+
}
114+
115+
return Result.Fail(response.ReasonPhrase ?? PaymentConstants.PaymentRequestFailedWithStatus(response.StatusCode));
116+
}
117+
catch (HttpRequestException ex)
118+
{
119+
_logger.LogError(ex, "Error occured when processing payment request: {ErrorMessage}", ex.Message);
120+
return Result.Fail(PaymentConstants.FailedToCommunicateWithPaymentGateway(ex.Message));
121+
}
122+
catch (TaskCanceledException ex)
123+
{
124+
_logger.LogError(ex, PaymentConstants.PaymentRequestWasCancelledOrTimedOut);
125+
return Result.Fail(PaymentConstants.PaymentRequestWasCancelledOrTimedOut);
126+
}
127+
}
128+
129+
private string GenerateMerchantSignature(PaymentCommand request, Guid orderReference, long orderDate)
130+
{
131+
var concatenatedValues = string.Join(
132+
';',
133+
_way4PayOptions.Value.MerchantLogin,
134+
_way4PayOptions.Value.MerchantDomainName,
135+
orderReference,
136+
orderDate,
137+
request.Request.Amount.ToString(CultureInfo.InvariantCulture),
138+
request.Request.Currency,
139+
PaymentConstants.ProductName,
140+
1,
141+
request.Request.Amount.ToString(CultureInfo.InvariantCulture));
142+
143+
var secretKeyBytes = Encoding.UTF8.GetBytes(_way4PayOptions.Value.MerchantSecretKey);
144+
var signatureBytes = Encoding.UTF8.GetBytes(concatenatedValues);
145+
146+
using var hmac = new HMACMD5(secretKeyBytes);
147+
148+
var bytes = hmac.ComputeHash(signatureBytes);
149+
var sb = new StringBuilder(bytes.Length * 2);
150+
foreach (var b in bytes)
151+
{
152+
sb.Append(b.ToString("x2"));
153+
}
154+
155+
return sb.ToString();
156+
}
157+
}

VictoryCenter/VictoryCenter.BLL/Constants/ErrorMessagesConstants.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,14 @@ public static string PropertyMustBeInAValidFormat(string property, string? forma
4848
? $"{property} must be in a valid format"
4949
: $"{property} must be in a valid format of {format}";
5050
}
51+
52+
public static string PropertyMustHaveALengthOfNCharacters(string property, int length)
53+
{
54+
return $"{property} must have a length of {length} characters";
55+
}
56+
57+
public static string PropertyMustBeValidEnum(string property)
58+
{
59+
return $"{property} must be a valid value";
60+
}
5161
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System.Net;
2+
3+
namespace VictoryCenter.BLL.Constants;
4+
5+
public static class PaymentConstants
6+
{
7+
public static readonly string ChosenPaymentSystemIsNotSupported = "Chosen payment system is not supported";
8+
public static readonly string ProductName = "Донат";
9+
public static readonly string RegularPaymentBehaviour = "preset";
10+
public static readonly string RegularPaymentMode = "monthly";
11+
public static readonly string RegularPaymentCount = "12";
12+
public static readonly string PaymentUrlIsNotAvailable = "Payment URL is not available";
13+
public static readonly string UnableToConductDonation = "Unable to conduct donation";
14+
public static readonly string PaymentRequestWasCancelledOrTimedOut = "Payment request was cancelled or timed out";
15+
16+
public static string PaymentRequestFailedWithStatus(HttpStatusCode status)
17+
{
18+
return $"Payment request failed with status: {status}";
19+
}
20+
21+
public static string FailedToCommunicateWithPaymentGateway(string errorMessage)
22+
{
23+
return $"Failed to communicate with payment gateway: {errorMessage}";
24+
}
25+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace VictoryCenter.BLL.DTOs.Payment.Common;
2+
3+
public record PaymentRequestDto
4+
{
5+
public decimal Amount { get; init; }
6+
public Currency Currency { get; init; }
7+
public bool IsSubscription { get; init; }
8+
public PaymentSystem PaymentSystem { get; init; }
9+
public string? ReturnUrl { get; init; }
10+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace VictoryCenter.BLL.DTOs.Payment.Common;
2+
3+
public record PaymentResponseDto
4+
{
5+
public string PaymentUrl { get; init; } = null!;
6+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace VictoryCenter.BLL.DTOs.Payment;
4+
5+
[JsonConverter(typeof(JsonStringEnumConverter))]
6+
public enum Currency
7+
{
8+
UAH,
9+
USD,
10+
EUR
11+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace VictoryCenter.BLL.DTOs.Payment;
4+
5+
[JsonConverter(typeof(JsonStringEnumConverter))]
6+
public enum PaymentSystem
7+
{
8+
WayForPay
9+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace VictoryCenter.BLL.DTOs.Payment.WayForPay;
2+
3+
public class WayForPayPurchaseRequest
4+
{
5+
required public string MerchantAccount { get; init; }
6+
required public string MerchantDomainName { get; init; }
7+
required public string MerchantSignature { get; init; }
8+
required public string OrderReference { get; init; }
9+
required public long OrderDate { get; init; }
10+
required public decimal Amount { get; init; }
11+
required public Currency Currency { get; init; }
12+
required public string[] ProductName { get; init; }
13+
required public decimal[] ProductPrice { get; init; }
14+
required public int[] ProductCount { get; init; }
15+
public string? ReturnUrl { get; set; }
16+
public string? ServiceUrl { get; set; }
17+
public string? RegularBehavior { get; set; }
18+
public string? RegularMode { get; set; }
19+
public decimal? RegularAmount { get; set; }
20+
public bool? RegularOn { get; set; }
21+
}

0 commit comments

Comments
 (0)