There’s no official step-by-step documentation for this flow in Commerce 14, so I decompiled parts of the platform and leaned on the (older) Commerce books to piece it together. This post shows a minimal, working path and explains only the bits you need—no deep dives.
I based the sample on Foundation; a few helper classes are reused from that project and not shown here. You can copy any missing helpers from the Foundation repo: https://github.com/episerver/Foundation/tree/main
What we’re building
We’ll add a very simple Account payment:
- A Payment Option that collects an
AccountNumberand creates aPaymentobject to put into the order. - A Payment Gateway that Optimizely calls during processing (authorize/capture/refund).
- A Payment configuration in the Admin UI, where you choose:
- Class Name → your gateway class (implements
IPaymentGateway) - Payment Class → the concrete
Paymentsubclass your option returns (we’ll useOtherPaymentto keep it simple). The “Payment Class” is explicitly a class that inheritsMediachase.Commerce.Orders.Payment.
- Class Name → your gateway class (implements
1) Payment Option — AccountPaymentOption
This is the UI model that surfaces “Account” at checkout, validates input, and creates a Payment.
using YourNamespace.Web.Infrastructure.Commerce.Markets;
using Mediachase.Commerce.Orders;
using Mediachase.Commerce.Orders.Managers;
namespace YourNamespace.Web.Features.Checkout.Payments
{
public class AccountPaymentOption(
LocalizationService localizationService,
IOrderGroupFactory orderGroupFactory,
ICurrentMarket currentMarket,
LanguageService languageService,
IPaymentService paymentService)
: PaymentOptionBase(localizationService, orderGroupFactory, currentMarket, languageService, paymentService)
{
public override string SystemKeyword => "Account";
public string AccountNumber { get; set; } = string.Empty;
public override IPayment CreatePayment(decimal amount, IOrderGroup orderGroup)
{
var implementationClassName = PaymentManager.GetPaymentMethod(base.PaymentMethodId, false)
.PaymentMethod[0].PaymentImplementationClassName;
var type = Type.GetType(implementationClassName);
var payment = type == null
? orderGroup.CreatePayment(OrderGroupFactory)
: orderGroup.CreatePayment(OrderGroupFactory, type);
payment.PaymentMethodId = PaymentMethodId;
payment.PaymentMethodName = SystemKeyword;
payment.Amount = amount;
payment.PaymentType = PaymentType.Other;
// Custom data for the gateway
payment.Properties["AccountNumber"] = this.AccountNumber;
return payment;
}
public override bool ValidateData()
{
// minimal validation; expand as needed
return !string.IsNullOrWhiteSpace(AccountNumber);
}
}
}
Why this class exists
Checkout needs a component to collect input and return a Payment object to OrderForm.Payments.
2) Payment Gateway — AccountPaymentGateway
This is the final integration point between Optimizely Commerce and your payment provider/logic. Every new payment type should have a gateway.
using Mediachase.Commerce.Orders;
using Mediachase.Commerce.Plugins.Payment;
namespace YourNamespace.Web.Features.Checkout.Payments
{
public class AccountPaymentGateway : AbstractPaymentGateway, IPaymentPlugin
{
public PaymentProcessingResult ProcessPayment(IOrderGroup orderGroup, IPayment payment)
{
if (string.IsNullOrEmpty(payment.Properties["AccountNumber"]?.ToString()))
{
return PaymentProcessingResult.CreateUnsuccessfulResult("Invalid account number.");
}
// Your custom business logic / external call would go here
return PaymentProcessingResult.CreateSuccessfulResult("");
}
public override bool ProcessPayment(Payment payment, ref string message)
{
var result = ProcessPayment(null, payment);
message = result.Message;
return result.IsSuccessful;
}
}
}
Why this class exists
Gateways implement the processing side (authorize/capture/refund).
3) Wire-up (DI) and Admin setup
Register the payment option so your checkout can resolve it:
// in an initialization module
context.Services.AddTransient<IPaymentMethod, AccountPaymentOption>();
Then in Settings → Payments add a new payment:
- Class Name → your fully-qualified
AccountPaymentGateway - Payment Class →
Mediachase.Commerce.Orders.OtherPayment - Market → Default Market

