allBlogsList

Implementing Adyen Payments with SFCC PWA Kit

Discover how to start processing payments with SFCC PWA kit.

Adyen has been one of the most commonly used payment gateways by merchants. It’s fast, reliable and comes with a sizeable list of payment methods to choose from, allowing merchants to add new payment methods in a cost-effective manner. This blog will show you how to implement Adyen web Drop-in solution with SFCC PWA Kit.

Adyen Web Drop-in Solution is one of the few solutions that Adyen provides for web, and was used to implement Adyen SFRA cartridge. It’s secure and uses IFRAME which results in lower PCI burden on the merchant. Adyen regularly updates its solution, so our implementation utilizes the latest version - 5.3.1.

Below is a diagram explaining how the data flows between the systems. In summary, we collect order and customer session information and pass it to the SFCC backend. SFCC backend then makes a call to Adyen to get a payment session. The payment session data is then passed back to the PWA application to initiate the drop-in component. By that point, customers do their interaction with the drop-in component by selecting a credit card payment, PayPal, Apple Pay, Google Pay…etc. Once the authorization is successful, the order is processed and placed. Adyen payment calls are asynchronous, so most of the time, the responses are not in real-time and they’re served back using a webhook. For that reason, we need to set up a webhook listener on the SFCC backend side to process these webhook calls, which is already provided by the cartridge but would require a little tweaking which will be explained later.

Adyen Blog img 1

1.  Install Adyen SFRA cartridge. We’ll be extending it with new APIs but having the structure is helpful especially when the cartridge comes with a webhook listener and processor already set up which we'll be using. Although we won’t be using most of what’s provided by the cartridge, it’s helpful to utilize it and build on it instead of starting from scratch.

2.  Add the new /sessions API to the Adyen cartridge. The new API takes at the minimum total amount, return URL (passed from PWA), reference. We’re passing more information like customers’ email, shipping, and billing addresses because these will be needed in case risk assessment is enabled, 3DS, or other features. To see the full list of attributes that can be passed, check:

https://docs.adyen.com/api-explorer/#/CheckoutService/v68/post/sessions

Also, individual payment methods could require specific attributes to be passed. For example, to enable Klarna or AfterPay, you need to pass lineItems, so instead of enabling new payment methods, you will need to refer to the specific payment method documentation and verify you’re passing all the required attributes.

+Add new Service in BM:

Name: AdyenCheckoutSessions

Profile: Adyen (Existing)

Credentials:

Host: https://checkout-test.adyen.com/checkout/v68/sessions

+cartridges/int_adyen_overlay/cartridge/scripts/adyenCheckoutSessions.js

"use strict";
/**
 * Create Checkout Payment Sessions
 */
// script include
var Logger = require('dw/system/Logger');
var BasketMgr = require('dw/order/BasketMgr');
var AdyenHelper = require('*/cartridge/scripts/util/adyenHelper');
var AdyenGetOpenInvoiceData = require('*/cartridge/scripts/adyenGetOpenInvoiceData');
function getCheckoutSession(returnUrl) {
  try {
    var service = AdyenHelper.getService(AdyenHelper.SERVICE.CHECKOUTSESSIONS);
    if (!service) {
      throw new Error('Could not connect to Checkout Sessions Endpoint');
    }
    var currentBasket = BasketMgr.getCurrentBasket();
    var total = currentBasket.totalGrossPrice;
    var myAmount = AdyenHelper.getCurrencyValueForApi(currentBasket.totalGrossPrice).getValueOrNull();
    var requestObject = {
      merchantAccount: AdyenHelper.getAdyenMerchantAccount(),
      amount: {
        value: myAmount,
        currency: total.currencyCode
      },
      returnUrl: returnUrl.value,
      reference: currentBasket.getUUID(),
      channel: 'Web'
    };
    requestObject = AdyenHelper.createAddressObjects(currentBasket, '', requestObject);
    requestObject = AdyenHelper.createShopperObject({
      order: currentBasket,
      paymentRequest: requestObject
    });
    requestObject.lineItems = AdyenGetOpenInvoiceData.getLineItems({
      Order: currentBasket
    });
    var xapikey = AdyenHelper.getAdyenApiKey();
    service.addHeader('Content-type', 'application/json');
    service.addHeader('charset', 'UTF-8');
    service.addHeader('X-API-key', xapikey);
    var callResult = service.call(JSON.stringify(requestObject));
    if (!callResult.isOk()) {
      throw new Error("Call error code".concat(callResult.getError().toString(), " Error => ResponseStatus: ").concat(callResult.getStatus(), " | ResponseErrorText: ").concat(callResult.getErrorMessage(), " | ResponseText: ").concat(callResult.getMsg()));
    }
    var resultObject = callResult.object;
    if (!resultObject || !resultObject.getText()) {
      throw new Error('No correct response from adyenCheckoutSessions call');
    }
    return JSON.parse(resultObject.getText());
  } catch (e) {
    Logger.getLogger('Adyen').error("Adyen: ".concat(e.toString(), " in ").concat(e.fileName, ":").concat(e.lineNumber));
  }
}
module.exports = {
  getCheckoutSession: getCheckoutSession
};

