Sana Assistant (online)
Table of Contents

Implementing New Payment Service Provider (PSP)

This guide demonstrates how to implement a custom payment service provider (PSP) extension using Stripe Checkout in Sana Commerce.

Prerequisites

Before starting this implementation, ensure you have:

  • A Stripe account with API credentials

Reference these foundational articles for deeper understanding:

Project Setup

Create a new add-on project named "Sana.Extensions.CustomStripe" as detailed in the add-on development tutorial.

Required NuGet Packages

Add the following Nuget package (without version) references to your project:

<PackageReference Include="Stripe.net" />

Defining Constants

Before implementing the main extension, let's establish a constants class to maintain consistency and avoid magic strings throughout our code:

using System;

namespace Sana.Extensions.CustomStripe
{
    /// <summary>
    /// Constants used by the Stripe payment extension.
    /// </summary>
    public static class StripeConstants
    {
        // Cache keys and constants
        public const string CACHE_GROUP = "StripeExtension";
        public const string ORDER_DETAILS_SUFFIX = "OrderDetails";

        // Stripe payment status constants
        public const string STATUS_SUCCEEDED = "succeeded";
        public const string STATUS_PAID = "paid";
        public const string STATUS_PROCESSING = "processing";
        public const string STATUS_AMOUNT_CAPTURABLE_UPDATED = "amount_capturable_updated";
        public const string STATUS_PAYMENT_FAILED = "payment_failed";
        public const string STATUS_UNPAID = "unpaid";

        // Stripe checkout mode
        public const string CHECKOUT_MODE_PAYMENT = "payment";
    }
}

These constants serve as a single source of truth for:

  • Cache management: Ensuring consistent cache key generation
  • Status mapping: Maintaining accurate payment status translations
  • API parameters: Standardizing Stripe API interaction values

Implement the Extension Class

Create a class CustomStripePaymentExtension that inherits from PaymentExtension and decorate it with the unique identifier attribute PaymentModuleId.

using Sana.Extensions.Cache;
using Sana.Extensions.Models.Orders;
using Sana.Extensions.Payment;
using Sana.Extensions.Payment.Contexts;
using System;
using System.Collections.Generic;
using System.Linq;
using Stripe;
using Stripe.Checkout;


/// <summary>
/// Custom Stripe Payment Extension for processing payments through Stripe Checkout.
/// This extension handles the payment flow including initiating payments, processing callbacks,
/// and finalizing transactions.
/// </summary>
[PaymentModuleId("CustomStripePayment")]
public class CustomStripePaymentExtension : PaymentExtension
{
}

Key Components Explained

  • PaymentModuleIdAttribute: This attribute uniquely identifies your payment extension within Sana. The ID must be unique across all payment providers.
  • IConfigurable Interface: Enables configuration management through Sana Admin, automatically populating the Configuration property with settings from the admin interface.

Define Configuration Class

Create the configuration class CustomStripeConfiguration inherited from ExtensionConfiguration. The configuration class defines what settings administrators can configure through Sana Admin.

using System.ComponentModel.DataAnnotations;
using Sana.ComponentModel;

namespace Sana.Extensions.CustomStripe
{
    [ConfigurationKey("CustomStripeConfiguration")]
    public class CustomStripeConfiguration : ExtensionConfiguration
    {
        [Display(Name = "API Key")]
        [Required]
        [SecureString]
        public string ApiKey { get; set; }

        [Display(Name = "Allowed IP addresses", Description = "IPs which allowed to change the payment status of an order by calling the confirm page.Example: 127.0.0.1-127.0.0.256|192.168.*.*|10.0.0.1.")]
        public string IpRange { get; set; }
    }
}

This class serves as a view-model within Sana Admin to configure payment extensions. For comprehensive details about extension configuration classes, refer to the Configuration documentation.

Note

You can add the IntegrationMode property of type PaymentIntegrationMode as a notable enhancement. This property facilitates selecting integration modes, essential for effectively testing the payment extension. Properties within this class can be enhanced using data annotation attributes since it functions as a view-model. For further insights and implementation guidance, consult the Integration Mode section of the Configuration documentation.

To associate the configuration class with your payment extension, update and implement the IConfigurable<TConfiguration> interface in CustomStripePaymentExtension, specifying CustomStripeConfiguration as the generic type parameter. This makes it explicit that the extension is configured using this class.

using Sana.Extensions.Cache;
using Sana.Extensions.Models.Orders;
using Sana.Extensions.Payment;
using Sana.Extensions.Payment.Contexts;
using System;
using System.Collections.Generic;
using System.Linq;
using Stripe;
using Stripe.Checkout;


/// <summary>
/// Custom Stripe Payment Extension for processing payments through Stripe Checkout.
/// This extension handles the payment flow including initiating payments, processing callbacks,
/// and finalizing transactions.
/// </summary>
[PaymentModuleId("CustomStripePayment")]
public class CustomStripePaymentExtension : PaymentExtension, IConfigurable<CustomStripeConfiguration>
{
    public CustomStripeConfiguration Configuration { get; set; }
}

