Assign Payment Data in Magento 2

Both Magento 2 Open Source and Adobe Commerce have a feature that allows you to copy fields from Quote Payment to Order Payment objects during Order creation. In my experience, when something needs to be copied, I tend to use official recommendations for backward compatibility and extensibility purposes. Sometimes, however, it doesn't work as expected and we, as Magento 2 developers, have to find ways to overcome limitations and find reasonably correct ways for customization.

I've been working with Payment Method integration for one of my clients. The goal was to pass a custom field and its value via Quote Payment to one of the payment gateway API command classes for further processing. One of the ideas I always follow when creating custom payment methods for Magento 2 Open Source is to use a data assign observer class and assign all required parameters from the checkout payment form to a Quote Payment object, the class of which implements the Magento\Payment\Model\InfoInterface interface. Payment data, that is passed from the payment form, is assigned to a Quote Payment and later converted to an Order Payment object.

Passing Data from Payment Form to Quote Payment Object

Data from the payment form comes to one of the two classes, for guest checkout and logged-in checkout experience accordingly. The difference is only in the $cartId argument that is hashed or represents the real quote's entity_id value.

Here is the sequence UML diagram to better understand the situation when the assignData() method is triggered in the checkout flow.

Pic 1. Assign Data to Quote Payment

The process starts when a customer hits the "Place Order" button on the checkout payment page. Payment Data is then submitted via HTTP POST request to one of the REST API endpoints. In the diagram above, you may notice the first object is the Guest Payment Information Management. This is the GuestPaymentInformationManagement class (Magento\Checkout) that processes the incoming HTTP POST request in the savePaymentInformationAndPlaceOrder() method. The payment data is passed into the savePaymentInformationAndPlaceOrder() method in the form of an Magento\Quote\Model\Quote\Payment object. The Magento\Quote\Model\Quote\Payment object represents data submitted by the customer.

The savePaymentInformationAndPlaceOrder() method then submits the payment data to the Payment Method Management. The Magento\Quote\Model\PaymentMethodManagement class provides a set() method. The method then reads the payment object from the quote object and triggers the importData() method from the Magento\Quote\Model\Quote\Payment class. The importData() method retrieves an instance of the Magento\Payment\Model\Method\Adapter class and triggers the assignData() method with the payment data that has to be assigned to a Quote Payment object.

The Adapter::assignData() method is the extension point for a custom payment method extension. The payment extension has to have an observer class so it can assign the payment data to a Quote Payment object.

The important thing to notice here is that the sensitive payment data is not stored in a database. Simply because there are no fields in the quote database table for fields such as cc_number (Credit Card Number) etc.

Assign Data Observer

Here is a sample of the custom DataAssignObserver class. The execute() method assigns payment data that is passed as an associate array. The payment data is then assigned to the Magento\Quote\Model\Quote\Payment object.

namespace MageMastery\PaymentMethod\Observer;

use Magento\Framework\DataObject;
use Magento\Framework\DataObjectFactory;
use Magento\Framework\Event\Observer;
use Magento\Payment\Observer\AbstractDataAssignObserver;

class DataAssignObserver extends AbstractDataAssignObserver
{

private const CC_NUMBER = 'cc_number';
private const CC_CID = 'cc_cid';
private const CC_EXP_DATE = 'cc_exp_date';

public function execute(Observer $observer): void
{
$additionalData = $this->readDataArgument($observer)->getData(self::KEY_ADDITIONAL_DATA);
$paymentInfo = $this->readPaymentModelArgument($observer);

$paymentInfo->setData(self::CC_NUMBER, $additionalData['cc_number']);
$paymentInfo->setData(self::CC_CID, $additionalData['cc_cid']);
$paymentInfo->setData(self::CC_EXP_DATE, $additionalData['cc_exp_date']);

       //...
}

//...
}

The Payment object later can be used in one of the Payment Gateway Request builder classes and prepare the request body of the Payment request to a payment service.

Payment Request Builder

As we can see from the CaptureDataBuilder class implementation, the cc_number, and cvn fields are available in the $payment object. The $payment in this scenario represents an instance of the Magento\Sales\Model\Order\Payment class.