3.  Now that we have the sessions/ service ready to use, we need to add a new custom API that utilizes this service. Make sure you have plugin_ocapi cartridge installed https://github.com/SalesforceCommerceCloud/ocapi_hooks_collection. A quick overview on the cartridge, it extends OCAPI to allow for custom APIs which can be very helpful when implementing a headless solution. The way the cartridge works is by creating a new custom object and having each new custom API as records:

Adyen Blog img 2

Using OCAPI hook: dw.ocapi.shop.custom_object.modifyGETResponse route the calls to the different controllers that would serve the custom APIs

Then, we can utilize customObject GET endpoint /custom_objects/{object_type}/{key} to make calls to the different custom APIs. Example:

/custom_objects/CustomApi/page-designer?c_pages=homepage

4.  To add the new custom API, add a new custom object under CustomApi Object Type: ID = adyen-checkout-sessions. Then we need to setup the new API in the code. You can see we’re bridging the session in order to access session information like order, customer…etc. This way we don’t have to pass any of this information in the API call. The only param we’re expecting is returnUrl. The API calls AdyenOcapi-CreatePaymentSession which will then make the service call.
+ cartridges/plugin_ocapi/cartridge/scripts/apis/adyenCheckoutSessions.js

var CacheMgr = require('dw/system/CacheMgr');
var URLUtils = require('dw/web/URLUtils');
var ImageTransformation = require('*/cartridge/experience/utilities/ImageTransformation.js');
/**
 *  Creates Adyen Payment Session
 */
function createAdyenPaymentSession(returnUrl, dwsid) {
    var controllerService = require('*/cartridge/scripts/services/controller.js');
    // call Adyen CreatePaymentSession endpoint. see ../../controllers/AdyenOcapi.js for implementation
    var params = {};
    if (returnUrl) {
        params.returnUrl = returnUrl;
    }
    var controllerResponse = controllerService.getService().call({ controller: 'AdyenOcapi-CreatePaymentSession', params: params, dwsid: dwsid });
    return { response: controllerResponse.object ? controllerResponse.object : null };
}
exports.get = function (httpParams) {
    var returnUrl = httpParams.c_returnUrl ? httpParams.c_returnUrl.pop() : null;

    var dwsid;
    // platform moves bearer to custom header. That was the Authorizaton header before
    var bearer = request.httpHeaders['x-is-authorization'];
    if (bearer && bearer.indexOf('Basic') === -1) {
        // strip bearer keyword
        bearer = bearer.replace('Bearer ', '');
        var cache = CacheMgr.getCache('BearerToSession');
        // store bearer's session for 15 minutes
        dwsid = cache.get(bearer, function () {
            var ocapiService = require('*/cartridge/scripts/services/ocapi.js');
            // call /sessions ocapi / ocapi session bridge
            var sessionsResponse = ocapiService.getService().call({ requestPath: 's/' + dw.system.Site.current.ID + '/dw/shop/v21_10/sessions', requestMethod: 'POST', token: bearer });
            var message = sessionsResponse.errorMessage;
            // get the set-cookie of the dwsid cookie
            var dwsidCookie = sessionsResponse.object.responseHeaders['set-cookie'].toArray().filter((cookie) => cookie.indexOf('dwsid') > -1).pop();
            // just get the cookie value
            return dwsidCookie.split(';')[0].replace('dwsid=', '');
        });
    }
    var result = createAdyenPaymentSession(returnUrl, dwsid);
    return result;
};

