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 0000000..598d72b Binary files /dev/null and b/src/cybersource-logo.png differ