ADOBE COMMERCE

Recurring payments and Credit Card Management in Adobe Commerce using PAYONE

Sunjay Patel

subscription_business_with_adobe_commerce Subscription business with Adobe Commerce

PAYONE - is one of the leading payment providers in Germany and Austria (https://www.payone.com/DE-de). PAYONE Online payments integration is used in a lot of our projects and usually the integration process is quite easy.

As support for this project, there is an available official Magento 2 extension: https://github.com/PAYONE-GmbH/magento-2.

But in rare cases, it is required to use PAYONE for recurring payment, and as this feature is not by default supported by official extension, we will show how to integrate recurring payment into your system.

Add Payone CreditCard Recurring

Recurring transactions credit card with Payment Service Directive 2 (PSD2) requiring, that all credit card payments have to be authenticated by the customer using strong customer authentication (SCA).

“This is precisely not the case with subscription models and micropayments (virtual account / billing), since these are carried out in the absence of the customer. For this purpose, the model "cards on file" or "credentials on file" (CoF in short) is offered, with which such payments are specially marked and then excluded from the SCA. Likewise, the first, initial payment must be authenticated using SCA to meet the PSD2 guidelines. Subsequent payment transactions can be initiated with reference to the initial payment transaction. The reference to the initial transaction will then be handled by the PAYONE platform.”

Initial transaction, followed by recurring payment

The PAYONE integration already supports parameters for:

These parameters will be used for credit card payments to indicate CoF payments.

Some information about requests flow

The customer wants to save their credit card for future payments. the first initial transaction will be handled with 3-D Secure. The following transactions will be without 3-D Secure.

Step
Use case
Server-API request
Parameters to set
Comments
1a

Get customer agreement for CoF - only get agreement,

amount

is sent with

1.

preauthorization
  • amount=1
  • recurrence=recurring
  • customer_is_present=yes
  • in this case, the amount that will be auhtorized later is not known yet
  • Merchant must obtain consent that data will be stored and be used for subsequent payments
  • Customer has to agree to CoF
  • Initial payment will be handled with 3D-secure
1b

OR get customer agreement for CoF -

with amount

being sent

preauthorization/authorization
  • amount=<amount>
  • recurrence=recurring
  • customer_is_present=yes
  • Merchant must obtain consent that data will be stored and be used for subsequent payments
  • Customer has to agree to CoF
  • Initial payment will be handled with 3D-secure
  • Amount has to be captured by request "capture" if preauthorization is used
2
Subsequent payments
preauthorization/authorization
  • amount=<amount>
  • recurrence=recurring
  • customer_is_present=no
  • userid or pseudocardpan
  • Subsequent payments will be handled with CoF if customer agreed to the initial payment process
  • Amount has to be captured by request "capture" if preauthorization is used

So on PAYONE API layer, the sample Initial Request will look like following:

/** RECURRING PARAMS **/
recurrence=recurring
customer_is_present=yes
/** RECURRING PARAMS ENDS **/
Full request
mid=23456 (your mid)
portalid=12345123 (your portalid)
key=abcdefghijklmn123456789 (your key)
api_version=3.10
mode=test (set to „live“ for live-requests)
request=preauthorization

/** RECURRING PARAMS **/
recurrence=recurring
customer_is_present=yes
/** RECURRING PARAMS ENDS **/

encoding=UTF-8
aid=12345 (your aid)
clearingtype=cc
cardtype=M
cardexpiredate=2110
pseudocardpan=1312312312312321
cardholder=Testperson Approved
amount=3000 (or 1 for initial authentication, without knowing the recurring amount)
currency=EUR
lastname=Approved
firstname=Testperson
salutation=Herr
country=DE
language=de
gender=m
birthday=19600707
street=Hellersbergstraße 14
city=Musterstad
zip=12345
email=youremail@email.com
telephonenumber=01512345678

From PAYONE API Layer it looks quite simple, lets have a look on Magento Integration. As Base module, default PAYONE Magento2 module will be used.

Add PAYONE recurring payment

In the custom module create after Plugin for:

\Payone\Core\Model\Methods\Creditcard::getPaymentSpecificParameters
public function afterGetPaymentSpecificParameters(Creditcard $subject, array $result)
 {
    $areaCode = $this->state->getAreaCode();

    /** @var CartInterface $quote */
    $quote = $this->session->getQuote();

    // First time payment on website by customer
    if ($this->quoteValidate->validateQuote($quote)) { // Our requirement depend on amasty subscription module so we added condition to check it is subscription product or not
        $result[self::RECURRENCE] = 'recurring';
        $result[self::CUSTOMER_IS_PRESENT] = 'yes';
    } elseif (in_array($areaCode, [Area::AREA_CRONTAB, Area::AREA_WEBAPI_REST])) { // Recurring payment for subscription product, order will be create by cron or rest api
        $result[self::RECURRENCE] = 'recurring';
        $result[self::CUSTOMER_IS_PRESENT] = 'no';
        $result[self::PSEUDOCARDPAN] = $pseudocardpan; // If not added in other place (take value from already saved credit card in table)
    }
    return $result;
 }

Then we need to add possibility for customer to add and manage his CC information.

Create new controller, template and layout file to show address field and customer field Template File for new CC:

This is an example how a Template for CC adding can look like, use it to add the form to the required location.

view/frontend/templates/newcard.phtml
<?php
/** @var \Comwrap\RecurringPayone\Block\Newcard $block */
/** @var \Magento\Customer\ViewModel\Address $viewModel */
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */
$viewModel = $block->getViewModel();
?>
<?php $_country_id = $block->getAttributeData()->getFrontendLabel('country_id'); ?>
<?php $_street = $block->getAttributeData()->getFrontendLabel('street'); ?>
<?php $_city = $block->getAttributeData()->getFrontendLabel('city'); ?>
<?php $_region = $block->getAttributeData()->getFrontendLabel('region'); ?>
<?php $_selectRegion = 'Please select a region, state or province.'; ?>
<?php $_displayAll = $block->getConfig('general/region/display_all'); ?>

<?php $_vatidValidationClass = $viewModel->addressGetAttributeValidationClass('vat_id'); ?>
<?php $_cityValidationClass = $viewModel->addressGetAttributeValidationClass('city'); ?>
<?php $_postcodeValidationClass_value = $viewModel->addressGetAttributeValidationClass('postcode'); ?>
<?php $_postcodeValidationClass = $_postcodeValidationClass_value; ?>
<?php $_streetValidationClass = $viewModel->addressGetAttributeValidationClass('street'); ?>
<?php $_streetValidationClassNotRequired = trim(str_replace('required-entry', '', $_streetValidationClass)); ?>
<?php $_regionValidationClass = $viewModel->addressGetAttributeValidationClass('region'); ?>
<form class="form-address-edit"
      action="<?= $escaper->escapeUrl($block->getSaveUrl()) ?>"
      method="post"
      id="payoneCcAddForm"
      name="payoneCcAddForm"
      enctype="multipart/form-data"
      data-hasrequired="<?= $escaper->escapeHtmlAttr(__('* Required Fields')) ?>">
    <fieldset class="fieldset">
        <legend class="legend"><span><?= $escaper->escapeHtml(__('Contact Information')) ?></span></legend><br>
        <?= $block->getBlockHtml('formkey') ?>
        <input type="hidden" name="success_url" value="<?= $escaper->escapeUrl($block->getSuccessUrl()) ?>">
        <input type="hidden" name="error_url" value="<?= $escaper->escapeUrl($block->getErrorUrl()) ?>">
        <input type="hidden" name="back_url" value="<?= $escaper->escapeUrl($block->getBackUrl()) ?>">
        <?= $block->getNameBlockHtml() ?>
        <div class="field company required">
            <label class="label" for="company">
                <span><?= $escaper->escapeHtmlAttr(__('Company')) ?></span>
            </label>
            <div class="control">
                <input type="text"
                       name="company"
                       value=""
                       title="<?= $escaper->escapeHtmlAttr(__('Company')) ?>"
                       class="input-text"
                       data-validate="{required:true}"
                       id="company">
            </div>
        </div>
        <div class="field telephonenumber required">
            <label class="label" for="telephone">
                <span><?= $escaper->escapeHtmlAttr(__('Phone Number')) ?></span>
            </label>
            <div class="control">
                <input type="text"
                       name="telephone"
                       value=""
                       title="<?= $escaper->escapeHtmlAttr(__('Phone Number')) ?>"
                       class="input-text"
                       data-validate="{required:true}"
                       id="telephone">
            </div>
        </div>
    </fieldset>
    <fieldset class="fieldset">
        <legend class="legend"><span><?= $escaper->escapeHtml(__('Address')) ?></span></legend><br>
        <div class="field street required">
            <label for="street_1" class="label"><span><?= /* @noEscape */ $_street ?></span></label>
            <div class="control">
                <div class="field primary">
                    <label for="street_1" class="label">
                        <span>
                            <?= $escaper->escapeHtml(__('Street Address: Line %1', 1)) ?>
                        </span>
                    </label>
                </div>
                <input type="text"
                       name="street[]"
                       value="<?= $escaper->escapeHtmlAttr($block->getStreetLine(1)) ?>"
                       title="<?= /* @noEscape */ $_street ?>"
                       id="street_1"
                       class="input-text <?= $escaper->escapeHtmlAttr($_streetValidationClass) ?>"/>
                <div class="nested">
                    <?php for ($_i = 1, $_n = $viewModel->addressGetStreetLines(); $_i < $_n; $_i++): ?>
                        <div class="field additional">
                            <label class="label" for="street_<?= /* @noEscape */ $_i + 1 ?>">
                                <span><?= $escaper->escapeHtml(__('Street Address: Line %1', $_i + 1)) ?></span>
                            </label>
                            <div class="control">
                                <input type="text" name="street[]"
                                       value="<?= $escaper->escapeHtmlAttr($block->getStreetLine($_i + 1)) ?>"
                                       title="<?= $escaper->escapeHtmlAttr(__('Street Address %1', $_i + 1)) ?>"
                                       id="street_<?= /* @noEscape */ $_i + 1 ?>"
                                       class="input-text
                                        <?= $escaper->escapeHtmlAttr($_streetValidationClassNotRequired) ?>">
                            </div>
                        </div>
                    <?php endfor; ?>
                </div>
            </div>
        </div>

        <div class="field zip required">
            <label class="label" for="zip">
                <span><?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('postcode') ?></span>
            </label>
            <div class="control">
                <input type="text"
                       name="postcode"
                       value="<?= $escaper->escapeHtmlAttr($block->getAddress()->getPostcode()) ?>"
                       title="<?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('postcode') ?>"
                       id="zip"
                       class="input-text validate-zip-international
                        <?= $escaper->escapeHtmlAttr($_postcodeValidationClass) ?>">
                <div role="alert" class="message warning">
                    <span></span>
                </div>
                <?= /* @noEscape */ $secureRenderer->renderStyleAsTag("display: none;", 'div.message.warning') ?>
            </div>
        </div>

        <div class="field city required">
            <label class="label" for="city">
                <span><?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('city') ?></span>
            </label>
            <div class="control">
                <input type="text"
                       name="city"
                       value="<?= $escaper->escapeHtmlAttr($block->getAddress()->getCity()) ?>"
                       title="<?= $escaper->escapeHtmlAttr(__('City')) ?>"
                       class="input-text <?= $escaper->escapeHtmlAttr($_cityValidationClass) ?>"
                       id="city">
            </div>
        </div>

        <?php if ($viewModel->addressIsVatAttributeVisible()): ?>
            <div class="field taxvat">
                <label class="label" for="vat_id">
                    <span><?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('vat_id') ?></span>
                </label>
                <div class="control">
                    <input type="text"
                           name="vat_id"
                           value="<?= $escaper->escapeHtmlAttr($block->getAddress()->getVatId()) ?>"
                           title="<?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('vat_id') ?>"
                           class="input-text <?= $escaper->escapeHtmlAttr($_vatidValidationClass) ?>"
                           id="vat_id">
                </div>
            </div>
        <?php endif; ?>
        <div class="field country required">
            <label class="label" for="country">
                <span><?= /* @noEscape */ $block->getAttributeData()->getFrontendLabel('country_id') ?></span>
            </label>
            <div class="control">
                <?= $block->getCountryHtmlSelect() ?>
            </div>
        </div>
    </fieldset>
    <fieldset class="fieldset">
        <input type="hidden" name="pseudocardpan" id="pseudocardpan">
        <input type="hidden" name="truncatedcardpan" id="truncatedcardpan">
        <input type="hidden" name="cardexpiredate" id="cardexpiredate">
        <legend class="legend"><span><?= $escaper->escapeHtml(__('Credit Card')) ?></span></legend><br>
        <div class="control">
            <div style="display: none" class="mage-error" generated="true" id="payone_creditcard-error">
                <?= $escaper->escapeHtmlAttr(__('Please enter complete credit card data.')) ?>
            </div>
        </div>
        <div class="field payone_creditcard_credit_card_type required">
            <label class="label" for="payone_creditcard_credit_card_type">
                <span><?= /* @noEscape */ __('Credit Card Type') ?></span>
            </label>
            <div class="control">
                <select id="payone_creditcard_credit_card_type" name="payment[cc_type]"
                        title="<?= /* @noEscape */ __('Credit Card Type') ?>"
                        class="validate-select"
                        data-validate="{required:true}">
                    <?php foreach ($block->getCardTypes() as $option): ?>
                        <option value="<?= $escaper->escapeHtmlAttr($option['id']) ?>">
                            <?= $block->escapeHtml($option['title']) ?>
                        </option>
                    <?php endforeach;?>
                </select>
            </div>
        </div>
        <div class="field firstname required">
            <label class="label" for="payone_creditcard_firstname">
                <span><?= $escaper->escapeHtmlAttr(__('Firstname')) ?></span>
            </label>
            <div class="control">
                <input type="text"
                       name="payment[cc_firstname]"
                       value=""
                       title="<?= $escaper->escapeHtmlAttr(__('Firstname')) ?>"
                       class="input-text"
                       data-validate="{required:true}"
                       id="payone_creditcard_firstname">
            </div>
        </div>
        <div class="field lastname required">
            <label class="label" for="payone_creditcard_lastname">
                <span><?= $escaper->escapeHtmlAttr(__('Lastname')) ?></span>
            </label>
            <div class="control">
                <input type="text"
                       name="payment[cc_lastname]"
                       value=""
                       title="<?= $escaper->escapeHtmlAttr(__('Lastname')) ?>"
                       class="input-text"
                       data-validate="{required:true}"
                       id="payone_creditcard_lastname">
            </div>
        </div>
        <div class="field payone_creditcard_cc_number required">
            <label class="label" for="payone_creditcard_cc_number">
                <span><?= $escaper->escapeHtmlAttr(__('Credit Card Number')) ?></span>
            </label>
            <div class="control">
                <span id="cardpan" class="inputIframe"></span>
            </div>
        </div>

        <div class="field expiration_date required">
            <label class="label" for="payone_creditcard_expiration">
                <span><?= $escaper->escapeHtmlAttr(__('Expiration Date')) ?></span>
            </label>
            <div class="control">
                <div class="fields group group-2">
                    <div class="field no-label month">
                        <div class="control">
                            <span id="cardexpiremonth"></span>
                        </div>
                    </div>
                    <div class="field no-label year">
                        <div class="control">
                            <span id="cardexpireyear"></span>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <div class="field payone_creditcard_cc_cid required">
            <label class="label" for="payone_creditcard_cc_cid">
                <span><?= $escaper->escapeHtmlAttr(__('Card Verification Number')) ?></span>
            </label>
            <div class="control">
                <span id="cardcvc2" class="inputIframe"></span>
            </div>
        </div>
    </fieldset>
    <div class="actions-toolbar">
        <div class="primary">
            <input type="hidden" name="hideit" id="hideit" value="" />
            <button id="savecc"
                    type="button"
                    class="action save primary"
                    title="<?= $escaper->escapeHtmlAttr(__('Save Card')) ?>"
            >
                <span><?= $escaper->escapeHtml(__('Save Card')) ?></span>
            </button>
        </div>
        <div class="secondary">
            <a class="action back" href="<?= $escaper->escapeUrl($block->getBackUrl()) ?>">
                <span><?= $escaper->escapeHtml(__('Go back')) ?></span>
            </a>
        </div>
    </div>
</form>
<?php $serializedPayoneConfig = /* @noEscape */ $block->getPayoneConfig();
$scriptString = <<<script
        window.payoneConfig = {$serializedPayoneConfig};
script;
?>
<?= /* @noEscape */ $secureRenderer->renderTag('script', [], $scriptString, false) ?>
<?php $scriptString = <<<script
    require(["jquery","mage/mage"],function($){
       var dataForm = $('#payoneCcAddForm');
       $('button#savecc').click( function() {
            if (dataForm.validation('isValid')) {
                if (iframes.isComplete()) {
                    iframes.creditCardCheck('checkCallback');
                    return true;
                } else {
                    $('#payone_creditcard-error').show();
                    return false;
                }
            }
        });
    });
script;
?>
<?= /* @noEscape */ $secureRenderer->renderTag('script', [], $scriptString, false); ?>
<script>
    var iframes = new Payone.ClientApi.HostedIFrames(
        window.payoneConfig.payment.payone.fieldConfig,
        window.payoneConfig.payment.payone.hostedRequest
    );
    iframes.setCardType("V");

    document.getElementById('payone_creditcard_credit_card_type').onchange = function () {
        iframes.setCardType(this.value);      // on change: set new type of credit card to process
    };

    function checkCallback(response) {
        if (response.status === "VALID") {
            document.getElementById("pseudocardpan").value = response.pseudocardpan;
            document.getElementById("truncatedcardpan").value = response.truncatedcardpan;
            document.getElementById("cardexpiredate").value = response.cardexpiredate;
            document.payoneCcAddForm.submit();
        }
    }

</script>
<script type="text/x-magento-init">
    {
        "#payoneCcAddForm": {
            "addressValidation": {
                "postCodes": <?= /* @noEscape */ $block->getPostCodeConfig()->getSerializedPostCodes() ?>
            }
        },
        "#country": {
            "regionUpdater": {
                "optionalRegionAllowed": <?= /* @noEscape */ $_displayAll ? 'true' : 'false' ?>,
                "regionListId": "#region_id",
                "regionInputId": "#region",
                "postcodeId": "#zip",
                "form": "#form-validate",
                "regionJson": <?= /* @noEscape */ $viewModel->dataGetRegionJson() ?>,
                "defaultRegion": "<?= (int) $block->getRegionId() ?>",
                "countriesWithOptionalZip": <?= /* @noEscape */ $viewModel->dataGetCountriesWithOptionalZip(true) ?>
            }
        }
    }
</script>

Once all required fields are fill up and user clicks on Save Card button, we need to send card details to Payone and save information in Magento.

In our example API communication class looks like the following (see sendRequest() as entry point, and saveCard() as method for processing response.

<?php
declare(strict_types=1);

namespace Comwrap\RecurringPayone\Model\Api;

use Comwrap\RecurringPayone\Plugin\Payone\Core\Model\Methods\CreditcardAfterGetPaymentSpecificParameters;
use Magento\Customer\Model\Session;
use Magento\Framework\Exception\LocalizedException;
use Payone\Core\Helper\Api;
use Payone\Core\Model\PayoneConfig;
use Payone\Core\Model\ResourceModel\SavedPaymentData;

/**
 * @SuppressWarnings(PHPMD.CookieAndSessionMisuse)
 */
class CreditCard
{
    const PAYONE_STATUS = ['APPROVED', 'REDIRECT'];

    /**
     * URL of PAYONE Server API
     *
     * @var string
     */
    protected $sApiUrl = 'https://api.pay1.de/post-gateway/';

    /**
     * @var PayoneRequest
     */
    private $payoneRequest;

    /**
     * @var Api
     */
    private $apiHelper;

    /**
     * @var Session
     */
    private $customerSession;

    /**
     * @var SavedPaymentData
     */
    private $savedPaymentData;

    /**
     * @param PayoneRequest $payoneRequest
     * @param Api $apiHelper
     * @param Session $customerSession
     * @param SavedPaymentData $savedPaymentData
     */
    public function __construct(
        PayoneRequest $payoneRequest,
        Api $apiHelper,
        Session $customerSession,
        SavedPaymentData $savedPaymentData
    ) {
        $this->payoneRequest = $payoneRequest;
        $this->apiHelper = $apiHelper;
        $this->customerSession = $customerSession;
        $this->savedPaymentData = $savedPaymentData;
    }

    /**
     * Send request to payone api for add new credit card
     *
     * @param array $requestParams
     * @return array
     */
    public function sendRequest(array $requestParams): array
    {
        $apiRequestParams = $this->payoneRequest->getApiRequestParams($requestParams);
        $requestUrl = $this->apiHelper->getRequestUrl($apiRequestParams, $this->sApiUrl);
        $response = $this->apiHelper->sendApiRequest($requestUrl); // send request to PAYONE

        if (isset($response['status']) && in_array($response['status'], self::PAYONE_STATUS)) {
            $savedCardParams = $this->getSaveCardParams($requestParams);
            $this->saveCard($savedCardParams);
        }
        return $response;
    }

    /**
     * Save new card details in payone table 'payone_saved_payment_data'
     *
     * @param array $paymentData
     */
    private function saveCard(array $paymentData): void
    {
        $customerId = $this->customerSession->getId();
        $this->savedPaymentData->addSavedPaymentData(
            $customerId,
            PayoneConfig::METHOD_CREDITCARD,
            $paymentData
        );
        $savedPaymentData = $this->savedPaymentData->getSavedPaymentData($customerId);
        $lastSavedPaymentData = end($savedPaymentData);
        if (isset($lastSavedPaymentData['id'])) {
            $this->savedPaymentData->setDefault($lastSavedPaymentData['id'], $customerId);
        }
    }

    /**
     * Prepare require parameter to save credit card in magento
     *
     * @param array $requestParams
     * @return array
     */
    private function getSaveCardParams(array $requestParams): array
    {
        return [
            'cardpan' => $requestParams['pseudocardpan'],
            'masked' => $requestParams['truncatedcardpan'],
            'firstname' => $requestParams['payment']['cc_firstname'],
            'lastname' => $requestParams['payment']['cc_lastname'],
            'cardtype' => $requestParams['payment']['cc_type'],
            'cardexpiredate' => $requestParams['cardexpiredate']
        ];
    }

    /**
     * Get active credit card
     *
     * @param int $customerId
     * @return array
     * @throws LocalizedException
     */
    public function getActiveCreditCard(int $customerId): array
    {
        if (!$customerId) {
            throw new LocalizedException(
                __('Credit Card not available. Please add new credit card from customer account')
            );
        }
        $savedPaymentsData = $this->savedPaymentData->getSavedPaymentData($customerId);

        if (count($savedPaymentsData) > 1) {
            $savedPaymentsData = array_filter($savedPaymentsData, function ($savedPaymentsData) {
                return $savedPaymentsData['is_default'] == 1;
            });
        }

        if (!empty($savedPaymentsData)) {
            reset($savedPaymentsData);
            $savedPaymentsData = $savedPaymentsData[0]['payment_data'];
            $savedPaymentsData[CreditcardAfterGetPaymentSpecificParameters::PSEUDOCARDPAN] =
                $savedPaymentsData['cardpan'];
            $savedPaymentsData['truncatedcardpan'] = $savedPaymentsData['masked'];
            unset($savedPaymentsData['cardpan']);
            unset($savedPaymentsData['masked']);

            return $savedPaymentsData;
        }
        return [];
    }
}

Creditcard Management in Customer Account

We can add, delete and set default credit card in customer account from Creditcard Management

credit_card_management_in_adobe_commerce_customer_account Credit Card Management in Adobe Commerce Customer Account

Summary

In presented example we showed a way how Recurring payment for PAYONE can be integrated. Definitely integration can be more complex then presented here, as everything depends on your particular usecases.

For more information you can always have a look in official documentation:

Dashboard - PAYONE docs

Photo by Martin Adams on Unsplash