+ cartridges/plugin_ocapi/cartridge/controllers/AdyenOcapi.js

/**
 *  Create Adyen Payment Session
 */
function createPaymentSession() {
    var adyenCheckoutSessions = require('*/cartridge/scripts/adyenCheckoutSessions.js');
    if (request.httpParameterMap.returnUrl.submitted) {
        var res = adyenCheckoutSessions.getCheckoutSession(request.httpParameterMap.returnUrl);
        response.writer.print(JSON.stringify(res));
    }
    response.addHttpHeader('Content-Type', 'application/json');
}
createPaymentSession.public = true;
exports.CreatePaymentSession = createPaymentSession;

5.  Now that we have the new custom Adyen API setup on the backend, we need to add it to the list of APIs in the PWA. Start by extending commerce-api with new API by adding:
+ app/commerce-api/ocapi-adyen.js

import {checkRequiredParameters, createOcapiFetch} from './utils'
class OcapiAdyen {
    constructor(config) {
        this.fetch = createOcapiFetch(config)
    }
    async createPaymentSession(...args) {
        const required = ['returnUrl']
        let requiredParametersError = checkRequiredParameters(args[0], required)
        if (requiredParametersError) {
            return requiredParametersError
        }
        let {
            parameters: {returnUrl}
        } = args[0]
        return this.fetch(
            `custom_objects/CustomApi/adyen-checkout-sessions?c_returnUrl=${returnUrl}`,
            'GET',
            args,
            'createPaymentSession'
        )
    }
}
export default OcapiAdyen

6.  Modify commerce-api/index.js to include the new API:

U app/commerce-api/index.js

       const apis = {
            shopperCustomers: sdk.ShopperCustomers,
            shopperBaskets: OcapiShopperBaskets,
            shopperGiftCertificates: sdk.ShopperGiftCertificates,
            shopperLogin: sdk.ShopperLogin,
            shopperOrders: OcapiShopperOrders,
            shopperProducts: sdk.ShopperProducts,
            shopperPromotions: sdk.ShopperPromotions,
            shopperSearch: sdk.ShopperSearch,
            shopperPageDesigner: OcapiPageDesigner,
            + adyen: OcapiAdyen
        }
        const apiConfigs = {
            shopperCustomers: {api: sdk.ShopperCustomers, canLocalize: false},
            shopperBaskets: {api: OcapiShopperBaskets, canLocalize: false},
            shopperGiftCertificates: {api: sdk.ShopperGiftCertificates, canLocalize: true},
            shopperLogin: {api: sdk.ShopperLogin, canLocalize: false},
            shopperOrders: {api: OcapiShopperOrders, canLocalize: true},
            shopperProducts: {api: sdk.ShopperProducts, canLocalize: true},
            shopperPromotions: {api: sdk.ShopperPromotions, canLocalize: true},
            shopperSearch: {api: sdk.ShopperSearch, canLocalize: true},
            shopperPageDesigner: {api: OcapiPageDesigner, canLocalize: true},
            + adyen: {api: OcapiAdyen, canLocalize: false}
        }

7.  Add adyen-api config file which includes the configurations needed to initiate the Adyen Component:

+ app/adyen-api.config.js

export const adyenAPIConfig = {
    environment: 'TEST',
    clientKey: 'test_XFBUOUYYLFDA7A********'
}

U app/components/_app-config/index.jsx

import {adyenAPIConfig} from '../../adyen-api.config'
const apiConfig = {
    ...commerceAPIConfig,
    einsteinConfig: einsteinAPIConfig,
    + adyenConfig: adyenAPIConfig
}

8.  Adyen has a client-side library that we can utilize to build the different components and, in our case, add the drop-in component and process payments. Add @adyen/adyen-web to package.json:

"@adyen/adyen-web": "^5.3.1"

