Extending MojoPortal’s Form Wizard Pro to Support Authorize.NET SIM 

 

Recently I thought it would be useful to be able to build out e-commerce forms directly within mojoPortal’s FWP.  That way, a client can have their own custom forms that would collect the info they need, then they’d be able to define whatever product/prices/quantities that could then be purchased using Authorize.NET’s SIM implementation.

Obviously, if your situation allows you to use Authorize.NET AIM (in which you can collect credit card info directly on your site), then you’re much better off using mojo’s Web Store module.  However, at CSU, we cannot utilize the AIM implementation and instead must pass off e-commerce transactions directly to Authorize.NET.  Additionally, if you don’t need to collect any custom info it would be better to modify the mojoPortal solution code to directly support Authorize.NET SIM using the Web Store module (if I get time I’ll also create/post this approach).

In any case, without further ado, here’s more info about the form submission event handler that was created for use with FWP.

Features/Goals of Handler

  • Create a FWP submission handler generic enough to handle (and thus replace) the 6 e-commerce applications I currently manage
  • Support multiple products/prices, and allow these products/prices to be in any order and location within the form.  Pricing/product values are “squirreled” away in mojo’s instruction block questions (since they are hard-coded and not input values)
  • Generate an e-mail to the individual completing the form
  • Give the ability for product title/prices to be defined within the instruction block of a FWP question
  • Provide a means to specify an invoice prefix for all transactions generated by the instance of FWP

