Building an Integration (Stripe) - Part 3 - Helpers and Event Handlers
This article series covers the planning and implementation of the Stripe payment gateway implementation. The first article covered setting up the Admin interface and the class library used for the live site. The second article covered the Web API endpoint used to accept webhook messages form Stripe.
The last article will focus on the process of getting funds from orders that use delayed capture, implementing a helper class and an event handler to serve this purpose.
The Series
-
Appraising the available tools and choosing which are right for an integration
-
Getting hands-on
Making the PaymentIntentService resolvable
While the Stripe documentation gives examples with hard dependencies for the PaymentIntentService, let’s make it resolvable, so it can be easily faked during testing.
Unfortunately, the method we need from PaymentIntentService, Capture, does not come from any interface. We’ll need to do some extra work to make sure we can resolve it.
Creating an interface
First, in a new folder called Services, create an interface called IResolvablePaymentIntentService in the namespace Custom.Xperience.Stripe.
public interface IResolvablePaymentIntentService
Make sure it has the following using directives.
using Stripe;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
Add signatures for all of the methods contained in Stripe’s PaymentIntentService. For our purposes, it would be okay to only do so for the Capture method, but including everything will make it more flexible for anyone expanding upon this repository.
For Stripe.net version 41.2.0, it should look something like this.
PaymentIntent ApplyCustomerBalance(string id, PaymentIntentApplyCustomerBalanceOptions options = null, RequestOptions requestOptions = null);
Task<PaymentIntent> ApplyCustomerBalanceAsync(string id, PaymentIntentApplyCustomerBalanceOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
PaymentIntent Cancel(string id, PaymentIntentCancelOptions options = null, RequestOptions requestOptions = null);
Task<PaymentIntent> CancelAsync(string id, PaymentIntentCancelOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
PaymentIntent Capture(string id, PaymentIntentCaptureOptions options = null, RequestOptions requestOptions = null);
Task<PaymentIntent> CaptureAsync(string id, PaymentIntentCaptureOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
PaymentIntent Confirm(string id, PaymentIntentConfirmOptions options = null, RequestOptions requestOptions = null);
Task<PaymentIntent> ConfirmAsync(string id, PaymentIntentConfirmOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
PaymentIntent Create(PaymentIntentCreateOptions options, RequestOptions requestOptions = null);
Task<PaymentIntent> CreateAsync(PaymentIntentCreateOptions options, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
PaymentIntent Get(string id, PaymentIntentGetOptions options = null, RequestOptions requestOptions = null);
Task<PaymentIntent> GetAsync(string id, PaymentIntentGetOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
PaymentIntent IncrementAuthorization(string id, PaymentIntentIncrementAuthorizationOptions options = null, RequestOptions requestOptions = null);
Task<PaymentIntent> IncrementAuthorizationAsync(string id, PaymentIntentIncrementAuthorizationOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
StripeList<PaymentIntent> List(PaymentIntentListOptions options = null, RequestOptions requestOptions = null);
Task<StripeList<PaymentIntent>> ListAsync(PaymentIntentListOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
IEnumerable<PaymentIntent> ListAutoPaging(PaymentIntentListOptions options = null, RequestOptions requestOptions = null);
IAsyncEnumerable<PaymentIntent> ListAutoPagingAsync(PaymentIntentListOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
StripeSearchResult<PaymentIntent> Search(PaymentIntentSearchOptions options = null, RequestOptions requestOptions = null);
Task<StripeSearchResult<PaymentIntent>> SearchAsync(PaymentIntentSearchOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
IEnumerable<PaymentIntent> SearchAutoPaging(PaymentIntentSearchOptions options = null, RequestOptions requestOptions = null);
IAsyncEnumerable<PaymentIntent> SearchAutoPagingAsync(PaymentIntentSearchOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
PaymentIntent Update(string id, PaymentIntentUpdateOptions options, RequestOptions requestOptions = null);
Task<PaymentIntent> UpdateAsync(string id, PaymentIntentUpdateOptions options, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
PaymentIntent VerifyMicrodeposits(string id, PaymentIntentVerifyMicrodepositsOptions options = null, RequestOptions requestOptions = null);
Task<PaymentIntent> VerifyMicrodepositsAsync(string id, PaymentIntentVerifyMicrodepositsOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default);
Creating and registering an implementation
Next, create a class named ResolvablePaymentIntentService in the same namespace, and give it the following using directives.
using Stripe;
using Custom.Xperience.Stripe;
using CMS;
using CMS.Core;
Make it inherit from PaymentIntentService and implement IResolvablePaymentIntentService.
namespace Custom.Xperience.Stripe
{
public class ResolvablePaymentIntentService : PaymentIntentService, IResolvablePaymentIntentService
{
}
}
Add an assembly attribute to register it as the default implementation of IResolvablePaymentService.
[assembly: RegisterImplementation(typeof(IResolvablePaymentIntentService), typeof(ResolvablePaymentIntentService), Priority = RegistrationPriority.Default)]
We do not need to define any members for this class, as the inheritance of PaymentIntentService implements all the required members of IResolvablePaymentIntentService.
Adding a helper class
Now, let’s create a helper class with methods for capturing payment. This class will be used in our event-based payment capture functionality, but it will also be available for developers who want to use it elsewhere.
Setting up the class
Create a new folder called Helpers and add a static class CaptureHelper.
Make sure the file has the following using directives.
using Stripe;
using CMS.Core;
using CMS.Ecommerce;
Ensure that the class is within the Custom.Xperience.Stripe namespace.
namespace Custom.Xperience.Stripe
{
public static class CaptureHelper
{
//…
}
}
Next, define some private static service members for the IEventLogService, our IResolvablePaymentIntentService, and an ILocalizationService, before materializing them in a method called Init.
private static IResolvablePaymentIntentService paymentIntentService;
private static IEventLogService eventLogService;
private static ILocalizationService localizationService;
public static void Init()
{
paymentIntentService = Service.Resolve<IResolvablePaymentIntentService>();
eventLogService = Service.Resolve<IEventLogService>();
localizationService = Service.Resolve<ILocalizationService>();
}
We can call the Init method before using this class to resolve these services. This allows us to avoid hard dependencies even in a static class.
Adding a simple but flexible method
The first helper method should verify that Stripe is configured and if so, capture the specified payment. Call it CapturePayment, with the type PaymentIntent, and make it accept a payment intent’s Id (a string) as the parameter.
public static PaymentIntent CapturePayment(string paymentIntentID)
Check if the API key for Stripe is configured. If it is not, throw a StripeException.
Validate the paymentIntentID parameter as well, throwing a StipeException if it is not available.
The capture will not work without the these components, and a StripeException is the same type that Stripe’s Capture method should throw if it encounters errors of its own, so using this type will ensure consistency.
if (string.IsNullOrEmpty(StripeConfiguration.ApiKey))
{
throw new StripeException(localizationService.GetString("custom.stripe.error.secretkeymissing"));
}
if (string.IsNullOrEmpty(paymentIntentID))
{
throw new StripeException(localizationService.GetString("custom.stripe.error.paymentintentmissing"));
}
Use the payment intent service to capture the payment and return the result.
return paymentIntentService.Capture(paymentIntentID);
The result should look like the following.
/// <summary>
/// Tries to capture the payment intent with the supplied ID.
/// </summary>
/// <param name="paymentIntentID">The ID of the payment intent to be captured.</param>
/// <returns>True if the full amount of the payment intent was captured</returns>
/// <exception cref="StripeException">Throws exception if secret key is missing from web.config, or if something goes wrong with the capture.</exception>
public static PaymentIntent CapturePayment(string paymentIntentID)
{
if (string.IsNullOrEmpty(StripeConfiguration.ApiKey))
{
throw new StripeException(localizationService.GetString("custom.stripe.error.secretkeymissing"));
}
if (string.IsNullOrEmpty(paymentIntentID))
{
throw new StripeException(localizationService.GetString("custom.stripe.error.paymentintentmissing"));
}
return paymentIntentService.Capture(paymentIntentID);
}
Adding a more complex method
Next, we’ll make a method that is not quite as flexible as CapturePayment, but which is a lot more convenient. The method should take an order and capture the associated PaymentIntent based on the Id saved to an order’s custom data, handling any necessary error logging along the way.
Create a method named CaptureOrder with a void return type, and have it accept an OrderInfo as a parameter.
public static void CaptureOrder(OrderInfo order)
If the provided order is not null, pull the payment intent ID from its custom data
if (order != null)
{
//Get the payment intent from the order's custom data.
var paymentIntentID = (string)order.OrderCustomData.GetValue(XperienceStripeConstants.PAYMENT_INTENT_ID_KEY);
//…
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.ordernotfound"), $"OrderID {order.OrderID}");
}
After the payment intent ID is retrieved, check if it is null and log an error if so.
if (!string.IsNullOrEmpty(paymentIntentID))
{
//…
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.paymentintentmissing"), $"OrderID {order.OrderID}");
}
If it is not null, call the CapturePayment method from earlier, logging a warning if the full amount is not captured, and an error for any exceptions it may throw.
try
{
//Capture the payment.
if (CapturePayment(paymentIntentID).AmountCapturable != 0)
{
//log a warning if the full amount was not captured
eventLogService.LogEvent(EventTypeEnum.Warning, "Stripe", localizationService.GetString("custom.stripe.warning.partialamount"), $"OrderID: {order.OrderID} \r\nPaymentIntentID: {paymentIntentID}");
}
}
catch (StripeException ex)
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", "Stripe", ex.Message + "\r\n" + ex.StackTrace);
}
The completed method should look like this.
/// <summary>
/// Tries to capture payment for the supplied order.
/// Logs errors to Event log if capture fails or less than the full amount is captured
/// </summary>
/// <param name="order">The order to capture payment for</param>
public static void CaptureOrder(OrderInfo order)
{
if (order != null)
{
//Get the payment intent from the order's custom data.
var paymentIntentID = (string)order.OrderCustomData.GetValue(XperienceStripeConstants.PAYMENT_INTENT_ID_KEY);
if (!string.IsNullOrEmpty(paymentIntentID))
{
try
{
//Capture the payment.
if (CapturePayment(paymentIntentID).AmountCapturable != 0)
{
//log a warning if the full amount was not captured
eventLogService.LogEvent(EventTypeEnum.Warning, "Stripe", localizationService.GetString("custom.stripe.warning.partialamount"), $"OrderID: {order.OrderID} \r\nPaymentIntentID: {paymentIntentID}");
}
}
catch (StripeException ex)
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", "Stripe", ex.Message + "\r\n" + ex.StackTrace);
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.paymentintentmissing"), $"OrderID {order.OrderID}");
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.ordernotfound"), $"OrderID {order.OrderID}");
}
}
Handling order events
Now it’s time to add a global event handler to our code.
This code will fire every time an order is updated and allow us to check whether the order was pushed from the approved status the capture status.
Event handlers need to be assigned during the application’s initialization. Since we already have a module where we initialize the route for the Web API endpoint, let’s add the event handler to the same class.
Setting up the class
Open XperienceStripeEndpointModule.cs from the ModuleRegistration folder and update the using directives to include all of the following.
using CMS;
using CMS.Core;
using CMS.DataEngine;
using CMS.Ecommerce;
using CMS.Helpers;
using Custom.Xperience.Stripe.Endpoint;
using Stripe;
using System.Linq;
using System.Web.Http;
Now, let’s add some services we’ll use in the class. Create private members for IEventLogService, IAppSettingsService, IOrderStatusInfoProvider, IPaymentOptionInfoProvider, ISettingsService, and ILocalizationService.
private IEventLogService eventLogService;
private IAppSettingsService appSettingsService;
private IOrderStatusInfoProvider orderStatusInfoProvider;
private IPaymentOptionInfoProvider paymentOptionInfoProvider;
private ISettingsService settingsService;
private ILocalizationService localizationService;
Adding custom caching
Here, we’ll add some caching for database calls that don’t change with each order.
One of the things we’ll need to load from the database is the Stripe payment option, as we need to check whether it is assigned to the given order.
Create a method called LoadOption, following the instructions for caching in the API from the Xperience documentation.
//Cache the payment option that is compared to each order to minimize extraneous database calls.
private PaymentOptionInfo LoadOption(CacheSettings cs)
{
PaymentOptionInfo paymentOption = paymentOptionInfoProvider.Get().WhereEquals("PaymentOptionName", "Stripe").First();
cs.CacheDependency = CacheHelper.GetCacheDependency("ecommerce.paymentoption|byname|stripe");
return paymentOption;
}
Next, do the same for retrieving the value of the Order status for capture settings key that we created in the first implementatino article. Name the method LoadSetting.
//Cache the value of the settings key that signifies the order status for payment capture
private string LoadSetting(CacheSettings cs)
{
string setting = settingsService["OrderStatusForCapture"];
cs.CacheDependency = CacheHelper.GetCacheDependency("cms.settingskey|byname|orderstatusforcapture");
return setting;
}
Adding the event handler
Add a new private void method called Order_Update_Before, taking Object and ObjectEventArts parameters as per the Xperience documentation for object events.
private void Order_Update_Before(object sender, ObjectEventArgs e)
First, let’s check if the settings key for the order capture status is configured. This way, we can minimize the code that executes for stores that are not using our event for delayed capture. Cache the result for 60 minutes, and try to parse an integer from it. Ensure the retrieved integer is greater than 0, meaning the setting is set to a potential status Id.
if (int.TryParse(CacheHelper.Cache(cs => LoadSetting(cs), new CacheSettings(60, "customxperiencestripe|settingkey")), out int captureStatusID) && captureStatusID > 0)
{
//…
}
Cast an OrderInfo from the method’s object parameter and retrieve the Stripe payment option from cache if it is not null.
var order = e.Object as OrderInfo;
if (order != null)
{
PaymentOptionInfo paymentOption = CacheHelper.Cache(cs => LoadOption(cs), new CacheSettings(60, "customxperiencestripe|paymentoption"));
//…
}
Check that this payment method is not null and that the order uses it.
if(paymentOption != null && order.OrderPaymentOptionID == paymentOption.PaymentOptionID)
{
//…
}
Then, find the current and original status of the order that triggered the event handler.
int originalStatus = (int)order.GetOriginalValue("OrderStatusID");
int currentStatus = order.OrderStatusID;
We don’t' need to worry so much about this int cast because the value being retrieved is coming from an integer primary key column, but you can optionally add a try/catch here to be safe.
Next, check if the current status is equal to the capture status designated by the settings key you created previously. This means the order is currently in the capture status.
if (currentStatus == captureStatusID)
{
//…
}
If so, find the payment method’s designated approved status, and capture the funds only if the previous order status was approved. If not, log an error.
var approvedStatus = orderStatusInfoProvider.Get(paymentOption.PaymentOptionAuthorizedOrderStatusID);
if(approvedStatus != null)
{
int approvedStatusID = approvedStatus.StatusID;
//If the order was previously approved.
if (originalStatus == approvedStatusID)
{
CaptureHelper.CaptureOrder(order);
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.paymentnotapproved"), $"OrderID: {order.OrderID}, PaymentIntentID: {order.OrderCustomData.GetValue(XperienceStripeConstants.PAYMENT_INTENT_ID_KEY) ?? "null"}");
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.authorizedstatusnotfound"), $"PaymentOptionAuthorizedOrderStatusID: {paymentOption.PaymentOptionAuthorizedOrderStatusID}");
}
In the end, the handler should look like the following.
private void Order_Update_Before(object sender, ObjectEventArgs e)
{
//Only do anything if the setting is configured, and get the ID of the order status in Settings that triggers order capture.
if (int.TryParse(CacheHelper.Cache(cs => LoadSetting(cs), new CacheSettings(60, "customxperiencestripe|settingkey")), out int captureStatusID) && captureStatusID > 0)
{
var order = e.Object as OrderInfo;
if (order != null)
{
PaymentOptionInfo paymentOption = CacheHelper.Cache(cs => LoadOption(cs), new CacheSettings(60, "customxperiencestripe|paymentoption"));
if (paymentOption != null && order.OrderPaymentOptionID == paymentOption.PaymentOptionID)
{
//Get previous and current status for the updated order.
int originalStatus = (int)order.GetOriginalValue("OrderStatusID");
int currentStatus = order.OrderStatusID;
//If the order is in the status that triggers payment capture.
if (currentStatus == captureStatusID)
{
var approvedStatus = orderStatusInfoProvider.Get(paymentOption.PaymentOptionAuthorizedOrderStatusID);
if(approvedStatus != null)
{
int approvedStatusID = approvedStatus.StatusID;
//If the order was previously approved.
if (originalStatus == approvedStatusID)
{
CaptureHelper.CaptureOrder(order);
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.paymentnotapproved"), $"OrderID: {order.OrderID}, PaymentIntentID: {order.OrderCustomData.GetValue(XperienceStripeConstants.PAYMENT_INTENT_ID_KEY) ?? "null"}");
}
}
else
{
eventLogService.LogEvent(EventTypeEnum.Error, "Stripe", localizationService.GetString("custom.stripe.error.authorizedstatusnotfound"), $"PaymentOptionAuthorizedOrderStatusID: {paymentOption.PaymentOptionAuthorizedOrderStatusID}");
}
}
}
}
}
}
Adding to the OnInit method
Finally, we can register the event handler and take care of a few other tasks in the OnInit method of the module.
After the existing code that registers the route, materialize all of the services that were declared at the beginning. Then, use the app setting service to set StripeConfiguration.APIKey based on the setting in the web.config file of the user’s admin application.
eventLogService = Service.Resolve<IEventLogService>();
appSettingsService = Service.Resolve<IAppSettingsService>();
orderStatusInfoProvider = Service.Resolve<IOrderStatusInfoProvider>();
paymentOptionInfoProvider = Service.Resolve<IPaymentOptionInfoProvider>();
settingsService = Service.Resolve<ISettingsService>();
localizationService = Service.Resolve<ILocalizationService>();
StripeConfiguration.ApiKey = appSettingsService["CustomStripeSecretKey"];
Next, call the Init method from our CaptureHelper class.
CaptureHelper.Init();
Finally, assign the event handler to the Update.Before event for orders.
OrderInfo.TYPEINFO.Events.Update.Before += Order_Update_Before;
The resulting OnInit should look something like this.
protected override void OnInit()
{
base.OnInit();
//Map route to endpoint.
GlobalConfiguration.Configuration.Routes.MapHttpRoute(
"xperience-stripe",
"xperience-stripe/updateorder",
defaults: new { controller = "Stripe", action = "Update" }
);
eventLogService = Service.Resolve<IEventLogService>();
appSettingsService = Service.Resolve<IAppSettingsService>();
orderStatusInfoProvider = Service.Resolve<IOrderStatusInfoProvider>();
paymentOptionInfoProvider = Service.Resolve<IPaymentOptionInfoProvider>();
settingsService = Service.Resolve<ISettingsService>();
localizationService = Service.Resolve<ILocalizationService>();
StripeConfiguration.ApiKey = appSettingsService["CustomStripeSecretKey"];
CaptureHelper.Init();
//Register event handler.
OrderInfo.TYPEINFO.Events.Update.Before += Order_Update_Before;
}
Conclusion
The Stripe integration repositories should now be complete. In order to try them out, you can follow the setup instructions in the repository, ignoring the part about installing NuGet packages because you’ve already created the code they contain.
I hope that following along through the process of creating this integration has provided you with some useful insight. Please reach out with any questions in the articles’ comments or submit an issue in the associated repository on GitHub.