Recurring payments and Credit Card Management in Adobe Commerce using PAYONE
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:
-
customer_is_present
-
recurrence
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.
Get customer agreement for CoF - only get agreement,
amount
is sent with
1.
- 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
OR get customer agreement for CoF -
with amount
being sent
- 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
- 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
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:
Photo by Martin Adams on Unsplash