Implementing StartPayment Method

The StartPayment method initiates the payment process by creating a Stripe Checkout session:

public override NextAction StartPayment(PaymentStartContext context)
{
    try
    {
        // Validate the input context
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context), "Payment context cannot be null");
        }

        // Convert order lines to Stripe line items
        var lineItems = CreateLineItemsFromOrderLines(context);

        // Configure Stripe with API key from extension configuration
        StripeConfiguration.ApiKey = Configuration.ApiKey;

        // Get the selected payment method from settings
        var paymentMethodSettings = (CustomStripePaymentMethodSettings)context.MethodSettings;
        var paymentMethod = paymentMethodSettings.paymentMethods;

        // Create Stripe checkout session
        var options = new SessionCreateOptions
        {
            PaymentMethodTypes = new List<string> { paymentMethod.ToString().ToLower() },
            Mode = StripeConstants.CHECKOUT_MODE_PAYMENT,
            SuccessUrl = context.SuccessUrl,
            CancelUrl = context.CancelUrl,
            LineItems = lineItems
        };

        // Create the session with Stripe
        var sessionService = new SessionService();
        Session session = sessionService.Create(options);

        // Cache the Stripe session ID and order details for later use
        SetStripeSessionIdInCache(context.TransactionId, session.Id);
        SetOrderDetailsInCache(context.TransactionId, context.Order);

        // Redirect customer to the Stripe checkout page
        return NextAction.Redirect(session.Url);
    }
    catch (Exception ex)
    {
        // Log the exception
        Api.LogError("Failed to start Stripe payment");
        throw; // Rethrow to be handled by the payment framework
    }
}

Understanding the Payment Flow

  • Validation: Always validate inputs to prevent null reference exceptions
  • Line Item Conversion: Transform Sana order lines into Stripe's expected format
  • API Configuration: Set the Stripe API key for authentication
  • Session Creation: Build a checkout session with proper redirect URLs
  • Caching: Store session information for later retrieval during callbacks
  • Redirection: Send the customer to Stripe's hosted checkout page

Helper Methods:

CreateLineItemsFromOrderLines

This method transforms Sana order data into Stripe's line item format:

private List<SessionLineItemOptions> CreateLineItemsFromOrderLines(PaymentStartContext context)
{
    var lineItems = new List<SessionLineItemOptions>();

    if (context.Order != null && context.Order.OrderLines.Any())
    {
        foreach (var line in context.Order.OrderLines)
        {
            if (string.IsNullOrEmpty(line.ProductTitle))
            {
                continue;
            }

            var lineItem = new SessionLineItemOptions
            {
                PriceData = new SessionLineItemPriceDataOptions
                {
                    Currency = context.CurrencyId,
                    UnitAmountDecimal = line.Price * 100, // Convert to cents as required by Stripe
                    ProductData = new SessionLineItemPriceDataProductDataOptions
                    {
                        Name = line.Title,
                        Images = string.IsNullOrEmpty(line.ProductImageUrl)
                            ? null
                            : new List<string> { line.ProductImageUrl }
                    }
                },
                Quantity = (long?)line.Quantity
            };

            lineItems.Add(lineItem);
        }
    }

    return lineItems;
}
Note

Stripe expects amounts in the smallest currency unit (cents for USD), so we multiply by 100

Storing and Retrieving Session IDs

private void SetStripeSessionIdInCache(string sanaTransactionId, string sessionId)
{
    if (string.IsNullOrEmpty(sanaTransactionId))
        throw new ArgumentNullException(nameof(sanaTransactionId));

    if (string.IsNullOrEmpty(sessionId))
        throw new ArgumentNullException(nameof(sessionId));

    var cacheKey = new CacheKey(sanaTransactionId, StripeConstants.CACHE_GROUP);
    Api.Cache.Set(cacheKey, sessionId);
}

private string GetSessionIdFromCache(string sanaTransactionId)
{
    if (string.IsNullOrEmpty(sanaTransactionId))
        throw new ArgumentNullException(nameof(sanaTransactionId));

    var cacheKey = new CacheKey(sanaTransactionId, StripeConstants.CACHE_GROUP);
    Api.Cache.TryGetValue<string>(cacheKey, out var sessionId);

    return sessionId;
}

Order Details Caching

private void SetOrderDetailsInCache(string sanaTransactionId, Order orderDetails)
{
    if (string.IsNullOrEmpty(sanaTransactionId))
        throw new ArgumentNullException(nameof(sanaTransactionId));

    if (orderDetails == null)
        throw new ArgumentNullException(nameof(orderDetails));

    var cacheKey = new CacheKey(sanaTransactionId + StripeConstants.ORDER_DETAILS_SUFFIX, 
        StripeConstants.CACHE_GROUP);
    Api.Cache.Set(cacheKey, orderDetails);
}

