Building an Integration (Stripe) - Part 1 - Admin Objects and Live Site Functionality
The previous article in this series covered the tools available for creating integrations, and the process of choosing which tools to use for the Stripe payment gateway integration.
The rest of the series will cover the implementation of this integration, so you can code along and learn from the process. This first article will cover the setup in the admin interface and the class library used for the live site.
If you want to use the integration without any coding, you can find it here.
The Series
-
Appraising the available tools and choosing which are right for an integration
-
Getting hands-on
Installation
So that we're starting in the same place, install a fresh instance of Kentico Xperience 13. Choose Custom installation, set ASP.NET Core for the Development model step, and choose the Dancing Goat sample site on the Installation type step. Complete the installation with your preferences for IIS, the database server, etc.
The integration we’ll be building is not specific to the Dancing Goat, so if you want to use a different project, just substitute your own live site solution or project wherever the Dancing Goat equivalent is mentioned.
Admin interface setup
Adding the payment method
As the documentation mentions, we'll start by creating a new payment method in the admin interface. Under Store configuration, go to the Payment methods tab, and add a new method with the codename Stripe. Note that if you're using an instance with multiple e-commerce sites and want to enable Stripe for all of them, you should use Multistore configuration instead.
This integration will accept messages from Stripe and use them to update orders to certain statuses automatically. Set each of the Order status if… settings to an appropriate status and save the payment method.
Importing resource strings
In many cases, especially those involving multi-lingual sites or integrations that will be distributed to other organizations who may want to tweak text, Resource strings are advantageous over hard-coded text. Using the Xperience Localization application, editors can create multiple language variants from within the UI without any help from developers. This integration will include several resource strings, mainly for use in settings and error logging
To use these strings, download this file and copy it into the ~/CMSSiteUtils/Import folder of your admin site. Then, open the Sites application, and click the button to Import site or objects. In the import wizard, choose the import package, pre-select all objects, and complete the import.
Creating settings categories and keys
In addition to the three statuses defined in the payment method, there will be an order status to trigger the capture of an authorized payment. Before leaving the admin interface, let's create a setting to save this status.
Go to the Modules application, then edit the Custom module, or create a new one. In the module details, go to the Settings tab and select Settings > Integration in the tree, then click New settings group. Set the Display name and Code name to Stripe, and save the new settings category.
In the tree, highlight your new Settings > Integration > Stripe category, and click the New settings group button. Set the Display name to the resource string with the key custom.stripe.settings.orderstatusforcapture.delayedcapture, and the Code name to DelayedCapture. Save the group.
Within this new group, click the New settings key button, then set the Display Name to the resource string with the key custom.stripe.settings.orderstatusforcapture, and set the Code name to OrderStatusForCapture. Set the Type to Integer number, and set the Editing control to Form control, selecting Order status selector from the dropdown.
Now, under Editing control settings, click the Advanced link to view the advanced settings of the order status selector. Make sure that only the Add none record and Display only enabled boxes are selected. Finally, click the Save button to save the settings key.
With the editing control settings now taken care of, you can save a value to the new setting. Navigate to the Settings app and go to Integration > Stripe > Delayed capture in the tree. The status chosen for this setting will be used to trigger the capture of approved payments when using delayed capture with Stripe.
Live site library
Since the integration will be used both for the live site and the admin app, we'll need to create libraries for both. The live site library will need to provide developers with utilities for setting up Stripe payments for their customers.
Setting up the library
First, in your DancingGoatcore solution, create a new .NET Standard class library called Custom.Xperience.Stripe. In this project, install the Kentico.Xperience.Libraries and Stripe.net NuGet packages (the Kentico libraries version should match the hotfix version of your project). Then, add a reference from the Dancing Goat project to the Custom.Xperience.Stripe class library.
Adding the service
Create an interface called IXperienceStripeService in the namespace Custom.Xperience.Stripe. Add using directives for Stripe.Checkout and CMS.Ecommerce, and the following method signatures:
SessionCreateOptions? GetDirectOptions(OrderInfo order, string successUrl, string cancelUrl);
SessionCreateOptions? GetDelayedOptions(OrderInfo order, string successUrl, string cancelUrl);
Since the integration we are creating is for community distribution and not just for one site, we'll try to keep it flexible by including just these methods rather than returning a
Session object. This way, if users want to modify the options before creating the Checkout session, they can do it without needing to customize our code.
Now let's implement this interface.
Create a new class called XperienceStripeService under the namespace Custom.Xperience.Stripe, with the following using directives:
using System;
using System.Linq;
using System.Collections.Generic;
using Stripe.Checkout;
using CMS.Ecommerce;
using CMS.Helpers;
Add some private properties of the types IOrderItemInfoProvider, ICurrencyInfoProvider, ILocalizationService, and IEventLogService, then populate them through constructor injection. We will use these services and providers later on.
private readonly IOrderItemInfoProvider orderItemInfoProvider;
private readonly ICurrencyInfoProvider currencyInfoProvider;
private readonly ILocalizationService localizationService;
private readonly IEventLogService eventLogService;
/// <summary>
/// Creates a new instance of <see cref="XperienceStripeService"/>.
/// </summary>
/// <param name="orderItemInfoProvider">An IOrderItemInfoProvider, provided by dependency injection.</param>
/// <param name="currencyInfoProvider">An ICurrencyInfoProvider, provided by dependency injection.</param>
/// <param name="localizationService">An ILocalizationService, provided by dependency injection.</param>
public XperienceStripeService(IOrderItemInfoProvider orderItemInfoProvider, ICurrencyInfoProvider currencyInfoProvider, ILocalizationService localizationService, IEventLogService eventLogService)
{
this.orderItemInfoProvider = orderItemInfoProvider;
this.currencyInfoProvider = currencyInfoProvider;
this.localizationService = localizationService;
this.eventLogService = eventLogService;
}
Let's start out by creating some utility methods to help keep the code a little more organized. Since this is a community integration that we want others to be able to modify for their own purposes, let's make these methods protected and virtual rather than private. This will allow for them to be overridden in classes which inherit from ours.
Building the description
One of the quirks of Stripe Checkout is that external prices can only be specified for individual line items, not the whole order. To work with this, we'll need to pass the entire Kentico order as a single item. To help avoid customer confusion, let's assemble a list of all the items in the order, so we can pass it to the line item's description. Create a new string method called CreateDescription, which takes an int called OrderID as a parameter.
protected virtual string CreateDescription(int orderId)
First, create an empty description, then query the order items associated with the provided OrderID.
string description = String.Empty;
var orderItemsQuery = orderItemInfoProvider.Get()
.Columns("OrderItemParentGUID", "OrderItemSKUName", "OrderItemGUID", "OrderItemOrderID")
.WhereEquals("OrderItemOrderID", orderId);
Next, evaluate the query by converting the results to an array. This way, we can use Linq to filter child items instead of further filtering the object query and causing new database calls as we iterate over the items. If the query returns no results, this will give us an empty array, so we don’t have to worry about null reference exceptions here.
var orderItems = orderItemsQuery.GetEnumerableTypedResult().ToArray();
Create a for loop, with logic to print the name of standard items, then list that item’s children (product options) in parentheses, if they exist.
for(int i = 0; i < orderItems.Length; i++)
{
//Check if the item is a "parent" product, rather than a product option.
if (orderItems[i].OrderItemParentGUID == System.Guid.Empty)
{
//Add the product name.
string name = String.Empty;
name += orderItems[i].OrderItemSKUName;
//Find and list any "child" product options for the item.
var options = orderItems.Where(x => x.OrderItemParentGUID.Equals(orderItems[i].OrderItemGUID));
foreach (OrderItemInfo option in options)
{
name += $" ({option.OrderItemSKUName})";
}
//…
}
}
Add a delimiting comma in-between items, and append the string we've been constructing to the description. (Unfortunately, Stripe seems to respect neither HTML line breaks nor C# escape sequences for carriage return and line feed, so breaking items onto separate lines is not an option.)
name += name.Equals(String.Empty) || (i == orderItems.Length - 1) ? String.Empty : ", ";
description += name;
Finally, at the end, check if the description is still empty and return a default string if so. For this example, specify a new resource string with the key custom.stripe.checkout.defaultdescription.
return description.Equals(String.Empty) ? localizationService.GetString("custom.stripe.checkout.defaultdescription") : description;
In the end, the method should look something like this:
//Lists items for the checkout description, as all the calculation happens on the Kentico side, meaning separate line items can't be used.
protected virtual string CreateDescription(int orderId)
{
string description = String.Empty;
var orderItemsQuery = orderItemInfoProvider.Get()
.Columns("OrderItemParentGUID", "OrderItemSKUName", "OrderItemGUID", "OrderItemOrderID")
.WhereEquals("OrderItemOrderID", orderId);
//Execute the query now, so that multiple database calls don't happen when filtering the data
var orderItems = orderItemsQuery.GetEnumerableTypedResult().ToArray();
for(int i = 0; i < orderItems.Length; i++)
{
//Check if the item is a "parent" product, rather than a product option.
if (orderItems[i].OrderItemParentGUID == System.Guid.Empty)
{
//Add the product name.
string name = String.Empty;
name += orderItems[i].OrderItemSKUName;
//Find and list any "child" product options for the item.
var options = orderItems.Where(x => x.OrderItemParentGUID.Equals(orderItems[i].OrderItemGUID));
foreach (OrderItemInfo option in options)
{
name += $" ({option.OrderItemSKUName})";
}
//If the previous stuff resulted in any text, and we're not on the last line, add a delimiter (line breaks don't work).
name += name.Equals(String.Empty) || (i == orderItems.Length - 1) ? String.Empty : ", ";
description += name;
}
}
return description.Equals(String.Empty) ? localizationService.GetString("custom.stripe.checkout.defaultdescription") : description;
}
Preparing the line item
Next, let's prepare the Stripe checkout line item (SessionLineItemOptions) collection which is required by the SessionCreateOptions. Create a method called GetLineItems with the return type List<SessionLineItemOptions>. The method should take the order as a parameter.
protected virtual List<SessionLineItemOptions> GetLineItems(OrderInfo order)
First, create the requisite list the method needs to return, then populate it with a new line item if the order and the currency referenced by the order are not null.
var lineItems = new List<SessionLineItemOptions>();
var currency = currencyInfoProvider.Get(order.OrderCurrencyID);
if (order != null && currency != null)
{
//Only use one line - separate lines requires calculation to happen on Stripe side, which would negate Kentico calculation pipeline.
lineItems.Add(new SessionLineItemOptions
{
//…
}
}
In the definition of this line item, it's necessary to create a SessionLineItemPriceDataOptions object. Start by assigning the currency to that of the current cart.
PriceData = new SessionLineItemPriceDataOptions
{
Currency = currency.CurrencyCode,
//…
}
The next piece of data needed is a SessionLineItemPriceDataProductDataOptions object, with two properties for the name and description of the product.
Since we're using a full order rather than an individual product, set the name to a generic label specified by a resource string. This string, with the key custom.stripe.checkout.paymentname, will allow anyone using the integration to substitute their own store information into the string, for example, "Your order from Kentico Book Store." The description can be set to the previous method, passing the order ID.
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = localizationService.GetString("custom.stripe.checkout.paymentname"),
Description = CreateDescription(order.OrderID)
},
Next, set the price. Stripe takes the price in cents or analogous units for the specified currency, so the order's grand total must be multiplied by 100. Because Xperience allows for currencies with more than two decimal places, the value must be set to UnitAmountDecimal rather than UnitAmount, which is an integer property.
UnitAmountDecimal = order.OrderGrandTotal * 100
This is the end of the PriceData object, so the SessionLineItemPriceDataOptions definition can be closed out with a closing curly brace, }.
The last property to set is Quantity, which should always be 1, because the price supplied represents the entire order.
Quantity = 1
With that, use curly braces, }, to close the SessionLineItemOptions definition and the conditional that checks for nulls in the order and currency, and return the list.
At the end, the code should look like this.
//Create line item list for the stripe checkout.
protected virtual List<SessionLineItemOptions> GetLineItems(OrderInfo order)
{
var lineItems = new List<SessionLineItemOptions>();
var currency = currencyInfoProvider.Get(order.OrderCurrencyID);
if (order != null && currency != null)
{
//Only use one line - separate lines requires calculation to happen on Stripe side, which would negate Kentico calculation pipeline.
lineItems.Add(new SessionLineItemOptions
{
PriceData = new SessionLineItemPriceDataOptions
{
Currency = currency.CurrencyCode,
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = localizationService.GetString("custom.stripe.checkout.paymentname"),
Description = CreateDescription(order.OrderID)
},
//Stripe uses cents or analogous units of whichever currency is being used.
UnitAmountDecimal = order.OrderGrandTotal * 100
},
Quantity = 1
});
}
return lineItems;
}
Assembling the checkout session options
With these in place, we can now add the primary methods defined in the interface.
Start off with the direct capture method, GetDirectOptions, with the nullable return type SessionCreateOptions. Take parameters for the order, the checkout success URL, and the checkout canceled URL.
public virtual SessionCreateOptions? GetDirectOptions(OrderInfo order, string successUrl, string cancelUrl)
First, make sure the order is not null. If it is, log an error and return null.
if (order == null)
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.ordernotfound"));
return null;
}
Then, get the list containing the singular checkout item via the GetLineItems method we made previously.
var lineItems = GetLineItems(order);
Next, make sure the lineItems list and the two string parameters are not empty, and create the SessionCreateOptions object that the method will return, setting the LineItems property to the previously created list.
if (lineItems.Count > 0 && !string.IsNullOrEmpty(successUrl) && !string.IsNullOrEmpty(cancelUrl))
{
var options = new SessionCreateOptions
{
LineItems = lineItems,
//…
}
//…
}
Set the Mode property to payment, as opposed to setup, which saves the customer's card data to Stripe without making a charge, and subscription, which sets up a recurring payment.
Mode = "payment",
Next, pass along the success and cancel URLs from the method's parameters, and then save the order ID as the ClientReferenceId, which Stripe will use in the future when sending webhook requests about this checkout session.
SuccessUrl = successUrl,
CancelUrl = cancelUrl,
ClientReferenceId = order.OrderID.ToString()
Close the SessionCreateOptions definition with a curly brace and a semicolon, };, and return the SessionCreateOptions.
return options;
Finally, close out the conditional that checks the line items and url parameters. If the code reaches the outside of this conditional, it means that one of them was empty, so log an error before returning null and cosing out the method.
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.failedsessioncreateoptions"), $"Line Items count: {lineItems.Count},\r\nSuccess url: {successUrl},\r\nCancel url: {cancelUrl} ");
return null;
Your final code should look like this:
/// <summary>
/// Prepares session options for a Stripe checkout session
/// </summary>
/// <param name="order">The kentico OrderInfo object for which payment is required</param>
/// <param name="successUrl">The Url that the cusotmer will be directed to after successful payment</param>
/// <param name="cancelUrl">The Url that the cusotmer will be directed to after failed payment</param>
/// <returns>Session options for creating a Stripe Checkout session.</returns>
public virtual SessionCreateOptions? GetDirectOptions(OrderInfo order, string successUrl, string cancelUrl)
{
if (order == null)
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.ordernotfound"));
return null;
}
var lineItems = GetLineItems(order);
if (lineItems.Count > 0 && !string.IsNullOrEmpty(successUrl) && !string.IsNullOrEmpty(cancelUrl))
{
var options = new SessionCreateOptions
{
LineItems = lineItems,
Mode = "payment",
SuccessUrl = successUrl,
CancelUrl = cancelUrl,
ClientReferenceId = order.OrderID.ToString()
};
return options;
}
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.failedsessioncreateoptions"), $"Line Items count: {lineItems.Count},\r\nSuccess url: {successUrl},\r\nCancel url: {cancelUrl} ");
return null;
}
The next method, GetDelayedOptions, should be the same as the first, with one exception— this version also specifies the PaymentIntentData property, which takes a SessionPaymentIntentDataOptions object with the CaptureMethod set to manual. The manual CaptureMethod ensures that the payment is not automatically captured upon completion of the checkout, saving it for delayed capture.
PaymentIntentData = new SessionPaymentIntentDataOptions
{
CaptureMethod = "manual",
}
In the end, your code should look like the following:
/// <summary>
/// Prepares async session options for a Stripe checkout session
/// </summary>
/// <param name="order">The kentico OrderInfo object for which payment is required</param>
/// <param name="successUrl">The Url that the cusotmer will be directed to after successful payment</param>
/// <param name="cancelUrl">The Url that the cusotmer will be directed to after failed payment</param>
/// <returns>Session options for creating a Stripe Checkout session.</returns>
public virtual SessionCreateOptions? GetDelayedOptions(OrderInfo order, string successUrl, string cancelUrl)
{
if (order == null)
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.ordernotfound"));
return null;
}
var lineItems = GetLineItems(order);
if (lineItems.Count > 0 && !string.IsNullOrEmpty(successUrl) && !string.IsNullOrEmpty(cancelUrl))
{
var options = new SessionCreateOptions
{
LineItems = lineItems,
Mode = "payment",
SuccessUrl = successUrl,
CancelUrl = cancelUrl,
ClientReferenceId = order.OrderID.ToString(),
PaymentIntentData = new SessionPaymentIntentDataOptions
{
CaptureMethod = "manual",
}
};
return options;
}
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.failedsessioncreateoptions"), $"Line Items count: {lineItems.Count},\r\nSuccess url: {successUrl},\r\nCancel url: {cancelUrl} ");
return null;
}
Though these could be combined into a single method with a Boolean parameter to specify direct or delayed capture, let's leave them be in this case. Aside from being a bit more readable, separate method names will draw a bit more attention to the fact that there are multiple options to consider. Leaving them separate will keep things more organized if future changes require further divergence as well.
Now, with the help of these methods, users can create Stripe checkout sessions from their live site and send users to Stripe to fill out the payment form.
What's next?
The following article in this series covers the creation and registration of the Web API endpoint that processes webhook messages from Stripe. The final article covers the setup of a global event handler for delayed capture. I hope you'll continue with me on this journey to implement Stripe.