Monday, October 15, 2012

The making of DevReach.com - Part 3, the eCommerce module

This is going to be a series of blog posts, that will review the process of making the latest version of DevReach.Com. For the this version we are going to use Sitefinity latest version and exploit all of its features as much as we can. We are currently working together with Sitefinity Team and producing a lot of feedback for them, part of the features that we need will probably be part of the next release of the product.

In the previous post, we created to new modules "Speakers" and "Lectures" with a relation between them. After that, we customized the front-end of these modules to fit our designs. Now, it's time to setup the eCommerce module and to build the registration process.

The registration process

For this edition of DevReach, together with our information architect and our event manager, we built entirely new registration process. We had main four steps, which were as follows:

1. Choosing how many passes of each type you wish and entering coupon code (if you have one).
2. Assigning the selected passes to people. This step was optional and this could be done after the registration is over as well.
3. Entering billing details - names, company information, invoice information, etc.
4. Choosing payment method.

Unfortunately, this workflow didn't fit very well in what Sitefinity eCommerce module offers out of the boxes. For, example step 2 is something that very little to do with the eCommerce module. Also, we wanted the coupon code to be entered before everything else in the shopping cart is done. The product list on our wire-frames was grouped by product type and sorted by a new field added by as - DisplayIndex. And many other tiny difference we had. Fortunately, the eCommerce module offers an API that basically gives you to do almost everything you wish using custom widgets and this is exactly what we did.

Step 1 - Choosing the products and entering the coupon code

This year we had two main products, which were basically the passes we offer - standard and vip. And we had some additional products to them, which were the various workshop that DevReach offered this for the first time. Every main product can be combined with every additional product (with some additional constraints). So, we decided to create two product types - conference pass and conference addon.
public IList<Product> GetProductsFromGivenType(ProductTypeEnum productTypeEnum)
{
      string productTypeTitle = productTypeEnum.ToProductTypeTitle();

      ProductType productType = this.catalogManager.GetProductTypes()
          .FirstOrDefault(pt => pt.Title == productTypeTitle);

      return catalogManager
           .GetProducts(productType.ClrType)
           .Where(p => p.IsActive)
           .OrderBy(p => p.FieldValue<decimal>("DisplayIndex"))
           .ToList();
}
This function takes an Enum as a parameter and retrieves that title of the product type and corresponds to this enum value. Before showing these products to the user, we have to get all active site-wide discounts and show the old and the new price. This is easy:
    public static IQueryable<Discount> GetDiscounts()
    {
        OrdersManager ordersmanager = OrdersManager.GetManager();
        return ordersmanager.GetDiscounts();
    }
And on item data bound for every product:
decimal newPrice = product.DisplayPrice;
foreach (Discount discount in SitewideDiscounts)
{
    Literal lRibbons = liHolder.FindControl("lRibbons") as Literal;
    lRibbons.Text += String.Format("<span class='ribbon'>{0}</span>", discount.Title);

    if (discount.DiscountAmountType == DiscountAmountType.Percent)
    {
        newPrice = newPrice - (newPrice * discount.DiscountAmount / 100);
    }
    else
    {
        newPrice = newPrice - discount.DiscountAmount;
    }
}

Label lnewPrice = e.Item.FindControl("lNewPrice") as Label;
lnewPrice.Text = String.Format("{0:0.##} EUR <span class='vat db '>including VAT*</span>", newPrice);

if (product.DisplayPrice > newPrice)
{
    Label loldPrice = e.Item.FindControl("loldPrice") as Label;
    loldPrice.Text = String.Format("{0:0.##} EUR", product.DisplayPrice);        }