Why these two fields matter:
- Class Name must point to your gateway (implementation of
IPaymentGateway). - Payment Class must be a concrete subclass of
Paymentthat your option returns.
4) (Optional, advanced) Persist AccountNumber in its own table
⚠️ Not recommended unless you truly need a first-class table like OrderFormPayment_CreditCard. Commerce 14 removed the old Commerce Manager that used to create these for you; now you must do it yourself. Proceed only if you understand the risks to upgrades & maintenance.
4.1 Create a concrete Payment subclass
using System.Runtime.Serialization;
using Mediachase.Commerce.Orders;
using Mediachase.MetaDataPlus.Configurator;
namespace YourNamespace.Web.Features.Checkout.Payments
{
[Serializable]
public class AccountPayment : Payment
{
public string AccountNumber
{
get => this.GetString(nameof(AccountNumber));
set => this[nameof(AccountNumber)] = value;
}
private static MetaClass? _metaClass;
public static MetaClass GenericAccountMetaClass
{
get
{
_metaClass ??= MetaClass.Load(OrderContext.MetaDataContext, nameof(AccountPayment));
return _metaClass;
}
}
public AccountPayment() : base(GenericAccountMetaClass, GetDefaultValue) { }
private static object? GetDefaultValue(string fieldName)
=> "PaymentType".Equals(fieldName) ? PaymentType.Other : null;
public AccountPayment(SerializationInfo info, StreamingContext context) : base(info, context)
{
this.PaymentType = PaymentType.Other;
this.ImplementationClass = GetType().AssemblyQualifiedName;
}
}
}
If you use this class, change your Admin Payment Class to YourNamespace.Web.Features.Checkout.Payments.AccountPayment

