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 the current implementation of the code, I opted to create a FWP submission handler which interfaces with a "controller" application that receives the redirects from the handler (via a querystring).
In any case, without further ado, here's more info about the form submission event handler that was created for use with FWP. Or, jump to the demonstration.
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)
-
Interface with a separate controller .NET app to handle the auth net form post (see below).
-
Encrypt/hash some, but not all querystring parameters as well as make them tamper-proof. Eg, the auth net login id, transaction key, datevalid, and amount
-
Append a datetime to the querystring that would allow the url to only be valid for a short period of time. This date cannot be modified by a savvy user.
-
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
Features/Goals of Controller
-
Taking a url with hashed/encrypted, tamper-proof parameters, read/decrypt querystring parameters and then format/forward requests to Authorize.NET in the following general fashion
-
Additional security measures, as specified below
Security
This implementation has the following levels of security:
-
FWP's built-in anti-spam, in which the user must pass some captcha test (if enabled)
-
The controller ensures that all requests are served over SSL
-
The controller checks that a valid referrer initiated the request (ie, a colostate domain). This way, you can't directly bookmark/navigate to the generated url.
-
Check that the required querystring parameters have been defined
-
Check that the tamper-proof parameters haven't been tampered with

-
Decrypt the encrypted/hashed parameters. This requires utilizing a secret hash that both the submission handler and the controller have access to (ie, for the program to work, both the handler and the controller must reference some identical hash value)
-
Check that the request has an expiration date and that the date is valid. In other words, the url will have some unalterable date which must be in the future in order to proceed with the transaction. I currently just set that value to be 1 minute in the future.
-
When an error does occur, be intentionally vague about what happened (of course, if a hacker sees this blog they will have some idea about what's going on behind the scenes. However, they still can't tamper with the querystring parameters, nor decrypt them without the hash).
Developer Implementation
-
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.
-
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" />
-
Download/publish the controller solution file (unless you are a CSU web developer and instead want to utilize what I've already created, in which case, you'll need to contact me for the "salt phrase")
-
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
-
Go to your form's settings. Under Submission Event Handler, select MojoAuthorizeNET_SIMFormSubmissionHandler
-
If you want your auto-generated invoice numbers to have a custom prefix (recommended), define that prefix under custom CSS class. Eg, "VET_RMC_"
-
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.
-
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:
-
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)
-
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.
-
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("\r\n");
results.Append("\r\n");
//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("\r\n");
}
//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("\r\n" + question.QuestionText + "\r\n");
results.Append(response);
results.Append("\r\n");
}
}
//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() + "\r\nTotal Charges:\r\n" + 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
}
}