On submit of this page, we have to gather all provided information and save it in the current order (or create new order if non exists).
public static CartOrder GetShoppingCartForUser(OrdersManager ordersManager)
{
    Guid shoppingCartId = GetShoppingCartId();
    CartOrder shoppingCart = ordersManager.TryGetCartOrder(shoppingCartId);
    if (shoppingCart == null)
    {
       shoppingCartId = Guid.NewGuid();
       RemoveShoppingCartCookie();
       SetShoppingCartId(shoppingCartId);
       shoppingCart = ordersManager.CreateCartOrder(shoppingCartId, null);
       ordersManager.SaveChanges();
    }

    return shoppingCart;
}

public static void RemoveShoppingCartCookie()
{
     HttpCookie httpCookie = new HttpCookie(EcommerceConstants.OrdersConstants.ShoppingCartIdCookieName, "");
     DateTime now = DateTime.Now;
     httpCookie.Expires = now.AddDays(-1);
     HttpContext.Current.Response.Cookies.Add(httpCookie);
}

Adding orderDetails for each product or updating the quantity if it already exists:
foreach (Product product in passes)
{
    int quantity = productQuantities[product.Id];
    CartDetail cartDetail = cartOrder.Details.SingleOrDefault(d => d.ProductId == product.Id);
    //if such product exists in the order
    if (cartDetail != null)
    {
        orderManager.SetQuantity(cartDetail, quantity);
    }
    else
    {
       OptionsDetails optionsDetails = new OptionsDetails();

       cartOrder.Currency = Config.Get<EcommerceConfig>().DefaultCurrency;
       orderManager.AddToCart(cartOrder, product, optionsDetails, quantity);
    }
}

And if a discount code is entered, we have to validate it:
var couponCodeValidator = new CouponCodeValidator(orderManager);
 if (couponCodeValidator.IsCouponCodeValid(couponName, cartOrder.SubTotalDisplay, out negativeMessage))
    {
          cartOrder.CouponCodes.Add(new CouponCode { Code = couponName });
    }
    else
    {
          if (oldCouponName != null)
          {
               cartOrder.CouponCodes.Add(new CouponCode { Code = oldCouponName });
          }
     }

Step 2 - Adding attendees to the order

For this step, we created a new module (using the module builder) called Attendees, which we use as a journal for attendees records. All attendees (names, email, pass type, etc.) for an order are kept here. Once the order is marked paid, from this data we create public users in the CMS and attach the users to the attendees. This way we have relationship between user and an order. We have created an hierarchy of objects for easier work with dynamic modules, but I will write about this some other time.

Step 3 - Billing details page

This page is probably the one that is most close to what the cms does. Nothing interesting to explain here:
void rbProceedPayment_Click(object sender, EventArgs e)
{
    if (rbInvoice.Checked)
    {
        if (!cbSameAsBilling.Checked)
        {
            Page.Validate("shippingAddress");
        }
        Page.Validate("billingAddress");

        if (rbCompanyInfo.Checked)
        {
            Page.Validate("companyInfo");
        }
        else
        {
            Page.Validate("personalInfo");
        }
    }

    if (Page.IsValid)
    {
        CartOrder cartOrder = ShoppingCartManager.GetShoppingCartForUser();
        cartOrder.Addresses.Clear();
        OrdersManager orderManager = new OrdersManager();
                    
        CartAddress billingAddress = orderManager.CreateCartAddress();
        billingAddress.FirstName = txtFirstName.Text;
        billingAddress.LastName = txtLastName.Text;
        billingAddress.Phone = txtPhone.Text;
        billingAddress.Email = txtEmail.Text;
        billingAddress.AddressType = AddressType.Billing;
        billingAddress.Address = "";
        billingAddress.City = "";
        billingAddress.Country = "";
        billingAddress.PostalCode = "";
        billingAddress.Company = "";

        if (rbInvoice.Checked)
        {
            billingAddress.Address = txtBillingStreet.Text;
            billingAddress.City = txtBillingCity.Text;
            billingAddress.Country = txtBillingCountry.Text;
            billingAddress.PostalCode = txtBillingPostalCode.Text;
            billingAddress.Company = txtCompanyName.Text;

            CartAddress shippingAddress = orderManager.CreateCartAddress();
            shippingAddress.FirstName = txtFirstName.Text;
            shippingAddress.LastName = txtLastName.Text;
            shippingAddress.Phone = txtPhone.Text;
            shippingAddress.Email = txtEmail.Text;
            shippingAddress.Company = txtCompanyName.Text;
            shippingAddress.AddressType = AddressType.Shipping;

            if (cbSameAsBilling.Checked)
            {
                shippingAddress.Address = txtBillingStreet.Text;
                shippingAddress.City = txtBillingCity.Text;
                shippingAddress.PostalCode = txtBillingPostalCode.Text;
                shippingAddress.Country = txtBillingCountry.Text;
            }
            else
            {
                shippingAddress.Address = txtShippingStreet.Text;
                shippingAddress.City = txtShippingCity.Text;
                shippingAddress.PostalCode = txtShippingPostalCode.Text;
                shippingAddress.Country = txtShippingCountry.Text;
            }

            cartOrder.Addresses.Add(shippingAddress);
        }

        cartOrder.Addresses.Add(billingAddress);
        orderManager.SaveChanges();

        Response.Redirect("~/register/checkout");
    }
}