4.2 Create the backing tables
Run this exact script in your Commerce DB to create the tables and FKs:
CREATE TABLE [dbo].[OrderFormPayment_Account](
[ObjectId] [int] NOT NULL,
[CreatorId] [nvarchar](100) NULL,
[Created] [datetime] NULL,
[ModifierId] [nvarchar](100) NULL,
[Modified] [datetime] NULL,
[AccountNumber] [nvarchar](512) NULL,
CONSTRAINT [PK_OrderFormPayment_Account] PRIMARY KEY CLUSTERED
(
[ObjectId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[OrderFormPayment_Account] WITH CHECK ADD CONSTRAINT [FK_OrderFormPayment_Account_OrderFormPayment] FOREIGN KEY([ObjectId])
REFERENCES [dbo].[OrderFormPayment] ([PaymentId])
ON UPDATE CASCADE
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[OrderFormPayment_Account] CHECK CONSTRAINT [FK_OrderFormPayment_Account_OrderFormPayment]
GO
CREATE TABLE [dbo].[OrderFormPayment_Account_Localization](
[Id] [int] IDENTITY(1,1) NOT NULL,
[ObjectId] [int] NOT NULL,
[ModifierId] [nvarchar](100) NULL,
[Modified] [datetime] NULL,
[Language] [nvarchar](20) NOT NULL,
[AccountNumber] [nvarchar](512) NULL,
CONSTRAINT [PK_OrderFormPayment_Account_Localization] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[OrderFormPayment_Account_Localization] WITH CHECK ADD CONSTRAINT [FK_OrderFormPayment_Account_Localization_OrderFormPayment] FOREIGN KEY([ObjectId])
REFERENCES [dbo].[OrderFormPayment] ([PaymentId])
ON UPDATE CASCADE
GO
ALTER TABLE [dbo].[OrderFormPayment_Account_Localization] CHECK CONSTRAINT [FK_OrderFormPayment_Account_Localization_OrderFormPayment]
GO
4.3 Register the MetaClass and field relations
This upserts a MetaClass named AccountPayment, adds a MetaField called AccountNumber, mirrors standard fields used by CashCard, and relates your new field:
BEGIN TRY
BEGIN TRAN;
----------------------------------------------------------------------
-- 1) Upsert MetaClass: AccountPayment
----------------------------------------------------------------------
DECLARE @NewMetaClassId int;
SELECT @NewMetaClassId = mc.MetaClassId
FROM MetaClass mc
WHERE mc.[Namespace] = N'Mediachase.Commerce.Orders.System'
AND mc.[Name] = N'AccountPayment';
IF @NewMetaClassId IS NULL
BEGIN
DECLARE @tNewClass table (MetaClassId int);
INSERT INTO MetaClass
(Namespace, Name, FriendlyName, IsSystem, IsAbstract, ParentClassId, TableName, PrimaryKeyName, [Description])
OUTPUT INSERTED.MetaClassId INTO @tNewClass(MetaClassId)
SELECT TOP (1)
N'Mediachase.Commerce.Orders.System',
N'AccountPayment',
N'Account Payment',
0,
0,
10,
N'OrderFormPayment_Account',
N'PK_OrderFormPayment_Account',
N'Account payment'
FROM MetaClass
WHERE Name LIKE N'CashCard%';
SELECT @NewMetaClassId = MetaClassId FROM @tNewClass;
END;
IF @NewMetaClassId IS NULL
BEGIN
INSERT INTO MetaClass
(Namespace, Name, FriendlyName, IsSystem, IsAbstract, ParentClassId, TableName, PrimaryKeyName, [Description])
VALUES
(N'Mediachase.Commerce.Orders.System',
N'AccountPayment',
N'Account Payment',
0, 0, 10,
N'OrderFormPayment_Account',
N'PK_OrderFormPayment_Account',
N'Account payment');
SET @NewMetaClassId = SCOPE_IDENTITY();
END;
----------------------------------------------------------------------
-- 2) Upsert MetaField: AccountNumber
----------------------------------------------------------------------
DECLARE @NewMetaFieldId int;
SELECT @NewMetaFieldId = mf.MetaFieldId
FROM MetaField mf
WHERE mf.[Name] = N'AccountNumber'
AND mf.[Namespace] = N'Mediachase.Commerce.Orders.System';
IF @NewMetaFieldId IS NULL
BEGIN
INSERT INTO MetaField
(Name, Namespace, SystemMetaClassId, FriendlyName, [Description],
DataTypeId, [Length], AllowNulls, MultiLanguageValue, AllowSearch, IsEncrypted, IsKeyField)
VALUES
(N'AccountNumber', N'Mediachase.Commerce.Orders.System', 0,
N'Cash Card Number', N'Contains cash card number',
31, 512, 1, 0, 0, 0, 0);
SET @NewMetaFieldId = SCOPE_IDENTITY();
END;
----------------------------------------------------------------------
-- 3) Relations: MetaClassMetaFieldRelation
----------------------------------------------------------------------
-- 3a) Mirror fields related to any *CashCard* class (and SystemMetaClassId = 10) onto the new class
INSERT INTO MetaClassMetaFieldRelation (MetaClassId, MetaFieldId, [Weight], Enabled)
SELECT DISTINCT
@NewMetaClassId,
f.MetaFieldId,
0,
1
FROM MetaField f
INNER JOIN MetaClassMetaFieldRelation r ON f.MetaFieldId = r.MetaFieldId
INNER JOIN MetaClass c ON c.MetaClassId = r.MetaClassId
WHERE c.[Name] LIKE N'%CashCard%'
AND f.SystemMetaClassId = 10
AND NOT EXISTS (
SELECT 1
FROM MetaClassMetaFieldRelation r2
WHERE r2.MetaClassId = @NewMetaClassId
AND r2.MetaFieldId = f.MetaFieldId
);
-- 3b) Ensure the new "AccountNumber" field is related to the new class
IF NOT EXISTS (
SELECT 1
FROM MetaClassMetaFieldRelation
WHERE MetaClassId = @NewMetaClassId
AND MetaFieldId = @NewMetaFieldId
)
BEGIN
INSERT INTO MetaClassMetaFieldRelation (MetaClassId, MetaFieldId, [Weight], Enabled)
VALUES (@NewMetaClassId, @NewMetaFieldId, 0, 1);
END;
-- 3c) Run the metadata proc generator for your new class
EXEC mdpsp_sys_CreateMetaClassProcedure @NewMetaClassId;
COMMIT TRAN;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRAN;
DECLARE @Err nvarchar(2048) = ERROR_MESSAGE();
RAISERROR('Provisioning AccountPayment failed: %s', 16, 1, @Err);
END CATCH;
GO
4.4 Hook deletion into ecf_OrderForm_Delete
This step modifies a stock proc to delete your custom rows. Not recommended—but if you mirror the legacy pattern, you must add your delete proc call:
-- inside the "Delete payments" cursor loop:
EXEC [dbo].[mdpsp_avto_OrderFormPayment_Account_Delete] @TempObjectId;
-- (the built-in ones are already there:)
EXEC [dbo].[mdpsp_avto_OrderFormPayment_CashCard_Delete] @TempObjectId;
EXEC [dbo].[mdpsp_avto_OrderFormPayment_CreditCard_Delete] @TempObjectId;
EXEC [dbo].[mdpsp_avto_OrderFormPayment_GiftCard_Delete] @TempObjectId;
EXEC [dbo].[mdpsp_avto_OrderFormPayment_Invoice_Delete] @TempObjectId;
EXEC [dbo].[mdpsp_avto_OrderFormPayment_Other_Delete] @TempObjectId;
EXEC [dbo].[mdpsp_avto_OrderFormPayment_Exchange_Delete] @TempObjectId;