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:
- Test the implemented extension
- Assemble the add-on package
- Ensure that the package has been correctly assembled.