namespace MageMastery\PaymentMethod\Gateway\Request;

use Magento\Payment\Gateway\Data\PaymentDataObjectInterface;
use Magento\Payment\Gateway\Request\BuilderInterface;

/**
* Capture and Authorize transactions
*/
class CaptureDataBuilder implements BuilderInterface
{
private const NUMBER = 'number';
private const CVN = 'cvn';

public function build(array $buildSubject): array
{
/** @var PaymentDataObjectInterface $paymentDataObject */
$paymentDataObject = $buildSubject['payment'];

$payment = $paymentDataObject->getPayment();

return [
self::NUMBER => $payment->getData('cc_number'),
self::CVN => $payment->getData('cc_cid'),
];
}
}

 

Order Placement

Let's get back to the data conversion from a Quote Payment to an Order Payment object. As we've learned from Pic 1. Assign Data to Quote Payment sequence diagram, the savePaymentInformationAndPlaceOrder() method of the GuestPaymentInformationManagement class triggers the PaymentMethodManagement::set() method in order to process payment data and set this data into a Quote Payment object.

But what happens next, you may ask? Well, the same savePaymentInformationAndPlaceOrder() method is also responsible for triggering the place order logic. The below sequence UML diagram shows the place order sequence logic and how it works together with converting payment data from Quote Payment to Order Payment.

Pic 2. Assign Data to Order Payment

Within the same "place order" process (here I refer to the savePaymentInformationAndPlaceOrder() method discussed earlier), after the successful payment management execution, the conversion from Quote Payment to order payment order happens. The GuestCartManagement class (Magento\Quote) is used with its placeOrder() method. The placeOrder() then triggers another placeOrder() method from the Magento\Quote\Model\QuoteManagement class where all magic happens.

The ToOrderPayment class (Magento\Quote) then prepares an Order Payment object. In order to do this, the ToOrderPayment::convert() method creates a new instance of the Magento\Sales\Model\Order\Payment and performs the conversion logic so that the data from the Quote Payment is assigned to the Order Payment object.

Passing Data from Quote Payment to Order Payment

Back to the main question of this post. How is the data transferred from a Quote Payment object to an Order Payment object?

As we can see from Pic 2. Assign Data to the Order Payment sequence diagram, all the magic happens inside the convert() method of the Magento\Quote\Model\Quote\Payment\ToOrderPayment class. The ToOrderPayment class uses a utility class for copying data sets between objects. This class is called Magento\Framework\DataObject\Copy.

The Copy class uses a fieldset.xml configuration file that can be created inside a custom payment method extension. Here is a sample of the fieldset.xml file from the Magento\Checkout module.

xml
<config>
<scope id="global">
<fieldset id="quote_convert_payment">
            <field name="method">
                <aspect name="to_order_payment" />
            </field>
<field name="cc_type">
                <aspect name="to_order_payment" />
            </field>

// other fields
</fieldset>
</scope>
</config>

The Copy::getDataFromFieldset() method returns an associate array of payment fields that have to be copied from the Quote Payment object to the Order Payment object.

$paymentData = $this->objectCopyService->getDataFromFieldset(
'quote_convert_payment',
'to_order_payment',
$object
);

The $paymentData array is later used to be assigned to the Order Payment object.

$orderPayment = $this->orderPaymentRepository->create();
$this->dataObjectHelper->populateWithArray(
$orderPayment,
array_merge($paymentData, $data),
\Magento\Sales\Api\Data\OrderPaymentInterface::class
);

Here is the important statement. The field from a fieldset.xml file will only be assigned to an Order Payment object in case there is a public setter method available in the OrderPaymentInterface interface (Magento\Sales).

Let's Write Some Code

Why is this important? Let's say you want to follow the approach described above and assign some custom field to the Quote Payment object so this field later can be accessed somewhere inside a payment request builder class as part of the Order Payment object. How would you do this?

My first assumption was that I could simply use the setData() method and put anything I needed into a Quote Payment object.

$payment->setData('something_custom', $somethingCustom);

Then I would create a custom fieldset.xml file inside the MageMastery/PaymentMethod/etc directory. In the fieldset.xml file, I would add the something_custom name of the field so the Copy class can read it and assign it to an Order Payment object.

xml
<config>
<scope id="global">
<fieldset id="quote_convert_payment">
          <field name="something_custom">
                <aspect name="to_order_payment" />
          </field>
</fieldset>
</scope>
</config>

I was so upset seeing it didn't work out for my scenario. As we discussed earlier, in order to assign anything to an object, in our case it is Order Payment, a class or interface should have a public setter method or setSomethingCustom() method. This is not the case with the Order Payment class or OrderPaymentInterface interface.

Alternatively, we can use custom_attributes or extension_attributes.

The implementation will then have to be changed a bit. We would need to declare custom or extension attributes configuration and implement class and interface to handle the attributes assign and unassign logic.

I considered this option, however, it doesn't seem to be a good one. Creation of the extension_attributes logic would mean that an Order Payment should always have the "something_custom" in the extension_attributes array assigned to the Order Payment object. The Gateway API communication to capture or authorize payment usually happens once and this operation for a particular set of orders and its related payment doesn't repeat. So every time an order is loaded together with the related payment, the extension_attributes logic will be used to assign this mysterious "something_custom" attribute.

But I only need this once, when I process a response from a payment service provider before creating an order in my custom order creation flow.

Think Outside the Box

If there is no way we can use a fieldset.xml file with the custom field, and there is no reason to add an extension attribute to an Order Payment implementation, we should approach the customization differently. Later, the field can be accessed in one of the Payment Response Handlers classes.

Let's get rid of the fieldset.xml and add a Plugin class. The plugin is responsible for assigning data from a Quote Payment object to an Order Payment object. Simple and straightforward way. We need to add a plugin for the ToOrderPayment::convert() method. The plugin should ensure to execute the main logic without any interruption, and check if a payment method code is the one we would like to add the customization.

Below is an example of such a plugin class implementation.

namespace MageMastery\PaymentMethod\Plugin;

use Magento\Quote\Model\Quote\Payment\ToOrderPayment;
use Magento\Sales\Api\Data\OrderPaymentInterface;
use Magento\Quote\Model\Quote\Payment;

class QuoteToOrderPaymentPlugin
{
public function aroundConvert(
ToOrderPayment $subject,
\Closure $proceed,
Payment $payment,
$data = []
): OrderPaymentInterface {
$orderPayment = $proceed($payment, $data);

if ('magemastery_paymentmethod' !== $payment->getMethod()) {
return $orderPayment;
}
if ($payment->hasData('something_custom')) {
$somethingCustom = $payment->getData('something_custom');
$orderPayment->setData('something_custom', $somethingCustom);
}

return $orderPayment;
}
}

The aroundConvert() method assigns the something_custom value to an Order Payment object and returns the modified object as the result.

The QuoteToOrderPaymentPlugin class should be mentioned in the di.xml configuration so the plugin is declared for the ToOrderPayment class.

xml
<type name="Magento\Quote\Model\Quote\Payment\ToOrderPayment">
<plugin name="MageMasteryQuoteToOrderPaymentPlugin" type="MageMastery\PaymentMethod\Plugin\QuoteToOrderPaymentPlugin"/>
</type>

As a result, I can now assign the something_custom value to the Quote Payment and expect it in the Order Payment. As I mentioned earlier, I do not expect this field to be stored in a database during order creation. What I was looking for, is to pass the response associative array from a payment service provider API response and process it in one or many payment gateway handler classes. And it perfectly works for my scenario.

I hope it makes sense for you and next time you need to implement a payment integration in Magento 2, you know what to look for and how to implement it. By the way, I created an online walkthrough course on how to build a payment integration for Magento 2. During the course, you will create your very own Magento 2 extension that integrates with the Authorize.NET payment service provider. You will learn everything about Payment Gateway API in Magento 2 and be the best in it.

Do you think there is an easier way to pass data from a Quote Payment to an Order Payment object? Please leave the comment below and let me know your thoughts.