Building a Custom Payment in Optimizely Commerce 14 (with a simple “Account” method)

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:

  1. A Payment Option that collects an AccountNumber and creates a Payment object to put into the order.
  2. A Payment Gateway that Optimizely calls during processing (authorize/capture/refund).
  3. A Payment configuration in the Admin UI, where you choose:
    • Class Name → your gateway class (implements IPaymentGateway)
    • Payment Class → the concrete Payment subclass your option returns (we’ll use OtherPayment to keep it simple). The “Payment Class” is explicitly a class that inherits Mediachase.Commerce.Orders.Payment.

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 ClassMediachase.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 Payment that 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;

Leave a comment