Step 4 - Choosing the payment method

On this step we had to retrieve from the API all active payment methods and show them to the user:

                IQueryable<PaymentMethod> paymentMethods = ordersManager.GetPaymentMethods().Where(method => method.IsActive);

                rlvPaymentMethods.DataSource = paymentMethods;
                rlvPaymentMethods.DataBind();

The interesting part is when the Place Order button is clicked:
void btnPlaceOrder_Click(object sender, EventArgs e)
{
    Guid selectedPaymentMethodGuid = new Guid(Page.Request["paymentMethods"]);
    PaymentMethod paymentMethod = ordersManager.GetPaymentMethod(selectedPaymentMethodGuid);

    if (paymentMethod == null)
    {
        throw new ConfigurationErrorsException("the selected payment method do not exists");
    }

    if (!paymentMethod.IsActive)
    {
        throw new ConfigurationErrorsException("the selected payment method is not active");
    }

    if (paymentMethod.PaymentMethodType == PaymentMethodType.Offline)
    {
        PlaceOfflineOrder(paymentMethod);
        EmailHelper.SendInvoiceEmail(ShoppingCartManager.GetShoppingCartId());
    }
    else
    {
        switch (paymentMethod.Title)
        {
            case "PayPal Express Checkout":
                PlacePayPalExpressOrder(paymentMethod);
                break;
            default:
                throw new InvalidOperationException("Not implemented online payment method: " + paymentMethod.Title);
        }
    }

    this.RemoveShoppingCartCookie();
}

private void PlaceOfflineOrder(PaymentMethod paymentMethod)
{
    CartOrder cartOrder = ShoppingCartManager.GetShoppingCartForUser();
            
    ShoppingCartManager.CleanNotPurchasedProducts(cartOrder);
    CheckoutState checkoutState = cartOrder.GetCheckoutState();
    checkoutState.PaymentMethodId = paymentMethod.Id;
    checkoutState.PaymentMethodType = paymentMethod.PaymentMethodType;

    CartPayment payment = ShoppingCartHelper.GetCartPaymentFromCheckoutState(ordersManager, checkoutState);
    cartOrder.Payments.Add(payment);
    ordersManager.SaveChanges();

    ShoppingCartManager.PlaceOfflineOrder(checkoutState);

    Page.Response.Redirect("~/register/thank-you-offline", false);
}