9.  Add the Adyen hook that will utilize the API. The hook includes two functions:

      a.      createPaymentComponent: which takes type (Adyen component type. Ex: drop-in), paymentContainer (a reference to the div element where the drop-in component will be mounted), additionalConfig (any additional configs needed to create the payment component). The function will make a call to the Adyen API endpoint after forming the returnUrl. We will then dynamically import adyen-web and adyen.css. Adyen documentation don’t instruct you to dynamically add the libraries. However, if they’re imported normally, the checkout page (which makes a call to this hook will fail after a refresh because the code will be processed on the server side and adyen-web includes browser side APIs that would fail if processed on the server side. Therefore, why we have the workaround here of importing the libraries dynamically so that happens on the client side even after a refresh. Note: importing adyen.css would fail due to webpack configuration which we will discuss later. Then, we will form the configuration object and create an AdyenCheckout instance that will then be mounted to the paymentContainer.

      b.     createRedirectSession: Some Adyen payment methods require a redirect like AfterPay, Klarna, and some 3Ds pay flows. This function is to form the AdyenCheckout instance needed when the user returns from the 3rd party payment method.

+ app/commerce-api/hooks/useAdyen.js

import {useState} from 'react'
import {useCommerceAPI} from '../utils'
import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url'
import {useIntl} from 'react-intl'
const useAdyen = () => {
    const api = useCommerceAPI()
    const [state, setState] = useState({
        adyen: {
            checkout: 0
        }
    })
    return {
        ...state,
        async createPaymentComponent(type, paymentContainer, additionalConfig) {
            const config = api._config?.adyenConfig
            const {locale} = useIntl()
            const paymentSession = await api.adyen.createPaymentSession({
                parameters: {
                    returnUrl: `${getAppOrigin()}/${locale}/checkout/handleShopperRedirect`
                }
            })
            // Import Adyen Library dynamically
            const AdyenCheckout = (await import('@adyen/adyen-web')).default
            // Import styles dynamically
            await import('@adyen/adyen-web/dist/adyen.css')
            const response = paymentSession.c_result.response
            if (response) {
                const configuration = {
                    ...additionalConfig,
                    session: {
                        id: response.id,
                        sessionData: response.sessionData
                    },
                    environment: config.environment,
                    clientKey: config.clientKey,
                    paymentMethodsConfiguration: {
                        card: {
                            showPayButton: false
                        },
                        paywithgoogle: {
                            showPayButton: true
                        },
                        paypal: {
                            showPayButton: true
                        }
                    }
                }
                if (configuration) {
                    const checkout = await AdyenCheckout(configuration)
                    const checkoutInstance = checkout
                        .create(type, {instantPaymentTypes: ['paywithgoogle', 'applepay']})
                        .mount(paymentContainer.current)
                    setState({
                        adyen: {
                            checkout: checkoutInstance
                        }
                    })
                    return checkoutInstance
                }
            }
        },
        async createRedirectSession(additionalConfig) {
            const config = api._config?.adyenConfig
            // Import Adyen Library dynamically
            const AdyenCheckout = (await import('@adyen/adyen-web')).default
            const configuration = {
                ...additionalConfig,
                environment: config.environment,
                clientKey: config.clientKey
            }
            return await AdyenCheckout(configuration)
        }
    }
}
export default useAdyen

10.  As stated above, adyen.css import would fail due to webpack configuration related to the base PWA kit. The webpack config is imported using the pwa-kit-react-sdk so as a workaround to modify the library, we can do a post install patch to update the generated webpack config file:

      a.      Update node_modules/pwa-kit-react-sdk/webpack/config.js. Add style-loader and css-loader to all module.rules:

rules: [{
      test: /\.js(x?)$/,
      exclude: /node_modules/,
      use: babelLoader
    }, {
      test: /\.svg$/,
      loader: 'ignore-loader'
    }, {
      test: /\.html$/,
      exclude: /node_modules/,
      use: {
        loader: 'html-loader'
      }
    }, {
      test: /\.css$/i,
      use: ['style-loader', 'css-loader']
    }]

      b.      After making the changes, we want to create a patch file that would run after installing project dependencies. Run patch-package to create a .patch file:

npx patch-package pwa-kit-react-sdk

This will create a new patch file with the changes:

patches/pwa-kit-react-sdk+1.0.0.patch

      c.     Then, add postinstall script to the scripts object under package.json:

"postinstall": "npx patch-package"

This will run automatically after npm install and patch the package

11.  Now that we have the Aden API configuration setup in the PWA, we need to setup the front-end pages to utilize it:

       a.      Update the payment component to include the div element that will hold the Adyen drop-in component. Remove the existing logic and paymentSelection component:

U app/pages/checkout/partials/payment.jsx

<div ref={paymentContainer} className="payment" />
{/* {!selectedPayment?.paymentCard ? (
    <PaymentSelection form={paymentMethodForm} hideSubmitButton />

We’re adding a ref to the div element that will be used in the Adyen hook to mount the Adyen drop-in component to this div element

      b.      Update the checkout page with the adyen logic:

            i.     Update SubmitOrder to read the adyen instance from the state and call .submit() which basically submits the Adyen drop-in component form for the selected payment method

            ii.     Update userEffect to create the adyen component on step 3 (payment step). Pass the callback methods needed, most importantly, onPaymentCompleted which handles the different results from processing the payment. The reason why reviewOrder and placeOrder functions are ran back to back here is because step#4 (review order step) was eliminated because it would complicate the integration with Adyen and would require us to make additional calls and the setup of another endpoint on SFCC side. So, for the purpose of this integration, I found that it would be better to merge the last 2 steps (payment and summary) and turn it into a place order step. In order to merge the two steps, simply search the code for any “step === 4” occurences and replace it with “step === 3”.

            iii.     setGlobalError is a function that I introduced to app/pages/checkout/util/checkout-context.js which simply updates the state with the global error:

setGlobalError(msg) {
mergeState({globalError: msg})
}

U app/pages/checkout/index.jsx

const navigate = useNavigation()
const adyen = useAdyen()
const {globalError, step, placeOrder, setGlobalError} = useCheckout()
const {reviewOrder} = usePaymentForms()
const [isLoading, setIsLoading] = useState(false)
const paymentContainer = useRef(null)
// Scroll to the top when we get a global error
useEffect(() => {
    if (globalError || step === 3) {
        window.scrollTo({top: 0})
    }
    if (step === 3) {
        adyen.createPaymentComponent('dropin', paymentContainer, {
            onPaymentCompleted: async (result, component) => {
                console.info(result, component)
                if (result.resultCode === 'Refused' || result.resultCode === 'Error') {
                    // Handle errors
                    setGlobalError(
                        'There is an error processing your payment, please try again.'
                    )
                    window.scrollTo({top: 0})
                    component.setStatus('ready')
                } else {
                    await reviewOrder()
                    await placeOrder()
                    navigate('/checkout/confirmation')
                }
            },
            beforeSubmit: async (data, component, actions) => {
                actions.resolve(data)
            },
            onError: (error, component) => {
                setGlobalError('There is an error processing your payment, please try again.')
                console.error(error.name, error.message, error.stack, component)
                setIsLoading(false)
            }
        })
    }
}, [globalError, step])
const submitOrder = async () => {
    setIsLoading(true)
    try {
        const checkout = adyen.adyen.checkout
        checkout.submit()
        setIsLoading(false)
    } catch (error) {
        setIsLoading(false)
    }
}

            iv.     Update Payment component to pass paymentContainer reference: 

U app/pages/checkout/index.jsx

<Payment paymentContainer={paymentContainer} />

      c.      Update checkout-context.js to pass AdyenComponent as a payment method instead of the default CREDIT_CARD, and remove unnecessary data:

U app/pages/checkout/util/checkout-context.js

// const [expirationMonth, expirationYear] = expiry.split('/')
const paymentInstrument = {
    paymentMethodId: 'AdyenComponent',
    paymentCard: {
        ...selectedPayment
        // number: selectedPayment.number.replace(/ /g, ''),
        // cardType: getPaymentInstrumentCardType(selectedPayment.cardType),
        // expirationMonth: parseInt(expirationMonth),
        // expirationYear: parseInt(`20$&#123;expirationYear&#125;`),

        // TODO: These fields are required for saving the card to the customer's
        // account. Im not sure what they are for or how to get them, so for now
        // we're just passing some values to make it work. Need to investigate.
        // issueNumber: '',
        // validFromMonth: 1,
        // validFromYear: 2020
    }
}

12.  Some payment methods require a redirect endpoint like AfterPay, Klarna, some 3Ds payment flows. For that reason, we need to setup the endpoint needed to handle those payment methods:

      a.      Update routes:

U app/routes.jsx

const CheckoutRedirect = loadable(() => import('./pages/checkout/checkout-redirect'), {fallback})
{
    path: '/:locale/checkout/handleShopperRedirect',
    component: CheckoutRedirect,
    exact: true
},

      b.      Add the new redirect page:

+ app/pages/checkout/checkout-redirect.jsx

import React, {useEffect} from 'react'
import {useLocation} from 'react-router-dom'
import useAdyen from '../../commerce-api/hooks/useAdyen'
import useNavigation from '../../hooks/use-navigation'
import useCustomer from '../../commerce-api/hooks/useCustomer'
import useBasket from '../../commerce-api/hooks/useBasket'
import {CheckoutProvider, useCheckout} from './util/checkout-context'
import usePaymentForms from './util/usePaymentForms'
import CheckoutSkeleton from './partials/checkout-skeleton'

const Checkout = () => {
    const useQuery = () => {
        return new URLSearchParams(useLocation().search)
    }
    const query = useQuery()
    const adyen = useAdyen()
    const navigate = useNavigation()
    const {placeOrder, setGlobalError} = useCheckout()
    const {reviewOrder} = usePaymentForms()
    const sessionId = query.get('sessionId')
    const redirectResult = query.get('redirectResult')
    useEffect(() => {
        const createCheckout = async () => {
            const checkout = await adyen.createRedirectSession({
                session: {id: sessionId},
                onPaymentCompleted: async (result, component) => {
                    if (result.resultCode === 'Refused' || result.resultCode === 'Error') {
                        // Handle errors
                        setGlobalError(
                            'There is an error processing your payment, please try again.'
                        )
                        navigate('/checkout')
                    } else {
                        await reviewOrder()
                        await placeOrder()
                        navigate('/checkout/confirmation')
                    }
                },
                onError: (error, component) => {
                    console.error(error)
                    // TODO
                }
            })
            checkout.submitDetails({details: {redirectResult}}) // we finalize the redirect flow with the reeived payload
        }
        createCheckout()
    }, []) 
    return <div>Redirect Page</div>
  }
const CheckoutRedirect = () => {
const customer = useCustomer()
const basket = useBasket()
 if (!customer || !customer.customerId || !basket || !basket.basketId) { 
   return <CheckoutSkeleton/>
}
return (
  <CheckoutProvider>
    <Checkout />
  </CheckoutProvider>
 )
}
export default CheckoutRedirect

13.  When creating the Adyen session from the backend, Adyen expects us to pass a reference ID for the transaction. This is typically the order ID. However, in our case, we don’t yet have an order ID. Instead, we’re passing basket UUID and storing it on the order level so we can later reference the transaction with Adyen. After the order is placed on the client-side, we need to setup Adyen to send payment notifications back to SFCC, so we can handle the different payment results for the order and confirm that the payment has been processed successfully. The webhook setup and the listener in SFCC is part of SFCC Adyen documentation, however we need to make a slight change in order to locate the order based on the new reference ID instead of the order ID:

       a.      Need to store basket UUID by updating dw.ocapi.shop.order.beforePOST hook:

exports.beforePOST = function name(basket) {
    // basket.UUID is Adyen Merchant Reference number
    // Therefore, we want to keep track of it for when we're setting up the webhooks listner
    basket.custom.adyenMerchantReference = basket.getUUID()
};

      b.      Update the adyen notification processor:

U cartridges/int_adyen_overlay/cartridge/scripts/handleCustomObject.js

  // adyen is passing merchant reference in this case:
  var adyenMerchantReference = customObj.custom.orderId.split('-', 1);

  // Need to look up the order based on adyenMerchantReference
  var order = OrderMgr.searchOrder('custom.adyenMerchantReference = {0}', adyenMerchantReference)

14. After the webhook is setup and we run adyen notification processing job, we should get all the payment details like PSP Reference and payment method which then can be passed to an OMS for any post order payment processing (captures, void, refund).

 Below you can find a demonstration of the Adyen integration with the PWA kit: