Register - Login
 

SagePay Payment Processor for NopCommerce 1.3

Following on from a customer development in which we worked on a very custom Commidea NopCommerce payment processor module, we thought we'd investigate a payment processor for the popular SagePay payment portal.

Basic strategy

To achieve this, we based our work on:

  • the existing WorldPay processor within the Nop source.
  • the Asp.Net-Kit integration material from SagePay.

The initial method we selected was to use the FORM integration from Sage, and to use the PostProcessPayment of IPaymentMethod.

When an error occurs, this isn't ideal in terms of user flow - there's no way back to your shopping cart to try again.

However, as a basic flow it works OK - and certainly (I hope) good enough for a free module!

Payment Processor Implementation

The payment processor uses the Nop RemotePost object in order to redirect the client's browser to the SagePay flow - including the postcode/address and CVV2 checks - plus the 3DSecure implementation.

This basic implementation is:

 

        /// <summary>

        /// Post process payment (payment gateways that require redirecting)

        /// </summary>

        /// <param name="order">Order</param>

        /// <returns>The error status, or String.Empty if no errors</returns>

        public string PostProcessPayment(Order order)

        {

            RemotePost remotePostHelper = new RemotePost();

            remotePostHelper.FormName = "SagePayForm";

            remotePostHelper.Url = GetSagePayUrl();

 

            remotePostHelper.Add("VPSProtocol", protocolNumber);

            remotePostHelper.Add("TxType", transactionType);

            remotePostHelper.Add("Vendor", vendorName);

            remotePostHelper.Add("Crypt", GenerateCryptField(order));

            remotePostHelper.Post();

            return string.Empty;

        }

 

 

