Learn how to build a custom checkout flow in Umbraco Commerce.
If you need a more custom checkout experience, you can build a custom checkout flow using the Umbraco Commerce API. This approach gives you full control over the checkout experience, allowing you to tailor it to your specific requirements.
Create a Checkout Surface Controller
To create a custom checkout flow, add a custom Surface Controller to handle the checkout process.
Create a new class and inherit from Umbraco.Cms.Web.Website.Controllers.SurfaceController.
Name the class CheckoutSurfaceController.
Add the following code to the class:
CheckoutSurfaceController.cs
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Logging;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Web.Website.Controllers;
using Umbraco.Commerce.Core.Api;
namespace Umbraco.Commerce.SwiftShop.Controllers;
public class CheckoutSurfaceController : SurfaceController
{
private readonly IUmbracoCommerceApi _commerceApi;
public CheckoutSurfaceController(
IUmbracoContextAccessor umbracoContextAccessor,
IUmbracoDatabaseFactory databaseFactory,
ServiceContext services,
AppCaches appCaches,
IProfilingLogger profilingLogger,
IPublishedUrlProvider publishedUrlProvider,
IUmbracoCommerceApi commerceApi)
: base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider)
{
_commerceApi = commerceApi;
}
// Add your checkout actions here
}
Define the Checkout Steps
Before you can start building the checkout flow, you must define the steps the customer goes through. A typical checkout flow consists of the following steps:
Collecting Customer Information.
Selecting a Shipping Method.
Selecting a Payment Method.
Reviewing Order Details.
Showing an Order Confirmation.
To accommodate these steps, you must create a few new Document Types for each step (or one with multiple templates, one per step). Each Document Type will represent a step in the checkout flow.
Create Document Types for each of the steps above, adding only properties that make sense for your setup.
Collecting Customer Information
To collect customer information, you need to create an action method in our CheckoutSurfaceController. This should accept the details and update the order accordingly. The properties will be wrapped in a DTO class to pass it to the controller.
Create a new class and name it UpdateOrderInformationDto using the following properties.
UpdateOrderInformationDto.cs
namespace Umbraco.Commerce.DemoStore.Dtos;
public class UpdateOrderInformationDto
{
public string Email { get; set; }
public bool MarketingOptIn { get; set; }
public OrderAddressDto BillingAddress { get; set; }
public OrderAddressDto ShippingAddress { get; set; }
public bool ShippingSameAsBilling { get; set; }
public string Comments { get; set; }
}
public class OrderAddressDto
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Line1 { get; set; }
public string Line2 { get; set; }
public string ZipCode { get; set; }
public string City { get; set; }
public Guid Country { get; set; }
public string Telephone { get; set; }
}
Add the following action method to your CheckoutSurfaceController.
The customer can fill out their details and proceed to the next step in the checkout flow.
Selecting a Shipping Method
Create a new class and name it UpdateShippingMethodDto using the following properties.
UpdateShippingMethodDto.cs
namespace Umbraco.Commerce.DemoStore.Dtos;
public class UpdateOrderShippingMethodDto
{
public Guid ShippingMethod { get; set; }
public string ShippingOptionId { get; set; }
}
Add the following action method to your CheckoutSurfaceController.
CheckoutSurfaceController.cs
public async Task<IActionResult> UpdateOrderShippingMethod(UpdateOrderShippingMethodDto model)
{
try
{
await commerceApi.Uow.ExecuteAsync(async uow =>
{
var store = CurrentPage!.GetStore()!;
var order = await commerceApi.GetCurrentOrderAsync(store.Id)!
.AsWritableAsync(uow);
if (!string.IsNullOrWhiteSpace(model.ShippingOptionId))
{
var shippingMethod = await commerceApi.GetShippingMethodAsync(model.ShippingMethod);
Attempt<ShippingRate> shippingRateAttempt = await shippingMethod.TryCalculateRateAsync(model.ShippingOptionId, order);
await order.SetShippingMethodAsync(model.ShippingMethod, shippingRateAttempt.Result!.Option);
}
else
{
await order.SetShippingMethodAsync(model.ShippingMethod);
}
await commerceApi.SaveOrderAsync(order);
uow.Complete();
});
}
catch (ValidationException ex)
{
ModelState.AddModelError("", "Failed to set order shipping method");
return CurrentUmbracoPage();
}
return RedirectToUmbracoPage("/checkout/payment-method");
}
Open the view for the Selecting a Shipping Method step.
Create a form that posts to the UpdateOrderShippingMethod action method of the CheckoutSurfaceController, passing the selected shipping method.
CheckoutShippingMethod.cshtml
@inject IUmbracoCommerceApi UmbracoCommerceApi
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Commerce.DemoStore.Models.CheckoutShippingMethodPage>
@{
var store = Model.GetStore();
var order = await UmbracoCommerceApi.GetCurrentOrderAsync(store!.Id);
var shippingMethods = await UmbracoCommerceApi.GetShippingMethodsAllowedInAsync(order!.ShippingInfo.CountryId!.Value).ToListAsync();
var shippingMethodsRates = await Task.WhenAll(shippingMethods.Select(sm => sm.TryCalculateRatesAsync()));
}
<script>
setShippingOptionId = function() {
var shippingMethod = document.querySelector('input[name="shippingMethod"]:checked');
var shippingOptionId = shippingMethod.getAttribute('data-option-id');
document.querySelector('input[name="shippingOptionId"]').value = shippingOptionId;
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('input[name="shippingMethod"]').forEach(function(radio) {
radio.addEventListener('change', setShippingOptionId);
});
setShippingOptionId();
});
</script>
@using (Html.BeginUmbracoForm("UpdateOrderShippingMethod", "CheckoutSurface"))
{
<h3>Shipping Method</h3>
<ul>
@foreach (var item in shippingMethods.Select((sm, i) => new { ShippingMethod = sm, Index = i }))
{
var rates = shippingMethodsRates[item.Index];
if (rates.Success)
{
foreach (var rate in rates.Result!)
{
<li>
<label>
<input name="shippingMethod" type="radio" value="@item.ShippingMethod.Id" required
data-option-id="@rate.Option?.Id" />
<span>
@(item.ShippingMethod.Name)
@if (rate.Option != null)
{
<text> - @rate.Option.Name</text>
}
</span>
<span>@(await rate.Value.FormattedAsync())</span>
</label>
</li>
}
}
}
</ul>
<input type="hidden" name="shippingOptionId" value="@(selectedShippingOptionId)" />
<div>
<a href="/checkout/customer-information">Return to Customer Information</a>
<button type="submit">Continue to Payment Method</button>
</div>
}
The customer can select a shipping method and proceed to the next step in the checkout flow.
Selecting a Payment Method
Create a new class and name it UpdatePaymentMethodDto using the following properties.
UpdatePaymentMethodDto.cs
namespace Umbraco.Commerce.DemoStore.Web.Dtos;
public class UpdateOrderPaymentMethodDto
{
public Guid PaymentMethod { get; set; }
}
Add the following action method to your CheckoutSurfaceController.
CheckoutSurfaceController.cs
public async Task<IActionResult> UpdateOrderPaymentMethod(UpdateOrderPaymentMethodDto model)
{
try
{
await commerceApi.Uow.ExecuteAsync(async uow =>
{
var store = CurrentPage!.GetStore()!;
var order = await commerceApi.GetCurrentOrderAsync(store.Id)!
.AsWritableAsync(uow)
.SetPaymentMethodAsync(model.PaymentMethod);
await commerceApi.SaveOrderAsync(order);
uow.Complete();
});
}
catch (ValidationException ex)
{
ModelState.AddModelError("", "Failed to set order payment method");
return CurrentUmbracoPage();
}
return Redirect("/checkout/review-order");
}
Open the view for the Selecting a Payment Method step
Create a form that posts to the UpdateOrderPaymentMethod action method of the CheckoutSurfaceController, passing the selected payment method.
CheckoutPaymentMethod.cshtml
@inject IUmbracoCommerceApi UmbracoCommerceApi
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Commerce.DemoStore.Models.CheckoutPaymentMethodPage>
@{
var store = Model.GetStore();
var order = await UmbracoCommerceApi.GetCurrentOrderAsync(store!.Id);
var paymentMethods = await UmbracoCommerceApi.GetPaymentMethodsAllowedInAsync(order!.PaymentInfo.CountryId!.Value).ToListAsync();
var paymentMethodFees = await Task.WhenAll(paymentMethods.Select(x => x.TryCalculateFeeAsync()));
}
@using (Html.BeginUmbracoForm("UpdateOrderPaymentMethod", "CheckoutSurface"))
{
<h3>Payment Method</h3>
<ul>
@foreach (var item in paymentMethods.Select((pm, i) => new { PaymentMethod = pm, Index = i }))
{
var fee = paymentMethodFees[item.Index];
<li>
<label>
<input name="paymentMethod" type="radio" value="@item.PaymentMethod.Id" required />
<span>@(item.PaymentMethod.Name)</span>
<span>@(await fee.ResultOr(Price.ZeroValue(order.CurrencyId)).FormattedAsync())</span>
</label>
</li>
}
</ul>
<div>
<a href="/checkout/shipping-method">Return to Shipping Method</a>
<button type="submit">Continue to Review Order</button>
</div>
}
The customer can select a payment method and proceed to the next step in the checkout flow.
Reviewing Order Details
Open the view for the Reviewing Order Details step.
Display the order details and provide a button to trigger capturing payment.
CheckoutReview.cshtml
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Commerce.DemoStore.Models.CheckoutReviewPage>
@{
var store = Model.GetStore();
var order = await UmbracoCommerceApi.GetCurrentOrderAsync(store!.Id);
}
// Ommitted for brevity, but displays the order details from the order object
@await Html.PartialAsync("OrderInformationSummary")
@using (await Html.BeginPaymentFormAsync(order))
{
<label>
<input id="acceptTerms" type="checkbox" value="1" required />
<span>I agree and accept the sites terms of service</span>
</label>
<div>
<a href="/checkout/payment-method">Return to Payment Method</a>
<button type="submit">Continue to Process Payment</button>
</div>
}
This is a unique step in the checkout flow as it doesn't post back to the CheckoutSurfaceController. Instead, it uses the BeginPaymentFormAsync method of the Html helper to render a payment method-specific form that triggers the payment capturing process.
It is within the BeginPaymentFormAsync method that an order number is assigned. No modifications must be made to the order after this point as it may result in the order number getting reset and the payment failing.
The customer can review their order details and proceed to the payment gateway.
Capturing Payment
The payment capture screen is entirely dependent on the payment method being used. It is the responsibility of the associated payment provider to redirect and handle the payment process. The payment provider will redirect back to the order confirmation page on success, or to the cart page with an error if there is a problem.
For more information on how to implement a payment provider, see the Payment Providers documentation.
Showing an Order Confirmation
Open the view for the Showing an Order Confirmation step.
Display the order confirmation details.
CheckoutConfirmation.cshtml
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Commerce.DemoStore.Models.CheckoutConfirmationPage>
@{
var store = Model.GetStore();
var order = await UmbracoCommerceApi.GetCurrentFinalizedOrderAsync(store!.Id);
}
<div>
<h3>Thank you for your order #@(order!.OrderNumber)</h3>
<p>A confirmation email has been sent to <strong>@(order!.CustomerInfo.Email)</strong></p>
<p><a href="/">Return to Store</a></p>
</div>
// Ommitted for brevity, but same partial as used on the review step
@await Html.PartialAsync("OrderInformationSummary")
In the order confirmation, you must use GetCurrentFinalizedOrderAsync instead of the previously used GetCurrentOrderAsync. This is because the order will have been finalized during the payment processing step and the current cart order will be cleared.
The customer can view their order confirmation details.
At this stage, the customer should receive an email confirmation of their order.
Umbraco Commerce comes with a default order confirmation email template. This can be customized to suit your store's branding. See the Customizing Templates documentation for more information.
Extras
Redeeming a Coupon / Gift Card
The checkout flow is a common place to allow customers to redeem coupons or gift cards. This can be done by adding another step to the checkout flow where the customer can enter the code and apply it to their order.
Create a new class and name it DiscountOrGiftCardCodeDto using the following properties.
DiscountOrGiftCardCodeDto.cs
namespace Umbraco.Commerce.DemoStore.Web.Dtos;
public class DiscountOrGiftCardCodeDto
{
public string Code { get; set; }
}
Add the following action methods to your CheckoutSurfaceController.
CheckoutSurfaceController.cs
public async Task<IActionResult> ApplyDiscountOrGiftCardCode(DiscountOrGiftCardCodeDto model)
{
try
{
await commerceApi.Uow.ExecuteAsync(async uow =>
{
var store = CurrentPage!.GetStore()!;
var order = await commerceApi.GetCurrentOrderAsync(store.Id)!
.AsWritableAsync(uow)
.RedeemAsync(model.Code);
await commerceApi.SaveOrderAsync(order);
uow.Complete();
});
}
catch (ValidationException ex)
{
ModelState.AddModelError("", "Failed to redeem discount code");
return CurrentUmbracoPage();
}
return RedirectToCurrentUmbracoPage();
}
public async Task<IActionResult> RemoveDiscountOrGiftCardCode(DiscountOrGiftCardCodeDto model)
{
try
{
await commerceApi.Uow.ExecuteAsync(async uow =>
{
var store = CurrentPage!.GetStore()!;
var order = await commerceApi.GetCurrentOrderAsync(store.Id)!
.AsWritableAsync(uow)
.UnredeemAsync(model.Code);
await commerceApi.SaveOrderAsync(order);
uow.Complete();
});
}
catch (ValidationException ex)
{
ModelState.AddModelError("", "Failed to redeem discount code");
return CurrentUmbracoPage();
}
return RedirectToCurrentUmbracoPage();
}
Open the base view for your checkout flow.
Create a form that posts to the ApplyDiscountOrGiftCardCode action method of the CheckoutSurfaceController, passing the code to redeem.