private Order GetOrderDetailsFromCache(string sanaTransactionId)
{
    if (string.IsNullOrEmpty(sanaTransactionId))
        throw new ArgumentNullException(nameof(sanaTransactionId));

    var cacheKey = new CacheKey(sanaTransactionId + StripeConstants.ORDER_DETAILS_SUFFIX, 
        StripeConstants.CACHE_GROUP);
    Api.Cache.TryGetValue<Order>(cacheKey, out var orderDetails);

    return orderDetails;
}

Payment Status Mapping

Accurately map Stripe statuses to Sana's payment status system:

private PaymentStatus GetPaymentStatus(Session session)
{
    if (session == null)
        throw new ArgumentNullException(nameof(session));

    // If payment_status is null, check session status instead
    if (session.PaymentStatus == null)
    {
        // Check if the session is expired
        if (session.Status == "expired")
            return PaymentStatus.Cancelled;

        return PaymentStatus.InProgress;
    }

    switch (session.PaymentStatus)
    {
        case StripeConstants.STATUS_SUCCEEDED:
        case StripeConstants.STATUS_PAID:
            return PaymentStatus.Paid;

        case StripeConstants.STATUS_PROCESSING:
        case StripeConstants.STATUS_AMOUNT_CAPTURABLE_UPDATED:
            return PaymentStatus.InProgress;

        case StripeConstants.STATUS_PAYMENT_FAILED:
        case StripeConstants.STATUS_UNPAID:
            return PaymentStatus.Cancelled;

        default:
            // Default to InProgress for any unrecognized status
            return PaymentStatus.InProgress;
    }
}

Implementing FinalizePayment Method

This method is called when the user is done with the payment on the PSP payment page. In this method you should update current payment status of the order to the actual status from PSP. In our case, this method executes when customers return from Stripe after completing payment:

public override void FinalizePayment(PaymentContext context)
{
    try
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context), "Payment context cannot be null");
        }

        // Configure Stripe API
        StripeConfiguration.ApiKey = Configuration.ApiKey;

        // Get the Stripe session ID from cache
        string sessionId = GetSessionIdFromCache(context.TransactionId);
        if (string.IsNullOrEmpty(sessionId))
        {
            context.State.PaymentStatus = PaymentStatus.Error;
            return;
        }

        // Retrieve the Stripe session
        var sessionService = new SessionService();
        Session session = sessionService.Get(sessionId);

        // Update the payment status based on Stripe's status
        context.State.PaymentStatus = GetPaymentStatus(session);

        // Process additional actions for successful payments
        if (context.State.PaymentStatus == PaymentStatus.Paid)
        {
            // Implement confirmation email or other post-payment actions
            // For example: SendConfirmationEmail(context, session);
        }
    }
    catch (Exception ex)
    {
        // Log the exception
        Api.LogError("Failed to finalize the Stripe payment");
        context.State.PaymentStatus = PaymentStatus.Error;
    }
}

Finalization Process

  • Session Retrieval: Fetch the cached session ID to maintain state
  • Status Check: Query Stripe for the current payment status
  • Status Mapping: Convert Stripe status to Sana's payment status enum
  • Post-Payment Actions: Execute any additional business logic for successful payments

See FinalizePayment method of payment extension for more details.

Implementing ProcessCallback Method

This method is called when the callback request is received from the payment gateway. In this method you should update current payment status of the order to the actual status from PSP. Usually this method returns NextAction.None object.

public override NextAction ProcessCallback(PaymentContext context)
{
    try
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context), "Payment context cannot be null");
        }

        // Configure Stripe API
        StripeConfiguration.ApiKey = Configuration.ApiKey;

        // Get the Stripe session ID from cache
        string sessionId = GetSessionIdFromCache(context.TransactionId);
        if (string.IsNullOrEmpty(sessionId))
        {
            context.State.PaymentStatus = PaymentStatus.Error;
            return NextAction.None;
        }

        // Retrieve the Stripe session
        var sessionService = new SessionService();
        Session session = sessionService.Get(sessionId);

        // Update the payment status based on Stripe's status
        context.State.PaymentStatus = GetPaymentStatus(session);

        return NextAction.None;
    }
    catch (Exception ex)
    {
        // Log the exception
        Api.LogError("Something went wrong!");
        context.State.PaymentStatus = PaymentStatus.Error;
        return NextAction.None;
    }
}

See ProcessCallback method of payment extension for more details.

Implement IP Address Filtering

Optional but recommended to secure callbacks. This method allows you to specify the IPv4 address filter where payment callbacks are allowed from.

public override string GetAllowedCallbackIpRange()
{
    return Configuration?.IpRange;
}

See GetAllowedCallbackIpRange method of payment extension for more details.

Next steps

After the extension is implemented, follow the regular add-on development guides:

See also