public static bool PlaceOfflineOrder(CheckoutState checkoutState, Guid cartOrderId)
{
    UserProfileManager userProfileManager = UserProfileManager.GetManager();
    OrdersManager ordersManager = OrdersManager.GetManager();
    try
    {
        User customerUser = null;
        Customer customer = ShoppingCartHelper.GetCustomerInfoOrCreateOneIfDoesntExsist(userProfileManager, ordersManager, checkoutState, out customerUser);
                  
        OrderValidator orderValidator = new OrderValidator { IsOrderValid = true, StatusMessage = string.Empty };
  
        if (orderValidator.IsOrderValid)
        {
            IPaymentResponse paymentResponse = ordersManager.Checkout(cartOrderId, checkoutState, customer);

            // record the "success" state of the checkout
            checkoutState.IsPaymentSuccessful = paymentResponse.IsSuccess;

            Order order = ordersManager.GetOrder(cartOrderId);
            IncrementOrderNumber(order, ordersManager);

            // add the order to customer
            customer.Orders.Add(order);

            // Update the order
            order.Customer = customer;
            order.OrderStatus = checkoutState.PaymentMethodType == PaymentMethodType.Offline ? OrderStatus.Pending : OrderStatus.Paid;
            ordersManager.SaveChanges();

            // Send an email to the customer.
            try
            {
                this.SendOrderPlacedEmailToClientAndMerchant(order);
            }
            catch (Exception emailEx)
            {
                // Do nothing. If sending email fails, we still want CleanUp to occur and the user to be directed to the next page.
                Log.Write(emailEx, ConfigurationPolicy.ErrorLog);
            }

            CleanUp(cartOrderId);
        }
        else
        {
            //this.PaymentProblemPanel.Visible = true;
            //this.MessageControl.ShowNegativeMessage(orderValidator.StatusMessage);
        }

        return orderValidator.IsOrderValid;
    }
    catch (Exception ex)
    {
        Log.Write(ex, ConfigurationPolicy.ErrorLog);
        //this.PaymentProblemPanel.Visible = true;
        //this.MessageControl.ShowNegativeMessage(ex.Message);
        return false;
    }
}
  
This is all that is needed to place an offline order. I will write a separate post for the paypal integration we did together with the eCommerce team.

The last thing that worth mentioning is that in order to create the users, once the order is marked as paid - we needed a hook to subscribe for this event. Fortunately for us, the product team introduced a new event for changing the status of an order which was exactly what we needed, no matter if the order is online or offline:

protected void Application_Start(object sender, EventArgs e)
{
  Bootstrapper.Initialized += new     EventHandler<Telerik.Sitefinity.Data.ExecutedEventArgs>(Bootstrapper_Initialized);
}

private void Bootstrapper_Initialized(object sender, Telerik.Sitefinity.Data.ExecutedEventArgs e)
{
  if (e.CommandName == "Bootstrapped")
  {
    EventHub.Subscribe<IEcommerceOrderStatusChangedEvent>(OnOrderStatusChanged);
  }
}

private void OnOrderStatusChanged(IEcommerceOrderStatusChangedEvent evt)
{
  var orderId = evt.OrderId;
  var oldOrderStatus = evt.OldOrderStatus;
  var newOrderStatus = evt.NewOrderStatus;
  if (newOrderStatus == OrderStatus.Paid)
  {
       if (!OrderHelper.IsOrderPaid(orderId))
       {
           AttendeeManager attendeeManager = new AttendeeManager();
           IList<Attendee> attendees = attendeeManager.GetAllPublishedByOrderId(orderId);
           UsersHelper.CreateCustomerUserAndSendActivationEmail(orderId, attendees);

           foreach (Attendee attendee in attendees)
           {
                UsersHelper.CreateUserAndSendActivationEmail(attendee, attendeeManager);
           }
       }
   }

   if (newOrderStatus == OrderStatus.Deleted)
   {
        OrderHelper.DeleteOrderInformation(orderId);
   }            
}

What's next?

Basically, this was our registration process. I skipped some of the code fore brevity and simplicity, but all the interaction with the eCommerce API is here. Next time I will show you the integration with paypal using the PayPal Express Checkout scenario. And stay tuned for the coming release of Sitefinity 5.2 - lots of new features and improvements will be released.