Obviously the guts of the implementaion is actually in the GenerateCryptField method :

 

        private string GenerateCryptField(Order order)

        {

            StringBuilder cryptBuilder = new StringBuilder();

 

            cryptBuilder.AppendFormat("VendorTxCode={0}", order.OrderID.ToString("N"));

            cryptBuilder.AppendFormat("&ReferrerID={0}", partnerID);

            cryptBuilder.AppendFormat("&Amount={0:0.00}", order.OrderTotal); // FormatNumber(order.OrderTotal, 2, -1, 0, 0)); // ** Formatted to 2 decimal places with leading digit **

            cryptBuilder.AppendFormat("&Currency={0}", CurrencyManager.PrimaryStoreCurrency.CurrencyCode);

            cryptBuilder.AppendFormat("&Description={0}", vendorDescription); // ** Up to 100 chars of free format description **

 

            // ** The SuccessURL is the page to which Form returns the customer if the transaction is successful **

            // ** You can change this for each transaction, perhaps passing a session ID or state flag if you wish **

            string successReturnURL = CommonHelper.GetStoreLocation(false) + "CirriousSagePaySuccess.aspx";

            cryptBuilder.AppendFormat("&SuccessURL={0}", successReturnURL);

 

            // ** The FailureURL is the page to which Form returns the customer if the transaction is unsuccessful **

            // ** You can change this for each transaction, perhaps passing a session ID or state flag if you wish **

            string failureReturnURL = CommonHelper.GetStoreLocation(false) + "CirriousSagePayFailure.aspx";

            cryptBuilder.AppendFormat("&FailureURL={0}", failureReturnURL);

 

            // ** Pass the Customer's name for use within confirmation emails and the Sage Pay Admin area.

            cryptBuilder.AppendFormat("&CustomerName={0} {1}", order.BillingFirstName, order.BillingLastName);

            cryptBuilder.AppendFormat("&CustomerEMail={0}", order.BillingEmail);

            cryptBuilder.AppendFormat("&VendorEMail={0}", NopSolutions.NopCommerce.Common.Messages.MessageManager.AdminEmailAddress);

 

            // SendEMail ** Optional setting. 0 = Do not send either customer or vendor e-mails, 1 = Send customer and vendor e-mails if address(es) are provided(DEFAULT).

            cryptBuilder.AppendFormat("&SendEMail={0}", sendEmails ? "1" : "0");

            // '** You can specify any custom message to send to your customers in their confirmation e-mail here **

            // '** The field can contain HTML if you wish, and be different for each order.  The field is optional **

            cryptBuilder.AppendFormat("&eMailMessage={0}", HttpUtility.UrlEncode(emailThanksMessage));

 

            // ** Populate Customer Details for crypt string

            // ** Billing Details

            cryptBuilder.AppendFormat("&BillingSurname={0}", order.BillingLastName);

            cryptBuilder.AppendFormat("&BillingFirstnames={0}", order.BillingFirstName);

            cryptBuilder.AppendFormat("&BillingAddress1={0}", order.BillingAddress1);

            if (!string.IsNullOrEmpty(order.BillingAddress1))

                cryptBuilder.AppendFormat("&BillingAddress2={0}", order.BillingAddress2);

            cryptBuilder.AppendFormat("&BillingCity={0}", order.BillingCity);

            cryptBuilder.AppendFormat("&BillingPostCode={0}", order.BillingZipPostalCode);

            var billingCountryCode = CountryManager.GetCountryByID(order.BillingCountryID).TwoLetterISOCode;

            cryptBuilder.AppendFormat("&BillingCountry={0}", billingCountryCode);

            if (!string.IsNullOrEmpty(order.BillingStateProvince))

            {

                if (billingCountryCode == "US")

                {

                    var stateProvince = StateProvinceManager.GetStateProvinceByID(order.BillingStateProvinceID);

                    if (stateProvince != null)

                    {

                        cryptBuilder.AppendFormat("&BillingState={0}", stateProvince.Abbreviation);

                    }

                }

            }

            if (!string.IsNullOrEmpty(order.BillingPhoneNumber))

                cryptBuilder.AppendFormat("&BillingPhone={0}", order.BillingPhoneNumber);

 

            cryptBuilder.AppendFormat("&DeliverySurname={0}", order.ShippingLastName);

            cryptBuilder.AppendFormat("&DeliveryFirstnames={0}", order.ShippingFirstName);

            cryptBuilder.AppendFormat("&DeliveryAddress1={0}", order.ShippingAddress1);

            if (!string.IsNullOrEmpty(order.ShippingAddress2))

                cryptBuilder.AppendFormat("&DeliveryAddress2={0}", order.ShippingAddress2);

            cryptBuilder.AppendFormat("&DeliveryCity={0}", order.ShippingCity);

            cryptBuilder.AppendFormat("&DeliveryPostCode={0}", order.ShippingZipPostalCode);

            var shippingCountryCode = CountryManager.GetCountryByID(order.ShippingCountryID).TwoLetterISOCode;

            cryptBuilder.AppendFormat("&DeliveryCountry={0}", shippingCountryCode);

            if (!string.IsNullOrEmpty(order.ShippingStateProvince))

            {

                if (shippingCountryCode == "US")

                {

                    var stateProvince = StateProvinceManager.GetStateProvinceByID(order.ShippingStateProvinceID);

                    if (stateProvince != null)

                    {

                        cryptBuilder.AppendFormat("&DeliveryState={0}", stateProvince.Abbreviation);

                    }

                }

            }

            if (!string.IsNullOrEmpty(order.ShippingPhoneNumber))

                cryptBuilder.AppendFormat("&DeliveryPhone={0}", order.ShippingPhoneNumber);

 

            cryptBuilder.AppendFormat("&Basket={0}", CreateBasketText(order));

           

            // ** For charities registered for Gift Aid, set to 1 to display the Gift Aid check box on the payment pages **

            // gift aid fixed to false

            cryptBuilder.Append("&AllowGiftAid=0");

 

            // ** Allow fine control over AVS/CV2 checks and rules by changing this value. 0 is Default **

            // ** It can be changed dynamically, per transaction, if you wish.  See the Server Protocol document **

            cryptBuilder.AppendFormat("&ApplyAVSCV2={0}", applyCVS ? "1" : "0");

 

            // ** Allow fine control over 3D-Secure checks and rules by changing this value. 0 is Default **

            // ** It can be changed dynamically, per transaction, if you wish.  See the Server Protocol document **

            cryptBuilder.AppendFormat("&Apply3DSecure={0}", apply3DS ? "1" : "0");

 

            string fullPlainTextCrypt = cryptBuilder.ToString();            

            byte[] xOrCryptArray = SimpleXor(fullPlainTextCrypt, encryptionPassword);

            string base64XOrCrypt = Convert.ToBase64String(xOrCryptArray);

 

            return base64XOrCrypt;

        }

 

which itself uses the CreateBasketText helper:

 

        private static string CreateBasketText(Order order)

        {

            StringBuilder toReturn = new StringBuilder();

 

            // final bit - here we go...

            int numberOfItemsToList = order.OrderProductVariants.Count;

            if (order.OrderShippingInclTax > 0.0M)

                numberOfItemsToList++;

 

            toReturn.AppendFormat("{0}", numberOfItemsToList);

 

            foreach (var item in order.OrderProductVariants)

            {

                //'** Extract the Quantity and Product from the list of "x of y," entries in the cart **

                //intQuantity = cleanInput(Left(strThisEntry, 1), "Number")

                //intProductID = cleanInput(Mid(strThisEntry, 6, InStr(strThisEntry, ",") - 6), "Number")

 

                //'** Add another item to our Form basket **

                //intBasketItems = intBasketItems + 1

                string name = item.ProductVariant.Name;

                if (string.IsNullOrEmpty(name))

                    name = item.ProductVariant.Product.Name;

                if (string.IsNullOrEmpty(name))

                    name = "anonymous product";

 

                toReturn.AppendFormat(":{0}:{1}", name, item.ProductVariantID);

                toReturn.AppendFormat(":{0:0.00}", item.UnitPriceExclTax);

                toReturn.AppendFormat(":{0:0.00}", item.UnitPriceInclTax - item.UnitPriceExclTax);

                toReturn.AppendFormat(":{0:0.00}", item.UnitPriceInclTax);

                toReturn.AppendFormat(":{0:0.00}", item.PriceInclTax);

            }

 

            if (order.OrderShippingInclTax > 0.0M)

            {

                // use code 999999 as the delivery code

                toReturn.AppendFormat(":Delivery:999999:{0:0.00}:{1:0.00}:{2:0.00}:{3:0.00}",

                    order.OrderShippingExclTax,

                    order.OrderShippingInclTax - order.OrderShippingExclTax,

                    order.OrderShippingInclTax,

                    order.OrderShippingInclTax

                    );

            }

            return toReturn.ToString();

        }

 