Developer Implementation

  1. Download the submission handler solution file, compile it, and take the accompanying MojoFormSubmissionHandler.dll and HtmlAgilityPack.dll and place it in the bin folder of your site (my project utilizes .NET 4.0, btw).  Or, edit the solution to meet your needs. Also create the accompanying .config file.  For more info on how to do this see http://www.mojoportal.com/implementing-a-custom-form-submission-handler.aspx.
  2. Place the following keys in your mojoPortal user.config file:
            <add key=”AuthorizeNetControllerUrl” value=”https://url_to_your_controller_app.aspx” />
    <add key=”ConfirmationEmailFromAddress” value=”your_from_email_address” />
    <add key=”Site1-AuthorizeNetProductionSIMAPILogin” value=”authnet_login_id” />
    <add key=”Site1-AuthorizeNetProductionSIMAPITransactionKey” value=”authnet_transaction_key” />
    <add key=”AuthNetSalt” value=”some_8_character_salt” /> <!– Eg, use http://www.pctools.com/guides/password/ to generate random salt value –>
  3. Next, we have a small hurdle to cross.  We know we have to initiate a form post to authorize.net from the code-behind.  The problem instances of FWP are housed within an updatepanel, so we can’t just use Response.Write or add the outputted from to a control outside the updatepanel.  So, my solution is to simply disable PartialRendering on the ScriptManager on those pages that contain FWP modules.
  4. . because instances of FWP are housed in an updatepanel, you have to find a workaround to posting your form to Authorize.NET (since normally I’d just do a Response.Write to write out the form or perhaps write
  5. Finally, from within your FWP module’s settings, you’ll need to select the newly-added submission handler

End-User Implementation

Once the form submission handler’s already been “installed” to your site, here’s how to configure your form.  To be safe, it is preferable to have your resident web developer perform the following steps.  To expedite setup, you can download this .config file as a template for building your form. Or, you can view a basic demo at http://ocl.colostate.edu/sandbox.  For a more complex example (which includes some jQuery to calculate totals directly on the form page), see this .config file, or the demo at http://www.ocl.colostate.edu/student-handbook

  1. Go to your form’s settings.  Under Submission Event Handler, select MojoAuthorizeNET_SIMFormSubmissionHandler
  2. If you want your auto-generated invoice numbers to have a custom prefix (recommended), define that prefix under custom CSS class.  Eg, “VET_RMC_”
  3. If you want the auto-generated confirmation e-mail to have a custom subject, define that subject under “Label for Email List” under Notification Settings.
  4. Next create the questions for your form. In order for the handler to work, you need to be especially sure you’ve done the following:
  5. Create one or more instruction block questions that contain your product/price info.  Each product/price must be wrapped in an html tag with an id containing either “product” or “price”.  Eg, <span id=”productREDSHOES”>Product Title</span>$<span id=”priceREDSHOES”>25.00</span>. BE VERY SURE THAT THE PRODUCT NAMES MATCH (EG, REDSHOES)
  6. Create the appropriate number of quantity textboxes to match the number of products/prices you defined in the instruction block (eg, if you have 3 products, you’ll obviously need 3 quantity boxes).  Make sure that each of your quantity questions includes the word “quantity” in either the question alias or question name, and then your unique product name.  Eg, quantityREDSHOES, quantityBLUESHOES, quantityGREENSHOES, etc.  These obviously need to correspond with your product/prices.
  7. Now you’re done.  Be sure to thoroughly test your form to ensure it works to your satisfaction.

Code


 

using System;
using System.Web;
using System.Collections.Generic;
using System.Text;
using log4net;
using sts.Business;
using mojoPortal.Business;
using sts.FormWizard.Web.UI;
using mojoPortal.Web;
using mojoPortal.Net;
using HtmlAgilityPack;
using System.Configuration;


namespace MojoFormSubmissionAuthNetHandler
{

    class MyHandler : FormSubmissionHandlerProvider
    {
        #region "Attributes"
        //******************************************************************
        //Attributes/Fields + Module-level Constants+Variables
        //******************************************************************

        private static readonly ILog log = LogManager.GetLogger(typeof(MyHandler));

        //authnet constants from user.config
        private string _loginID = ConfigurationManager.AppSettings["Site1-AuthorizeNetProductionSIMAPILogin"];
        private string _transactionKey = ConfigurationManager.AppSettings["Site1-AuthorizeNetProductionSIMAPITransactionKey"];
        private string _eCommerceUrl = ConfigurationManager.AppSettings["AuthorizeNetControllerUrl"];
        private string _confirmationFromAddress = ConfigurationManager.AppSettings["ConfirmationEmailFromAddress"];
        private string _salt = ConfigurationManager.AppSettings["AuthNetSalt"]; //MUST MATCH THE SALT VALUE USED AT E-COMMERCE CONTROLLER.

        //e-mail properties
        private string _subject = "Form Submission Received: ";

        //attributes
        FormSubmission _payment = new FormSubmission();
        SortedDictionary<String, String> _products = new SortedDictionary<String, String>();
        SortedDictionary<string, Decimal> _prices = new SortedDictionary<string, Decimal>();
        SortedDictionary<string, int> _quantities = new SortedDictionary<string, int>();

        SiteSettings _site = new SiteSettings();

        #endregion //Attributes

        #region "Constructors"
        //******************************************************************
        //Constructors
        //******************************************************************

        public MyHandler()
        { }

        #endregion //Constructors

        #region "Get/Set Methods"
        //******************************************************************
        //Get/Set Methods
        //******************************************************************

        public string LoginId
        {
            get
            {
                return Utils.Encrypt(_loginID);
                //return QueryStringModule.Encrypt(_loginID);
            }
        }

        public string TransactionKey
        {
            get
            {
                return Utils.Encrypt(_transactionKey);
                //return QueryStringModule.Encrypt(_transactionKey);
            }
        }

        //public string DateValid
        //{
        //    get
        //    {
        //        return HttpUtility.UrlEncode(_payment.DateValid.ToString());
        //    }
        //}

        #endregion //Get/Set Methods

        #region "Event Procedures"
        //******************************************************************
        //Event Procedures
        //******************************************************************

        public override void FormSubmittedEventHandler(object sender, FormSubmissionEventArgs e)
        {

            if (e == null) return;
            if (e.ResponseSet == null) return;

            log.Info("MojoAuthorizeNETFormSubmissionHandlerProvider called");

            StringBuilder results = new StringBuilder();

            //Note about e-mail
            results.Append("NOTE: This e-mail is a confirmation of your form submission ONLY.  Your registration isn't complete until your online payment has been accepted.");
            results.Append("rn");
            results.Append("rn");

            //how to get the site user if the user was authenticated
            if (e.User != null)
            {
                results.Append("submitted by user: " + e.User.Name);
                results.Append("rn");
            }

            //how to get the questions and answers
            List<WebFormQuestion> questionList = WebFormQuestion.GetByForm(e.ResponseSet.FormGuid);
            List<WebFormResponse> responses = WebFormResponse.GetByResponseSet(e.ResponseSet.Guid);

            //for each question
            foreach (WebFormQuestion question in questionList)
            {
                string response = string.Empty;

                //see if we have an instruction block (which should contain our products/prices
                if (question.QuestionTypeId == 8)
                {
                    HtmlDocument doc = new HtmlDocument();

                    //load the html
                    doc.LoadHtml(question.QuestionText);

                    //within the instruction block, parse out values contained in tags with
                    //ids containing "product" or "price".  Eg, <span id="product1">

                    try // to populate product info collected from instruction block(s)
                    {
                        // if products already is non-empty
                        if (_products.Count > 0)
                        {
                            // make a copy of the dictionary
                            var copy = _products;

                            // do normal assigning of dictionary
                            _products = Utils.ExtractProductByTag(doc, "//*[contains(@id, '" + QuestionAlias.Product + "')]");

                            // merge dictionaries
                            _products = _products.Merge(copy);
                        }
                        else
                        {
                            _products = Utils.ExtractProductByTag(doc, "//*[contains(@id, '" + QuestionAlias.Product + "')]");
                        }
                    }
                    catch (Exception)
                    {
                        log.Info("dealing with an instruction block that didn't include a product");
                        continue;
                    }

                    try // to populate price info collected from instruction block
                    {
                        // if products already is non-empty
                        if (_prices.Count > 0)
                        {
                            // make a copy of the dictionary
                            var copy = _prices;

                            // do normal assigning of dictionary
                            _prices = Utils.ExtractPriceByTag(doc, "//*[contains(@id, '" + QuestionAlias.Price + "')]");

                            // merge dictionaries
                            _prices = _prices.Merge(copy);
                        }
                        else
                        {
                            _prices = Utils.ExtractPriceByTag(doc, "//*[contains(@id, '" + QuestionAlias.Price + "')]");
                        }
                    }
                    catch (Exception)
                    {
                        log.Info("dealing with an instruction block that didn't include a price");
                        continue;
                    }

                    continue; //skip adding instruction block

                }
                else //get the actual response
                {
                    response = GetResponse(e.ResponseSet.Guid, question.Guid, responses);
                }

                //populate fields for authorize.net
                ExtractAuthNetFields(question, response);

                //append question text to results for e-mail (if there is a response):
                if (!string.IsNullOrEmpty(response))
                {
                    results.Append("rn" + question.QuestionText + "rn");
                    results.Append(response);
                    results.Append("rn");
                }

            }


            //ensure that product / price lists are of equal size
            if ((_products.Count == _prices.Count))
            {
                log.Info("Yay! Products and Price counts are equal");

                try
                {
                    //Log products dictionary for debugging
                    foreach (KeyValuePair<string, string> kvProductTitlePair in _products)
                    {
                        log.Info("kvProductTitlePair.Key: " + kvProductTitlePair.Key + " | kvProductTitlePair.Value: " + kvProductTitlePair.Value);
                    }
                }
                catch (Exception)
                {
                    log.Info("Can't iterate through products dictionary");
                }

                try
                {
                    //Log quantity dictionary for debugging
                    foreach (KeyValuePair<string, int> kvQtyPair in _quantities)
                    {
                        log.Info("kvQtyPair.Key: " + kvQtyPair.Key + " | kvQtyPair.Value: " + kvQtyPair.Value);
                    }
                }
                catch (Exception)
                {
                    log.Info("Can't iterate through quantities dictionary");
                }

                try
                {
                    //Log prices dictionary for debugging
                    foreach (KeyValuePair<string, Decimal> kvPricePair in _prices)
                    {
                        log.Info("kvPricePair.Key: " + kvPricePair.Key + " | kvPricePair.Value: " + kvPricePair.Value);
                    }
                }
                catch (Exception)
                {
                    log.Info("Can't iterate through prices dictionary");
                }

                //instantiate vars
                List<Product> products = new List<Product>();
                Decimal totalAmount = 0;

                try // to populate product info collected from instruction block
                {

                    foreach (KeyValuePair<string, int> kvpPair in _quantities)
                    {
                        //break loop if quantity is 0
                        if (kvpPair.Value == 0)
                        {
                            continue;
                        }

                        Product p = new Product();

                        string productName;
                        if (_products.TryGetValue(kvpPair.Key, out productName))
                        {
                            p.Title = productName;
                        }

                        Decimal productPrice;
                        if (_prices.TryGetValue(kvpPair.Key, out productPrice))
                        {
                            p.Price = productPrice;
                        }

                        p.Quantity = kvpPair.Value;
                        p.Total = (p.Quantity * p.Price);

                        products.Add(p);

                        log.Info("Product : " + p.Title + "|" + p.Price + "|" + p.Quantity + "|" + p.Total + "|");
                    }
                }
                catch (Exception ex)
                {
                    log.Info("An error occurred in populating product object: " + ex.Message.ToString());
                }




                try
                {
                    //build out form submission, including above products and total
                    _payment.Products = products;
                    _payment.IsTestRequest = false;
                    _payment.DateValid = DateTime.Now.AddMinutes(1);

                    //tabulate total amount of charges
                    foreach (Product p in products)
                    {
                        totalAmount += p.Total;
                    }

                    _payment.Amount = totalAmount;
                }
                catch (Exception ex)
                {
                    log.Info("An error occurred in amount total calculation method. " + ex.Message.ToString());
                }



                log.Info("Attempting to send e-mail");

                try //to send an email to person filling out form with the results 
                {
                    string fromAddress = _confirmationFromAddress;

                    if (string.IsNullOrEmpty(_confirmationFromAddress))
                        fromAddress = _site.DefaultEmailFromAddress;

                    string subject = _subject + e.Config.PickListLabel;
                    string msg = string.Empty;

                    //append total to msg, unless we think it already exists
                    if (!results.ToString().ToLower().Contains("total charges"))
                    {
                        msg = results.ToString() + "rnTotal Charges:rn" + totalAmount.ToString();
                    }
                    else //total charges are already displayed in the msg
                    {
                        msg = results.ToString();
                    }

                    Email.Send(
                            SiteUtils.GetSmtpSettings(),
                            fromAddress,
                            string.Empty,
                            string.Empty,
                            _payment.Email,
                            string.Empty,
                            string.Empty,
                            subject,
                            msg,
                            false,
                            Email.PriorityNormal);

                    log.Info(results.ToString());
                }
                catch (Exception ex)
                {
                    log.Info("An error occurred in e-mail method: " + ex.Message.ToString());
                }



                string securedUrl = string.Empty;

                try
                {


                    //Create new authorizeNET post.  Can't initiate new form post to authnet because of FWP's being contained in UpdatePanel
                    //AuthorizeNETPost authPost = new AuthorizeNETPost(payment, "https://secure.authorize.net/gateway/transact.dll", false);

                    //get invoice prefix
                    string invoice = string.Empty;

                    if (e.Config.CustomCssClass.Length > 0)
                        invoice = e.Config.CustomCssClass;

                    string nonTamperProofParams = string.Empty;

                    //build out non-tamperproof params
                    nonTamperProofParams = BuildParams(AuthNetField.FirstName.ToString(), _payment.FirstName, false);
                    nonTamperProofParams += BuildParams(AuthNetField.LastName.ToString(), _payment.LastName, false);
                    nonTamperProofParams += BuildParams(AuthNetField.Company.ToString(), _payment.Company, false);
                    nonTamperProofParams += BuildParams(AuthNetField.Address.ToString(), _payment.Address, false);
                    nonTamperProofParams += BuildParams(AuthNetField.City.ToString(), _payment.City, false);
                    nonTamperProofParams += BuildParams(AuthNetField.State.ToString(), _payment.State, false);
                    nonTamperProofParams += BuildParams(AuthNetField.Zip.ToString(), _payment.Zip, false);
                    nonTamperProofParams += BuildParams(AuthNetField.Country.ToString(), _payment.Country, false);
                    nonTamperProofParams += BuildParams(AuthNetField.Email.ToString(), _payment.Email, false);
                    nonTamperProofParams += BuildParams(AuthNetField.Phone.ToString(), _payment.Phone, false);
                    nonTamperProofParams += BuildParams(AuthNetField.Fax.ToString(), _payment.Fax, false);
                    nonTamperProofParams += BuildParams(CustomAuthNetField.InvoicePrefix.ToString(), invoice, false);
                    nonTamperProofParams += BuildParams(AuthNetField.IsTestRequest.ToString(), _payment.IsTestRequest.ToString(), true);

                    string tamperProofParams = string.Empty;

                    //build out tamper-proof params
                    tamperProofParams = BuildParams(AuthNetField.LoginId.ToString(), LoginId, false);
                    tamperProofParams += BuildParams(AuthNetField.TransactionKey.ToString(), TransactionKey, false);
                    tamperProofParams += BuildParams(AuthNetField.Amount.ToString(), _payment.Amount.ToString(), false);
                    tamperProofParams += BuildParams(CustomAuthNetField.DateValid.ToString(), _payment.DateValid.ToString(), true);

                    //create secured url
                    securedUrl = Utils.CreateTamperProofURL(_eCommerceUrl, nonTamperProofParams, tamperProofParams);

                }
                catch (Exception ex)
                {
                    //log.Info("An error occurred in authorize.net method: " + sf.GetMethod().ToString() + " at line #: " + sf.GetFileLineNumber().ToString());
                    log.Info("auth method error: " + ex.Message.ToString());
                }



                //oddly, the quantities variable seems to be persisted in memory, so to be safe I'll clear everything
                _products.Clear();
                _prices.Clear();
                _quantities.Clear();

                //Redirect to e-commerce controller
                if (!string.IsNullOrEmpty(securedUrl))
                    HttpContext.Current.Response.Redirect(securedUrl);
            }
            else
            {
                log.Info("Unequal product/price counts: Product Count:" + _products.Count +
                    " Quantity Count:" + _quantities.Count +
                    " Price Count:" + _prices.Count
                    );
            }
        }

        #endregion //Event Procedures

        #region "Behavioral Methods"
        //******************************************************************
        //Behavioral Methods
        //******************************************************************

        /// <summary>
        /// Builds url with querystring parameters
        /// </summary>
        /// <param name="qsName"></param>
        /// <param name="qsValue"></param>
        /// <param name="isEnd"></param>
        /// <returns></returns>
        private string BuildParams(string qsName, string qsValue, bool isEnd)
        {
            //do we have a param value
            if (!string.IsNullOrEmpty(qsValue))
            {
                if (!isEnd) //if not the end of the url
                    return qsName + "=" + qsValue + "&";
                else
                    return qsName + "=" + qsValue;
            }
            else //no param value
            {
                return string.Empty;
            }
        }


        /// <summary>
        /// Method designed to find FWP form fields containing relevant authorize.NET information using FWP question / question aliases.
        /// </summary>
        /// <param name="question"></param>
        /// <param name="response"></param>
        private void ExtractAuthNetFields(WebFormQuestion question, string response)
        {
            try // to extract first name
            {
                if (QuestionMatchesFieldName(question, AuthNet.FirstName, QuestionAlias.FirstName))
                {
                    _payment.FirstName = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get first name");
            }


            try //to extract last name
            {
                if (QuestionMatchesFieldName(question, AuthNet.LastName, QuestionAlias.LastName))
                {
                    _payment.LastName = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get last name");
            }


            try //to extract company
            {
                if (QuestionMatchesFieldName(question, AuthNet.Company, QuestionAlias.Company))
                {
                    _payment.Company = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get company");
            }


            try //to extract address
            {
                if (QuestionMatchesFieldName(question, AuthNet.Address, QuestionAlias.Address))
                {
                    _payment.Address = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get the address");
            }


            try //to extract city
            {
                if (QuestionMatchesFieldName(question, AuthNet.City, QuestionAlias.City))
                {
                    _payment.City = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get the city");
            }


            try //to extract state
            {
                if (QuestionMatchesFieldName(question, AuthNet.State, QuestionAlias.State))
                {
                    _payment.State = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get the state");
            }


            try //to extract zip code
            {
                if (QuestionMatchesFieldName(question, AuthNet.Zip, QuestionAlias.Zip))
                {
                    _payment.Zip = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get the zip code");
            }


            try //to extract country
            {
                if (QuestionMatchesFieldName(question, AuthNet.Country, QuestionAlias.Country))
                {
                    _payment.Country = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get the country");
            }


            try //to extract e-mail alias
            {
                if (QuestionMatchesFieldName(question, AuthNet.Email, QuestionAlias.Email))
                {
                    _payment.Email = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get the email");
            }


            try //to extract phone alias
            {
                if (QuestionMatchesFieldName(question, AuthNet.Phone, QuestionAlias.Phone))
                {
                    _payment.Phone = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get the phone");
            }



            try //to extract fax alias
            {
                if (QuestionMatchesFieldName(question, AuthNet.Fax, QuestionAlias.Fax))
                {
                    _payment.Fax = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get the phone");
            }


            try //to extract comments/description
            {
                if (QuestionMatchesFieldName(question, AuthNet.Description, QuestionAlias.Description))
                {
                    _payment.Description = response;
                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get description");
            }


            try // to extract quantities
            {
                //just setting duplicate params bc only questionalias is valid
                if (QuestionMatchesFieldName(question, "justignoremepleaseimnotneeded", QuestionAlias.Quantity))
                {
                    int qty = 0;
                    log.Info("Parsed Question Alias: " + question.QuestionAlias.Replace(QuestionAlias.Quantity, "") + " Response: " + response);

                    if (!string.IsNullOrEmpty(response))
                    {
                        //this is a quantity value, so it better be numeric
                        qty = Int32.Parse(Utils.RemoveNonAlphaFromString(response));
                    }

                    _quantities.Add(question.QuestionAlias.Replace(QuestionAlias.Quantity, ""), qty);

                }
            }
            catch (Exception)
            {
                log.Info("An error occurred in trying to get quantity");
            }
        }

        /// <summary>
        /// Helper method to match up fwp questions/question aliases with our predefined field names
        /// </summary>
        /// <param name="question"></param>
        /// <param name="authField"></param>
        /// <param name="aliasField"></param>
        /// <returns></returns>
        private bool QuestionMatchesFieldName(WebFormQuestion question, string authField, string aliasField)
        {
            string alias = question.QuestionAlias.ToLower();
            string questionText = Utils.RemoveNonAlphaFromString(question.QuestionText.ToLower());

            //log.Info(questionText + " " + alias);

            if (!string.IsNullOrEmpty(alias))
            {
                if ((
                    alias.Contains(authField) ||
                    alias.Contains(aliasField)
                   ))
                    return true;
                //else
                //    return false;
            }


            if (!string.IsNullOrEmpty(questionText))
            {
                if ((

                questionText.Contains(authField) ||
                questionText.Contains(aliasField
                )))
                    return true;
                else
                    return false;
            }
            else
            {
                return false;
            }
        }

        /// <summary>
        /// Method to retrieve FWP response
        /// </summary>
        /// <param name="responseSetGuid"></param>
        /// <param name="questionGuid"></param>
        /// <param name="responses"></param>
        /// <returns></returns>
        private string GetResponse(Guid responseSetGuid, Guid questionGuid, List<WebFormResponse> responses)
        {
            foreach (WebFormResponse response in responses)
            {
                if (
                    (response.ResponseSetGuid == responseSetGuid)
                    && (response.QuestionGuid == questionGuid)
                    )
                {
                    return response.Response;
                }
            }

            return string.Empty;
        }

        #endregion //Behavioral Methods

    }
}