From 226215fe294422e6219a7d743ccb5edd13bcde16 Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Tue, 28 Jan 2025 21:16:35 +1000 Subject: [PATCH] Refactored CyberSource provider. NetFramework version was changed to net 8.0 Changed the code according to current request model. Added logo. Added update provider. --- src/CyberSource.cs | 1871 +++++++---------- ...mmerce.CheckoutHandlers.CyberSource.csproj | 23 +- src/Helpers/Helper.cs | 52 + src/Helpers/SecurityHelper.cs | 82 + src/Models/Request/CaptureRequestData.cs | 18 + src/Models/Request/Common/AmountDetails.cs | 20 + src/Models/Request/Common/BillTo.cs | 68 + .../Common/ClientReferenceInformation.cs | 13 + src/Models/Request/Common/LegacyToken.cs | 14 + src/Models/Request/Common/OrderInformation.cs | 13 + .../Request/Common/PaymentInformation.cs | 10 + src/Models/Request/Error/CybersourceError.cs | 22 + src/Models/Request/Error/ErrorDetail.cs | 13 + src/Models/Request/PaymentRequestData.cs | 21 + src/Models/Response/CaptureResponse.cs | 23 + src/Models/Response/PaymentResponse.cs | 23 + src/Models/Response/ProcessorInformation.cs | 13 + src/Models/Response/RiskInformation.cs | 16 + src/Security.cs | 85 - src/Service/ApiCommand.cs | 21 + src/Service/CommandConfiguration.cs | 19 + src/Service/CyberSourceRequest.cs | 220 ++ src/Service/CyberSourceService.cs | 106 + src/TransactionTypes.cs | 8 + src/Updates/CyberSourceUpdateProvider.cs | 32 + src/Updates/Payment.html | 24 + src/Updates/checkouthandler_cancel.html | 9 + src/Updates/checkouthandler_error.html | 17 + src/WindowModes.cs | 7 + src/WorkModes.cs | 7 + src/cybersource-logo.png | Bin 0 -> 34238 bytes 31 files changed, 1722 insertions(+), 1148 deletions(-) create mode 100644 src/Helpers/Helper.cs create mode 100644 src/Helpers/SecurityHelper.cs create mode 100644 src/Models/Request/CaptureRequestData.cs create mode 100644 src/Models/Request/Common/AmountDetails.cs create mode 100644 src/Models/Request/Common/BillTo.cs create mode 100644 src/Models/Request/Common/ClientReferenceInformation.cs create mode 100644 src/Models/Request/Common/LegacyToken.cs create mode 100644 src/Models/Request/Common/OrderInformation.cs create mode 100644 src/Models/Request/Common/PaymentInformation.cs create mode 100644 src/Models/Request/Error/CybersourceError.cs create mode 100644 src/Models/Request/Error/ErrorDetail.cs create mode 100644 src/Models/Request/PaymentRequestData.cs create mode 100644 src/Models/Response/CaptureResponse.cs create mode 100644 src/Models/Response/PaymentResponse.cs create mode 100644 src/Models/Response/ProcessorInformation.cs create mode 100644 src/Models/Response/RiskInformation.cs delete mode 100644 src/Security.cs create mode 100644 src/Service/ApiCommand.cs create mode 100644 src/Service/CommandConfiguration.cs create mode 100644 src/Service/CyberSourceRequest.cs create mode 100644 src/Service/CyberSourceService.cs create mode 100644 src/TransactionTypes.cs create mode 100644 src/Updates/CyberSourceUpdateProvider.cs create mode 100644 src/Updates/Payment.html create mode 100644 src/Updates/checkouthandler_cancel.html create mode 100644 src/Updates/checkouthandler_error.html create mode 100644 src/WindowModes.cs create mode 100644 src/WorkModes.cs create mode 100644 src/cybersource-logo.png diff --git a/src/CyberSource.cs b/src/CyberSource.cs index 768d639..88eaf75 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -1,1285 +1,1036 @@ -using Dynamicweb.Core; -using Dynamicweb.Ecommerce.Cart; +using Dynamicweb.Ecommerce.Cart; +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Helpers; +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Response; +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Service; using Dynamicweb.Ecommerce.Orders; using Dynamicweb.Ecommerce.Orders.Gateways; using Dynamicweb.Extensibility.AddIns; using Dynamicweb.Extensibility.Editors; +using Dynamicweb.Frontend; using Dynamicweb.Rendering; using Dynamicweb.Security.UserManagement; using System; -using System.Collections; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Net; -using System.Security.Cryptography.X509Certificates; -using System.Web; -using System.Threading.Tasks; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using Dynamicweb.Content.Items.Annotations; - -namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource -{ - /// - /// CyberSource Checkout Handler - /// - [AddInName("CyberSource")] - [AddInDescription("Payment system, http://www.cybersource.com")] - public class CyberSource : CheckoutHandlerWithStatusPage, IParameterOptions, IRemoteCapture, ISavedCard, IRecurring, ICheckAuthorizationStatus - { - internal enum WorkModes { Test, Production } - internal enum WindowModes { Redirect, Embedded } - internal enum TransactionTypes { ZeroAuthorization, Authorization, Sale } - - private static string[] supportedCountryCodes; - private static string[] supportedCurrencyCodes; - - internal WorkModes workMode = WorkModes.Test; - internal WindowModes windowMode = WindowModes.Redirect; - private TransactionTypes transactionType = TransactionTypes.Sale; - private string decline_AVS_Flag; - private const string FormTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Payment"; - private const string CancelTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Cancel"; - private const string ErrorTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Error"; - - private static Dictionary CardTypes = new Dictionary - { - {"001", "Visa"}, - {"002", "MasterCard, Eurocard"}, - {"003", "American Express"}, - {"004", "Discover"}, - {"005", "Diners Club"}, - {"006", "Carte Blanche"}, - {"007", "JCB"}, - {"014", "EnRoute"}, - {"021", "JAL"}, - {"024", "Maestro (UK Domestic)"}, - {"031", "Delta, Global Collect"}, - {"033", "Visa Electron"}, - {"034", "Dankort"}, - {"036", "Carte Bleu"}, - {"037", "Carta Si"}, - {"042", "Maestro (International)"}, - {"043", "GE Money UK card"} - }; - private string paymentTemplate; - private string cancelTemplate; - private string errorTemplate; - - static CyberSource() - { - List cultures = new List(); - foreach (var r in CultureInfo.GetCultures(CultureTypes.SpecificCultures)) - { - try - { - cultures.Add(new RegionInfo(r.Name)); - } - catch - { - - } - } - supportedCountryCodes = cultures.Select(x => x.TwoLetterISORegionName.ToUpper()).Distinct().ToArray(); - supportedCurrencyCodes = cultures.Select(x => x.ISOCurrencySymbol.ToUpper()).Distinct().ToArray(); - } - - /// - /// Default constructor - /// - public CyberSource() - { - ServicePointManager.SecurityProtocol = ServicePointManager.SecurityProtocol | SecurityProtocolType.Tls12; - ErrorTemplate = "eCom7/CheckoutHandler/CyberSource/Error/checkouthandler_error.html"; - CancelTemplate = "eCom7/CheckoutHandler/CyberSource/Cancel/checkouthandler_cancel.html"; - PaymentTemplate = "eCom7/CheckoutHandler/CyberSource/Payment/Payment.html"; - } - - #region Addin parameters - - [AddInParameter("Merchant id"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=This is the name of your sandbox account;")] - public string MerchantId { get; set; } - - [AddInParameter("Profile id"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=This is a security key generated in the CyberSource Business Center under: Tools & Settings > Profiles > Security;")] - public string ProfileId { get; set; } +using System.Threading; - [AddInParameter("Access key"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=This is the public component of the security key;")] - public string AccessKey { get; set; } +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource; - [AddInParameter("Secret key"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;TextArea=true;infoText=This is the secret component of the security key;")] - public string SecretKey { get; set; } - - [AddInParameter("Certificate"), AddInParameterEditor(typeof(FileManagerEditor), "NewGUI=true;allowBrowse=true;folder=System;showfullpath=true;infoText=The certificate should be uploaded to the Dynamicweb File Archive;")] - public string CertificateFile { get; set; } - - [AddInParameter("Transaction type")] - [AddInParameterEditor(typeof(RadioParameterEditor), "")] - public string TransactionType - { - get { return transactionType.ToString(); } - set { Enum.TryParse(value, out transactionType); } - } +/// +/// CyberSource Checkout Handler +/// +[AddInName("CyberSource")] +[AddInDescription("Payment system, http://www.cybersource.com")] +public class CyberSource : CheckoutHandlerWithStatusPage, IParameterOptions, IRemoteCapture, ISavedCard, IRecurring, ICheckAuthorizationStatus +{ + private const string FormTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Payment"; + private const string CancelTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Cancel"; + private const string ErrorTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Error"; - [AddInParameter("Forced tokenization"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=;Forces the token to be saved on order or card for logged in users who have not chosen \"Save card\";")] - public bool ForceTokenization { get; set; } + private static HashSet SupportedCountryCodes { get; set; } + private static HashSet SupportedCurrencyCodes { get; set; } - [AddInParameter("Payment template"), AddInParameterEditor(typeof(TemplateParameterEditor), $"folder=Templates/{FormTemplateFolder}")] - public string PaymentTemplate + private static Dictionary CardTypes = new() + { + {"001", "Visa"}, + {"002", "MasterCard, Eurocard"}, + {"003", "American Express"}, + {"004", "Discover"}, + {"005", "Diners Club"}, + {"006", "Carte Blanche"}, + {"007", "JCB"}, + {"014", "EnRoute"}, + {"021", "JAL"}, + {"024", "Maestro (UK Domestic)"}, + {"031", "Delta, Global Collect"}, + {"033", "Visa Electron"}, + {"034", "Dankort"}, + {"036", "Carte Bleu"}, + {"037", "Carta Si"}, + {"042", "Maestro (International)"}, + {"043", "GE Money UK card"} + }; + + static CyberSource() + { + List cultures = new List(); + foreach (CultureInfo cultureInfo in CultureInfo.GetCultures(CultureTypes.SpecificCultures)) { - get + try { - return TemplateHelper.GetTemplateName(paymentTemplate); + cultures.Add(new(cultureInfo.Name)); } - set => paymentTemplate = value; - } - - [AddInParameter("Cancel template"), AddInParameterEditor(typeof(TemplateParameterEditor), $"folder=Templates/{CancelTemplateFolder}")] - public string CancelTemplate - { - get + catch { - return TemplateHelper.GetTemplateName(cancelTemplate); } - set => cancelTemplate = value; } - [AddInParameter("Error template"), AddInParameterEditor(typeof(TemplateParameterEditor), $"folder=Templates/{ErrorTemplateFolder}")] - public string ErrorTemplate - { - get - { - return TemplateHelper.GetTemplateName(errorTemplate); - } - set => errorTemplate = value; - } + SupportedCountryCodes = cultures.Select(regionInfo => regionInfo.TwoLetterISORegionName.ToUpper()).ToHashSet(StringComparer.OrdinalIgnoreCase); + SupportedCurrencyCodes = cultures.Select(regionInfo => regionInfo.ISOCurrencySymbol.ToUpper()).ToHashSet(StringComparer.OrdinalIgnoreCase); + } - [AddInParameter("Work Mode"), AddInParameterEditor(typeof(RadioParameterEditor), "")] - public string WorkMode - { - get { return workMode.ToString(); } - set { Enum.TryParse(value, out workMode); } - } + /// + /// Default constructor + /// + public CyberSource() + { + ServicePointManager.SecurityProtocol = ServicePointManager.SecurityProtocol | SecurityProtocolType.Tls12; + ErrorTemplate = "eCom7/CheckoutHandler/CyberSource/Error/checkouthandler_error.html"; + CancelTemplate = "eCom7/CheckoutHandler/CyberSource/Cancel/checkouthandler_cancel.html"; + PaymentTemplate = "eCom7/CheckoutHandler/CyberSource/Payment/Payment.html"; + } - [AddInParameter("Window Mode"), AddInParameterEditor(typeof(RadioParameterEditor), "Explanation=Select if the payment window should redirect or if it should be embedded;")] - public string WindowMode - { - get { return windowMode.ToString(); } - set { Enum.TryParse(value, out windowMode); } - } + #region Addin parameters + [AddInParameter("Merchant id"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=This is the name of your sandbox account;")] + public string MerchantId { get; set; } - [AddInParameter("Review AVS Codes"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;Explanation=Cybersource supports AVS (Address Verification System) validation;Hint=Should contain the AVS codes you want to receive an AVS validation for;")] - public string Decline_AVS_Flag - { - get { return string.IsNullOrEmpty(decline_AVS_Flag) ? "N" : decline_AVS_Flag; } - set { decline_AVS_Flag = value; } - } + [AddInParameter("Profile id"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=This is a security key generated in the CyberSource Business Center under: Tools & Settings > Profiles > Security;")] + public string ProfileId { get; set; } - [AddInParameter("Ignore AVS Result"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=When Ignore AVS results is checked, you will receive no AVS declines;")] - public bool Ignore_AVS_Result { get; set; } = false; + [AddInParameter("Access key"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=This is the public component of the security key;")] + public string AccessKey { get; set; } - [AddInParameter("Approve AVS Code"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true; Explanation=Cybersource supports AVS (Address Verification System) validation;Hint=Should contain a comma-separated list of AVS codes which will permit the transaction to be approved;")] - public string Result_AVS_Flag { get; set; } - #endregion + [AddInParameter("Secret key"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;TextArea=true;infoText=This is the secret component of the security key;")] + public string SecretKey { get; set; } - /// - /// Gets options according to behavior mode - /// - /// - /// Key-value pairs of settings - public IEnumerable GetParameterOptions(string parameterName) - { - try - { - switch (parameterName) - { - case "Work Mode": - return new List() { - new ("Test", WorkModes.Test.ToString() ){Hint = "Choose Test to simulate payment transactions without involving real money transfers"}, - new ("Production",WorkModes.Production.ToString() ){Hint = "Choose Production when you are ready to go live"} - }; - case "Window Mode": - return new List() - { - new("Redirect",WindowModes.Redirect.ToString()), - new("Embedded",WindowModes.Embedded.ToString()) - }; - case "Transaction type": - return new List() { - new( "Authorization (zero amount)",TransactionTypes.ZeroAuthorization.ToString()) - { - Hint = "All transactions are zero authorized. " + - "Capture is performed through AX or similar and you can carry out account " + - "verification checks to check the validity of a Visa/MasterCard Debit or credit card" - }, - new("Authorization (order amount)",TransactionTypes.Authorization.ToString()) - { - Hint = " The order is authorized at AuthorizeNET and then you can " + - "manually authorize from ecommerce backend order list. This is used for usual transactions" - }, - new("Sale",TransactionTypes.Sale.ToString()) - { - Hint = "The amount is sent for authorization, and if approved, is automatically submitted for settlement" - } - }; - default: - throw new ArgumentException(string.Format("Unknown dropdown name: '{0}'", parameterName)); - } - } - catch (System.Threading.ThreadAbortException) - { - return null; - } - catch (Exception ex) - { - LogError(null, ex, "Unhandled exception with message: {0}", ex.Message); - return null; - } - } + [AddInParameter("Certificate"), AddInParameterEditor(typeof(FileManagerEditor), "NewGUI=true;allowBrowse=true;folder=System;showfullpath=true;infoText=The certificate for REST API, which should be uploaded to the Dynamicweb File Archive;")] + public string CertificateFile { get; set; } - - + [AddInParameter("Certificate password"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=The password to read certificate;")] + public string CertificatePassword { get; set; } - /// - /// Send capture request to transaction service - /// - /// Order to be captured - /// Response from transaction service - OrderCaptureInfo IRemoteCapture.Capture(Order order) - { - try - { - var errorMessage = string.Empty; - if (order == null || string.IsNullOrEmpty(order.Id)) - { - errorMessage = "No valid Order object set"; - } - else if (string.IsNullOrWhiteSpace(order.TransactionNumber)) - { - errorMessage = "No transaction number set on the order"; - } + private TransactionTypes transactionType = TransactionTypes.Sale; - var certPath = GetCertificateFilePath(); - if (string.IsNullOrWhiteSpace(certPath)) - { - errorMessage = "No certificate not found"; - } + [AddInParameter("Transaction type")] + [AddInParameterEditor(typeof(RadioParameterEditor), "")] + public string TransactionType + { + get => transactionType.ToString(); + set => Enum.TryParse(value, out transactionType); + } - if (!string.IsNullOrEmpty(errorMessage)) - { - LogEvent(order, errorMessage); - return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Failed, errorMessage); - } + [AddInParameter("Forced tokenization"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=;Forces the token to be saved on order or card for logged in users who have not chosen \"Save card\";")] + public bool ForceTokenization { get; set; } - var request = new - { - clientReferenceInformation = new - { - code = order.Id - }, - orderInformation = new - { - amountDetails = new - { - totalAmount = GetTransactionAmount(order), - currency = order.Price.Currency.Code - } - }, - }; + private string paymentTemplate; - var requestJson = Converter.Serialize(request); - var url = $"https://{(workMode == WorkModes.Production ? "api" : "apitest")}.cybersource.com/pts/v2/payments/{order.TransactionNumber}/captures"; - var response = CallCyberSourceAPI(requestJson, url, order); - if (response.Result.StatusCode == HttpStatusCode.Created) - { - LogEvent(order, "Capture successful", DebuggingInfoType.CaptureResult); - return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, "Capture successful"); - } - else - { - string responseJson = Converter.Serialize(response); - string message = $"Remote Capture failed. Response: {responseJson}"; - LogEvent(order, message, DebuggingInfoType.CaptureResult); - return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Failed, message); - } - } - catch (System.Threading.ThreadAbortException) - { - return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Failed, "System.Threading.ThreadAbortException"); - } - catch (Exception ex) - { - var message = string.Format("Remote capture failed with the message: {0}", ex.Message); - LogError(order, ex, message); + [AddInParameter("Payment template"), AddInParameterEditor(typeof(TemplateParameterEditor), $"folder=Templates/{FormTemplateFolder}")] + public string PaymentTemplate + { + get => TemplateHelper.GetTemplateName(paymentTemplate); + set => paymentTemplate = value; + } - return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Failed, message); - } - } + private string cancelTemplate; - [Obsolete()] - /// - /// Starts order checkout procedure - /// - /// Order to be checked out - /// String representation of template output - public override string StartCheckout(Order order) - { - try - { - string errorMessage; - if (!ValidateOrderFields(order, out errorMessage)) - { - return OnError(order, errorMessage); - } + [AddInParameter("Cancel template"), AddInParameterEditor(typeof(TemplateParameterEditor), $"folder=Templates/{CancelTemplateFolder}")] + public string CancelTemplate + { + get => TemplateHelper.GetTemplateName(cancelTemplate); + set => cancelTemplate = value; + } - bool isIFrameMode = windowMode == WindowModes.Embedded; + private string errorTemplate; - Dictionary form; - string gatewayUrl; + [AddInParameter("Error template"), AddInParameterEditor(typeof(TemplateParameterEditor), $"folder=Templates/{ErrorTemplateFolder}")] + public string ErrorTemplate + { + get => TemplateHelper.GetTemplateName(errorTemplate); + set => errorTemplate = value; + } - if (order.IsRecurringOrderTemplate || !String.IsNullOrWhiteSpace(GetSavedCardName(order))) - { - gatewayUrl = transactionType == TransactionTypes.ZeroAuthorization ? GetCreateCardGatewayUrl(isIFrameMode) : GetGatewayUrl(isIFrameMode); - form = PrepareCreateCardRequest(order); - } - else - { - gatewayUrl = GetGatewayUrl(isIFrameMode); - if (transactionType == TransactionTypes.Sale) - { - form = PrepareSaleRequest(order); - } - else - { - form = PrepareAuthorizationRequest(order); - } - } + private WorkModes workMode = WorkModes.Test; - if (isIFrameMode) - { - return RenderPaymentFrame(order, gatewayUrl, form); - } - else - { - SubmitForm(gatewayUrl, form); - } - } - catch (System.Threading.ThreadAbortException) - { - return string.Empty; - } - catch (Exception ex) - { - LogError(order, ex, "Unhandled exception with message: {0}", ex.Message); - return OnError(order, ex.Message); - } + [AddInParameter("Work Mode"), AddInParameterEditor(typeof(RadioParameterEditor), "")] + public string WorkMode + { + get => workMode.ToString(); + set => Enum.TryParse(value, out workMode); + } - return string.Empty; - } + private WindowModes windowMode = WindowModes.Redirect; + [AddInParameter("Window Mode"), AddInParameterEditor(typeof(RadioParameterEditor), "Explanation=Select if the payment window should redirect or if it should be embedded;")] + public string WindowMode + { + get => windowMode.ToString(); + set => Enum.TryParse(value, out windowMode); + } - private string RenderPaymentFrame(Orders.Order order, string gatewayUrl, Dictionary form) - { - if (string.IsNullOrWhiteSpace(PaymentTemplate)) - { - LogError(order, "Embedded payment template not set"); - return OnError(order, "Embedded payment template not set"); - } + private string declineAVSFlag; - // Get template - var formTemplate = new Template(TemplateHelper.GetTemplatePath(PaymentTemplate, FormTemplateFolder)); + [AddInParameter("Review AVS Codes"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;Explanation=Cybersource supports AVS (Address Verification System) validation;Hint=Should contain the AVS codes you want to receive an AVS validation for;")] + public string Decline_AVS_Flag + { + get => string.IsNullOrEmpty(declineAVSFlag) ? "N" : declineAVSFlag; + set => declineAVSFlag = value; + } - // Render tags - formTemplate.SetTag("CyberSource.HostedPaymentURL", gatewayUrl); + [AddInParameter("Ignore AVS Result"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=When Ignore AVS results is checked, you will receive no AVS declines;")] + public bool Ignore_AVS_Result { get; set; } = false; - formTemplate.SetTag("CyberSource.CancelURL", GetCancelUrl(order)); + [AddInParameter("Approve AVS Code"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true; Explanation=Cybersource supports AVS (Address Verification System) validation;Hint=Should contain a comma-separated list of AVS codes which will permit the transaction to be approved;")] + public string Result_AVS_Flag { get; set; } - var loopTemplate = formTemplate.GetLoop("CyberSourceFields"); - foreach (var field in form) - { - loopTemplate.SetTag("CyberSource.FieldName", field.Key); - loopTemplate.SetTag("CyberSource.FieldValue", field.Value); - loopTemplate.CommitLoop(); - } + #endregion - // Render and return - return this.Render(order, formTemplate); - } - [Obsolete()] - public override string Redirect(Order order) - { - LogEvent(order, "Redirected to CyberSource CheckoutHandler"); - string result; + private string GetHost() + { + string apiType = workMode is WorkModes.Production ? "api" : "apitest"; + + return $"{apiType}.cybersource.com"; + } - switch (Dynamicweb.Context.Current.Request["cmd"]) + /// + /// Gets options according to behavior mode + /// + /// + /// Key-value pairs of settings + public IEnumerable GetParameterOptions(string parameterName) + { + try + { + switch (parameterName) { - case "Accept": - result = ValidateAVSCode(order); - if (result != null) + case "Work Mode": + return new List { - return result; - } - return StateOk(order); - case "CardSaved": - result = ValidateAVSCode(order); - if (result != null) + new("Test", WorkModes.Test.ToString()) { Hint = "Choose Test to simulate payment transactions without involving real money transfers" }, + new("Production", WorkModes.Production.ToString()) { Hint = "Choose Production when you are ready to go live" } + }; + + case "Window Mode": + return new List { - return result; - } - return StateCardSaved(order); - case "Cancel": - return StateCancel(order); - case "IFrameError": - return StateIFrameError(order); + new("Redirect", WindowModes.Redirect.ToString()), + new("Embedded", WindowModes.Embedded.ToString()) + }; + + case "Transaction type": + return new List + { + new("Authorization (zero amount)", TransactionTypes.ZeroAuthorization.ToString()) + { + Hint = "All transactions are zero authorized. " + + "Capture is performed through AX or similar and you can carry out account " + + "verification checks to check the validity of a Visa/MasterCard Debit or credit card" + }, + new("Authorization (order amount)", TransactionTypes.Authorization.ToString()) + { + Hint = " The order is authorized at AuthorizeNET and then you can " + + "manually authorize from ecommerce backend order list. This is used for usual transactions" + }, + new("Sale",TransactionTypes.Sale.ToString()) + { + Hint = "The amount is sent for authorization, and if approved, is automatically submitted for settlement" + } + }; + default: - Context.Current.Response.End(); - return null; + throw new ArgumentException(string.Format("Unknown dropdown name: '{0}'", parameterName)); } } - - private string ValidateAVSCode(Order order) + catch (ThreadAbortException) { - string transact = Context.Current.Request["transaction_id"]; - string avsResult = Context.Current.Request["auth_avs_code"]; - string avsResultRaw = Context.Current.Request["auth_avs_code_raw"]; - var resultCodesAllowed = new List(); - if (!string.IsNullOrWhiteSpace(Result_AVS_Flag)) - { - resultCodesAllowed.AddRange(Result_AVS_Flag.Replace(' ', ',').Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); - } - LogEvent(order, "CyberSource response: avs_code: '{0}', avs_code_raw: '{1}'.", avsResult, avsResultRaw); - - if (!string.IsNullOrEmpty(avsResult) && resultCodesAllowed.Any() && !resultCodesAllowed.Contains(avsResult)) - { - LogEvent(order, "Transaction {0} not approved.", transact); - return OnError(order, string.Format("Transaction {0} not approved. {1} (code={2})" - , transact, Context.Current.Request["message"], avsResult), windowMode == WindowModes.Embedded); - } - return null; } - - private bool ValidateOrderFields(Order order, out string errorMessage) + catch (Exception ex) { - if (!supportedCurrencyCodes.Any(x => x == order.CurrencyCode)) - { - errorMessage = $"Only {string.Join(",", supportedCurrencyCodes)} currency codes is allowed. Order currency: {order.CurrencyCode}"; - return false; - } - - if (string.IsNullOrWhiteSpace(order.CustomerCountryCode)) - { - errorMessage = "Required customer country code"; - return false; - } - - if (!supportedCountryCodes.Any(x => x == order.CustomerCountryCode)) - { - errorMessage = $"Only {string.Join(",", supportedCountryCodes)} country codes is supported. Order country code: {order.CustomerCountryCode}"; - return false; - } - - errorMessage = string.Empty; - - return true; + LogError(null, ex, "Unhandled exception with message: {0}", ex.Message); + return null; } + } - private string StateCancel(Order order) + /// + /// Send capture request to transaction service + /// + /// Order to be captured + /// Response from transaction service + OrderCaptureInfo IRemoteCapture.Capture(Order order) + { + try { - LogEvent(order, "State cancel"); - string calculatedSignature; - if (windowMode != WindowModes.Embedded && !Security.ValidateResponseSignation(AccessKey, SecretKey, out calculatedSignature)) + string errorMessage = string.Empty; + if (order is null || string.IsNullOrEmpty(order.Id)) + errorMessage = "No valid Order object set"; + else if (string.IsNullOrWhiteSpace(order.TransactionNumber)) + errorMessage = "No transaction number set on the order"; + + string certPath = Helper.GetCertificateFilePath(CertificateFile); + if (string.IsNullOrWhiteSpace(certPath)) + errorMessage = "Certificate for REST API is not found"; + + if (!string.IsNullOrEmpty(errorMessage)) { - LogError(order, "The signature returned from callback does not match: {0}, calculated: {1}", Dynamicweb.Context.Current.Request["signature"], calculatedSignature); - return OnError(order, "Wrong signature"); + LogEvent(order, errorMessage); + return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Failed, errorMessage); } - order.TransactionStatus = "Cancelled"; - Services.Orders.Save(order); - CheckoutDone(order); - - var cancelTemplate = new Template(TemplateHelper.GetTemplatePath(CancelTemplate, CancelTemplateFolder)); - var orderRenderer = new Dynamicweb.Ecommerce.Frontend.Renderer(); - orderRenderer.RenderOrderDetails(cancelTemplate, order, true); + var service = new CyberSourceService(GetHost(), MerchantId, CertificateFile, CertificatePassword); + CaptureResponse response = service.Capture(order, order.TransactionNumber); + LogEvent(order, "Capture successful", DebuggingInfoType.CaptureResult); - return cancelTemplate.Output(); + return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, "Capture successful"); } - - private string StateIFrameError(Order order) + catch (ThreadAbortException) { - return OnError(order, Dynamicweb.Context.Current.Request["ErrorMessage"]); + return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Failed, "System.Threading.ThreadAbortException"); } - - private string StateOk(Order order) + catch (Exception ex) { - LogEvent(order, "State ok"); + string message = string.Format("Remote capture failed with the message: {0}", ex.Message); + LogEvent(order, message, DebuggingInfoType.CaptureResult); + LogError(order, ex, message); - if (!order.Complete) - { - return ProcessOrder(order); - } - - PassToCart(order); - - return null; + return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Failed, message); } + } - - private string StateCardSaved(Order order) + /// + /// Starts order checkout procedure + /// + /// Order to be checked out + public override OutputResult BeginCheckout(Order order, CheckoutParameters parameters) + { + try { - LogEvent(order, "CyberSource Card Authorized successfully"); - - var cardName = HttpUtility.UrlDecode(Context.Current.Request["CardTokenName"]); - if (string.IsNullOrEmpty(cardName)) - { - cardName = order.Id; - } - - if (Context.Current.Request["reason_code"] == "100") - { - var requestCardType = Context.Current.Request["req_card_type"]; - var subscribtionId = Context.Current.Request["payment_token"]; - var cardType = CardTypes.Keys.Any(key => key == requestCardType) ? CardTypes[requestCardType] : String.Format("Unrecognized card type - {0}", requestCardType); - var cardNubmer = order.TransactionCardNumber = Context.Current.Request["req_card_number"].ToUpper(); - - order.TransactionCardType = cardType; - string transactionId = Dynamicweb.Context.Current.Request["transaction_id"]; - var user = UserManagementServices.Users.GetUserById(order.CustomerAccessUserId); - if (user != null) - { - var savedCard = Services.PaymentCard.CreatePaymentCard(user.ID, order.PaymentMethodId, cardName, cardType, cardNubmer, subscribtionId); - order.SavedCardId = savedCard.ID; - } - else - { - order.TransactionToken = subscribtionId; - } - - int decimals = order.Currency.Rounding == null || order.Currency.Rounding.Id == string.Empty ? 2 : order.Currency.Rounding.Decimals; - if (!order.IsRecurringOrderTemplate && transactionType == TransactionTypes.ZeroAuthorization) - { - order.TransactionAmount = 0.00; - order.TransactionStatus = "Succeeded"; - order.TransactionCardType = cardType; - order.TransactionCardNumber = cardNubmer; - order.TransactionType = "Zero authorization"; - string msg = "Zero authorization succeeded"; - LogEvent(order, msg); - order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); - } - else if (transactionType == TransactionTypes.Sale) - { - order.TransactionAmount = Math.Round(order.Price.Price, decimals); - order.TransactionStatus = "Succeeded"; - string msg = "Capture succeeded"; - LogEvent(order, msg); - order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); - } - else if (transactionType == TransactionTypes.Authorization) - { - order.TransactionType = "Zero authorization"; - order.TransactionAmount = Math.Round(order.Price.Price, decimals); - order.TransactionStatus = "Succeeded"; - string msg = "Authorization succeeded"; - LogEvent(order, msg); - } + string errorMessage; + if (!ValidateOrderFields(order, out errorMessage)) + return OnError(order, errorMessage); - Services.Orders.Save(order); + bool isIFrameMode = windowMode is WindowModes.Embedded; - LogEvent(order, "Saved Card created"); + Dictionary form; + string gatewayUrl; - SetOrderComplete(order, transactionId); + if (order.IsRecurringOrderTemplate || !string.IsNullOrWhiteSpace(GetSavedCardName(order))) + { + gatewayUrl = transactionType is TransactionTypes.ZeroAuthorization + ? GetCreateCardGatewayUrl(isIFrameMode) + : GetGatewayUrl(isIFrameMode); - LogEvent(order, "Create Card successful and order completed"); + form = PrepareCreateCardRequest(order); } else { - LogError(order, string.Format("Create card failed. Decision: '{0}' messageId: '{1}', messageText: '{2}'", Context.Current.Request["decision"], Context.Current.Request["reason_code"], Context.Current.Request["message"])); + gatewayUrl = GetGatewayUrl(isIFrameMode); + form = transactionType is TransactionTypes.Sale + ? PrepareSaleRequest(order) + : PrepareAuthorizationRequest(order); } - CheckoutDone(order); - if (!order.Complete) - { - return OnError(order, "Some error happened on creating saved card.", windowMode == WindowModes.Embedded); - } + if (isIFrameMode) + return RenderPaymentFrame(order, gatewayUrl, form); - if (windowMode != WindowModes.Embedded) - { - PassToCart(order); - } - else - { - Context.Current.Response.Write(string.Format("", GetBaseUrl(order))); - Context.Current.Response.End(); - } - return null; + return GetSubmitFormResult(gatewayUrl, form); } - - private string ProcessOrder(Order order) + catch (ThreadAbortException) { - bool orderWasCompleted = order.Complete; - - try - { - bool errorOccured = false; - string calculatedSignature; + return NoActionOutputResult.Default; + } + catch (Exception ex) + { + LogError(order, ex, "Unhandled exception with message: {0}", ex.Message); - if (!Security.ValidateResponseSignation(AccessKey, SecretKey, out calculatedSignature)) - { - errorOccured = true; - LogError(order, "The signature returned from callback does not match: {0}, calculated: {1}", Dynamicweb.Context.Current.Request["signature"], calculatedSignature); - } + return OnError(order, ex.Message); + } + } - string transactionId = Dynamicweb.Context.Current.Request["transaction_id"]; - if (string.IsNullOrEmpty(transactionId)) - { - errorOccured = true; - LogEvent(order, "No transaction number sent to callback"); - } + private OutputResult RenderPaymentFrame(Order order, string gatewayUrl, Dictionary form) + { + if (string.IsNullOrWhiteSpace(PaymentTemplate)) + { + LogError(order, "Embedded payment template not set"); - if (Dynamicweb.Context.Current.Request["reason_code"] != "100") - { - errorOccured = true; - LogEvent(order, "Transaction {0} not approved. CyberSource response: messageId: '{1}', messageText: '{2}'.", transactionId, Dynamicweb.Context.Current.Request["auth_response"], Dynamicweb.Context.Current.Request["message"]); + return OnError(order, "Embedded payment template not set"); + } - return OnError(order, string.Format("Transaction {0} not approved. {1}", transactionId, Dynamicweb.Context.Current.Request["message"]), windowMode == WindowModes.Embedded); - } + // Get template + var formTemplate = new Template(TemplateHelper.GetTemplatePath(PaymentTemplate, FormTemplateFolder)); - string amount = Dynamicweb.Context.Current.Request["req_amount"]; - int decimals = order.Currency.Rounding == null || order.Currency.Rounding.Id == string.Empty ? 2 : order.Currency.Rounding.Decimals; + // Render tags + formTemplate.SetTag("CyberSource.HostedPaymentURL", gatewayUrl); + formTemplate.SetTag("CyberSource.CancelURL", GetCancelUrl(order)); - if (errorOccured) - { - LogError(order, "At least one validation error exists - exiting callback routine."); - order.TransactionStatus = "Failed"; - Services.Orders.Save(order); - } - else - { - LogEvent(order, "Payment succeeded with transaction number {0}", transactionId); - var requestCardType = Dynamicweb.Context.Current.Request["req_card_type"]; - var cardType = CardTypes.Keys.Any(key => key == requestCardType) ? CardTypes[requestCardType] : String.Format("Unrecognized card type - {0}", requestCardType); + Template loopTemplate = formTemplate.GetLoop("CyberSourceFields"); + foreach ((string key, string value) in form) + { + loopTemplate.SetTag("CyberSource.FieldName", key); + loopTemplate.SetTag("CyberSource.FieldValue", value); + loopTemplate.CommitLoop(); + } - order.TransactionAmount = Math.Round(order.Price.Price, decimals); - order.TransactionStatus = "Succeeded"; - order.TransactionCardType = cardType; - order.TransactionCardNumber = HideCardNumber(Dynamicweb.Context.Current.Request["req_card_number"]); + return new ContentOutputResult + { + Content = Render(order, formTemplate) + }; + } - if (transactionType == TransactionTypes.Sale) - { - string msg = "Capture succeeded"; - LogEvent(order, msg); - order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); - } - else if (transactionType == TransactionTypes.ZeroAuthorization) - { - order.TransactionAmount = 0.00; - order.TransactionType = "Zero authorization"; - string msg = "Zero authorization succeeded"; - LogEvent(order, msg); - order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); - } - SetOrderComplete(order, transactionId); - } - } - finally - { - if (!orderWasCompleted && order.Complete) - { - CheckoutDone(order); + public override OutputResult HandleRequest(Order order) + { + LogEvent(order, "Redirected to CyberSource CheckoutHandler"); - if (windowMode != WindowModes.Embedded) - { - PassToCart(order); - } - else - { - Context.Current.Response.Write(string.Format("", GetBaseUrl(order))); - Context.Current.Response.End(); - } - } - } - return null; + switch (Context.Current.Request["cmd"]) + { + case "Accept": + if (ValidateAVSCode(order) is ContentOutputResult errorAcceptResult) + return errorAcceptResult; + return StateOk(order); + case "CardSaved": + if (ValidateAVSCode(order) is ContentOutputResult errorCardSavedResult) + return errorCardSavedResult; + return StateCardSaved(order); + case "Cancel": + return StateCancel(order); + case "IFrameError": + return StateIFrameError(order); + default: + return ContentOutputResult.Empty; } + } + + private OutputResult ValidateAVSCode(Order order) + { + string transact = Context.Current.Request["transaction_id"]; + string avsResult = Context.Current.Request["auth_avs_code"]; + string avsResultRaw = Context.Current.Request["auth_avs_code_raw"]; - private string OnError(Orders.Order order, string message, bool isIFrameError = false) + var resultCodesAllowed = new List(); + if (!string.IsNullOrWhiteSpace(Result_AVS_Flag)) { - if (windowMode == WindowModes.Embedded && isIFrameError) - { - Context.Current.Response.Write(string.Format("", GetBaseUrl(order), HttpUtility.UrlEncode(message))); - Context.Current.Response.End(); - } + string formattedResult = Result_AVS_Flag.Replace(' ', ','); + resultCodesAllowed.AddRange(formattedResult.Split(',', StringSplitOptions.RemoveEmptyEntries)); + } - order.TransactionAmount = 0; - order.TransactionStatus = "Failed"; - order.Errors.Add(message); - Services.Orders.Save(order); + LogEvent(order, "CyberSource response: avs_code: '{0}', avs_code_raw: '{1}'.", avsResult, avsResultRaw); - Services.Orders.DowngradeToCart(order); - order.CartV2StepIndex = 0; - order.TransactionStatus = string.Empty; - Common.Context.SetCart(order); + if (!string.IsNullOrEmpty(avsResult) && resultCodesAllowed.Any() && !resultCodesAllowed.Contains(avsResult)) + { + LogEvent(order, "Transaction {0} not approved.", transact); - if (string.IsNullOrWhiteSpace(ErrorTemplate)) - { - PassToCart(order); - } + string message = Context.Current.Request["message"]; + return OnError(order, $"Transaction {transact} not approved. {message} (code={avsResult})", windowMode is WindowModes.Embedded); + } + + return NoActionOutputResult.Default; + } - var errorTemplate = new Template(TemplateHelper.GetTemplatePath(ErrorTemplate, ErrorTemplateFolder)); - errorTemplate.SetTag("CheckoutHandler:ErrorMessage", message); + private bool ValidateOrderFields(Order order, out string errorMessage) + { + string supportedCodes = string.Join(",", SupportedCurrencyCodes); - return Render(order, errorTemplate); + if (!SupportedCurrencyCodes.Any(code => code.Equals(order.CurrencyCode, StringComparison.OrdinalIgnoreCase))) + { + errorMessage = $"Only {supportedCodes} currency codes is allowed. Order currency: {order.CurrencyCode}"; + return false; } - private Dictionary PrepareAuthorizationRequest(Order order, string token = "") + if (string.IsNullOrWhiteSpace(order.CustomerCountryCode)) { - return PrepareRequest(order, "authorization", token); + errorMessage = "Required customer country code"; + return false; } - private Dictionary PrepareSaleRequest(Order order, string token = "") + if (!SupportedCountryCodes.Any(code => code.Equals(order.CustomerCountryCode, StringComparison.OrdinalIgnoreCase))) { - return PrepareRequest(order, "sale", token); + errorMessage = $"Only {supportedCodes} country codes is supported. Order country code: {order.CustomerCountryCode}"; + return false; } - private Dictionary PrepareCreateCardRequest(Order order) + errorMessage = string.Empty; + + return true; + } + + private OutputResult StateCancel(Order order) + { + LogEvent(order, "State cancel"); + string calculatedSignature; + if (windowMode is not WindowModes.Embedded && !SecurityHelper.ValidateResponseSignation(AccessKey, SecretKey, out calculatedSignature)) { - if (transactionType == TransactionTypes.Sale) - { - return PrepareRequest(order, "sale,create_payment_token", ""); - } - else if (transactionType == TransactionTypes.Authorization) - { - return PrepareRequest(order, "authorization,create_payment_token", ""); - } - else - { - return PrepareRequest(order, "create_payment_token", ""); - } + string signature = Context.Current.Request["signature"]; + LogError(order, "The signature returned from callback does not match: {0}, calculated: {1}", signature, calculatedSignature); + return OnError(order, "Wrong signature"); } - #region Request building + order.TransactionStatus = "Cancelled"; + Services.Orders.Save(order); + CheckoutDone(order); - private Dictionary PrepareRequest(Order order, string requestTransactionType, string token) + var cancelTemplate = new Template(TemplateHelper.GetTemplatePath(CancelTemplate, CancelTemplateFolder)); + + return new ContentOutputResult { - var customerName = Converter.ToString(order.CustomerName).Trim(); + Content = Render(order, cancelTemplate) + }; + } + + private OutputResult StateIFrameError(Order order) + { + string errorMessage = Context.Current.Request["ErrorMessage"]; - var firstName = GetCustomerFirstName(order, customerName); - var lastName = GetCustomerLastName(order, customerName); + return OnError(order, errorMessage); + } - string amount = transactionType == TransactionTypes.ZeroAuthorization ? "0.00" : GetTransactionAmount(order); - var requestParameters = new Dictionary - { - {"profile_id", ProfileId}, - {"access_key", AccessKey}, - {"transaction_uuid", Guid.NewGuid().ToString()}, - {"signed_date_time", DateTime.Now.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")}, - {"unsigned_field_names", ""}, - {"locale", GetLanguageCode()}, - - {"transaction_type", requestTransactionType}, - {"payment_method", "card"}, - {"reference_number", !order.IsRecurringOrderTemplate ? order.Id : order.CustomerAccessUserId.ToString()}, - {"amount", amount}, - {"currency", order.Price.Currency.Code}, - {"override_custom_cancel_page", GetCancelUrl(order)}, - {"override_custom_receipt_page", GetAcceptUrl(order)}, - {"businessRules_declineAVSFlags", Decline_AVS_Flag }, - {"businessRules_ignoreAVSResult", Ignore_AVS_Result.ToString().ToLower() } - }; + private OutputResult StateOk(Order order) + { + LogEvent(order, "State ok"); + + if (!order.Complete) + return ProcessOrder(order); + + return PassToCart(order); + } - if (!string.IsNullOrWhiteSpace(token)) + private OutputResult StateCardSaved(Order order) + { + LogEvent(order, "CyberSource Card Authorized successfully"); + + string cardName = WebUtility.UrlDecode(Context.Current.Request["CardTokenName"]); + if (string.IsNullOrEmpty(cardName)) + cardName = order.Id; + + if (Context.Current.Request["reason_code"] == "100") + { + string requestCardType = Context.Current.Request["req_card_type"]; + string subscribtionId = Context.Current.Request["payment_token"]; + string cardType = CardTypes.Keys.Any(key => key.Equals(requestCardType, StringComparison.OrdinalIgnoreCase)) + ? CardTypes[requestCardType] + : $"Unrecognized card type - {requestCardType}"; + + string cardNubmer = Context.Current.Request["req_card_number"].ToUpper(); + order.TransactionCardNumber = cardNubmer; + + order.TransactionCardType = cardType; + string transactionId = Context.Current.Request["transaction_id"]; + User user = UserManagementServices.Users.GetUserById(order.CustomerAccessUserId); + if (user is not null) { - requestParameters.Add("payment_token", token); + PaymentCardToken savedCard = Services.PaymentCard.CreatePaymentCard(user.ID, order.PaymentMethodId, cardName, cardType, cardNubmer, subscribtionId); + order.SavedCardId = savedCard.ID; } else + order.TransactionToken = subscribtionId; + + int decimals = string.IsNullOrEmpty(order.Currency.Rounding?.Id) + ? 2 + : order.Currency.Rounding.Decimals; + + if (!order.IsRecurringOrderTemplate && transactionType is TransactionTypes.ZeroAuthorization) { - requestParameters = requestParameters.Union( - new Dictionary - { - {"bill_to_forename", firstName}, - {"bill_to_surname", lastName}, - {"bill_to_email", Converter.ToString(order.CustomerEmail)}, - {"bill_to_phone", Converter.ToString(order.CustomerPhone)}, - {"bill_to_company_name", Converter.ToString(order.CustomerCompany)}, - {"bill_to_address_line1", Converter.ToString(order.CustomerAddress)}, - {"bill_to_address_line2", Converter.ToString(order.CustomerAddress2)}, - {"bill_to_address_city", Converter.ToString(order.CustomerCity)}, - {"bill_to_address_state", Converter.ToString(order.CustomerRegion)}, - {"bill_to_address_postal_code", Converter.ToString(order.CustomerZip)}, - {"bill_to_address_country", Converter.ToString(order.CustomerCountryCode)}, - - }).ToDictionary(x => x.Key, x => x.Value); + order.TransactionAmount = 0.00; + order.TransactionStatus = "Succeeded"; + order.TransactionCardType = cardType; + order.TransactionCardNumber = cardNubmer; + order.TransactionType = "Zero authorization"; + string msg = "Zero authorization succeeded"; + LogEvent(order, msg); + order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); } - bool useBillInfoForDelivery = string.IsNullOrEmpty(order.DeliveryAddress); - if (useBillInfoForDelivery) + else if (transactionType is TransactionTypes.Sale) { - requestParameters.Add("ship_to_forename", firstName); - requestParameters.Add("ship_to_surname", lastName); - requestParameters.Add("ship_to_email", Converter.ToString(order.CustomerEmail)); - requestParameters.Add("ship_to_phone", Converter.ToString(order.CustomerPhone)); - requestParameters.Add("ship_to_company_name", Converter.ToString(order.CustomerCompany)); - requestParameters.Add("ship_to_address_line1", Converter.ToString(order.CustomerAddress)); - requestParameters.Add("ship_to_address_line2", Converter.ToString(order.CustomerAddress2)); - requestParameters.Add("ship_to_address_city", Converter.ToString(order.CustomerCity)); - requestParameters.Add("ship_to_address_state", Converter.ToString(order.CustomerRegion)); - requestParameters.Add("ship_to_address_postal_code", Converter.ToString(order.CustomerZip)); - requestParameters.Add("ship_to_address_country", Converter.ToString(order.CustomerCountryCode)); + order.TransactionAmount = Math.Round(order.Price.Price, decimals); + order.TransactionStatus = "Succeeded"; + string msg = "Capture succeeded"; + LogEvent(order, msg); + order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); } - else + else if (transactionType is TransactionTypes.Authorization) { - requestParameters.Add("ship_to_forename", string.IsNullOrWhiteSpace(order.DeliveryFirstName) ? firstName : order.DeliveryFirstName); - requestParameters.Add("ship_to_surname", string.IsNullOrWhiteSpace(order.DeliverySurname) ? lastName : order.DeliverySurname); - requestParameters.Add("ship_to_email", Converter.ToString(order.DeliveryEmail)); - requestParameters.Add("ship_to_phone", Converter.ToString(order.DeliveryPhone)); - requestParameters.Add("ship_to_company_name", Converter.ToString(order.DeliveryCompany)); - requestParameters.Add("ship_to_address_line1", Converter.ToString(order.DeliveryAddress)); - requestParameters.Add("ship_to_address_line2", Converter.ToString(order.DeliveryAddress2)); - requestParameters.Add("ship_to_address_city", Converter.ToString(order.DeliveryCity)); - requestParameters.Add("ship_to_address_state", Converter.ToString(order.DeliveryRegion)); - requestParameters.Add("ship_to_address_postal_code", Converter.ToString(order.DeliveryZip)); - requestParameters.Add("ship_to_address_country", supportedCountryCodes.Any(x => x == order.DeliveryCountryCode) ? order.DeliveryCountryCode : string.Empty); + order.TransactionType = "Zero authorization"; + order.TransactionAmount = Math.Round(order.Price.Price, decimals); + order.TransactionStatus = "Succeeded"; + string msg = "Authorization succeeded"; + LogEvent(order, msg); } - requestParameters = requestParameters.Where(x => !string.IsNullOrEmpty(x.Value)).ToDictionary(x => x.Key, x => x.Value); - var signedFieldNames = string.Join(",", requestParameters.Keys) + ",signed_field_names"; - requestParameters.Add("signed_field_names", signedFieldNames); - requestParameters.Add("signature", Security.Sign(requestParameters, SecretKey)); - - return requestParameters; + Services.Orders.Save(order); + LogEvent(order, "Saved Card created"); + SetOrderComplete(order, transactionId); + LogEvent(order, "Create Card successful and order completed"); } + else + LogError(order, string.Format("Create card failed. Decision: '{0}' messageId: '{1}', messageText: '{2}'", Context.Current.Request["decision"], Context.Current.Request["reason_code"], Context.Current.Request["message"])); - private static string GetCustomerLastName(Order order, string customerName) - { - string lastName = order.CustomerSurname; - var delimeterPosition = customerName.IndexOf(' '); - if (string.IsNullOrWhiteSpace(lastName)) - { - lastName = delimeterPosition > -1 ? customerName.Substring(delimeterPosition + 1) : customerName; - } + CheckoutDone(order); + if (!order.Complete) + return OnError(order, "Some error happened on creating saved card.", windowMode == WindowModes.Embedded); - return lastName; - } + if (windowMode is not WindowModes.Embedded) + return PassToCart(order); - private static string GetCustomerFirstName(Order order, string customerName) + return new ContentOutputResult { - var firstName = order.CustomerFirstName; - var delimeterPosition = customerName.IndexOf(' '); - if (string.IsNullOrWhiteSpace(firstName)) - { - firstName = delimeterPosition > -1 ? customerName.Substring(0, delimeterPosition) : customerName; - } - return firstName; - } + Content = $"" + }; + } - #endregion + private OutputResult ProcessOrder(Order order) + { + bool orderWasCompleted = order.Complete; - /// - /// This demonstrates what a generic API request helper method would look like. - /// - /// Request to send to API endpoint< - /// Task - public async Task CallCyberSourceAPI(string request, string resource, Order order) + try { - var client = new HttpClient(); + bool errorOccured = false; + string calculatedSignature; - var jwtToken = GenerateJWT(request, "POST", order); + if (!SecurityHelper.ValidateResponseSignation(AccessKey, SecretKey, out calculatedSignature)) + { + errorOccured = true; + LogError(order, "The signature returned from callback does not match: {0}, calculated: {1}", Context.Current.Request["signature"], calculatedSignature); + } - LogEvent(order, "JWT token created", jwtToken); + string transactionId = Context.Current.Request["transaction_id"]; + if (string.IsNullOrEmpty(transactionId)) + { + errorOccured = true; + LogEvent(order, "No transaction number sent to callback"); + } - StringContent content = new StringContent(request); - content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken); + if (Context.Current.Request["reason_code"] != "100") + { + errorOccured = true; + string messageId = Context.Current.Request["auth_response"]; + string messageText = Context.Current.Request["message"]; + LogEvent(order, "Transaction {0} not approved. CyberSource response: messageId: '{1}', messageText: '{2}'.", transactionId, messageId, messageText); - var response = await client.PostAsync(resource, content); - return response; - } + return OnError(order, $"Transaction {transactionId} not approved. {messageText}", windowMode is WindowModes.Embedded); + } - /// - /// This method demonstrates the creation of the JWT Authentication credential - /// Takes Request Paylaod and Http method(GET/POST) as input. - /// - /// Value from which to generate JWT - /// The HTTP Verb that is needed for generating the credential - /// String containing the JWT Authentication credential - public string GenerateJWT(string request, string method, Order order) - { - string digest; - string token = "TOKEN_PLACEHOLDER"; + string amount = Context.Current.Request["req_amount"]; + int decimals = string.IsNullOrEmpty(order.Currency.Rounding?.Id) + ? 2 + : order.Currency.Rounding.Decimals; - try + if (errorOccured) { - // Generate the hash for the payload - using (SHA256 sha256Hash = SHA256.Create()) - { - byte[] payloadBytes = sha256Hash.ComputeHash(Encoding.ASCII.GetBytes(request)); - digest = Convert.ToBase64String(payloadBytes); - } - - // Create the JWT payload (aka claimset / JWTBody) - string jwtBody = "0"; + LogError(order, "At least one validation error exists - exiting callback routine."); + order.TransactionStatus = "Failed"; + Services.Orders.Save(order); + } + else + { + LogEvent(order, "Payment succeeded with transaction number {0}", transactionId); + string requestCardType = Context.Current.Request["req_card_type"]; + string cardType = CardTypes.Keys.Any(key => key.Equals(requestCardType, StringComparison.OrdinalIgnoreCase)) + ? CardTypes[requestCardType] + : $"Unrecognized card type - {requestCardType}"; + + order.TransactionAmount = Math.Round(order.Price.Price, decimals); + order.TransactionStatus = "Succeeded"; + order.TransactionCardType = cardType; + order.TransactionCardNumber = HideCardNumber(Context.Current.Request["req_card_number"]); - if (method.Equals("POST")) + if (transactionType is TransactionTypes.Sale) { - jwtBody = "{\n\"digest\":\"" + digest + "\", \"digestAlgorithm\":\"SHA-256\", \"iat\":\"" + DateTime.Now.ToUniversalTime().ToString("r") + "\"}"; + string msg = "Capture succeeded"; + LogEvent(order, msg); + order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); } - else if (method.Equals("GET")) + else if (transactionType is TransactionTypes.ZeroAuthorization) { - jwtBody = "{\"iat\":\"" + DateTime.Now.ToUniversalTime().ToString("r") + "\"}"; + order.TransactionAmount = 0.00; + order.TransactionType = "Zero authorization"; + string msg = "Zero authorization succeeded"; + LogEvent(order, msg); + order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); } + SetOrderComplete(order, transactionId); + } + } + catch (Exception ex) + { + LogError(order, ex, $"Unhandled exception: {ex.Message}"); + } + + if (!orderWasCompleted && order.Complete) + { + CheckoutDone(order); + + if (windowMode is not WindowModes.Embedded) + return PassToCart(order); + + return new ContentOutputResult + { + Content = $"" + }; + } + return ContentOutputResult.Empty; + } - // P12 certificate public key is sent in the header and the private key is used to sign the token - X509Certificate2 x5Cert = new X509Certificate2(GetCertificateFilePath(), MerchantId, X509KeyStorageFlags.MachineKeySet); + private OutputResult OnError(Order order, string message, bool isIFrameError = false) + { + if (windowMode is WindowModes.Embedded && isIFrameError) + { + return new ContentOutputResult + { + Content = $"" + }; + } - // Extracting Public Key from .p12 file - string x5cPublicKey = Convert.ToBase64String(x5Cert.RawData); + order.TransactionAmount = 0; + order.TransactionStatus = "Failed"; + order.Errors.Add(message); + Services.Orders.Save(order); - // Extracting Private Key from .p12 file - var privateKey = x5Cert.GetRSAPrivateKey(); + Services.Orders.DowngradeToCart(order); + order.TransactionStatus = string.Empty; + Common.Context.SetCart(order); - // Extracting serialNumber - string serialNumber = null; - string serialNumberPrefix = "SERIALNUMBER="; + if (string.IsNullOrWhiteSpace(ErrorTemplate)) + return PassToCart(order); - string principal = x5Cert.Subject; + var errorTemplate = new Template(TemplateHelper.GetTemplatePath(ErrorTemplate, ErrorTemplateFolder)); + errorTemplate.SetTag("CheckoutHandler:ErrorMessage", message); - int beg = principal.IndexOf(serialNumberPrefix); - if (beg >= 0) - { - int x5cBase64List = principal.IndexOf(",", beg); - if (x5cBase64List == -1) - { - x5cBase64List = principal.Length; - } + return new ContentOutputResult + { + Content = Render(order, errorTemplate) + }; + } - serialNumber = principal.Substring(serialNumberPrefix.Length, x5cBase64List - serialNumberPrefix.Length); - } + private Dictionary PrepareAuthorizationRequest(Order order, string token = "") => PrepareRequest(order, "authorization", token); - // Create the JWT Header custom fields - var x5cList = new List() - { - x5cPublicKey - }; - var cybsHeaders = new Dictionary() - { - { "v-c-merchant-id", MerchantId }, - { "x5c", x5cList } - }; + private Dictionary PrepareSaleRequest(Order order, string token = "") => PrepareRequest(order, "sale", token); - // JWT token is Header plus the Body plus the Signature of the Header & Body - // Here the Jose-JWT helper library (https://github.com/dvsekhvalnov/jose-jwt) is used create the JWT - token = Jose.JWT.Encode(jwtBody, privateKey, Jose.JwsAlgorithm.RS256, cybsHeaders); - } - catch (Exception ex) - { - LogError(order, ex, "JWT token create failed"); - } + private Dictionary PrepareCreateCardRequest(Order order) => transactionType switch + { + TransactionTypes.Sale => PrepareRequest(order, "sale,create_payment_token", ""), + TransactionTypes.Authorization => PrepareRequest(order, "authorization,create_payment_token", ""), + _ => PrepareRequest(order, "create_payment_token", "") + }; - return token; - } + #region Request building - private string GetSavedCardName(Order order) + private Dictionary PrepareRequest(Order order, string requestTransactionType, string token) + { + string customerName = order.CustomerName?.Trim() ?? ""; + string firstName = Helper.GetCustomerFirstName(order, customerName); + string lastName = Helper.GetCustomerLastName(order, customerName); + string amount = transactionType is TransactionTypes.ZeroAuthorization + ? "0.00" + : Helper.GetTransactionAmount(order); + + var requestParameters = new Dictionary { - return !string.IsNullOrWhiteSpace(order.SavedCardDraftName) ? order.SavedCardDraftName : (order.DoSaveCardToken || order.IsRecurringOrderTemplate || ForceTokenization ? order.Id : ""); - } + ["profile_id"] = ProfileId, + ["access_key"] = AccessKey, + ["transaction_uuid"] = Guid.NewGuid().ToString(), + ["signed_date_time"] = DateTime.Now.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), + ["unsigned_field_names"] = "", + ["locale"] = GetLanguageCode(), + ["transaction_type"] = requestTransactionType, + ["payment_method"] = "card", + ["reference_number"] = !order.IsRecurringOrderTemplate ? order.Id : order.CustomerAccessUserId.ToString(), + ["amount"] = amount, + ["currency"] = order.Price.Currency.Code, + ["override_custom_cancel_page"] = GetCancelUrl(order), + ["override_custom_receipt_page"] = GetAcceptUrl(order), + ["businessRules_declineAVSFlags"] = Decline_AVS_Flag, + ["businessRules_ignoreAVSResult"] = Ignore_AVS_Result.ToString().ToLower() + }; - private string GetAcceptUrl(Order order) + if (!string.IsNullOrWhiteSpace(token)) { - var cardName = GetSavedCardName(order); - return string.Format("{0}&cmd={1}{2}", GetBaseUrl(order), (order.IsRecurringOrderTemplate || !string.IsNullOrWhiteSpace(cardName)) ? "CardSaved" : "Accept", - !string.IsNullOrWhiteSpace(cardName) ? string.Format("&CardTokenName={0}", HttpUtility.UrlEncode(cardName)) : ""); + requestParameters.Add("payment_token", token); } - - private string GetCancelUrl(Order order) + else { - return string.Format("{0}&cmd=Cancel", GetBaseUrl(order)); + requestParameters = requestParameters.Union(new Dictionary + { + ["bill_to_forename"] = firstName, + ["bill_to_surname"] = lastName, + ["bill_to_email"] = order.CustomerEmail ?? "", + ["bill_to_phone"] = order.CustomerPhone ?? "", + ["bill_to_company_name"] = order.CustomerCompany ?? "", + ["bill_to_address_line1"] = order.CustomerAddress ?? "", + ["bill_to_address_line2"] = order.CustomerAddress2 ?? "", + ["bill_to_address_city"] = order.CustomerCity ?? "", + ["bill_to_address_state"] = order.CustomerRegion ?? "", + ["bill_to_address_postal_code"] = order.CustomerZip ?? "", + ["bill_to_address_country"] = order.CustomerCountryCode ?? "" + }).ToDictionary(param => param.Key, param => param.Value); } - private string GetLanguageCode() + bool useBillInfoForDelivery = string.IsNullOrEmpty(order.DeliveryAddress); + if (useBillInfoForDelivery) { - var currentLanguageCode = Dynamicweb.Environment.ExecutingContext.GetCulture(true).TwoLetterISOLanguageName; - return currentLanguageCode; + requestParameters.Add("ship_to_forename", firstName); + requestParameters.Add("ship_to_surname", lastName); + requestParameters.Add("ship_to_email", order.CustomerEmail ?? ""); + requestParameters.Add("ship_to_phone", order.CustomerPhone ?? ""); + requestParameters.Add("ship_to_company_name", order.CustomerCompany ?? ""); + requestParameters.Add("ship_to_address_line1", order.CustomerAddress ?? ""); + requestParameters.Add("ship_to_address_line2", order.CustomerAddress2 ?? ""); + requestParameters.Add("ship_to_address_city", order.CustomerCity ?? ""); + requestParameters.Add("ship_to_address_state", order.CustomerRegion ?? ""); + requestParameters.Add("ship_to_address_postal_code", order.CustomerZip ?? ""); + requestParameters.Add("ship_to_address_country", order.CustomerCountryCode ?? ""); + } + else + { + requestParameters.Add("ship_to_forename", string.IsNullOrWhiteSpace(order.DeliveryFirstName) ? firstName : order.DeliveryFirstName); + requestParameters.Add("ship_to_surname", string.IsNullOrWhiteSpace(order.DeliverySurname) ? lastName : order.DeliverySurname); + requestParameters.Add("ship_to_email", order.DeliveryEmail ?? ""); + requestParameters.Add("ship_to_phone", order.DeliveryPhone ?? ""); + requestParameters.Add("ship_to_company_name", order.DeliveryCompany ?? ""); + requestParameters.Add("ship_to_address_line1", order.DeliveryAddress ?? ""); + requestParameters.Add("ship_to_address_line2", order.DeliveryAddress2 ?? ""); + requestParameters.Add("ship_to_address_city", order.DeliveryCity ?? ""); + requestParameters.Add("ship_to_address_state", order.DeliveryRegion ?? ""); + requestParameters.Add("ship_to_address_postal_code", order.DeliveryZip ?? ""); + requestParameters.Add("ship_to_address_country", SupportedCountryCodes.Any(code => code.Equals(order.DeliveryCountryCode, StringComparison.OrdinalIgnoreCase)) + ? order.DeliveryCountryCode + : string.Empty + ); } - #region Gateway URLs + requestParameters = requestParameters + .Where(param => !string.IsNullOrEmpty(param.Value)) + .ToDictionary(param => param.Key, param => param.Value); - private string GetGatewayUrl(bool isIFrameMode) - { - if (isIFrameMode) - { - if (workMode == WorkModes.Production) - { - return "https://secureacceptance.cybersource.com/embedded/pay"; - } - return "https://testsecureacceptance.cybersource.com/embedded/pay"; - } + string signedFieldNames = string.Join(",", requestParameters.Keys) + ",signed_field_names"; + requestParameters.Add("signed_field_names", signedFieldNames); + requestParameters.Add("signature", SecurityHelper.Sign(requestParameters, SecretKey)); - if (workMode == WorkModes.Production) - { - return "https://secureacceptance.cybersource.com/pay"; - } - return "https://testsecureacceptance.cybersource.com/pay"; - } + return requestParameters; + } - private string GetCreateCardGatewayUrl(bool isIFrameMode) - { - if (isIFrameMode) - { - if (workMode == WorkModes.Production) - { - return "https://secureacceptance.cybersource.com/embedded/token/create"; - } - return "https://testsecureacceptance.cybersource.com/embedded/token/create"; - } + #endregion - if (workMode == WorkModes.Production) - { - return "https://secureacceptance.cybersource.com/token/create"; - } - return "https://testsecureacceptance.cybersource.com/token/create"; - } + private string GetSavedCardName(Order order) + { + string fallbackCardName = order.DoSaveCardToken || order.IsRecurringOrderTemplate || ForceTokenization + ? order.Id + : string.Empty; + + return !string.IsNullOrWhiteSpace(order.SavedCardDraftName) + ? order.SavedCardDraftName + : fallbackCardName; + } - #endregion + private string GetAcceptUrl(Order order) + { + string cardName = GetSavedCardName(order); + string command = order.IsRecurringOrderTemplate || !string.IsNullOrWhiteSpace(cardName) + ? "CardSaved" + : "Accept"; + string queryString = !string.IsNullOrWhiteSpace(cardName) + ? $"&CardTokenName={WebUtility.UrlEncode(cardName)}" + : string.Empty; + + return $"{GetBaseUrl(order)}&cmd={command}{queryString}"; + } + + private string GetCancelUrl(Order order) => $"{GetBaseUrl(order)}&cmd=Cancel"; + + private string GetLanguageCode() => Environment.ExecutingContext.GetCulture(true).TwoLetterISOLanguageName; - private string GetCertificateFilePath() + #region Gateway URLs + + private string GetGatewayUrl(bool isIFrameMode) + { + if (isIFrameMode) { - if (string.IsNullOrWhiteSpace(CertificateFile)) - { - return string.Empty; - } - var path = Context.Current.Server.MapPath(string.Format("/Files/{0}", CertificateFile)); - if (File.Exists(path)) - { - return path; - } - return string.Empty; + if (workMode is WorkModes.Production) + return "https://secureacceptance.cybersource.com/embedded/pay"; + return "https://testsecureacceptance.cybersource.com/embedded/pay"; } - private string GetTransactionAmount(Order order) + if (workMode is WorkModes.Production) + return "https://secureacceptance.cybersource.com/pay"; + return "https://testsecureacceptance.cybersource.com/pay"; + } + + private string GetCreateCardGatewayUrl(bool isIFrameMode) + { + if (isIFrameMode) { - int decimals = order.Currency.Rounding == null || order.Currency.Rounding.Id == "" ? 2 : order.Currency.Rounding.Decimals; - string amount = Math.Round(order.Price.Price, decimals).ToString("0.00", System.Globalization.CultureInfo.CreateSpecificCulture("en-US")); - return amount; + if (workMode is WorkModes.Production) + return "https://secureacceptance.cybersource.com/embedded/token/create"; + return "https://testsecureacceptance.cybersource.com/embedded/token/create"; } - #region ISavedCard interface + if (workMode is WorkModes.Production) + return "https://secureacceptance.cybersource.com/token/create"; + return "https://testsecureacceptance.cybersource.com/token/create"; + } + + #endregion + #region ISavedCard interface - public void DeleteSavedCard(int savedCardID) + public void DeleteSavedCard(int savedCardID) + { + //not supported due new cybersouyrce api + } + + /// + /// Directs checkout handler to use saved card + /// + /// Order that should be processed using saved card information + /// Empty string, if operation succeeded, otherwise string template with exception mesage + public string UseSavedCard(Order order) + { + /*PassToCart part doesn't work because of changes in Redirect behavior. + * We need to return RedirectOutputResult as OutputResult, and handle output result to make it work. + * It means, that we need to change ISavedCard.UseSavedCard method, probably create new one (with OutputResult as returned type) + * To make it work (temporarily), we use Response.Redirect here + */ + + try { - //not supported due new cybersouyrce api - } + if (UseSavedCardInternal(order) is RedirectOutputResult redirectResult) + RedirectToCart(redirectResult); - public string UseSavedCard(Orders.Order order) + if (!order.Complete) + return ProcessError("Some error happened on creating payment using saved card"); + + return string.Empty; + } + catch (ThreadAbortException) { - try - { - UseSavedCardInternal(order); - if (!order.Complete) - { - LogEvent(order, "Order not complete on using saved card", DebuggingInfoType.UseSavedCard); - return OnError(order, "Some error happened on creating payment using saved card"); - } - return string.Empty; - } - catch (System.Threading.ThreadAbortException) - { - return string.Empty; - } - catch (Exception ex) - { - LogEvent(order, ex.Message, DebuggingInfoType.UseSavedCard); - return OnError(order, ex.Message); - } + return string.Empty; } - - public bool SavedCardSupported(Orders.Order order) + catch (Exception ex) { - return !string.IsNullOrWhiteSpace(GetCertificateFilePath()); + return ProcessError(ex.Message); } - private async void UseSavedCardInternal(Orders.Order order) + string ProcessError(string errorMessage) { - var savedCard = Services.PaymentCard.GetById(order.SavedCardId); - if (savedCard == null || order.CustomerAccessUserId != savedCard.UserID) - { - throw new Exception("Token is incorrect."); - } + LogEvent(order, $"Order not complete on using saved card. Error: {errorMessage}", DebuggingInfoType.UseSavedCard); + OutputResult errorResult = OnError(order, $"Some error happened on creating payment using saved card: {errorMessage}"); + if (errorResult is ContentOutputResult contentErrorResult) + return contentErrorResult.Content; + if (errorResult is RedirectOutputResult redirectErrorResult) + RedirectToCart(redirectErrorResult); - var certPath = GetCertificateFilePath(); - if (string.IsNullOrWhiteSpace(certPath)) - { - LogError(order, "No certificate not found"); - return; - } + return string.Empty; + } + } - LogEvent(order, "Using saved card({0}) with id: {1}", savedCard.Identifier, savedCard.ID); + public bool SavedCardSupported(Order order) + { + string certPath = Helper.GetCertificateFilePath(CertificateFile); - if (order.IsRecurringOrderTemplate) - { - SetOrderComplete(order); - LogEvent(order, "Recurring order template created"); - CheckoutDone(order); + return !string.IsNullOrWhiteSpace(certPath); + } - if (!order.Complete) - { - LogError(order, "Some error happened on creating saved card."); - } - PassToCart(order); - } - else - { - string request = PreparePaymentRequest(order, savedCard); + private OutputResult UseSavedCardInternal(Order order) + { + PaymentCardToken savedCard = Services.PaymentCard.GetById(order.SavedCardId); + if (savedCard is null || order.CustomerAccessUserId != savedCard.UserID) + throw new Exception("Token is incorrect."); - var url = $"https://{(workMode == WorkModes.Production ? "api" : "apitest")}.cybersource.com/pts/v2/payments"; - var response = CallCyberSourceAPI(request, url, order); - var responseContent = await response.Result.Content.ReadAsStringAsync(); - var responseJson = Converter.Deserialize>(responseContent); + string certPath = Helper.GetCertificateFilePath(CertificateFile); + if (string.IsNullOrWhiteSpace(certPath)) + throw new Exception("Certificate for REST API is not found"); - if (response.Result.StatusCode == HttpStatusCode.Created) - { - var transactionId = Converter.ToString(responseJson["id"]); + LogEvent(order, "Using saved card({0}) with id: {1}", savedCard.Identifier, savedCard.ID); - LogEvent(order, "Transaction succeeded with transaction number {0}", transactionId); + if (order.IsRecurringOrderTemplate) + { + SetOrderComplete(order); + LogEvent(order, "Recurring order template created"); + CheckoutDone(order); - int decimals = order.Currency.Rounding == null || order.Currency.Rounding.Id == "" ? 2 : order.Currency.Rounding.Decimals; - order.TransactionAmount = Math.Round(order.Price.Price, decimals); - order.TransactionStatus = "Succeeded"; - order.TransactionCardType = savedCard.CardType; - order.TransactionCardNumber = savedCard.Identifier; - if (transactionType == TransactionTypes.Sale) - { - string msg = "Capture succeeded"; - LogEvent(order, msg); - order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); - } - else if (transactionType == TransactionTypes.ZeroAuthorization) - { - order.TransactionAmount = 0.00; - order.TransactionType = "Zero authorization"; - string msg = "Zero authorization succeeded"; - LogEvent(order, msg); - order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); - } + if (!order.Complete) + throw new Exception("Some error happened on creating saved card."); - SetOrderComplete(order, transactionId); + return PassToCart(order); + } - LogEvent(order, "Order completed"); + var service = new CyberSourceService(GetHost(), MerchantId, CertificateFile, CertificatePassword); + PaymentResponse response = service.CreatePayment(order, savedCard); - CheckoutDone(order); + string transactionId = response.Id; + LogEvent(order, "Transaction succeeded with transaction number {0}", transactionId); - LogEvent(order, "Recurring successful"); - } - else - { - var errorMessage = $"Payment using saved card information failed. Response: {responseContent}"; - LogError(order, errorMessage); - return; - } - } + int decimals = string.IsNullOrEmpty(order.Currency.Rounding?.Id) + ? 2 + : order.Currency.Rounding.Decimals; - if (order.RecurringOrderId <= 0) - { - PassToCart(order); - } + order.TransactionAmount = Math.Round(order.Price.Price, decimals); + order.TransactionStatus = "Succeeded"; + order.TransactionCardType = savedCard.CardType; + order.TransactionCardNumber = savedCard.Identifier; + if (transactionType is TransactionTypes.Sale) + { + string msg = "Capture succeeded"; + LogEvent(order, msg); + order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); } - - private string PreparePaymentRequest(Order order, PaymentCardToken savedCard) + else if (transactionType is TransactionTypes.ZeroAuthorization) { - var customerName = Converter.ToString(order.CustomerName).Trim(); - var firstName = GetCustomerFirstName(order, customerName); - var lastName = GetCustomerLastName(order, customerName); - var request = new - { - clientReferenceInformation = new - { - code = order.Id - }, - paymentInformation = new - { - legacyToken = new - { - id = savedCard.Token - } - }, - orderInformation = new - { - amountDetails = new - { - totalAmount = GetTransactionAmount(order), - currency = order.Price.Currency.Code - } - }, - billTo = new - { - firstName, - lastName, - address1 = Converter.ToString(order.CustomerAddress), - locality = Converter.ToString(order.CustomerCity), - administrativeArea = Converter.ToString(order.CustomerRegion), - postalCode = Converter.ToString(order.CustomerZip), - country = Converter.ToString(order.CustomerCountryCode), - email = Converter.ToString(order.CustomerEmail), - phoneNumber = Converter.ToString(order.CustomerPhone) - } - }; - - var requestJson = Converter.Serialize(request); - return requestJson; + order.TransactionAmount = 0.00; + order.TransactionType = "Zero authorization"; + string msg = "Zero authorization succeeded"; + LogEvent(order, msg); + order.CaptureInfo = new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Success, msg); } - #endregion + SetOrderComplete(order, transactionId); + LogEvent(order, "Order completed"); + CheckoutDone(order); + LogEvent(order, "Recurring successful"); + + if (order.RecurringOrderId <= 0) + return PassToCart(order); + + return NoActionOutputResult.Default; + } + + /// + /// A temporary method to maintain previous behavior. Redirects to cart by Response.Redirect. Please remove it when the needed changes will be done. + /// + private void RedirectToCart(RedirectOutputResult redirectResult) => Context.Current.Response.Redirect(redirectResult.RedirectUrl, redirectResult.IsPermanent); + + #endregion - #region IRecurring + #region IRecurring - public void Recurring(Order order, Order initialOrder) + public void Recurring(Order order, Order initialOrder) + { + if (order is null) + return; + + try { - if (order != null) - { - try - { - UseSavedCardInternal(order); - LogEvent(order, "Recurring succeeded"); - } - catch (System.Threading.ThreadAbortException) - { - } - catch (Exception ex) - { - LogEvent(order, "Recurring order failed for {0} (based on {1}). The payment failed with the message: {2}", - DebuggingInfoType.RecurringError, order.Id, initialOrder.Id, ex.Message); - } - } + UseSavedCardInternal(order); + LogEvent(order, "Recurring succeeded"); } - - public bool RecurringSupported(Order order) + catch (ThreadAbortException) + { + } + catch (Exception ex) { - return true; + LogEvent(order, "Recurring order failed for {0} (based on {1}). The payment failed with the message: {2}", + DebuggingInfoType.RecurringError, order.Id, initialOrder.Id, ex.Message); } + } - #endregion + public bool RecurringSupported(Order order) => true; - #region ICheckAuthorizationStatus + #endregion - public AuthorizationStatus CheckAuthorizationStatus(Order order) + #region ICheckAuthorizationStatus + + public AuthorizationStatus CheckAuthorizationStatus(Order order) + { + if (string.Equals(order.TransactionStatus, "Succeeded", StringComparison.OrdinalIgnoreCase)) { - if (order.TransactionStatus == "Succeeded") - { - return order.TransactionType == "Zero authorization" && order.CaptureInfo.State == OrderCaptureInfo.OrderCaptureState.Success ? - AuthorizationStatus.AuthorizedZeroAmount : AuthorizationStatus.AuthorizedFullAmount; - } - else - { - return AuthorizationStatus.NotAuthorized; - } + return string.Equals(order.TransactionType, "Zero authorization", StringComparison.OrdinalIgnoreCase) && order.CaptureInfo.State is OrderCaptureInfo.OrderCaptureState.Success + ? AuthorizationStatus.AuthorizedZeroAmount + : AuthorizationStatus.AuthorizedFullAmount; } - - #endregion - + return AuthorizationStatus.NotAuthorized; } + + #endregion } diff --git a/src/Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.csproj b/src/Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.csproj index cfc20d0..d91a778 100644 --- a/src/Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.csproj +++ b/src/Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.csproj @@ -1,6 +1,6 @@  - 10.0.3 + 10.11.2 1.0.0.0 CyberSource "Payment system, http://www.cybersource.com" @@ -14,17 +14,34 @@ Copyright © 2023 Dynamicweb Software A/S - net7.0 + net8.0 true true true true true snupkg + cybersource-logo.png - + + + + + + + + + + + + + + True + \ + + diff --git a/src/Helpers/Helper.cs b/src/Helpers/Helper.cs new file mode 100644 index 0000000..ffdde93 --- /dev/null +++ b/src/Helpers/Helper.cs @@ -0,0 +1,52 @@ +using Dynamicweb.Core.Helpers; +using Dynamicweb.Ecommerce.Orders; +using System; +using System.IO; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Helpers; + +internal static class Helper +{ + public static string GetCertificateFilePath(string certificateFile) + { + if (string.IsNullOrWhiteSpace(certificateFile)) + return string.Empty; + + string path = FilePathHelper.GetAbsolutePath(certificateFile); + if (File.Exists(path)) + return path; + + return string.Empty; + } + + public static string GetCustomerLastName(Order order, string customerName) + { + string lastName = order.CustomerSurname; + int delimeterPosition = customerName.IndexOf(' '); + if (string.IsNullOrWhiteSpace(lastName)) + lastName = delimeterPosition > -1 ? customerName.Substring(delimeterPosition + 1) : customerName; + + return lastName; + } + + public static string GetCustomerFirstName(Order order, string customerName) + { + string firstName = order.CustomerFirstName; + int delimeterPosition = customerName.IndexOf(' '); + if (string.IsNullOrWhiteSpace(firstName)) + firstName = delimeterPosition > -1 ? customerName.Substring(0, delimeterPosition) : customerName; + + return firstName; + } + + public static string GetTransactionAmount(Order order) + { + int decimals = order.Currency.Rounding is null || string.IsNullOrEmpty(order.Currency.Rounding.Id) + ? 2 + : order.Currency.Rounding.Decimals; + + string amount = Math.Round(order.Price.Price, decimals).ToString("0.00", System.Globalization.CultureInfo.CreateSpecificCulture("en-US")); + + return amount; + } +} diff --git a/src/Helpers/SecurityHelper.cs b/src/Helpers/SecurityHelper.cs new file mode 100644 index 0000000..97737fd --- /dev/null +++ b/src/Helpers/SecurityHelper.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Security.Cryptography; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Helpers; + +internal static class SecurityHelper +{ + /// + /// + /// + /// + /// + /// + /// + /// ToDo: Params property hasn't been defined in new Dynamicweb.Context class. + public static bool ValidateResponseSignation(string publicKey, string secretKey, out string signature) + { + var parameters = Context.Current.Request.Params; + return ValidateResponseSignation(parameters, publicKey, secretKey, out signature); + } + + /// + /// Validates transaction signature + /// + /// Transaction parameters + /// public key + /// secret key + /// signature + /// Boolean result of validating transaction signature + public static bool ValidateResponseSignation(NameValueCollection parameters, string publicKey, string secretKey, out string signature) + { + var transactionSignature = parameters["signature"]; + var signedFieldNames = parameters["signed_field_names"].Split(','); + + var dataToSign = new List(); + foreach (string signedFieldName in signedFieldNames) + { + dataToSign.Add(signedFieldName + "=" + parameters[signedFieldName]); + } + signature = Sign(string.Join(",", dataToSign), secretKey).Replace("\n", string.Empty); + + return transactionSignature.Equals(signature); + } + + /// + /// Signs parameters with secret key + /// + /// set of key value pairs + /// key that is used for encription + /// Encrypted string + public static string Sign(Dictionary parameters, string secretKey) + { + return Sign(BuildSignation(parameters), secretKey); + } + + private static string Sign(string data, string secretKey) + { + var encoding = new System.Text.UTF8Encoding(); + var keyBytes = encoding.GetBytes(secretKey); + + using (var hmacsha256 = new HMACSHA256(keyBytes)) + { + var messageBytes = encoding.GetBytes(data); + return Convert.ToBase64String(hmacsha256.ComputeHash(messageBytes)); + } + } + + private static string BuildSignation(IDictionary parameters) + { + var signedFieldNames = parameters["signed_field_names"].Split(','); + var dataToSign = new List(); + + foreach (string signedFieldName in signedFieldNames) + { + dataToSign.Add(signedFieldName + "=" + parameters[signedFieldName]); + } + + return string.Join(",", dataToSign); + } +} diff --git a/src/Models/Request/CaptureRequestData.cs b/src/Models/Request/CaptureRequestData.cs new file mode 100644 index 0000000..ab201ab --- /dev/null +++ b/src/Models/Request/CaptureRequestData.cs @@ -0,0 +1,18 @@ +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Common; +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request; + +/// +/// Request to capture the payment +/// See: https://developer.cybersource.com/api-reference-assets/index.html#payments_capture_capture-a-payment +/// +[DataContract] +internal sealed class CaptureRequestData +{ + [DataMember(Name = "clientReferenceInformation")] + public ClientReferenceInformation ClientReferenceInformation { get; set; } + + [DataMember(Name = "orderInformation")] + public OrderInformation OrderInformation { get; set; } +} \ No newline at end of file diff --git a/src/Models/Request/Common/AmountDetails.cs b/src/Models/Request/Common/AmountDetails.cs new file mode 100644 index 0000000..ff630cb --- /dev/null +++ b/src/Models/Request/Common/AmountDetails.cs @@ -0,0 +1,20 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Common; + +[DataContract] +internal sealed class AmountDetails +{ + /// + /// Grand total for the order. This value cannot be negative. You can include a decimal point (.), but no other special characters. + /// CyberSource truncates the amount to the correct number of decimal places. + /// + [DataMember(Name = "totalAmount")] + public string TotalAmount { get; set; } + + /// + /// Currency used for the order. Use the three-character ISO Standard Currency Codes. + /// + [DataMember(Name = "currency")] + public string Currency { get; set; } +} diff --git a/src/Models/Request/Common/BillTo.cs b/src/Models/Request/Common/BillTo.cs new file mode 100644 index 0000000..4955bdd --- /dev/null +++ b/src/Models/Request/Common/BillTo.cs @@ -0,0 +1,68 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Common; + +[DataContract] +internal sealed class BillTo +{ + /// + /// Customer’s first name. This name must be the same as the name on the card. + /// + [DataMember(Name = "firstName")] + public string FirstName { get; set; } + + /// + /// Customer’s last name. This name must be the same as the name on the card. + /// + [DataMember(Name = "lastName")] + public string LastName { get; set; } + + /// + /// Payment card billing street address as it appears on the credit card issuer’s records. + /// + [DataMember(Name = "address1")] + public string Address1 { get; set; } + + /// + /// Payment card billing city. + /// + [DataMember(Name = "locality")] + public string Locality { get; set; } + + /// + /// State or province of the billing address. Use the State, Province, and Territory Codes for the United States and Canada. + /// + [DataMember(Name = "administrativeArea")] + public string AdministrativeArea { get; set; } + + /// + /// Postal code for the billing address. The postal code must consist of 5 to 9 digits. + /// When the billing country is the U.S., the 9-digit postal code must follow this format: + /// [5 digits][dash][4 digits] + /// Example 12345-6789 + /// When the billing country is Canada, the 6-digit postal code must follow this format: + /// [alpha][numeric][alpha][space][numeric][alpha][numeric] + /// Example A1B 2C3 + /// + [DataMember(Name = "postalCode")] + public string PostalCode { get; set; } + + /// + /// Payment card billing country. Use the two-character ISO Standard Country Codes. + /// + [DataMember(Name = "country")] + public string Country { get; set; } + + /// + /// Customer's email address, including the full domain name. + /// + [DataMember(Name = "email")] + public string Email { get; set; } + + /// + /// Customer’s phone number. + /// It is recommended that you include the country code when the order is from outside the U.S. + /// + [DataMember(Name = "phoneNumber")] + public string PhoneNumber { get; set; } +} \ No newline at end of file diff --git a/src/Models/Request/Common/ClientReferenceInformation.cs b/src/Models/Request/Common/ClientReferenceInformation.cs new file mode 100644 index 0000000..efaab1e --- /dev/null +++ b/src/Models/Request/Common/ClientReferenceInformation.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Common; + +[DataContract] +internal sealed class ClientReferenceInformation +{ + /// + /// Merchant-generated order reference or tracking number. It is recommended that you send a unique value for each transaction so that you can perform meaningful searches for the transaction. + /// + [DataMember(Name = "code")] + public string Code { get; set; } +} diff --git a/src/Models/Request/Common/LegacyToken.cs b/src/Models/Request/Common/LegacyToken.cs new file mode 100644 index 0000000..fc628c0 --- /dev/null +++ b/src/Models/Request/Common/LegacyToken.cs @@ -0,0 +1,14 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Common; + +[DataContract] +internal sealed class LegacyToken +{ + /// + /// Unique identifier for the legacy Secure Storage token used in the transaction. + /// When you include this value in your request, many of the fields that are normally required for an authorization or credit become optional. + /// + [DataMember(Name = "id")] + public string Id { get; set; } +} diff --git a/src/Models/Request/Common/OrderInformation.cs b/src/Models/Request/Common/OrderInformation.cs new file mode 100644 index 0000000..2d0fd40 --- /dev/null +++ b/src/Models/Request/Common/OrderInformation.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Common; + +[DataContract] +internal sealed class OrderInformation +{ + [DataMember(Name = "amountDetails")] + public AmountDetails AmountDetails { get; set; } + + [DataMember(Name = "billTo", EmitDefaultValue = false)] + public BillTo BillTo { get; set; } +} diff --git a/src/Models/Request/Common/PaymentInformation.cs b/src/Models/Request/Common/PaymentInformation.cs new file mode 100644 index 0000000..41760d2 --- /dev/null +++ b/src/Models/Request/Common/PaymentInformation.cs @@ -0,0 +1,10 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Common; + +[DataContract] +internal sealed class PaymentInformation +{ + [DataMember(Name = "legacyToken")] + public LegacyToken LegacyToken { get; set; } +} \ No newline at end of file diff --git a/src/Models/Request/Error/CybersourceError.cs b/src/Models/Request/Error/CybersourceError.cs new file mode 100644 index 0000000..0269d98 --- /dev/null +++ b/src/Models/Request/Error/CybersourceError.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Error; + +internal sealed class CybersourceError +{ + [DataMember(Name = "submitTimeUtc")] + public string SubmitTimeUtc { get; set; } + + [DataMember(Name = "status")] + public string Status { get; set; } + + [DataMember(Name = "reason")] + public string Reason { get; set; } + + [DataMember(Name = "message")] + public string Message { get; set; } + + [DataMember(Name = "details")] + public IEnumerable Details { get; set; } +} diff --git a/src/Models/Request/Error/ErrorDetail.cs b/src/Models/Request/Error/ErrorDetail.cs new file mode 100644 index 0000000..c3a3470 --- /dev/null +++ b/src/Models/Request/Error/ErrorDetail.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Error; + +[DataContract] +internal sealed class ErrorDetail +{ + [DataMember(Name = "field")] + public string Field { get; set; } + + [DataMember(Name = "reason")] + public string Reason { get; set; } +} diff --git a/src/Models/Request/PaymentRequestData.cs b/src/Models/Request/PaymentRequestData.cs new file mode 100644 index 0000000..7cefe39 --- /dev/null +++ b/src/Models/Request/PaymentRequestData.cs @@ -0,0 +1,21 @@ +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Common; +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request; + +/// +/// A payment authorizes the amount for the transaction. There are a number of supported payment features, such as E-commerce and Card Present - Credit Card/Debit Card, Echeck, e-Wallets, Level II/III Data, etc. +/// See: https://developer.cybersource.com/api-reference-assets/index.html#payments_payments_process-a-payment +/// +[DataContract] +internal sealed class PaymentRequestData +{ + [DataMember(Name = "clientReferenceInformation")] + public ClientReferenceInformation ClientReferenceInformation { get; set; } + + [DataMember(Name = "paymentInformation")] + public PaymentInformation PaymentInformation { get; set; } + + [DataMember(Name = "orderInformation")] + public OrderInformation OrderInformation { get; set; } +} \ No newline at end of file diff --git a/src/Models/Response/CaptureResponse.cs b/src/Models/Response/CaptureResponse.cs new file mode 100644 index 0000000..a13f64e --- /dev/null +++ b/src/Models/Response/CaptureResponse.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Response; + +/// +/// Response for payment operation +/// See: https://developer.cybersource.com/api-reference-assets/index.html#payments_capture_capture-a-payment_responsefielddescription_201 +/// +[DataContract] +internal sealed class CaptureResponse +{ + [DataMember(Name = "id")] + public string Id { get; set; } + + [DataMember(Name = "reconciliationId")] + public string ReconciliationId { get; set; } + + [DataMember(Name = "status")] + public string Status { get; set; } + + [DataMember(Name = "processorInformation")] + public ProcessorInformation ProcessorInformation { get; set; } +} diff --git a/src/Models/Response/PaymentResponse.cs b/src/Models/Response/PaymentResponse.cs new file mode 100644 index 0000000..349c64e --- /dev/null +++ b/src/Models/Response/PaymentResponse.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Response; + +/// +/// Response for payment operation +/// See: https://developer.cybersource.com/api-reference-assets/index.html#payments_payments_create-a-payment-order-request_responsefielddescription_201_clientReferenceInformation +/// +[DataContract] +internal sealed class PaymentResponse +{ + [DataMember(Name = "id")] + public string Id { get; set; } + + [DataMember(Name = "reconciliationId")] + public string ReconciliationId { get; set; } + + [DataMember(Name = "status")] + public string Status { get; set; } + + [DataMember(Name = "riskInformation")] + public RiskInformation RiskInformation { get; set; } +} diff --git a/src/Models/Response/ProcessorInformation.cs b/src/Models/Response/ProcessorInformation.cs new file mode 100644 index 0000000..5de8c51 --- /dev/null +++ b/src/Models/Response/ProcessorInformation.cs @@ -0,0 +1,13 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Response; + +[DataContract] +internal sealed class ProcessorInformation +{ + [DataMember(Name = "networkTransactionId")] + public string NetworkTransactionId { get; set; } + + [DataMember(Name = "responseDetails")] + public string ResponseDetails { get; set; } +} diff --git a/src/Models/Response/RiskInformation.cs b/src/Models/Response/RiskInformation.cs new file mode 100644 index 0000000..07db707 --- /dev/null +++ b/src/Models/Response/RiskInformation.cs @@ -0,0 +1,16 @@ +using System.Runtime.Serialization; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Response; + +[DataContract] +internal sealed class RiskInformation +{ + [DataMember(Name = "id")] + public string Id { get; set; } + + [DataMember(Name = "fraudDecision")] + public string FraudDecision { get; set; } + + [DataMember(Name = "fraudDecisionReason")] + public string FraudDecisionReason { get; set; } +} diff --git a/src/Security.cs b/src/Security.cs deleted file mode 100644 index e46e837..0000000 --- a/src/Security.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Dynamicweb.Ecommerce.Orders; -using Dynamicweb.Logging; -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Security.Cryptography; - -namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource -{ - internal class Security - { - /// - /// - /// - /// - /// - /// - /// - /// ToDo: Params property hasn't been defined in new Dynamicweb.Context class. - public static bool ValidateResponseSignation(string publicKey, string secretKey, out string signature) - { - var parameters = Context.Current.Request.Params; - return ValidateResponseSignation(parameters, publicKey, secretKey, out signature); - } - - /// - /// Validates transaction signature - /// - /// Transaction parameters - /// public key - /// secret key - /// signature - /// Boolean result of validating transaction signature - public static bool ValidateResponseSignation(NameValueCollection parameters, string publicKey, string secretKey, out string signature) - { - var transactionSignature = parameters["signature"]; - var signedFieldNames = parameters["signed_field_names"].Split(','); - - var dataToSign = new List(); - foreach (string signedFieldName in signedFieldNames) - { - dataToSign.Add(signedFieldName + "=" + parameters[signedFieldName]); - } - signature = Sign(string.Join(",", dataToSign), secretKey).Replace("\n", string.Empty); - - return transactionSignature.Equals(signature); - } - - /// - /// Signs parameters with secret key - /// - /// set of key value pairs - /// key that is used for encription - /// Encrypted string - public static string Sign(Dictionary parameters, string secretKey) - { - return Sign(BuildSignation(parameters), secretKey); - } - - private static string Sign(string data, string secretKey) - { - var encoding = new System.Text.UTF8Encoding(); - var keyBytes = encoding.GetBytes(secretKey); - - using (var hmacsha256 = new HMACSHA256(keyBytes)) - { - var messageBytes = encoding.GetBytes(data); - return Convert.ToBase64String(hmacsha256.ComputeHash(messageBytes)); - } - } - - private static string BuildSignation(IDictionary parameters) - { - var signedFieldNames = parameters["signed_field_names"].Split(','); - var dataToSign = new List(); - - foreach (string signedFieldName in signedFieldNames) - { - dataToSign.Add(signedFieldName + "=" + parameters[signedFieldName]); - } - - return string.Join(",", dataToSign); - } - } -} diff --git a/src/Service/ApiCommand.cs b/src/Service/ApiCommand.cs new file mode 100644 index 0000000..67dbdd9 --- /dev/null +++ b/src/Service/ApiCommand.cs @@ -0,0 +1,21 @@ +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Service; + +/// +/// REST API Commands +/// +internal enum ApiCommand +{ + /// + /// Create a payment operation to authorize the amount for the transaction. + /// POST /pts/v2/payments + /// See: https://developer.cybersource.com/api-reference-assets/index.html#payments_payments_process-a-payment + /// + CreatePayment, + + /// + /// Captures payment. Include the payment ID in the POST request to capture the payment amount. + /// POST /pts/v2/payments/{operatorId}/captures + /// See: https://developer.cybersource.com/api-reference-assets/index.html#payments_capture_capture-a-payment + /// + CapturePayment, +} diff --git a/src/Service/CommandConfiguration.cs b/src/Service/CommandConfiguration.cs new file mode 100644 index 0000000..6a4969e --- /dev/null +++ b/src/Service/CommandConfiguration.cs @@ -0,0 +1,19 @@ +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Service; + +internal sealed class CommandConfiguration +{ + /// + /// Cyber source command. See operation urls in and + /// + public ApiCommand CommandType { get; set; } + + /// + /// Command operator id, like https://.../pts/v2/payments/{OperatorId}/captures + /// + public string OperatorId { get; set; } + + /// + /// Data to serialize + /// + public object Data { get; set; } +} diff --git a/src/Service/CyberSourceRequest.cs b/src/Service/CyberSourceRequest.cs new file mode 100644 index 0000000..0b4cf29 --- /dev/null +++ b/src/Service/CyberSourceRequest.cs @@ -0,0 +1,220 @@ +using Dynamicweb.Core; +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Helpers; +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Error; +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.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading.Tasks; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Service; + +/// +/// Sends request to CyberSource and gets response. +/// +internal sealed class CyberSourceRequest +{ + public string MerchantId { get; set; } + + public string CertificateFile { get; set; } + + public string CertificatePassword { get; set; } + + public CyberSourceRequest(string merchantId, string certificateFile, string certificatePassword) + { + MerchantId = merchantId; + CertificateFile = certificateFile; + CertificatePassword = certificatePassword; + } + + public string SendRequest(Order order, string host, CommandConfiguration configuration) + { + using (HttpMessageHandler messageHandler = GetMessageHandler()) + { + using var client = new HttpClient(messageHandler); + + client.Timeout = new TimeSpan(0, 0, 0, 90); + client.DefaultRequestHeaders.Add("Host", host); + + UriBuilder baseAddress = GetBaseAddress(host); + client.BaseAddress = new Uri(baseAddress.ToString()); + + HttpMethod method = configuration.CommandType switch + { + ApiCommand.CreatePayment or + ApiCommand.CapturePayment => HttpMethod.Post, + _ => throw new NotSupportedException($"Unknown operation was used. The operation code: {configuration.CommandType}.") + }; + + string data = Converter.Serialize(configuration.Data); + string jwtToken = GenerateJWT(order, method, data); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken); + + string apiCommand = GetCommandLink(baseAddress, configuration.CommandType, configuration.OperatorId); + Task requestTask = method switch + { + _ when method == HttpMethod.Post => client.PostAsync(apiCommand, GetContent(data)), + _ => throw new NotSupportedException($"Unknown http method was used: {method.ToString()}.") + }; + + 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 errorResponse = Converter.Deserialize(responseText); + if (string.IsNullOrEmpty(errorResponse.Status)) + throw new Exception($"Unhandled exception. Operation failed: '{response.ReasonPhrase}'. Response text: '{responseText}'"); + + string errorMessage = $"Operation failed. Status: '{errorResponse.Status}'. Reason: '{errorResponse.Reason}'. Message: '{errorResponse.Message}'."; + if (response.StatusCode is HttpStatusCode.BadRequest) + { + if (errorResponse.Details?.Any() is true) + { + var detailsMessage = new StringBuilder(); + foreach (ErrorDetail detail in errorResponse.Details) + detailsMessage.AppendLine($"{detail.Field}: {detail.Reason}"); + + errorMessage += $" Details: '{detailsMessage.ToString()}'"; + } + } + throw new Exception(errorMessage); + } + + return responseText; + } + } + catch (HttpRequestException requestException) + { + throw new Exception($"An error occurred during CyberSource request. Error code: {requestException.StatusCode}"); + } + } + + HttpMessageHandler GetMessageHandler() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip + }; + + HttpContent GetContent(string content) => new StringContent(content, Encoding.UTF8, "application/json"); + } + + private UriBuilder GetBaseAddress(string host) => new UriBuilder(Uri.UriSchemeHttps, host); + + private string GetCommandLink(UriBuilder baseAddress, ApiCommand command, string operatorId) + { + return command switch + { + ApiCommand.CreatePayment => GetCommandLink("payments"), + ApiCommand.CapturePayment => GetCommandLink($"payments/{operatorId}/captures"), + _ => throw new NotSupportedException($"The api command is not supported. Command: {command}") + }; + + string GetCommandLink(string gateway) + { + baseAddress.Path = $"pts/v2/{gateway}"; + return baseAddress.ToString(); + } + } + + private void Log(Order order, string message) + { + if (order is null) + return; + + Services.OrderDebuggingInfos.Save(order, message, typeof(CyberSource).FullName, DebuggingInfoType.Undefined); + } + + /// + /// This method demonstrates the creation of the JWT Authentication credential + /// Takes Request Payload and Http method(GET/POST) as input. + /// This code is an example from: https://github.com/CyberSource/cybersource-rest-samples-csharp/blob/master/Source/Samples/Authentication/StandAloneJWT.cs + /// + /// Value from which to generate JWT + /// The HTTP Verb that is needed for generating the credential + /// String containing the JWT Authentication credential + private string GenerateJWT(Order order, HttpMethod method, string data) + { + string digest; + string token = "TOKEN_PLACEHOLDER"; + + try + { + // Generate the hash for the payload + using (SHA256 sha256Hash = SHA256.Create()) + { + byte[] payloadBytes = sha256Hash.ComputeHash(Encoding.ASCII.GetBytes(data)); + digest = Convert.ToBase64String(payloadBytes); + } + + // Create the JWT payload (aka claimset / JWTBody) + string jwtBody = "0"; + + if (method == HttpMethod.Post) + jwtBody = "{\n\"digest\":\"" + digest + "\", \"digestAlgorithm\":\"SHA-256\", \"iat\":\"" + DateTime.Now.ToUniversalTime().ToString("r") + "\"}"; + else if (method == HttpMethod.Get) + jwtBody = "{\"iat\":\"" + DateTime.Now.ToUniversalTime().ToString("r") + "\"}"; + + string certificatePath = Helper.GetCertificateFilePath(CertificateFile); + if (string.IsNullOrEmpty(certificatePath)) + throw new Exception("Certificate for REST API is not found"); + + // P12 certificate public key is sent in the header and the private key is used to sign the token + X509Certificate2 x5Cert = new X509Certificate2(certificatePath, CertificatePassword, X509KeyStorageFlags.MachineKeySet); + + // Extracting Public Key from .p12 file + string x5cPublicKey = Convert.ToBase64String(x5Cert.RawData); + + // Extracting Private Key from .p12 file + var privateKey = x5Cert.GetRSAPrivateKey(); + + // Extracting serialNumber + string serialNumber = null; + string serialNumberPrefix = "SERIALNUMBER="; + + string principal = x5Cert.Subject; + + int beg = principal.IndexOf(serialNumberPrefix); + if (beg >= 0) + { + int x5cBase64List = principal.IndexOf(",", beg); + if (x5cBase64List == -1) + x5cBase64List = principal.Length; + + serialNumber = principal.Substring(serialNumberPrefix.Length, x5cBase64List - serialNumberPrefix.Length); + } + + // Create the JWT Header custom fields + var x5cList = new List() + { + x5cPublicKey + }; + + var cybsHeaders = new Dictionary() + { + { "v-c-merchant-id", MerchantId }, + { "x5c", x5cList } + }; + + // JWT token is Header plus the Body plus the Signature of the Header & Body + // Here the Jose-JWT helper library (https://github.com/dvsekhvalnov/jose-jwt) is used create the JWT + token = Jose.JWT.Encode(jwtBody, privateKey, Jose.JwsAlgorithm.RS256, cybsHeaders); + } + catch (Exception ex) + { + throw new Exception("JWT token create failed", ex); + } + + return token; + } +} diff --git a/src/Service/CyberSourceService.cs b/src/Service/CyberSourceService.cs new file mode 100644 index 0000000..31ab46f --- /dev/null +++ b/src/Service/CyberSourceService.cs @@ -0,0 +1,106 @@ +using Dynamicweb.Core; +using Dynamicweb.Ecommerce.Cart; +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Helpers; +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request; +using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Response; +using Dynamicweb.Ecommerce.Orders; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Service; + +internal sealed class CyberSourceService +{ + public CyberSourceRequest Request { get; } + + public string BaseAddress { get; } + + public CyberSourceService(string baseAddress, string merchantId, string certificateFile, string certificatePassword) + { + Request = new(merchantId, certificateFile, certificatePassword); + BaseAddress = baseAddress; + } + + public PaymentResponse CreatePayment(Order order, PaymentCardToken savedCard) + { + PaymentRequestData requestData = PreparePaymentRequest(order, savedCard); + var configuration = new CommandConfiguration + { + CommandType = ApiCommand.CreatePayment, + Data = requestData + }; + + string response = Request.SendRequest(order, BaseAddress, configuration); + return Converter.Deserialize(response); + } + + public CaptureResponse Capture(Order order, string transactionNumber) + { + var captureRequestData = new CaptureRequestData + { + ClientReferenceInformation = new() + { + Code = order.Id + }, + OrderInformation = new() + { + AmountDetails = new() + { + Currency = order.Price.Currency.Code, + TotalAmount = Helper.GetTransactionAmount(order) + } + } + }; + + var configuration = new CommandConfiguration + { + CommandType = ApiCommand.CapturePayment, + OperatorId = transactionNumber, + Data = captureRequestData + }; + + string response = Request.SendRequest(order, BaseAddress, configuration); + return Converter.Deserialize(response); + } + + private PaymentRequestData PreparePaymentRequest(Order order, PaymentCardToken savedCard) + { + string customerName = order.CustomerName?.Trim() ?? string.Empty; + string firstName = Helper.GetCustomerFirstName(order, customerName); + string lastName = Helper.GetCustomerLastName(order, customerName); + + return new() + { + ClientReferenceInformation = new() + { + Code = order.Id + }, + PaymentInformation = new() + { + LegacyToken = new() + { + Id = savedCard.Token + } + }, + OrderInformation = new() + { + AmountDetails = new() + { + TotalAmount = Helper.GetTransactionAmount(order), + Currency = order.Price.Currency.Code + }, + BillTo = new() + { + FirstName = firstName, + LastName = lastName, + Address1 = Converter.ToString(order.CustomerAddress), + Locality = Converter.ToString(order.CustomerCity), + AdministrativeArea = Converter.ToString(order.CustomerRegion), + PostalCode = Converter.ToString(order.CustomerZip), + Country = Converter.ToString(order.CustomerCountryCode), + Email = Converter.ToString(order.CustomerEmail), + PhoneNumber = Converter.ToString(order.CustomerPhone) + } + } + }; + } + +} diff --git a/src/TransactionTypes.cs b/src/TransactionTypes.cs new file mode 100644 index 0000000..2be335d --- /dev/null +++ b/src/TransactionTypes.cs @@ -0,0 +1,8 @@ +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource; + +internal enum TransactionTypes +{ + ZeroAuthorization, + Authorization, + Sale +} diff --git a/src/Updates/CyberSourceUpdateProvider.cs b/src/Updates/CyberSourceUpdateProvider.cs new file mode 100644 index 0000000..e15fc96 --- /dev/null +++ b/src/Updates/CyberSourceUpdateProvider.cs @@ -0,0 +1,32 @@ +using Dynamicweb.Updates; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Updates; + +public sealed class CyberSourceUpdateProvider : UpdateProvider +{ + private static Stream GetResourceStream(string name) + { + string resourceName = $"Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Updates.{name}"; + + return Assembly.GetAssembly(typeof(CyberSourceUpdateProvider)).GetManifestResourceStream(resourceName); + } + + public override IEnumerable GetUpdates() + { + return new List() + { + new FileUpdate("7c7b5833-f67f-4eb7-88bb-a7460e538f4f", this, "/Files/Templates/eCom7/CheckoutHandler/CyberSource/Cancel/checkouthandler_cancel.html", () => GetResourceStream("checkouthandler_cancel.html")), + new FileUpdate("1d7c25a8-cf3d-4f9a-a85e-c82e3219cba5", this, "/Files/Templates/eCom7/CheckoutHandler/CyberSource/Error/checkouthandler_error.html", () => GetResourceStream("checkouthandler_error.html")), + new FileUpdate("541895f6-0e7f-4ad4-9bd7-333aa86dfa07", this, "/Files/Templates/eCom7/CheckoutHandler/CyberSource/Payment/Payment.html", () => GetResourceStream("Payment.html")) + }; + } + + /* + * IMPORTANT! + * Use a generated GUID string as id for an update + * - Execute command in C# interactive window: Guid.NewGuid().ToString() + */ +} \ No newline at end of file diff --git a/src/Updates/Payment.html b/src/Updates/Payment.html new file mode 100644 index 0000000..d8f68fd --- /dev/null +++ b/src/Updates/Payment.html @@ -0,0 +1,24 @@ + +

+

+
+
+ +

+ +
+ + + + " onclick="cancel();" /> +
+ + + + \ No newline at end of file diff --git a/src/Updates/checkouthandler_cancel.html b/src/Updates/checkouthandler_cancel.html new file mode 100644 index 0000000..cc82980 --- /dev/null +++ b/src/Updates/checkouthandler_cancel.html @@ -0,0 +1,9 @@ +
+ +

+ +

+

+ . +

+
\ No newline at end of file diff --git a/src/Updates/checkouthandler_error.html b/src/Updates/checkouthandler_error.html new file mode 100644 index 0000000..2cf3061 --- /dev/null +++ b/src/Updates/checkouthandler_error.html @@ -0,0 +1,17 @@ +
+ +

+ : +

+
+ +
+ + + + +

+ . +

+ +
\ No newline at end of file diff --git a/src/WindowModes.cs b/src/WindowModes.cs new file mode 100644 index 0000000..9372a1f --- /dev/null +++ b/src/WindowModes.cs @@ -0,0 +1,7 @@ +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource; + +internal enum WindowModes +{ + Redirect, + Embedded +} \ No newline at end of file diff --git a/src/WorkModes.cs b/src/WorkModes.cs new file mode 100644 index 0000000..945cd31 --- /dev/null +++ b/src/WorkModes.cs @@ -0,0 +1,7 @@ +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource; + +internal enum WorkModes +{ + Test, + Production +} \ No newline at end of file diff --git a/src/cybersource-logo.png b/src/cybersource-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..598d72b8ce8029a114865b09f7ceef8c468411ff GIT binary patch literal 34238 zcmce7Wl&vP&}D=m!QCNPaCZ-`!QI{6odgK(aFO8d?he7--Ga---I>FCqcu`9-;YTZ z1(%$Cc6aaY)!nN%A#yTe@Skx$zkBx%UP4@0;oZCUSHM?3G$inw&}bJI;M;pg1u?;Q z(CFQ}Uy2gK0!nT=$8AvY zX#Ms1o~9>hk0!fo?zSV%m%F1QY0i$`V{!+c}jH~#?MsJLQ&n!R3ojb`ns;+sb5UR1pIqp;C6XRLlT50A{`bIAP+zi zjD7c+izomo7D8?UhA1GK^oO4eG*JNQdo-uFhhQ=@QGxFg?>@aPlK4Qe4=fQ!`Sw38 z9qhYMk;@3v zYP5MtM~cM)HkMF;Bdd?f2-RvQt9}h1Abf;NuIY*+%#CkvC5&(9B=FrAg0iHyw^YWrha>qY=jWqs`yY&^ zsA9G-%fe6uncx9kB|3Rohxj~|Bl2hW-A_bgPBCz%^s9+<5icb|()sQMVVu(00#OWO zR;=UeQ15fwsu6ZG?p#mm6N<+91M3i&Xm{t}lj3}n9l0iNA_EW_#T)v*bx*?z_#x)>cL?;i? zo_%U1724qMl|t%u_EaEaW)yc|zOH4# z;ln%e1-=d)xC(B50)&(PptBD(7AmcC5SPFoinhNkh3ifJ=gK`du;^A6RQTE+mMtUB z1NaPz*q6M19h{3(zd^<+0!BgPp8R|%P)zDw5BJE8Kmd_WLOK#-oFxUM1z zCk(m6ER*03thR@?iP0h_Pl1t(d@c&4>kQRi-HD8QVY(G0d!*EIKeSR5Wje{=8#^>;$d50ZHCo;u!V=D zcJe`t4ZX*p)&9V?%}yM@QgY8{x9QjV-e`{;c;_-xkKK>VA79jwoU*S<#XDa~bVXVo zjXJfaiDIHl5dh0k*dHUz^}!eMe%Q2zC=D}}C6Z-V#*q9L)Hkr@{faI0NOIRO%t?KD zRp{;}L*|(>j!;7tV5AIKM|j|XIxt-Kt3tVMbm78hi5r%9iEc(d_lp^-T8b*O6e2PY zD5rF*^5Y%`hyKDgu#%9?+7=7X;BO$V&Y@dp#b{Y_oe0S`Uy4?(_;DK8gN;Ey1Jiqh zF>2-?Np;DE3ankfHv}RG5X>@~am^J7uQo#ysY3&SLf7@4)UKIouKVh5U^Mx(N{t%U zP@$7;1{HS+5{c2M4_UkQXr$ljHBU;N(v*F*Y=)AUxQd^jOt6q0+(DypVEjJ%muluCJMaNc|16uq(Y1iJZkKiB#cv#J1PqN!AX9 zypW-sy9gY;%RTW@5uO!KgHHg706+_dhZ@70vJWN7sAVmfr`(Rg?x{M4$RN-c6AT)h zyA1AzgW-aAU%q#;`yyEV0r02~--72D!{8(i47kMXAJx&ModX$K7|(A90?vELuC+=( zT3J<~8b3DXC7+%q%1ubjy=~H4%o{j^mmkoYW-+kbV~Uo959fJ#Sys%UiMhUD*trDX-K+JLm9I=BX}| zrH5K(0-_MW?Tn=8)|qnWrG;I>GUAl$nW@F4T44CxarDElxDkadA{fLT>*qsb4i3DH z)gp;VC$oSppkxF~cqUTYcM5r2AkTa4^Cp84g5*kBgNj%T(WJk|3rxt1B^&|5^aT0I zRKs)3p;gUSMRBhEiC%^KX>{StVD$WRADT#X-&wThwaHvSHBjcuzrw&qYBA(e&gp@^ zbb^=4&+|pPN65lIXnR$=XIrGz*G9>&C3y<@lozSZbyd5#~wk-F@LpLt#$C&1o&abmKi(nztLz*#Cd9OR3G zVRo?(HRsarh$fQe&X>7wPGvz`rIhn^p zt`%X163*W^s*m0Z)|FX9=#y7oQ`=BN4^NwM_bG6kK8!x#VeNw7X)KSA;pIdce=(0U z2CKs6dSN8&0OJ(hc{hkpp1g?ix5!kHB!y8LBg&{_Z;)POoftHq+h69tP}zikEG?5Ax@OJY zz{IY&mMQ)bT`ZLiDhHK8md;QN3pS%UZylOLQy-K2@(p<6Dq=zBH5WWD#S|caTMC%JSlLLrDm@48NnRurz);N;bgJwt_>Qe z6T)ViHw5(Z=7lTUaCdzx4~_}+cd};QX2=q-C<9oNFF^93P`u)DQKj6+LV&RW>c!i5hoO>QZ|*DY|EQB6>%U zHD%CxFfM1>!+h^jD(f+RF*w8TRlTP)r;S0B3l}&jFW(g_Rq{+`6`#-Gx z4(|=>MR>RLy1loR*Nb?kq7N+LnjFFuUweSe7`wyaj;&enWROln;^_EH_{Yz)m}?+f zJ`8Tr*^i{oPH$N&B_tmyTPDc|ZG+q0TncMw=!UuBZ?E6?AQ9K0X!_Tw=<shoZ+=NcyyE%X*TW)U1U;YW#6iRcL{q}X{wJYgI2PRgQi3?`sLn5J#Z`3k%R0YP%%sWxBq)GO0xVH$hpE=u1ggp!9ur^e`g|=x8ji zfSCOxd3j|_OWtKRx};&$(8&-6Z~dVJ!QW?0GT0jY6Ux5=+5}%CJTa;CGOiTyQyM9c z*$>TEqY!rt{A9ys<%*~h$d?r4z>!rWI}Kim;&IZ)PW4`eRaPLzXxhxme=~ibZ|)sMDt*s_jEQTCBHv>nOv=fkK3IZS9bVHDiQjUP!1GP&v zrN=%R=20tgeoNE+%B(9L@;FruZpO`{*5ra5@u&m5EL7CbToU3)()nu?!-E6)M8h;f zdWga9J6(aLYr}!@N#W{8u4~~HnTC)h-9{OeU*CEpn8A-^`|zSA^0FUbxbl@^GR71| z#&m0#B!dlBm>loP*8Cz1?b8b6#zOuPMK&X}_JPvAzBH@qB|As9kCt_NP?0Tj+ufwR zjw>UXVQ1|Yv;1E{)E>qe3($^nsvDCnGGl_Oz2Vgx)`zf&1fHr8_mcYE!^r%G3uSt8 zm5=mL-UL@Ov1MTDoun8oy}sZg@pd@+fcqWgw;`s;XIDi(f_|i4E@FMljM7C@V?QI$ zw{sH6t)u7uZ8`4(uR#N^ImPgBz^1+5EAz+C%$mcLH(g4&FeA_7@oMr<08orcsHGQ* zR-YrD>x%W4b`}&@;nhKdcT`NEVY;tai&3!}+V)NM=hv7y9fhX=XbmvArxW566fWf` z`z|=buL{2tUS~xS!d^Lw^vQiq!i zM&7mScBhmFjF9_h?gV>`kOkmO)jvqnZn7}5#79GnA~BEjX{}H^OgfSr0pilApD~m(gb(RD zdv#RC&?x2$DY9;bec@~(4>1A*FrQu*0;cbpMXlFK=%cgM6FPmviMiOHbN9NBJ)7sD*1sM zNB-57&)iVF44D|V0hBl$Bcie6r=vjw1zxa6bu0*t|m)S9G)wWoEWPXiY87jmn#bn?6*h~*5gdJ%I zs9p{j2FcN(g1y$pv@j7%E1-8Ynj45{ebAC0f@H^Z8+@dL-(E`TcD4}BtSZ8W&t}wx zA!Jg@>_l^iF4=jT#HZPqeSZy1=`VlrTuzAwfD zoXUTI$RMrfY5MA5+J>c?A7bwlkF^&VY_DI7rD|IEx=wm=m2fQr5FatXyC?fyb+`Kj z86#&=43{m5Q(kKZmVBwJ1(X}U51s8bcc}Wl(i1N`2HUr04UD1__n}h12Ugkuj$UY> zrTfL|Az$q04xGq-C+aU;mZ?m`@##=p z{25CeMX!PRB>)KpVb%c!<-O!c#A;J(#E=m7RF~QFH+|NNy`MY4ZVHplxa36;H`EG*!O_ zAQ=Q%(8Aacwyhdi-O&@L_y@471cI&Z`2m^Lu-{}3x1fZv53;Y%;6x`Vsk_wccCJkK zF%AWN1a$5yREb5x?A&_#Mmb%xD&u9Sf958}(q!mdCJm5MFrsy_0UZ5qUJ~KQoFmB1 zX?J}?l{y_@63|a5l{YR6B3na3Q0we(G*-&X`hsTzRLYHuZ*RDy7&OuLCnai$VAvBY zbi-%bnhpD($?r7QRkApenE85t1)13}0UeX;1_`%PSHAvo8baDELBqYIL{Ng&& z9uh!7t4IE-6pwzT#~XHGf{YqEaX?(O;$BkH4>mL?hL^rG)gD37kEJ1$6%{S`?I<^Sr(dR&4Lza#gLSdH<5{Kfn10&$*dr*r- z$Vf$bxTyQC*M((VkvSE#jeHqn+KLR0=*wTU6}P47V^W4^f}g6d-<(hfi04NAyY%2Y zf|3rNx|blp18V)WWRxihfDEW@;(bhcl7z+!+|41nT-WADJ3aV!eSa9oTS{GtN@ z5?>{)0LIyCU&1S0U)B(DgNjB75KJMTif744dHO)=21j1=Xh+9GbJUszR@RV#4ERzJ?~=}i|wyzl-wD}ApNeaG-~Ab|096R9f< zfH?b`iL&@gusrFWd92`48`)f`uag0U0J)N-r1(42$K;$#JI|3ENl9~=$QZp3AX%a<+Xc$k|+G>;cR>+ zE;k?A`RYHA)I(d$O4hSEG&6#HRVF#nus2iCge(Ys9%%6$rmFEF>>x7%#*dDB9n;-q zc?j4Dsi80-9w|(-Bdqk1J(+o@eNPJXJ|h_sIGA$zGD;#0M1-)a1Kkn%=CrfEKYw>3 zkso3z{z2ArKMM(%DmND~BUhVg@X8zTA~^;8Xk+*Tuq**c^uT+g-^tXobyqEg*wRQ> zEWXBJ-MXm1;rZsL+21HAoyXUbMz5ScUB-6dhd_erW2XKHsMngQp(yx)QTPTFEPJPF z6|;PCOFZvWRsJ1_O6s?$4A!)aUwC<3h?-*LT31!-x$XQXyHo(?j>cq^sb%@ZuA;f{ zcd`B86}hQp^`D$2E>}iHL~p>;lqEX;%aG*o%wqlNRG2QJrd$;80R^RDNpWZbU8KcF zcPA0=;q{Oz0qikt03%UYm0+dBApsn4$ZQ;NQC4QCu17T|4@7uBwQwi2I1i#2Bt8iBPA1mq5u=;M;)0nro!KozyOh#y0(D2xC?UAzeeR4aK=Qnt4IQRIsYei1Nw1QUx{ zglc6_Z+aS$eFGeU7R?ETH@tvY)iksUOcDeCDr!_vtfLuUrdmJN$424bcq&(aPbhNZ zT@>)lPR$5-vyB`rC6WB!4GC7_3ae0d(Jdr!9i3H9_3;o}q5R|EE?{y^7+{)Z?`I(Z z6B6y&BI43PUJdJ4Is(*CzsZjJ*64fO>_(d30lL~1&c#JTH~M}H$G1(I1}BL&pqe)X z%C{flVR7@LxO;UqBLPo^%h8e(5x9G3M~pb87}$`H#9(njiM#qrAa8{@3jiWz$b0Y;`!3VW-SG%ZEgUOXK~mu13pjL-bmpXfa-0OS!j zdn=_h;bJ}-f3I-jMvkmMR?4|Dj3~1NG^i4)O9*J(K4D{Idm?9bKtNej!!EBm@D1kS zGc^7IEM|Di`(&n`DW0Q-buI)}XCD0?4|y;ukZT0EKid(eLG zODVAaUuvm;R`Ze-;=XD=6|5lZnG9!$5a8800Bip(!tk~x)nlxEN6`v63?G+?Wo^_r z0T4n7ek&ZiYM_W86@*$M-UuKfrU;%R7K#2Qn-IB z&iH`F{zk6;m1Yd^vE@Q}{Z596O+{6?G~l=hvl=Ly?|Yp3k&COs4-iZ_a($09!P$U~ z1hX`x1Y7lacmRm<4P9;8=LWI~{%HtLyMcDhKORvcAr7&kMpb?P8rfbbG}7>_zI}oK z76jHZ3D%_s2nX6*rw@$1T8DDgCoRI%Dt4b%k=Z=z3ftJ$mcI#(SG+??`uUsJt*=Qa zSN4GZGteFgVYf$}l@EnKQxe}Is{gkDK#?sEv2V`7T7FS&t~5mC6kYB55ap}+-mo^h zEDf6p?XO`0$)CkpHS@W8IzZES>iV|JwIz`w(St z^zd!lN;f8`zYGHKvc|Wx+7oQrAZ+tWrCL6iMM7>gpDtAG`ijiyz1zCd=&tF-*Zlynt>e=L zvO5nEnhu*Y>WeL>l}9Sq*9#%j4%bWY?uCbpL^=SgO*5J!7G*rzTsCDofvB*PGIybF z!Rij)Qn*<16xohI3>^&0NKE{b2bPReX*0U0)!kV*pPr+K3E_W4B#5ixAC2``cLBwt z)NT9A4j*_t0IPOZWhJHB|IFrzuHG;iZ3{29T1OoQKa}dCpb!9@K&)N>^WOZ9Lc50J z{WABjoji8fp^SBD5PwHZcIh_&6+4m*e9B+axqXcf7QB-PdQnm6*=$!yB7Wa0;g5VS zrpvcr23zQPv%W?&e;!UQ$wj$!sdnnsV1yfEh&g+s54QeppemH;o8C`@??Gxpg|Pb_ zLF}_;hkdn~t*`3t*sLzUd3>IFBtg;pj^G>o1Ae2{)^gE}7nwZoRj zG=QWM%QaMqNF5^axXC_A&Bi2>9JxW_2=l70eO@vJ+RHZzqSB??#eK_v(?iL6enG^* zjAB^nMP)3t%}hH2=`_<(t=5WHyW;3jlQeMh1u6rS=nWOAp-MuE571O@GHIOj_n+s3 zLy{ihNv>njaR%i*y5gYSZjO>!tXH|e8l14_YF7&yHhJ%rJ1YxH1N&wDbhJ&8`KU}1 zSS0lbNv9E5%u1fw!-=^kUKUl=n zSL!Oi5p8tsuN=Ac6+Kr74^nJcI3{UgW!dSj!1@}1oLZQ)eOYf_R2 zEH~q7mRn8cOzFIUOh%jDPI&a5dYg-smN&07b9e6xi+ec;Hg@%m>f1h;Hl4~eziq6@ zSWdw%f(>ddT^BdyydYLIrK=R(J4bWku*Zm{YZKLI`m*?TM&c*@ZWJKP`cN>M35Sr5 zgO8GeXjDr6EiUS&Y0rMfRha@x*w*lKHZBPp37BvAVPDxGW=~NLKSMhNL zO~%1Va9B5n`7j>cyS#2VxRZeFf)1}Q{sFjI1u5i1+*Z-h^iv)_jyotWX>$oc?>BEdR%o(d%k_ZWabO zI0)0d6aR~1zDTA>%eYAfUJY50>Q#MqdbRN~0XR~;`r>SZgi^iAUW+T!*N0hT?X@;F zm9KmM*S~Sb<%FM)Gq{iuM1Wco@LU!pDVZ|nZ+!YfGN&D51i{QiuOC1#jbQfvzUGi3 z5{G-XnTqJ^8+EJI=q92dIivC?X@TGr8`}gc`n7?djtsvUev%oHloL3F$|i0SQN>g7 zmLc}C?Hl|Dv*^7yIO*!6ZE;?hb@M>(|1;plJkt(FCeIfpPOq!n z>#%+QEhXe;wQ(vD^W4Z<2VZ#!HrpLGRs&!nWJKws*($mAP^hF3%r)Z6L;ZqKad(bt zO3j#=6Nf32&PDAXC_ACqq%)XY3^Bpd2L96dMEoe<9;#4buG)5T9M(1PJT!|7fIIoK z7m5AhObwSsZPY`R>$*PBZ|eX!QjIsPTAr9HIkUC#9gkh{L(Zuf>l)!sS)o|Mu#awq zSPH439n*TD{WR#CYBdDzGGuo|=x9w3(TT5nN%V}(3fWhLFhr%b_DB6EX%nRAG6qTWk;K$a45+7%QIw@ z;%2)to%XKTac7pHLZ%xnYiMsm-@tqkQ7(s->CD2x9C|ilHla*flicry19Gw<_L&U> zegHXPygyyv7Aq7g;U6n9-V~@ioSmH~(R_;D{skD3B?uv7B4=S6-FbRMK9@7JyF*cG zE&&5biM7eFM(Zy*7hh5$);4yYMPh!-`<`H78!j3i1*p$~44@*R`CMOO@wglm<_&{+ zdA4EBKLh9PK9<%&qdRM3YKS-H>K6B%oty?LsLZ$~{ z#H`y(bt!YoFcMw>x|n}^7jxn*n*1TCNHHali#sVj-M4!s-FQr6J2ACM(!$q&3iMmN z+hjFQY+atoW|N-jb{@Uj<(ZB5I4sy}A8WPAQ`XZohbM#2n~E<(A7r=d_|${#ejCSR z@7})80*7-G=WHy9LI@x|*VpeU6$M$(;L*jkbllzoPkBrETG1w3FF9!kUv-J7BPnHz zQj#=p#g3dA%PHmGwTEP*%QtZ<_>kgYnU8snQOHbo3sFp_FAj@!dzlz7Zj+{e7aDn0 z;}KJ5>2#V6w9}${0{vuSW3N+OhA>6i$N0d9>F~xMyBdV~zui3(+<4>?nsI*41A&*D zq@)(9)`z0v_Ig$>34Gos8BgFNFMEd67gfv8|7JN>rG^S1WdjUei^uDu!9AS(o|ocK zzM(Uw3x`R6s5L2K0h3mfgnsW^0g2Bz3Vu=r*i~8sN+?zfa9sue>%Zk~9f}b( zD1>A@E)i_BI3@}70r-!FRiCJruJC#2PR5aLFVzoK9d4dPs4)Xb>442>lUQw&BOy#a ze-Hy#F@>iy7Sn1qmq$io&?4#O*~FEb%lMo#K|g>`(LvVSH|m^3r1k(7T6O>I?$0x6 zLog?iXn{<{!0%=fYyZQ#Egmn!(&BlW9$zx2fa~h(cfikCvLVUMY`S z3@Dt|r*jEBxLy0$(IC0X&2GCI6N`>(8k%Vs(5^Q~2=MR{T7Y*g`&|}$Ug4G@@@<&$ zvIXWvu=CT`F$ z0&jm3%@#VL(y1Gn;hSgXUdoeI9%G~RpE)XY|7;OJQ7IY*Oj?$jQQ&2|#YVq`o_c9% z$YgNZg6i4rKKx$X2wkt~8u=c>biF-Co9-w2=88n`t(K}agLr2J6gl7t(I1)dhU32{ zpQk7ftu53$W~>QfoOUwdHzU+Jd=obSK#w%o^OkU6CK-08Cf=f1V2{OXJ8(??vfjH4 zPQpT4K2THd)MPxwFRn$Vu^lOK)0d`;&(E*5?*Q4-qIX9G>=6YcT>|{DZpc zkr^D8AS0u2tjij@M>VJ7O4f3@A})u63uUlHfDt^fr`z9+ZZR%Av))7688cpys0GTS ztWo|QpK}E#VU(acrxR8|kf#&3l3Qd3Mj=(p4PJG*u=pG>s1%3L>xp!1w%;G7=7iPw zAXIUu=P#E~hLYBJ;$wH>5#?i8N)|o9vpz}rjUWtU`2`9QzoGn__#AN0P#o$x#BtJr7iQDunaUcI-3)NaFT)0d&mv^^jtKOcaKN~%#$qK~b;bk< zdJql}S~z+25kaEjz2Wwy-|W>=Rnu6yx7aZ>)yIugPSX%&!O6XWD#fY#VZFCb(8OKS zxV&KNI=*oFIQ)!xtgaX_c0B#dQH!?lW8&4H?zw@RD$2OVk=)ljpEhRsLo=|9!CwIRK zt^#(40<=MaOC4=@xc+*;@06-kxTYmLnv$dK=n2BO3n1t!Y2BqDC8J`Am0E!m6<+~p z&e+Z`#H$-Q^Y>B3x1aG5ds>4M`H4t<0R099zx|a> z<)QAN)gc4QsA#T;X7R3AXD0?+XsR9-JJO2z&AN$xjib@)3)M9$Qfn^wSj~o0HO{XA zJ+J^0sHa-a+YGHei(RE3>gRlwx~zZBHHg5t0HFEe;jwp0SR1w#MH|}o@4y=U(@D?z zQ@f0i^OrMU;}SMd@9^CIzI8zK-5W%}G`;@K(rR-Ny20n5Dw(sId#r>)c$8Rj#iv?l z*{Ap=H4ke2Rcv|s;OdZ=Fuz7kMZYJvS^5j-17a2%_d6u3*MW7GOJ*;A*_UF41_{i2 z=MBjwnr(1So%8ek-3C;pkZ0UyXlE+>42AJ=I#n*?Aksgl_ov!w6b%fqqxwWIE! z;GX3l;Bm;?puFgLA0rZcg!%@1C>@O#PjR<!Zj!W^kA%ZW+piW;Bx>y z{Qu*p;Fe5&w>!Bp-Bw~K1pK&T?%TlxYtO5UyW3CKuRP<+qzQgL#}D5*9jB1@euf(5s&RhQc_zQ`I=@hMzt}Fi{(b>;2u&Wxfq4Ax zDlVtIq{}WzJHJd@ftsJw{UKGW!(J-=-fNDYDFg84fgXWu{JUt>9UGQfdTlE+s)Z+AwT!XCW;JpO7uS#bUaFfe)R zys7cY>OEQ$ggjokI0@ZMHh<`+YQO6OeP4`Mg2MN=rmyu{o^tS9A7&7spk8iGDRm{P z9Io@^!_t|&M#ii4FJ+#qJ>UBUOFCT6;e+~CD+5onvMxo6vRYhUf37IaH?*i!ZY&L0 zewjpba*AcMStENZSPz|-B;HDv?(yN03wlaU2hha$hX44`;NGVasb3Wu;sqz&I}IWw z+ZQ{QDxc4HVo>aYZ)M~5Brjc_bA><6Bv&}$3up_ZFa;0muCX8WTggpd5wLjZadL!zhJ!`ZVuc|cP;*-H* zRLrHlnEBt1OK|_(!z+8=A4e{WSBJ=Dd+51wc$L<2>x65ND4I!B7S7e_db&1`{|uwJ zE78EWF2Ou&@(89{+KE}04UxpwYI4v{C{-hGdx(hn?Yu1IbDip(Hma?`?~C>FBG0D` z6*?15rQ4uzJjIXX(SS5@oZbhb%$bz0qP;K-M{Yu zIZ%;KtF;uTp*>ckx*m;AQ5K-T2B>bEAZ>~(f^pqP9M{#^9%9oL-`YUt|z!n^AO-{Umyr&=|a z16GAwzODB-56Lx&1M}D11K?9kZ$8ci69-3hNr47=H9?W(!as3s;YQ0X^)l5NZX3lwbB#NK@y*pg(53iSl z>kk(pC@=MCK?A2H>>{bMPJg+baEsTyTm4qs7xg72pLWQMfu6X=X9pqp3~+#}A4C;6Jb&FG`3*H;@1A6b)|3U;GtCiaRcI2V z81bA^Iowr1gDjMqbw#bQ(?c2j^jR0UNvh7i?yz&wK-=P&ryGOhEYRU_9eC2~t#d~0 zZCLoO5U=acR*zF&^;9Qxj(*LFhX)BB!0AmyVbtP=jv#bG(oP6j*6vycNJ@(v2XSfD%5eycL}sC*-PzM zn5Q2pyEPqOY@%KFY#pucjE*lyGycQ;aJBmg zFMU+X{Ce=^zV*t4h*G&I#K&~?$L$#y5`V|m!meOgYlozoOE3Rt46jMS#EEFwTP;#4 zA4ewpv$&aOYPg4vA6xw!Zt11|3+uV;)1Pvv zR&WV1=6TbHRRyE6i9)fQ-Q1xET(cCMch=gVR4h)^+DyV)t=c$T4uV)f^Yd%8#q&`= zZjn8T(=m$Ga(>ZOh~s)DbsX_4lHBO-(#_t7VR+u)$JpXHDa`*Y6ejOr@D+n3dH&h$ z6TjSWs-X&pm|rEOYO`5du@R>SjcaBo>js zB6HeVy8{nrYL_^sC84qWLV&iAq({|L0WOQ=}cL>Fj0S$&)#X8zx2Pf&E z*-I1~`vO{DY&DtLH`%#v9~E7|_wFp_YM))>_IHjtw976%mUOS7#WDg1%{HZ6ucD4z z)9Z~6GN;>q0O&V&`=d{>&;8NV*uYszw(%iY6v++aihZ~bT;DPA^SqRMhA-1cca>QCtM&$Gt0wKbsTw~Qmr@=j*wS9!eMkWarO2H zZoT*SN)_sjUt|nZeld|wm0Nke3F`&W`A|>$s2lC{aH^qDsFtZOi4Wc2OfrCEX9(j7 zP0&{u08UqBZ3TuaA)8LS-6C%j;cl)|w)fdHjS9axS<3upDyOGB-caD zJmnLDb^6O>ZDEabT+*Ts0uaoT{cB9mUA6G!*J?b0#2 zAlC%DFQ1_Kms6`kLUlEGu>gNb?qJy!-y&~Yd&1D#t&c*rbUKG*55%}-Qs^{NdsL39 ztr*en@fFKC=RI5yra;iMNJULn_=>ZPTz(b=K7PR%uc+D|tP~kp(Lkmj&pcb~ZE)KG z_pqu%j~NNd9bwwtSXKjDC8uZ!gg&XpJH$MTDig}Qg4)*&&MxaP#xkGq5yAnBV$O5@ zcob|h=o{x}aQJ}Nu)&Q^4==no;#)&0Mvd=wFS#=p25aWXZ%K1_sQI3P{i-;V~<02U|w3Oz$oD)5R|nc z`VoQNg#1W8t=yGQlN=Zt^r~)ppa^O=Y1W@&`6{mE|~* z`{}d;jJ&RV`q^G`$JxDgK$qOas-~!4WG5BjPD#>%GsZ5Vopf?l=g?}Ho-B8JTry6e zEkFs$G@ z$LH)nXDJy4uNHX*vFH%^ffpiQe%V=CAQJE>z$YJ&8Yjx|z+Ype&j-txi4j9T6ix5# z0JCP(3-0{2kJofhO!S5oE8MOp5`&=;sGExq@HIHlk`8AR_SA{=f #KybVU=HQF= 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 literal 0 HcmV?d00001