Handling the response from SagePay

SagePay allows you to use two separate pages to handle return calls - a success and an error page.

The logic within Nop isn't entirely suited to this configuration - which is a shame - but Nop itself is still growing and improving :)

The logic path I've implemented within these success and error pages is to decode the returned encrypted message and to then mark the customer order as either Paid or as Cancelled.

Note that there is still some missing logic here - caused by gaps in the current Nop API - for example , it would be very nice if Nop allowed you to update some of the order transaction fields at the same time as marking the order paid.

 

    public partial class CirriousSagePaySuccess : BaseNopPage

    {

        protected void Page_Load(object sender, EventArgs e)

        {

            Response.CacheControl = "private";

            Response.Expires = 0;

            Response.AddHeader("pragma", "no-cache");

 

            string crypt = Request.Params["Crypt"];

            SagePayPaymentProcessor proc = new SagePayPaymentProcessor();

            string decrypted = proc.Decrypt(crypt);

            var decryptedDict = proc.Parse(decrypted);

 

            string status = decryptedDict["Status"];

            if (string.IsNullOrEmpty(status))

                status = string.Empty;

 

            if (status != "OK")

            {

                // this is a very serious error - it means that the SagePay message is corrupt :(

                string message = "Error in response from SagePay - status is " + status;

                LogManager.InsertLog(LogTypeEnum.OrderError, message, decrypted);

                throw new NopException(GetLocaleResourceString("Cirrious.SageError.ResponseIncorrect"));

            }

 

            string TxAuthNo = decryptedDict["TxAuthNo"];

            if (string.IsNullOrEmpty(TxAuthNo))

                TxAuthNo = string.Empty;

 

            string VendorTxCode = decryptedDict["VendorTxCode"];

            if (string.IsNullOrEmpty(VendorTxCode))

                VendorTxCode = string.Empty;

 

            if (string.IsNullOrEmpty(TxAuthNo))

            {

                // this is a very serious error - it means that the SagePay message is corrupt :(

                string message = "Error in TXAuthNo response from SagePay - status is " + status;

                LogManager.InsertLog(LogTypeEnum.OrderError, message, decrypted);

                throw new NopException(GetLocaleResourceString("Cirrious.SageError.ResponseIncorrect"));

            }

 

            if (string.IsNullOrEmpty(VendorTxCode))

            {

                // this is a very serious error - it means that the SagePay message is corrupt :(

                string message = "Error in VendorTxCode response from SagePay - status is " + status;

                LogManager.InsertLog(LogTypeEnum.OrderError, message, decrypted);

                throw new NopException(GetLocaleResourceString("Cirrious.SageError.ResponseIncorrect"));

            }

 

            if ((NopContext.Current.User == null) || (NopContext.Current.User.IsGuest && !NopContext.Current.AnonymousCheckoutAllowed))

            {

                // this is a very serious error - it means that the SagePay payment has happened but we've lost login info

                string message = "Error in session state within site - order not marked as paid - but payment has been collected";

                LogManager.InsertLog(LogTypeEnum.OrderError, message, decrypted);

            }

 

            // we've got here and we have a valid VendorTxCode and a valid order id

            Order order = OrderManager.GetOrderByID(Convert.ToInt32(VendorTxCode));

            if (order == null)

                throw new NopException(string.Format("The order ID {0} doesn't exists", VendorTxCode));

 

            // would like to add more secure code here - this flow doesn't feel entirely safe/secure!

            // would like to store the TxAuthNo returned.

 

            OrderManager.MarkOrderAsPaid(order.OrderID);

            Response.Redirect("~/CheckoutCompleted.aspx");

        }

    }

 

 

I can't follow this article - how do I get the code?

I've posted the full sourcecode on CodePlex at http://nopsage.codeplex.com/

 

Future extensions

Possible future extensions of this include:

  • Improving error handling
  • Allowing users to try more than one card
  • Storing the transaction code when the transaction is successful - e.g extending OrderManager.cs with a method MarkOrderAsPaid(int OrderID, string AuthorizationTransactionID, string AuthorizationTransactionCode, string AuthorizationTransactionResult)
  • Changing the Sage method used to DIRECT or SERVER - in addition to FORM.

 

 

Date » 09 March, 2010    Copyright (c) 2010 cirrious  
Site skin inspired by Nina - thx!