diff --git a/src/ApiService.cs b/src/ApiService.cs index f774ed2..7cd2033 100644 --- a/src/ApiService.cs +++ b/src/ApiService.cs @@ -2,14 +2,63 @@ internal enum ApiService { + /// + /// Create payment + /// POST /payments + /// CreatePayment, + + /// + /// Get payment + /// GET /payments/{operatorId} + /// + GetPayment, + + /// + /// Authorize payment + /// POST /payments/{operatorId}/authorize + /// AuthorizePayment, + + /// + /// Capture payment + /// POST /payments/{operatorId}/capture + /// CapturePayment, + + /// + /// Refund payment + /// POST /payments/{operatorId}/refund + /// + RefundPayment, + + /// + /// Create saved card + /// POST /cards + /// CreateCard, + + /// + /// Get saved card + /// GET /cards/{operatorId} + /// + GetCard, + + /// + /// Create or update a card link + /// PUT /cards/{operatorId}/link + /// GetCardLink, - GetCardData, - GetCardToken, + + /// + /// Create card token + /// POST /cards/{operatorId}/tokens + /// + CreateCardToken, + + /// + /// Delete card link + /// POST /cards/{operatorId}/cancel + /// DeleteCard, - RefundPayment, - GetPaymentStatus } diff --git a/src/CheckedData.cs b/src/CheckedData.cs new file mode 100644 index 0000000..504492b --- /dev/null +++ b/src/CheckedData.cs @@ -0,0 +1,18 @@ +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow; + +internal sealed class CheckedData +{ + public CheckDataResult Result { get; set; } + + public string Message { get; set; } + + public CheckedData(CheckDataResult result) + { + Result = result; + } + + public CheckedData(CheckDataResult result, string message) : this(result) + { + Message = message; + } +} diff --git a/src/CommandConfiguration.cs b/src/CommandConfiguration.cs new file mode 100644 index 0000000..984bf28 --- /dev/null +++ b/src/CommandConfiguration.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow; + +internal sealed class CommandConfiguration +{ + /// + /// Quick pay command. See operation urls in and + /// + public ApiService CommandType { get; set; } + + /// + /// Command operator id, like /cards/{OperatorId} + /// + public string OperatorId { get; set; } + + /// + /// Command operator second id, like /cards/{OperatorId}/operations/{OperatorSecondId} + /// + public string OperatorSecondId { get; set; } + + /// + /// Command query parameters, like /payments/{OperatorId}/refund?{QueryParameters} + /// + public Dictionary QueryParameters { get; set; } + + /// + /// Parameters for request + /// + public Dictionary Parameters { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/src/Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.csproj b/src/Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.csproj index 24dbfcb..d34b946 100644 --- a/src/Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.csproj +++ b/src/Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.csproj @@ -1,6 +1,6 @@  - 10.1.0 + 10.1.1 1.0.0.0 QuickPay Payment Window The QuickPay Payment Window checkout handler is designed to work with QuickPay v10. @@ -25,7 +25,15 @@ true - + + + + + + + + + diff --git a/src/Hash.cs b/src/Hash.cs new file mode 100644 index 0000000..f6fe696 --- /dev/null +++ b/src/Hash.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow; + +internal static class Hash +{ + public static string ComputeHash(string key, IDictionary formValues) + { + string message = GetMacString(formValues); + + return ComputeHash(key, message); + } + + + public static string ComputeHash(string key, string message) + { + var encoding = new UTF8Encoding(); + byte[] byteKey = encoding.GetBytes(key); + + using (HMACSHA256 hmac = new HMACSHA256(byteKey)) + { + var messageBytes = encoding.GetBytes(message); + var hashedBytes = hmac.ComputeHash(messageBytes); + + return ByteArrayToHexString(hashedBytes); + } + } + + private static string ByteArrayToHexString(byte[] bytes) + { + var result = new StringBuilder(); + foreach (byte b in bytes) + { + result.Append(b.ToString("x2")); + } + + return result.ToString(); + } + + private static string GetMacString(IDictionary formValues) + { + var excludeList = new List { "MAC" }; + var keysSorted = formValues.Keys.ToArray(); + Array.Sort(keysSorted, StringComparer.Ordinal); + + var message = new StringBuilder(); + foreach (string key in keysSorted) + { + if (excludeList.Contains(key)) + continue; + + if (message.Length > 0) + message.Append(" "); + + var value = formValues[key]; + message.Append(value); + } + + return message.ToString(); + } +} diff --git a/src/Models/CardLinkUrl.cs b/src/Models/CardLinkUrl.cs new file mode 100644 index 0000000..afa1c5d --- /dev/null +++ b/src/Models/CardLinkUrl.cs @@ -0,0 +1,10 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.Models; + +[DataContract] +internal sealed class CardLinkUrl +{ + [DataMember(Name = "url")] + public string Url { get; set; } +} diff --git a/src/Models/Frontend/CallbackError.cs b/src/Models/Frontend/CallbackError.cs new file mode 100644 index 0000000..6cb4dfd --- /dev/null +++ b/src/Models/Frontend/CallbackError.cs @@ -0,0 +1,11 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.Models.Frontend; + +//This is a model for ajax-interactions with our templates only +[DataContract] +internal sealed class CallbackError +{ + [DataMember(Name = "errorMessage")] + public string ErrorMessage { get; set; } +} diff --git a/src/Models/Frontend/CreateCardRequestData.cs b/src/Models/Frontend/CreateCardRequestData.cs new file mode 100644 index 0000000..32a634d --- /dev/null +++ b/src/Models/Frontend/CreateCardRequestData.cs @@ -0,0 +1,35 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.Models.Frontend; + +//This is a model for ajax-interactions with our templates only +[DataContract] +internal sealed class CreateCardRequestData +{ + [DataMember(Name = "agreementId")] + public string AgreementId { get; set; } + + [DataMember(Name = "brandingId")] + public string BrandingId { get; set; } + + [DataMember(Name = "languageCode")] + public string LanguageCode { get; set; } + + [DataMember(Name = "paymentMethods")] + public string PaymentMethods { get; set; } + + [DataMember(Name = "googleAnalyticsTrackingId")] + public string GoogleAnalyticsTrackingId { get; set; } + + [DataMember(Name = "googleAnalyticsClientId")] + public string GoogleAnalyticsClientId { get; set; } + + [DataMember(Name = "receiptUrl")] + public string ReceiptUrl { get; set; } + + [DataMember(Name = "cancelUrl")] + public string CancelUrl { get; set; } + + [DataMember(Name = "callbackUrl")] + public string СallbackUrl { get; set; } +} diff --git a/src/Models/ServiceError.cs b/src/Models/ServiceError.cs new file mode 100644 index 0000000..41f0fe4 --- /dev/null +++ b/src/Models/ServiceError.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.Models; + +[DataContract] +internal sealed class ServiceError +{ + [DataMember(Name = "message")] + public string Message { get; set; } + + [DataMember(Name = "error_code")] + public int ErrorCode { get; set; } +} diff --git a/src/QuickPayPaymentWindow.cs b/src/QuickPayPaymentWindow.cs index 27e0e06..720db15 100644 --- a/src/QuickPayPaymentWindow.cs +++ b/src/QuickPayPaymentWindow.cs @@ -2,6 +2,8 @@ using Dynamicweb.Configuration; using Dynamicweb.Core; using Dynamicweb.Ecommerce.Cart; +using Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.Models; +using Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.Models.Frontend; using Dynamicweb.Ecommerce.Orders; using Dynamicweb.Ecommerce.Orders.Gateways; using Dynamicweb.Ecommerce.Prices; @@ -12,15 +14,12 @@ using Dynamicweb.Security.UserManagement; using System; using System.Collections.Generic; +using System.Data; +using System.Globalization; using System.IO; using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; using System.Text; using System.Threading; -using System.Threading.Tasks; namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow; @@ -95,7 +94,7 @@ public string PostModeSelection /// /// Gets or sets path to template that renders before user will be redirected to Quick Pay service /// - [AddInParameter("Post template"), AddInParameterEditor(typeof(TemplateParameterEditor), $"folder=Templates/{PostTemplateFolder}; infoText=The Post template is used to post data to QuickPay when the render mode is Render template or Render inline form.;")] + [AddInParameter("Post template"), AddInParameterEditor(typeof(TemplateParameterEditor), $"folder=Templates/{PostTemplateFolder}; infoText=The Post template is used to post data to QuickPay when the render mode is Render template or Render inline form.")] public string PostTemplate { get => TemplateHelper.GetTemplateName(postTemplate); @@ -159,30 +158,34 @@ public string ErrorTemplate #region Form properties + private static string[] SupportedLanguages { get; set; } = + [ + "da", + "de", + "en", + "es", + "fi", + "fo", + "fr", + "it", + "kl", + "nb", + "nl", + "nn", + "no", + "pl", + "pt", + "ru", + "se", + "sv" + ]; + private static string LanguageCode { get { string currentLanguageCode = Environment.ExecutingContext.GetCulture(true).TwoLetterISOLanguageName; - string[] supportedLanguageCodes = - [ - "da", - "de", - "es", - "fo", - "fi", - "fr", - "kl", - "it", - "nl", - "pl", - "pt", - "ru", - "sv", - "nb", - "nn" - ]; - if (!supportedLanguageCodes.Contains(currentLanguageCode)) + if (!SupportedLanguages.Contains(currentLanguageCode)) return "en"; else { @@ -197,6 +200,15 @@ private static string LanguageCode } } + private static Dictionary GetSupportedLanguagesWithLabels() + { + var cultures = CultureInfo.GetCultures(CultureTypes.AllCultures); + return SupportedLanguages.ToDictionary( + code => code, + code => cultures.FirstOrDefault(culture => culture.TwoLetterISOLanguageName.Equals(code, StringComparison.OrdinalIgnoreCase))?.DisplayName ?? string.Empty + ); + } + private static string BaseUrl(Order order, bool headless = false) { bool disablePortNumber = SystemConfiguration.Instance.GetValue("/Globalsettings/System/http/DisableBaseHrefPort") == "True"; @@ -217,21 +229,6 @@ private static string BaseUrl(Order order, bool headless = false) private static string CallbackUrl(Order order, bool headless = false) => string.Format("{0}&QuickPayState=Callback&redirect=false", BaseUrl(order, headless)); - private string GetServiceLink(ApiService service, string operationID = "", string parameters = "") => service switch - { - ApiService.CreatePayment => string.Format("https://api.quickpay.net/payments"), - ApiService.CreateCard => string.Format("https://api.quickpay.net/cards"), - ApiService.GetCardLink => string.Format("https://api.quickpay.net/cards/{0}/link", operationID), - ApiService.GetCardData => string.Format("https://api.quickpay.net/cards/{0}", operationID), - ApiService.GetCardToken => string.Format("https://api.quickpay.net/cards/{0}/tokens", operationID), - ApiService.AuthorizePayment => string.Format("https://api.quickpay.net/payments/{0}/authorize{1}", operationID, parameters), - ApiService.CapturePayment => string.Format("https://api.quickpay.net/payments/{0}/capture", operationID), - ApiService.DeleteCard => string.Format("https://api.quickpay.net/cards/{0}/cancel", operationID), - ApiService.RefundPayment => string.Format("https://api.quickpay.net/payments/{0}/refund{1}", operationID, parameters), - ApiService.GetPaymentStatus => string.Format("https://api.quickpay.net/payments/{0}{1}", operationID, parameters), - _ => string.Empty - }; - #endregion /// @@ -278,53 +275,43 @@ public override OutputResult BeginCheckout(Order order, CheckoutParameters param ProcessPayment(order, token, true); return PassToCart(order); } - else if (order.DoSaveCardToken || !string.IsNullOrEmpty(cardName) || order.IsRecurringOrderTemplate) - return CreateCard(cardName, order, headless, receiptUrl, cancelUrl); - else + + if (order.DoSaveCardToken || !string.IsNullOrEmpty(cardName) || order.IsRecurringOrderTemplate) { - var formValues = new Dictionary + if (postMode is PostModes.Template) { - {"version", "v10"}, - {"merchant_id", Merchant.Trim()}, - {"agreement_id", Agreement.Trim()}, - {"order_id", order.Id}, - {"language", LanguageCode}, - {"amount", order.Price.PricePIP.ToString()}, - {"currency", order.Price.Currency.Code}, - {"continueurl", receiptUrl ?? ContinueUrl(order)}, - {"cancelurl", cancelUrl ?? CancelUrl(order)}, - {"callbackurl", CallbackUrl(order, headless)}, - {"autocapture", AutoCapture ? "1" : "0"}, - {"autofee", AutoFee ? "1" : "0"}, - {"payment_methods", PaymentMethods}, - {"branding_id", Branding}, - {"google_analytics_tracking_id", GoogleAnalyticsTracking}, - {"google_analytics_client_id", GoogleAnalyticsClient} - }; + var cardTemplate = new Template(TemplateHelper.GetTemplatePath(PostTemplate, PostTemplateFolder)); + GetTemplateHelper().SetCardTemplateTags(cardTemplate); - formValues.Add("checksum", ComputeHash(ApiKey, GetMacString(formValues))); + return new ContentOutputResult + { + Content = Render(order, cardTemplate) + }; + } - switch (postMode) - { - case PostModes.Auto: - LogEvent(order, "Autopost to QuickPay"); - return GetSubmitFormResult("https://payment.quickpay.net", formValues); + return CreateCard(order, headless, receiptUrl, cancelUrl); + } - case PostModes.Template: - LogEvent(order, "Render template"); + QuickpayTemplateHelper templateHelper = GetTemplateHelper(); + switch (postMode) + { + case PostModes.Auto: + LogEvent(order, "Autopost to QuickPay"); + return GetSubmitFormResult("https://payment.quickpay.net", templateHelper.GetQuickpayFormValues(ApiKey)); - var formTemplate = new Template(TemplateHelper.GetTemplatePath(PostTemplate, PostTemplateFolder)); - foreach (var formValue in formValues) - formTemplate.SetTag(string.Format("QuickPayPaymentWindow.{0}", formValue.Key), formValue.Value); + case PostModes.Template: + LogEvent(order, "Render template"); - return new ContentOutputResult { Content = formTemplate.Output() }; + var formTemplate = new Template(TemplateHelper.GetTemplatePath(PostTemplate, PostTemplateFolder)); + templateHelper.SetQuickpayFormTemplateTags(ApiKey, formTemplate); - default: - var errorMessage = string.Format("Unhandled post mode: '{0}'", postMode); - LogError(order, errorMessage); - return PrintErrorTemplate(order, errorMessage); + return new ContentOutputResult { Content = formTemplate.Output() }; + + default: + var errorMessage = string.Format("Unhandled post mode: '{0}'", postMode); + LogError(order, errorMessage); + return PrintErrorTemplate(order, errorMessage); - } } } catch (ThreadAbortException ex) @@ -336,6 +323,26 @@ public override OutputResult BeginCheckout(Order order, CheckoutParameters param LogError(order, ex, "Unhandled exception with message: {0}", ex.Message); return PrintErrorTemplate(order, ex.Message); } + + QuickpayTemplateHelper GetTemplateHelper() => new() + { + Agreement = Agreement.Trim(), + AutoCapture = AutoCapture, + AutoFee = AutoFee, + Branding = Converter.ToInt32(Branding), + CallbackUrl = CallbackUrl(order, headless), + CancelUrl = cancelUrl ?? CancelUrl(order), + ContinueUrl = receiptUrl ?? ContinueUrl(order), + GoogleAnalyticsClient = GoogleAnalyticsClient, + GoogleAnalyticsTracking = GoogleAnalyticsTracking, + LanguageCode = LanguageCode, + Merchant = Merchant.Trim(), + Order = order, + PaymentMethods = PaymentMethods, + ReceiptUrl = receiptUrl, + AvailableLanguages = GetSupportedLanguagesWithLabels(), + AvailablePaymentMethods = GetCardTypes(false, true) + }; } /// @@ -353,6 +360,8 @@ public override OutputResult HandleRequest(Order order) { case "Ok": return StateOk(order); + case "CreateCard": + return HandleCreateCard(order); case "CardSaved": return StateCardSaved(order); case "Cancel": @@ -406,64 +415,118 @@ private OutputResult StateOk(Order order) return PrintErrorTemplate(order, errorMessage); } - private OutputResult StateCardSaved(Order order) + private OutputResult HandleCreateCard(Order order) { - LogEvent(order, "QuickPay Card Authorized successfully"); + try + { + using var streamReader = new StreamReader(Context.Current.Request.InputStream); + string jsonData = streamReader.ReadToEndAsync().GetAwaiter().GetResult(); - string cardId = Context.Current.Request["CardId"] ?? ""; - string cardName = Context.Current.Request["CardName"] ?? ""; + if (string.IsNullOrEmpty(jsonData)) + ThrowException("Callback failed with message: data is empty."); - var cardData = Converter.Deserialize>(ExecuteRequest(order, ApiService.GetCardData, cardId)); - if (cardData.ContainsKey("metadata")) + CreateCardRequestData requestData; + try + { + requestData = Converter.Deserialize(jsonData); + } + catch + { + ThrowException("Callback failed with message: data has wrong format."); + } + + string paymentLink = CreateCard(order, requestData); + var cardLinkUrl = new CardLinkUrl { Url = paymentLink }; + + return EndRequest(Converter.Serialize(cardLinkUrl)); + } + catch (Exception ex) + { + LogError(order, ex.Message); + var callbackError = new CallbackError { ErrorMessage = ex.Message }; + + return EndRequest(Converter.Serialize(callbackError)); + } + + void ThrowException(string errorMessage) + { + LogError(order, errorMessage); + throw new Exception(errorMessage); + } + } + + private OutputResult StateCardSaved(Order order) + { + try { - var metadata = Converter.Deserialize>(cardData["metadata"].ToString()); - var cardType = Converter.ToString(metadata["brand"]); - var cardNubmer = order.TransactionCardNumber = Converter.ToString(metadata["last4"]).PadLeft(16, 'X'); - int? expirationMonth = Converter.ToNullableInt32(metadata["exp_month"]); - int? expirationYear = Converter.ToNullableInt32(metadata["exp_year"]); + LogEvent(order, "QuickPay Card Authorized successfully"); - if (UserContext.Current.User is User user) + string cardId = Context.Current.Request["CardId"] ?? ""; + string cardName = Context.Current.Request["CardName"] ?? ""; + + var request = new QuickPayRequest(ApiKey, order); + var cardData = Converter.Deserialize>(request.SendRequest(new() + { + CommandType = ApiService.GetCard, + OperatorId = cardId + })); + + if (cardData.ContainsKey("metadata")) { - var paymentCard = new PaymentCardToken + var metadata = Converter.Deserialize>(cardData["metadata"].ToString()); + var cardType = Converter.ToString(metadata["brand"]); + var cardNubmer = order.TransactionCardNumber = Converter.ToString(metadata["last4"]).PadLeft(16, 'X'); + int? expirationMonth = Converter.ToNullableInt32(metadata["exp_month"]); + int? expirationYear = Converter.ToNullableInt32(metadata["exp_year"]); + + if (UserContext.Current.User is User user) { - UserID = user.ID, - PaymentID = order.PaymentMethodId, - Name = cardName, - CardType = cardType, - Identifier = cardNubmer, - Token = cardId, - UsedDate = DateTime.Now, - ExpirationMonth = expirationMonth, - ExpirationYear = expirationYear - }; - Services.PaymentCard.Save(paymentCard); - order.SavedCardId = paymentCard.ID; - Services.Orders.Save(order); - LogEvent(order, "Saved Card created"); - UseSavedCardInternal(order, paymentCard); + var paymentCard = new PaymentCardToken + { + UserID = user.ID, + PaymentID = order.PaymentMethodId, + Name = cardName, + CardType = cardType, + Identifier = cardNubmer, + Token = cardId, + UsedDate = DateTime.Now, + ExpirationMonth = expirationMonth, + ExpirationYear = expirationYear + }; + Services.PaymentCard.Save(paymentCard); + order.SavedCardId = paymentCard.ID; + Services.Orders.Save(order); + LogEvent(order, "Saved Card created"); + UseSavedCardInternal(order, paymentCard); + } + else + { + order.TransactionToken = cardId; + Services.Orders.Save(order); + ProcessPayment(order, cardId); + } + + if (!order.Complete) + return PrintErrorTemplate(order, "Some error happened on creating payment using saved card", ErrorType.SavedCard); + + return PassToCart(order); } else { - order.TransactionToken = cardId; - Services.Orders.Save(order); - ProcessPayment(order, cardId); + LogError(order, "Unable to get card meta data from QuickPay"); } - if (!order.Complete) - return PrintErrorTemplate(order, "Some error happened on creating payment using saved card", ErrorType.SavedCard); + CheckoutDone(order); - return PassToCart(order); + var errorMessage = "Card saved but payment failed"; + LogError(order, errorMessage); + return PrintErrorTemplate(order, errorMessage); } - else + catch (Exception ex) { - LogError(order, "Unable to get card meta data from QuickPay"); + LogError(order, ex, ex.Message); + return PrintErrorTemplate(order, ex.Message); } - - CheckoutDone(order); - - var errorMessage = "Card saved but payment failed"; - LogError(order, errorMessage); - return PrintErrorTemplate(order, errorMessage); } private OutputResult StateCancel(Order order) @@ -511,8 +574,8 @@ private void Callback(Order order) callbackResponce = reader.ReadToEndAsync().GetAwaiter().GetResult(); } - CheckDataResult result = CheckData(order, callbackResponce ?? string.Empty, order.Price.PricePIP); - string resultInfo = result switch + CheckedData checkedData = CheckData(order, callbackResponce ?? string.Empty, order.Price.PricePIP); + string resultInfo = checkedData.Result switch { //ViaBill autocapture starts callback. CheckDataResult.FinalCaptureSucceed or CheckDataResult.SplitCaptureSucceed => "Autocapture callback completed successfully", @@ -599,26 +662,37 @@ public OrderCaptureInfo Capture(Order order, long amount, bool final) return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Failed, "Amount to capture should be less of order total"); } - string content = string.Format(@"{{""amount"": {0}}}", amount); - var responseText = ExecuteRequest(order, ApiService.CapturePayment, order.TransactionNumber, content); + var request = new QuickPayRequest(ApiKey, order); + string responseText = request.SendRequest(new() + { + CommandType = ApiService.CapturePayment, + OperatorId = order.TransactionNumber, + Parameters = new Dictionary + { + ["amount"] = amount + } + }); order.GatewayResult = responseText; - switch (CheckData(order, responseText, amount)) + CheckedData checkedData = CheckData(order, responseText, amount); + + float capturedAmount = amount / 100f; + switch (checkedData.Result) { case CheckDataResult.FinalCaptureSucceed: { - LogEvent(order, "Capture successful", DebuggingInfoType.CaptureResult); + LogEvent(order, string.Format("Message=\"{0}\" Amount=\"{1:f2}\"", "Capture successful", capturedAmount), DebuggingInfoType.CaptureResult); return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, "Capture successful"); } case CheckDataResult.SplitCaptureSucceed: if (final) { - LogEvent(order, string.Format("Message=\"{0}\" Amount=\"{1:f2}\"", "Split capture(final)", amount / 100f), DebuggingInfoType.CaptureResult); + LogEvent(order, string.Format("Message=\"{0}\" Amount=\"{1:f2}\"", "Split capture(final)", capturedAmount), DebuggingInfoType.CaptureResult); return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, "Split capture successful"); } else { - LogEvent(order, string.Format("Message=\"{0}\" Amount=\"{1:f2}\"", "Split capture", amount / 100f), DebuggingInfoType.CaptureResult); + LogEvent(order, string.Format("Message=\"{0}\" Amount=\"{1:f2}\"", "Split capture", capturedAmount), DebuggingInfoType.CaptureResult); return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Split, "Split capture successful"); } default: @@ -670,7 +744,12 @@ public void DeleteSavedCard(int savedCardID) var cardID = savedCard.Token; try { - ExecuteRequest(null, ApiService.DeleteCard, cardID); + var request = new QuickPayRequest(ApiKey); + request.SendRequest(new() + { + CommandType = ApiService.DeleteCard, + OperatorId = cardID + }); } catch (Exception ex) { @@ -792,35 +871,75 @@ private ContentOutputResult PrintErrorTemplate(Order order, string errorMessage, }; } - private OutputResult CreateCard(string cardName, Order order, bool headless, string receiptUrl, string cancelUrl) + private OutputResult CreateCard(Order order, bool headless, string receiptUrl, string cancelUrl) + { + var requestData = new CreateCardRequestData + { + AgreementId = Agreement, + LanguageCode = LanguageCode, + ReceiptUrl = receiptUrl, + CancelUrl = cancelUrl, + СallbackUrl = CallbackUrl(order, headless), + PaymentMethods = PaymentMethods, + GoogleAnalyticsTrackingId = GoogleAnalyticsTracking, + GoogleAnalyticsClientId = GoogleAnalyticsClient, + BrandingId = Branding + }; + + return new RedirectOutputResult { RedirectUrl = CreateCard(order, requestData) }; + } + + private string CreateCard(Order order, CreateCardRequestData requestData) { string errorMessage = "Error happened during creating QuickPay card"; + string cardName = order.SavedCardDraftName; if (string.IsNullOrEmpty(cardName)) cardName = order.Id; - var response = Converter.Deserialize>(ExecuteRequest(order, ApiService.CreateCard)); + var request = new QuickPayRequest(ApiKey, order); + var response = Converter.Deserialize>(request.SendRequest(new() + { + CommandType = ApiService.CreateCard + })); LogEvent(order, "QuickPay Card created"); if (response.ContainsKey("id")) { var cardID = Converter.ToString(response["id"]); + string continueUrl = string.IsNullOrWhiteSpace(requestData.ReceiptUrl) ? CardSavedUrl(order, cardID, cardName) : requestData.ReceiptUrl; + string cancelUrl = string.IsNullOrWhiteSpace(requestData.CancelUrl) ? CancelUrl(order) : requestData.CancelUrl; + string callbackUrl = string.IsNullOrWhiteSpace(requestData.СallbackUrl) ? CallbackUrl(order) : requestData.СallbackUrl; + if (!string.IsNullOrEmpty(cardID)) { - string reqbody = string.Format(@"{{""agreement_id"": ""{0}"", ""language"": ""{1}"", ""continueurl"": ""{2}"", ""cancelurl"": ""{3}"", ""callbackurl"": ""{4}"", ""payment_methods"": ""{5}"", ""google_analytics_tracking_id"": ""{6}"", ""google_analytics_client_id"": ""{7}""}}", - Agreement.Trim(), LanguageCode, receiptUrl ?? CardSavedUrl(order, cardID, cardName), cancelUrl ?? CancelUrl(order), CallbackUrl(order, headless), PaymentMethods, GoogleAnalyticsTracking, GoogleAnalyticsClient); - int brandingId = Converter.ToInt32(Branding); - if (brandingId > 0) - reqbody = string.Format("{0},\"branding_id\": {1}}}", reqbody.Substring(0, reqbody.Length - 1), brandingId); + var parameters = new Dictionary + { + ["agreement_id"] = requestData.AgreementId.Trim(), + ["language"] = requestData.LanguageCode, + ["continueurl"] = continueUrl, + ["cancelurl"] = cancelUrl, + ["callbackurl"] = callbackUrl, + ["payment_methods"] = requestData.PaymentMethods, + ["google_analytics_tracking_id"] = requestData.GoogleAnalyticsTrackingId, + ["google_analytics_client_id"] = requestData.GoogleAnalyticsClientId + }; - response = Converter.Deserialize>(ExecuteRequest(order, ApiService.GetCardLink, cardID, reqbody)); + if (Converter.ToInt32(requestData.BrandingId) is int brandingId && brandingId > 0) + parameters["branding_id"] = brandingId; + + response = Converter.Deserialize>(request.SendRequest(new() + { + CommandType = ApiService.GetCardLink, + OperatorId = cardID, + Parameters = parameters + })); LogEvent(order, "QuickPay Card authorize link received"); if (response.ContainsKey("url")) { Services.Orders.Save(order); - string redirectUrl = Converter.ToString(response["url"]); - return new RedirectOutputResult { RedirectUrl = redirectUrl }; + return Converter.ToString(response["url"]); } else { @@ -839,129 +958,79 @@ private OutputResult CreateCard(string cardName, Order order, bool headless, str errorMessage = string.Format("Bad QuickPay response on creating card. Response text{0}", response.ToString()); LogError(order, "Bad QuickPay response on creating card. Response text{0}", response.ToString()); } - return PrintErrorTemplate(order, errorMessage); + + throw new Exception(errorMessage); } private void ProcessPayment(Order order, string savedCardToken, bool isRawToken = false) { - if (order.Complete) - return; - - Dictionary response; - - var token = savedCardToken; - if (!isRawToken) + try { - response = Converter.Deserialize>(ExecuteRequest(order, ApiService.GetCardToken, savedCardToken)); - token = Converter.ToString(response["token"]); - } - LogEvent(order, "QuickPay card token recieved"); - - string formValues = string.Format(@"{{""order_id"": ""{0}"", ""currency"": ""{1}""}}", order.Id, order.CurrencyCode); - response = Converter.Deserialize>(ExecuteRequest(order, ApiService.CreatePayment, "", formValues)); - LogEvent(order, "QuickPay new payment created"); - var paymentID = Converter.ToString(response["id"]); - - formValues = string.Format("amount={0}&card[token]={1}&auto_capture={2}", order.Price.PricePIP, token, (AutoCapture ? "1" : "0")); - var respText = ExecuteRequest(order, ApiService.AuthorizePayment, paymentID, formValues, "?synchronized"); - LogEvent(order, "QuickPay payment authorized"); - - CheckDataResult result = CheckData(order, respText, order.Price.PricePIP, false); + if (order.Complete) + return; - if (result == CheckDataResult.CallbackSucceed) - { - LogEvent(order, "Callback completed successfully"); - } - else - { - LogEvent(order, "Some error occurred during callback process, check error logs"); - order.TransactionStatus = "Failed"; - CheckoutDone(order); - } - } + Dictionary response; + var request = new QuickPayRequest(ApiKey, order); - private string ExecuteRequest(Order order, ApiService apiService, string serviceObjID = "", string body = "", string serviceParameters = "") - { - try - { - if (apiService is not ApiService.DeleteCard) + var token = savedCardToken; + if (!isRawToken) { - // Check order - if (order is null) + response = Converter.Deserialize>(request.SendRequest(new() { - LogError(null, "Order not set"); - throw new Exception("Order not set"); - } - else if (string.IsNullOrEmpty(order.Id)) + CommandType = ApiService.CreateCardToken, + OperatorId = savedCardToken + })); + token = Converter.ToString(response["token"]); + } + LogEvent(order, "QuickPay card token recieved"); + + response = Converter.Deserialize>(request.SendRequest(new() + { + CommandType = ApiService.CreatePayment, + Parameters = new() { - LogError(null, "Order id not set"); - throw new Exception("Order id not set"); + ["order_id"] = order.Id, + ["currency"] = order.CurrencyCode } - } + })); + LogEvent(order, "QuickPay new payment created"); + var paymentID = Converter.ToString(response["id"]); - using (var handler = GetHandler()) + var responseText = request.SendRequest(new() { - using (var client = new HttpClient(handler)) + CommandType = ApiService.AuthorizePayment, + OperatorId = paymentID, + Parameters = new() { - handler.ClientCertificateOptions = ClientCertificateOption.Manual; - handler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true; - - string apiKey = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Format(":{0}", ApiKey))); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", apiKey); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain")); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); - client.DefaultRequestHeaders.Add("Accept-Version", "v10"); - - string contentType = apiService is ApiService.AuthorizePayment - ? "application/x-www-form-urlencoded" - : "application/json"; - var content = new StringContent(body, Encoding.UTF8, contentType); - - string requestUri = GetServiceLink(apiService, serviceObjID, serviceParameters); - Task requestTask = apiService switch - { - ApiService.GetCardLink => client.PutAsync(requestUri, content), - ApiService.GetCardData or ApiService.GetPaymentStatus => client.GetAsync(requestUri), - _ => client.PostAsync(requestUri, content) - }; + ["amount"] = order.Price.PricePIP, + ["card[token]"] = token, + ["auto_capture"] = AutoCapture ? "1" : "0" + }, + QueryParameters = new() + { + ["synchronized"] = string.Empty + } + }); + LogEvent(order, "QuickPay payment authorized"); - try - { - using (HttpResponseMessage response = requestTask.GetAwaiter().GetResult()) - { - LogEvent(order, "Remote server response: HttpStatusCode = {0}, HttpStatusDescription = {1}", - response.StatusCode, response.ReasonPhrase); - string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - LogEvent(order, "Remote server ResponseText: {0}", responseText); + CheckedData checkedData = CheckData(order, responseText, order.Price.PricePIP, false); - return responseText; - } - } - catch (Exception ex) - { - string errorMessage = "Unable to make http request to QuickPay"; - LogError(order, ex, $"{errorMessage}: {ex.Message}"); + if (checkedData.Result is CheckDataResult.CallbackSucceed) + LogEvent(order, "Callback completed successfully"); + else + { + LogEvent(order, "Some error occurred during callback process, check error logs"); + order.TransactionStatus = "Failed"; + CheckoutDone(order); - if (ex is HttpRequestException requestException) - throw new Exception($"{errorMessage}. Error: {requestException.StatusCode}"); - throw new Exception(errorMessage); - } - } + throw new Exception(checkedData.Message); } } catch (Exception ex) { - string errorMessage = "Unexpected error during request to QuickPay"; - LogError(order, ex, $"{errorMessage}: {ex.Message}"); - - throw new Exception(errorMessage); + LogError(order, ex, ex.Message); + throw new Exception($"Some exception is occured during payment: {ex.Message}"); } - - HttpClientHandler GetHandler() => new() - { - AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip - }; } /// @@ -973,7 +1042,7 @@ private string ExecuteRequest(Order order, ApiService apiService, string service /// QuickPay-API-Version API version of the callback-generating request /// body /// id id - /// order_id The order id that is proceeding + /// order_id The order id that is proceeding --- NOTE, that this field is only for payment operations, like /payments/{id} (card operations will not have it in the response!) /// accepted If transaction accepted /// test_mode If is in test mode /// branding_id The branding id @@ -1004,29 +1073,24 @@ private string ExecuteRequest(Order order, ApiService apiService, string service /// balance Captured balance /// currency Currency code /// - private CheckDataResult CheckData(Order order, string responsetext, long transactionAmount, bool doCheckSum = true) + private CheckedData CheckData(Order order, string responseText, long transactionAmount, bool doCheckSum = true) { LogEvent(order, "Response validation started"); - var quickpayResponse = Converter.Deserialize>(responsetext); + var quickpayResponse = Converter.Deserialize>(responseText); var operations = Converter.Deserialize[]>(Converter.ToString(quickpayResponse["operations"])); var metadata = Converter.Deserialize>(Converter.ToString(quickpayResponse["metadata"])); + string errorMessage = "Some unhandled error is occured."; + Dictionary operation; if (!order.Complete) - { operation = operations.LastOrDefault(op => Converter.ToString(op["type"]) == "authorize"); - } else - { operation = operations.Last(); - } if (operation is null) - { - LogError(order, "QuickPay returned no transaction information"); - return CheckDataResult.Error; - } + return GetErrorResult("QuickPay returned no transaction information"); var isAccepted = Converter.ToBoolean(quickpayResponse["accepted"]); var quickPayStatusCode = Converter.ToString(operation["qp_status_code"]); @@ -1034,48 +1098,43 @@ private CheckDataResult CheckData(Order order, string responsetext, long transac // Skip "Forced 3DS test operation" callback - it have no information we need to know / update // A new callback will be initiated after user passed the 3D Secure if (!isAccepted && "30100".Equals(quickPayStatusCode, StringComparison.OrdinalIgnoreCase)) - { - return CheckDataResult.CallbackSucceed; - } + return new(CheckDataResult.CallbackSucceed); var requiredFields = new List { "id", - "order_id", "accepted", "operations", "metadata", "created_at" }; + var result = CheckDataResult.Error; - foreach (var key in requiredFields.Where(x => !quickpayResponse.ContainsKey(x) || quickpayResponse[x] == null)) - { - LogError( - order, - "The expected parameter from QuickPay '{0}' was not send", - key - ); - return CheckDataResult.Error; - } + foreach (var key in requiredFields.Where(x => !quickpayResponse.ContainsKey(x) || quickpayResponse[x] is null)) + return GetErrorResult($"The expected parameter from QuickPay '{key}' was not send"); if (!isAccepted) { - LogError( - order, - "The quick pay did not accept the transaction" - ); - return CheckDataResult.Error; + errorMessage = "The quick pay did not accept the transaction"; + + string fraudSuspectedKey = "fraud_suspected"; + if (metadata.ContainsKey(fraudSuspectedKey) && Converter.ToBoolean(metadata[fraudSuspectedKey]) is true) + { + string[] fraudRemarks = Converter.Deserialize(Converter.ToString(metadata["fraud_remarks"])); + string fraudText = string.Join(System.Environment.NewLine, fraudRemarks); + errorMessage = $"{errorMessage}. {fraudText}"; + } + + return GetErrorResult(errorMessage); } - if (Converter.ToString(quickpayResponse["order_id"]) != order.Id) + string orderIdKey = "order_id"; + if (quickpayResponse.ContainsKey(orderIdKey)) { - LogError( - order, - "The ordernumber returned from callback does not match with the ordernumber set on the order: Callback: '{0}', order: '{1}'", - quickpayResponse["order_id"], order.Id - ); - return CheckDataResult.Error; + string responseOrderId = Converter.ToString(quickpayResponse[orderIdKey]); + if (!string.IsNullOrEmpty(responseOrderId) && !responseOrderId.Equals(order.Id, StringComparison.OrdinalIgnoreCase)) + return GetErrorResult($"The order id returned from callback does not match with the order id set on the order: Callback: '{responseOrderId}', order: '{order.Id}'"); } LogEvent( @@ -1088,59 +1147,25 @@ private CheckDataResult CheckData(Order order, string responsetext, long transac { case "authorize": if (Converter.ToString(quickpayResponse["type"]) != "Payment") - { - LogError( - order, - "Unsupported transaction type: {0}", - Converter.ToString(quickpayResponse["type"]) - ); - return CheckDataResult.Error; - } + return GetErrorResult($"Unsupported transaction type: {Converter.ToString(quickpayResponse["type"])}"); if (Converter.ToString(quickpayResponse["currency"]) != order.Price.Currency.Code) - { - LogError( - order, - "The currency return from callback does not match the amount set on the order: Callback: {0}, order: {1}", - quickpayResponse["currency"], order.Price.Currency.Code - ); - return CheckDataResult.Error; - } + return GetErrorResult($"The currency return from callback does not match the amount set on the order: Callback: {quickpayResponse["currency"]}, order: {order.Price.Currency.Code}"); if (doCheckSum) { - var calculatedHash = ComputeHash(PrivateKey, responsetext); + var calculatedHash = Hash.ComputeHash(PrivateKey, responseText); var callbackCheckSum = Context.Current.Request.Headers["QuickPay-Checksum-Sha256"]; if (!calculatedHash.Equals(callbackCheckSum, StringComparison.CurrentCultureIgnoreCase)) - { - LogError( - order, - "The HMAC checksum returned from callback does not match: Callback: {0}, calculated: {1}", - callbackCheckSum, calculatedHash - ); - return CheckDataResult.Error; - } + return GetErrorResult($"The HMAC checksum returned from callback does not match: Callback: {callbackCheckSum}, calculated: {calculatedHash}"); } if (Converter.ToString(operation["amount"]) != order.Price.PricePIP.ToString()) - { - LogError( - order, - "The amount returned from callback does not match the amount set on the order: Callback: {0}, order: {1}", - Converter.ToString(operation["amount"]), order.Price.PricePIP - ); - return CheckDataResult.Error; - } + return GetErrorResult($"The amount returned from callback does not match the amount set on the order: Callback: {Converter.ToString(operation["amount"])}, order: {order.Price.PricePIP}"); if (Converter.ToBoolean(quickpayResponse["test_mode"]) && !TestMode) - { - LogError( - order, - "Test card info was used for payment. To make test payment enable test mode in backoffice" - ); - return CheckDataResult.Error; - } + return GetErrorResult("Test card info was used for payment. To make test payment enable test mode in backoffice"); // Check the state of the callback // 20000 Approved @@ -1154,44 +1179,29 @@ private CheckDataResult CheckData(Order order, string responsetext, long transac break; case "40000": - LogEvent( - order, - "Not approved: QuickPay response: 'Rejected by acquirer', qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", - quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"]) - ); - return CheckDataResult.Error; + errorMessage = string.Format("Not approved: QuickPay response: 'Rejected by acquirer', qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", + quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"])); + return GetErrorResult(errorMessage, true); case "40001": - LogEvent( - order, - "Not approved: QuickPay response: 'Request Data Error', qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", - quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"]) - ); - return CheckDataResult.Error; + errorMessage = string.Format("Not approved: QuickPay response: 'Request Data Error', qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", + quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"])); + return GetErrorResult(errorMessage, true); case "50000": - LogEvent( - order, - "Not approved: QuickPay response: 'Gateway Error', qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", - quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"]) - ); - return CheckDataResult.Error; + errorMessage = string.Format("Not approved: QuickPay response: 'Gateway Error', qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", + quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"])); + return GetErrorResult(errorMessage, true); case "50300": - LogEvent( - order, - "Not approved: QuickPay response: 'Communications Error (with Acquirer)', qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", - quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"]) - ); - return CheckDataResult.Error; + errorMessage = string.Format("Not approved: QuickPay response: 'Communications Error (with Acquirer)', qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", + quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"])); + return GetErrorResult(errorMessage, true); default: - LogEvent( - order, - "Not approved: Unexpected status code. QuickPay response: , qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", - quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"]) - ); - return CheckDataResult.Error; + errorMessage = string.Format("Not approved: Unexpected status code. QuickPay response: , qp_status_code: {0}, qp_status_msg: {1}, aq_status_code: '{2}', aq_status_msg: '{3}'.", + quickPayStatusCode, Converter.ToString(operation["qp_status_msg"]), Converter.ToString(operation["aq_status_code"]), Converter.ToString(operation["aq_status_msg"])); + return GetErrorResult(errorMessage, true); } if (AutoFee && operation.ContainsKey("fee")) @@ -1232,9 +1242,8 @@ private CheckDataResult CheckData(Order order, string responsetext, long transac CheckoutDone(order); if (!order.Complete) - { SetOrderSucceeded(order, false); - } + result = CheckDataResult.CallbackSucceed; break; @@ -1242,16 +1251,8 @@ private CheckDataResult CheckData(Order order, string responsetext, long transac long captureAmount, balance; if (long.TryParse(Converter.ToString(operation["amount"]), out captureAmount) && long.TryParse(Converter.ToString(quickpayResponse["balance"]), out balance)) { - if (transactionAmount != captureAmount) - { - LogError( - order, - "The amount returned from response does not match the amount set to capture: Response: {0}, Amount to capture: {1}", - Converter.ToString(operation["amount"]), transactionAmount - ); - return CheckDataResult.Error; - } + return GetErrorResult($"The amount returned from response does not match the amount set to capture: Response: {Converter.ToString(operation["amount"])}, Amount to capture: {transactionAmount}"); var qpStatusCode = Converter.ToString(operation["qp_status_code"]); if (Converter.ToBoolean(operation["pending"])) @@ -1275,47 +1276,29 @@ private CheckDataResult CheckData(Order order, string responsetext, long transac } if (attempts == maxAttempts && captureStatus.IsPending) - { - LogError(order, $"Capture was not completed within {attempts} seconds. Try again later"); - return CheckDataResult.Error; - } + return GetErrorResult($"Capture was not completed within {attempts} seconds. Try again later"); } if (!string.IsNullOrEmpty(qpStatusCode) && qpStatusCode != "20000") - { - LogError(order, $"Capture failed with error message: {qpStatusCode}"); - return CheckDataResult.Error; - } + return GetErrorResult($"Capture failed with error message: {qpStatusCode}"); if (order.Price.PricePIP == captureAmount + balance) - { - return CheckDataResult.FinalCaptureSucceed; - } + return new(CheckDataResult.FinalCaptureSucceed); else - { - return CheckDataResult.SplitCaptureSucceed; - } + return new(CheckDataResult.SplitCaptureSucceed); } else { - LogError(order, "Error with handle amounts from quickpay data"); - return CheckDataResult.Error; + return GetErrorResult(errorMessage); } case "refund": long returnAmount; if (long.TryParse(Converter.ToString(operation["amount"]), out returnAmount) && long.TryParse(Converter.ToString(quickpayResponse["balance"]), out balance)) { - if (transactionAmount != returnAmount) - { - LogError( - order, - "The amount returned from response does not match the amount set to return: Response: {0}, Amount to return: {1}", - Converter.ToString(operation["amount"]), transactionAmount - ); - return CheckDataResult.Error; - } + return GetErrorResult($"The amount returned from response does not match the amount set to return: Response: {Converter.ToString(operation["amount"])}, Amount to return: {transactionAmount}"); + //Nets does not allow capture on previously refunded if (order.CaptureInfo.State == OrderCaptureInfo.OrderCaptureState.Split) { @@ -1325,72 +1308,86 @@ private CheckDataResult CheckData(Order order, string responsetext, long transac var returned = PriceHelper.ConvertToPIP(order.Currency, order.ReturnOperations.Where(returnOperation => returnOperation.State == OrderReturnOperationState.PartiallyReturned).Sum(x => x.Amount)); if (PriceHelper.ConvertToPIP(order.Currency, order.CaptureAmount) == (returnAmount + returned)) - { - return CheckDataResult.FullReturnSucceed; - } + return new(CheckDataResult.FullReturnSucceed); else - { - return CheckDataResult.PartialReturnSucceed; - } + return new(CheckDataResult.PartialReturnSucceed); } else { - LogError(order, "Error with handle amounts from quickpay data"); - return CheckDataResult.Error; + return GetErrorResult("Error with handle amounts from quickpay data"); } default: - LogError(order, "Unsuported transaction type"); - return CheckDataResult.Error; + return GetErrorResult("Unsuported transaction type"); } Cache.Current.Set(orderCacheKey + order.Id, order, new CacheItemPolicy { AbsoluteExpiration = DateTime.Now.AddMinutes(10) }); - return result; + return new(result); + + CheckedData GetErrorResult(string errorMessage, bool logAsEvent = false) + { + if (logAsEvent) + LogEvent(order, errorMessage); + else + LogError(order, errorMessage); + + return new(CheckDataResult.Error, errorMessage); + } } private OperationStatus GetLastOperationStatus(Order order, string operationTypeLock = "") { - var operationStatus = new OperationStatus(); - - var serviceParameters = ""; - if (string.IsNullOrWhiteSpace(order.TransactionNumber)) + try { - serviceParameters = $"?order_id={order.Id}"; - } + var operationStatus = new OperationStatus(); + var request = new QuickPayRequest(ApiKey, order); + var responseText = request.SendRequest(new() + { + CommandType = ApiService.GetPayment, + OperatorId = order.TransactionNumber, + QueryParameters = string.IsNullOrWhiteSpace(order.TransactionNumber) + ? new() { ["order_id"] = order.Id } + : null + }); + + Dictionary paymentModel; + if (string.IsNullOrWhiteSpace(order.TransactionNumber)) + { + paymentModel = Converter.Deserialize[]>(responseText).FirstOrDefault(); + if (paymentModel == null) + { + LogError(order, $"QuickPay returned no transaction information on get status. DW order id - {order.Id}, transaction number - {order.TransactionNumber}"); + operationStatus.Succeded = false; + return operationStatus; + } + } + else + { + paymentModel = Converter.Deserialize>(responseText); + } + var operations = Converter.Deserialize[]>(Converter.ToString(paymentModel["operations"])); - var responsetext = ExecuteRequest(order, ApiService.GetPaymentStatus, order.TransactionNumber, serviceParameters: serviceParameters); - Dictionary paymentModel; - if (string.IsNullOrWhiteSpace(order.TransactionNumber)) - { - paymentModel = Converter.Deserialize[]>(responsetext).FirstOrDefault(); - if (paymentModel == null) + Dictionary operation; + operation = operations.Last(o => string.IsNullOrEmpty(operationTypeLock) || + string.Equals(operationTypeLock, Converter.ToString(o["type"]), StringComparison.OrdinalIgnoreCase)); + + if (operation is null) { LogError(order, $"QuickPay returned no transaction information on get status. DW order id - {order.Id}, transaction number - {order.TransactionNumber}"); operationStatus.Succeded = false; return operationStatus; } - } - else - { - paymentModel = Converter.Deserialize>(responsetext); - } - var operations = Converter.Deserialize[]>(Converter.ToString(paymentModel["operations"])); - - Dictionary operation; - operation = operations.Last(o => string.IsNullOrEmpty(operationTypeLock) || - string.Equals(operationTypeLock, Converter.ToString(o["type"]), StringComparison.OrdinalIgnoreCase)); - if (operation is null) - { - LogError(order, $"QuickPay returned no transaction information on get status. DW order id - {order.Id}, transaction number - {order.TransactionNumber}"); - operationStatus.Succeded = false; + operationStatus.IsPending = Converter.ToBoolean(operation["pending"]); + operationStatus.StatusCode = Converter.ToString(operation["qp_status_code"]); + operationStatus.Succeded = !operationStatus.IsPending & operationStatus.StatusCode.Equals("20000"); return operationStatus; } - - operationStatus.IsPending = Converter.ToBoolean(operation["pending"]); - operationStatus.StatusCode = Converter.ToString(operation["qp_status_code"]); - operationStatus.Succeded = !operationStatus.IsPending & operationStatus.StatusCode.Equals("20000"); - return operationStatus; + catch (Exception ex) + { + LogError(order, ex, ex.Message); + return new() { Succeded = false, IsPending = false }; + } } private Dictionary GetCardTypes(bool recurringOnly, bool translate) @@ -1456,68 +1453,11 @@ private Dictionary GetCardTypes(bool recurringOnly, bool transla return translate ? cardTypes.ToDictionary(x => x.Key, y => y.Value) : cardTypes; } - private string GetMacString(IDictionary formValues) - { - var excludeList = new List { "MAC" }; - var keysSorted = formValues.Keys.ToArray(); - Array.Sort(keysSorted, StringComparer.Ordinal); - - var message = new StringBuilder(); - foreach (string key in keysSorted) - { - if (excludeList.Contains(key)) - { - continue; - } - - if (message.Length > 0) - { - message.Append(" "); - } - - var value = formValues[key]; - message.Append(value); - } - - return message.ToString(); - } - - private string ByteArrayToHexString(byte[] bytes) - { - var result = new StringBuilder(); - foreach (byte b in bytes) - { - result.Append(b.ToString("x2")); - } - - return result.ToString(); - } - - private string ComputeHash(string key, Stream message) - { - var encoding = new System.Text.UTF8Encoding(); - var byteKey = encoding.GetBytes(key); - - using (HMACSHA256 hmac = new HMACSHA256(byteKey)) - { - var hashedBytes = hmac.ComputeHash(message); - return ByteArrayToHexString(hashedBytes); - } - } - - private string ComputeHash(string key, string message) + private StreamOutputResult EndRequest(string json) => new StreamOutputResult { - var encoding = new System.Text.UTF8Encoding(); - var byteKey = encoding.GetBytes(key); - - using (HMACSHA256 hmac = new HMACSHA256(byteKey)) - { - var messageBytes = encoding.GetBytes(message); - var hashedBytes = hmac.ComputeHash(messageBytes); - - return ByteArrayToHexString(hashedBytes); - } - } + ContentStream = new MemoryStream(Encoding.UTF8.GetBytes(json ?? string.Empty)), + ContentType = "application/json" + }; #endregion @@ -1574,14 +1514,27 @@ private void ProceedReturn(Order order, long amount) return; } - var formValues = $@"{{""id"": ""{order.Id}"", ""amount"": {amount}}}"; - try { - var responseText = ExecuteRequest(order, ApiService.RefundPayment, order.TransactionNumber, formValues, "?synchronized"); + var request = new QuickPayRequest(ApiKey, order); + string responseText = request.SendRequest(new() + { + CommandType = ApiService.RefundPayment, + OperatorId = order.TransactionNumber, + Parameters = new() + { + ["id"] = order.Id, + ["amount"] = amount + }, + QueryParameters = new() + { + ["synchronized"] = string.Empty + } + }); + LogEvent(order, "QuickPay has refunded payment", DebuggingInfoType.ReturnResult); - var result = CheckData(order, responseText, amount, false); - switch (result) + CheckedData checkedData = CheckData(order, responseText, amount, false); + switch (checkedData.Result) { case CheckDataResult.Error: OrderReturnInfo.SaveReturnOperation(OrderReturnOperationState.Failed, "QuickPay response validation failed. Check order logs for details.", doubleAmount, order); @@ -1594,8 +1547,9 @@ private void ProceedReturn(Order order, long amount) break; } } - catch + catch (Exception ex) { + LogError(order, ex, ex.Message); OrderReturnInfo.SaveReturnOperation(OrderReturnOperationState.Failed, "QuickPay refund request failed. Check order logs for details.", doubleAmount, order); return; } diff --git a/src/QuickPayRequest.cs b/src/QuickPayRequest.cs new file mode 100644 index 0000000..7905376 --- /dev/null +++ b/src/QuickPayRequest.cs @@ -0,0 +1,159 @@ +using Dynamicweb.Core; +using Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.Models; +using Dynamicweb.Ecommerce.Orders; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow; + +internal sealed class QuickPayRequest +{ + private static readonly string BaseAddress = "https://api.quickpay.net"; + + public Order Order { get; set; } + + public string ApiKey { get; set; } + + public QuickPayRequest(string apiKey) + { + ApiKey = apiKey; + } + + public QuickPayRequest(string apiKey, Order order) : this(apiKey) + { + Order = order; + } + + public string SendRequest(CommandConfiguration configuration) + { + if (configuration.CommandType is not ApiService.DeleteCard) + { + Ensure.NotNull(Order, "Order not set"); + Ensure.Not(string.IsNullOrEmpty(Order.Id), "Order id not set"); + } + + using (var handler = GetHandler()) + { + using (var client = new HttpClient(handler)) + { + handler.ClientCertificateOptions = ClientCertificateOption.Manual; + handler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true; + + string base64Key = Convert.ToBase64String(Encoding.UTF8.GetBytes(string.Format(":{0}", ApiKey))); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64Key); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain")); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); + client.DefaultRequestHeaders.Add("Accept-Version", "v10"); + + string commandLink = GetCommandLink(configuration.CommandType, configuration.OperatorId, configuration.OperatorSecondId, configuration.QueryParameters); + + Task requestTask = configuration.CommandType switch + { + //PUT + ApiService.GetCardLink => client.PutAsync(commandLink, GetContent()), + //GET + ApiService.GetCard or + ApiService.GetPayment => client.GetAsync(commandLink), + //POST + ApiService.CreatePayment or + ApiService.AuthorizePayment or + ApiService.CapturePayment or + ApiService.CreateCardToken or + ApiService.CreateCard or + ApiService.DeleteCard or + ApiService.RefundPayment => client.PostAsync(commandLink, GetContent()), + _ => throw new NotSupportedException($"Unknown operation was used. The operation code: {configuration.CommandType}.") + }; + + try + { + using (HttpResponseMessage response = requestTask.GetAwaiter().GetResult()) + { + Log(Order, $"Remote server response: HttpStatusCode = {response.StatusCode}, HttpStatusDescription = {response.ReasonPhrase}"); + string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + Log(Order, $"Remote server ResponseText: {responseText}"); + + if (!response.IsSuccessStatusCode) + { + var error = Converter.Deserialize(responseText); + if (error.ErrorCode > 0 || !string.IsNullOrWhiteSpace(error.Message)) + { + string errorMessage = error.ErrorCode > 0 + ? $"Error code: {error.ErrorCode}. Message: {error.Message}." + : $"Message: {error.Message}."; + throw new Exception(errorMessage); + } + } + + return responseText; + } + } + catch (HttpRequestException requestException) + { + throw new Exception($"An error occurred during QuickPay request. Error code: {requestException.StatusCode}"); + } + } + } + + HttpClientHandler GetHandler() => new() + { + AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip + }; + + HttpContent GetContent() + { + Dictionary parameters = configuration.Parameters?.ToDictionary(x => x.Key, y => configuration.Parameters[y.Key]?.ToString() ?? string.Empty, StringComparer.OrdinalIgnoreCase); + + if (configuration.CommandType is ApiService.AuthorizePayment) + return new FormUrlEncodedContent(parameters ?? new()); + + string content = parameters?.Any() is true ? Converter.Serialize(parameters) : string.Empty; + return new StringContent(content, Encoding.UTF8, "application/json"); + } + } + + private static void Log(Order order, string message) + { + if (order is null) + return; + + Services.OrderDebuggingInfos.Save(order, message, typeof(QuickPayPaymentWindow).FullName, DebuggingInfoType.Undefined); + } + + private static string GetCommandLink(ApiService command, string operatorId = "", string operatorSecondId = "", Dictionary queryParameters = null) + { + return command switch + { + ApiService.CreatePayment => GetCommandLink("payments"), + ApiService.GetPayment => GetCommandLink($"payments/{operatorId}", queryParameters), + ApiService.AuthorizePayment => GetCommandLink($"payments/{operatorId}/authorize", queryParameters), + ApiService.CapturePayment => GetCommandLink($"payments/{operatorId}/capture"), + ApiService.RefundPayment => GetCommandLink($"payments/{operatorId}/refund", queryParameters), + ApiService.CreateCard => GetCommandLink("cards"), + ApiService.GetCard => GetCommandLink($"cards/{operatorId}"), + ApiService.GetCardLink => GetCommandLink($"cards/{operatorId}/link"), + ApiService.CreateCardToken => GetCommandLink($"cards/{operatorId}/tokens"), + ApiService.DeleteCard => GetCommandLink($"cards/{operatorId}/cancel"), + _ => throw new NotSupportedException($"The api command is not supported. Command: {command}") + }; + + string GetCommandLink(string gateway, Dictionary queryParameters = null) + { + string link = $"{BaseAddress}/{gateway}"; + + if (queryParameters?.Count is 0 or null) + return link; + + string parameters = string.Join("&", queryParameters.Select(parameter => $"{parameter.Key}={parameter.Value}")); + + return $"{link}?{parameters}"; + } + } +} diff --git a/src/QuickpayTemplateHelper.cs b/src/QuickpayTemplateHelper.cs new file mode 100644 index 0000000..b22efb8 --- /dev/null +++ b/src/QuickpayTemplateHelper.cs @@ -0,0 +1,114 @@ +using Dynamicweb.Ecommerce.Orders; +using Dynamicweb.Rendering; +using System.Collections.Generic; +using System.Linq; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow; + +/// +/// The class to help set template tags and create form values for Quickpay Form +/// +internal sealed class QuickpayTemplateHelper +{ + public string Agreement { get; set; } + + public bool AutoCapture { get; set; } + + public bool AutoFee { get; set; } + + public Dictionary AvailableLanguages { get; set; } + + public Dictionary AvailablePaymentMethods { get; set; } + + public int Branding { get; set; } + + public string CallbackUrl { get; set; } + + public string CancelUrl { get; set; } + + public string ContinueUrl { get; set; } + + public string GoogleAnalyticsClient { get; set; } + + public string GoogleAnalyticsTracking { get; set; } + + public string LanguageCode { get; set; } + + public string Merchant { get; set; } + + public Order Order { get; set; } + + public string PaymentMethods { get; set; } + + public string ReceiptUrl { get; set; } + + /// + /// Gets values for Quickpay Form: https://learn.quickpay.net/tech-talk/payments/form/ + /// + /// The api key + public Dictionary GetQuickpayFormValues(string apiKey) + { + var quickpayValues = GetCommonValues(); + quickpayValues["version"] = "v10"; + quickpayValues["merchant_id"] = Merchant.Trim(); + quickpayValues["order_id"] = Order.Id; + quickpayValues["amount"] = Order.Price.PricePIP.ToString(); + quickpayValues["currency"] = Order.Price.Currency.Code; + quickpayValues["autocapture"] = AutoCapture ? "1" : "0"; + quickpayValues["autofee"] = AutoFee ? "1" : "0"; + quickpayValues["checksum"] = Hash.ComputeHash(apiKey, quickpayValues); + + return quickpayValues; + } + + /// + /// Sets tags for Quickpay Form template: https://learn.quickpay.net/tech-talk/payments/form/ + /// + /// The api key + /// Quickpay Form template (see: Post.cshtml as example) + public void SetQuickpayFormTemplateTags(string apiKey, Template template) + { + Dictionary formValues = GetQuickpayFormValues(apiKey); + SetTemplateTags(template, formValues); + } + + /// + /// Sets tags for Card template (see: Card.cshtml as example) + /// + /// Card template + public void SetCardTemplateTags(Template template) + { + var quickpayValues = GetCommonValues(); + + //these values are for our templates only + quickpayValues["receipturl"] = ReceiptUrl; + quickpayValues["availableLanguages"] = GetStringValue(AvailableLanguages); + quickpayValues["availablePaymentMethods"] = GetStringValue(AvailablePaymentMethods); + + SetTemplateTags(template, quickpayValues); + + string GetStringValue(Dictionary data) => string.Join(',', data.Select(pair => $"{pair.Key}|{pair.Value}")); + } + + private void SetTemplateTags(Template template, Dictionary values) + { + foreach ((string key, string value) in values) + template.SetTag(string.Format("QuickPayPaymentWindow.{0}", key), value); + } + + /// + /// Gets common values for both QuickPay Form and Card template + /// + private Dictionary GetCommonValues() => new() + { + ["agreement_id"] = Agreement.Trim(), + ["language"] = LanguageCode, + ["branding_id"] = Branding > 0 ? Branding.ToString() : string.Empty, + ["continueurl"] = ContinueUrl, + ["cancelurl"] = CancelUrl, + ["callbackurl"] = CallbackUrl, + ["payment_methods"] = PaymentMethods, + ["google_analytics_tracking_id"] = GoogleAnalyticsTracking, + ["google_analytics_client_id"] = GoogleAnalyticsClient + }; +} diff --git a/src/Updates/Card.cshtml b/src/Updates/Card.cshtml new file mode 100644 index 0000000..8b15904 --- /dev/null +++ b/src/Updates/Card.cshtml @@ -0,0 +1,125 @@ +@using System.Collections.Generic +@using Dynamicweb.Rendering +@inherits RazorTemplateBase> + +
+ + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/src/Updates/Post.cshtml b/src/Updates/Post.cshtml new file mode 100644 index 0000000..ecf376c --- /dev/null +++ b/src/Updates/Post.cshtml @@ -0,0 +1,20 @@ +
+ + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/src/Updates/QuickPayUpdateProvider.cs b/src/Updates/QuickPayUpdateProvider.cs new file mode 100644 index 0000000..33dd565 --- /dev/null +++ b/src/Updates/QuickPayUpdateProvider.cs @@ -0,0 +1,31 @@ +using Dynamicweb.Updates; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.Updates; + +public class QuickPayUpdateProvider : UpdateProvider +{ + private static Stream GetResourceStream(string name) + { + string resourceName = $"Dynamicweb.Ecommerce.CheckoutHandlers.QuickPayPaymentWindow.Updates.{name}"; + + return Assembly.GetAssembly(typeof(QuickPayUpdateProvider)).GetManifestResourceStream(resourceName); + } + + public override IEnumerable GetUpdates() + { + return new List() + { + new FileUpdate("ab0730a8-f5fa-4427-80b2-2c7635ab2c5c", this, "/Files/Templates/eCom7/CheckoutHandler/QuickPayPaymentWindow/Post/Card.cshtml", () => GetResourceStream("Card.cshtml")), + new FileUpdate("c30f6547-1722-4cc1-a581-03ddcdf97540", this, "/Files/Templates/eCom7/CheckoutHandler/QuickPayPaymentWindow/Post/Post.cshtml", () => GetResourceStream("Post.cshtml")) + }; + } + + /* + * IMPORTANT! + * Use a generated GUID string as id for an update + * - Execute command in C# interactive window: Guid.NewGuid().ToString() + */ +}