Vue Storefront is now Alokai! Learn More
Adyen commercetools SDK v1 -> v2

Adyen commercetools SDK v1 -> v2

In update, we decided to:

  • switch Adyen API from Node JS SDK to TypeScript SDK,
  • switch from Session flow to Advanced flow,
  • remove Session API flow,
  • introduce support for Partial Payments including handling of ORDER_CANCEL event.

If you want to migrate, firstly make sure version of packages, extension, and notification module matches specified on the Compatibility page.

Then let's adjust codebase.

Middleware config

We removed adyenCheckoutApiBaseUrl, adyenCheckoutApiVersion, returnUrl fields as they were redundant.

New required fields are adyenEnvironment, integrationName, origin, and optionals are buildCustomAdyenClientParameters, partialPaymentTTL, customOrderType.

middleware.config.js
adyen: {
  location: '@vsf-enterprise/adyen-commercetools-api/server',
  configuration: {
    ctApi: {
      apiHost: '<CT_HOST_URL>',
      authHost: '<CT_AUTH_URL>',
      projectKey: '<CT_PROJECT_KEY>',
      clientId: '<CT_CLIENT_ID>',
      clientSecret: '<CT_CLIENT_SECRET>',
-     scopes: ['manage_payments:<ADYEN_PROJECT_IDENTIFIER>', 'manage_orders:<ADYEN_PROJECT_IDENTIFIER>']
+     scopes: ['manage_payments:<ADYEN_PROJECT_IDENTIFIER>', 'manage_orders:<ADYEN_PROJECT_IDENTIFIER>', 'view_types:<ADYEN_PROJECT_IDENTIFIER>']
    },
    adyenApiKey: '<ADYEN_API_KEY>',
    adyenMerchantAccount: '<ADYEN_MERCHANT_ACCOUNT>',
-   returnUrl: 'http://localhost/adyen-redirect-back',
-   adyenCheckoutApiBaseUrl: '<ADYEN_CHECKOUT_API_BASE_URL>',
-   adyenCheckoutApiVersion: 71,
+   adyenEnvironment: 'TEST',
+   integrationName: 'adyen', // Keyname containing this integration object
+   origin: 'https://my-alokai-frontend.local', // URL of frontend
  }
},

If your Adyen instance is running in production and taking payments, then you also need to pass buildCustomAdyenClientParameters. It's a function for overwriting object used to instantiate Client from @adyen/api-library. Mostly all you need to is pass what you get and append liveEndpointUrlPrefix as such:

adyen: {
  location: '@vsf-enterprise/adyen-commercetools-api/server',
  configuration: {
    // ...
    buildCustomAdyenClientParameters(params) {
      return {
        ...params,
        liveEndpointUrlPrefix: "YOUR_LIVE_URL_PREFIX"
      }
    }
  }
},

Coming back from redirect based payment method

When user comes back from redirect based payment method they land on our /redirectBack endpoint. There we submits additional information related to performed payment and redirects where onRedirectBack function told us to.

This is the body of default onRedirectBack implementation:

// storefront-middleware/integrations/adyen/config.ts
import type { TOnRedirectBack } from "@vsf-enterprise/adyen-commercetools-api";

const onRedirectBack: TOnRedirectBack = async ({ config }, { ct }, cart) => {
  const order = await ct.orders.create(cart);
  const url = new URL(
    `/order/success?orderId=${order.id}&redirect_status=succeeded`,
    config.origin,
  );
  return url.toString();
};

export const config = {
  location: "@vsf-enterprise/adyen-commercetools-api/server",
  configuration: {
    // ...
    onRedirectBack,
  },
};

Feel free to adjust it and register own implementation using onRedirectBack property in storefront-middleware/integrations/adyen/config.ts.

Adyen Drop-in

We've changed a mountPaymentElement SDK method to suit Advanced flow. The following code should be used to mount Adyen Drop-in (call createAdyenDropin to mount):

const DROPIN_CONTAINER_ID = 'adyen-dropin';

function mapPaymentMethodsResponse(getPaymentMethodsResponse: GetPaymentMethodsResponse) {
  return {
    paymentMethods: getPaymentMethodsResponse.paymentMethods,
    ...(getPaymentMethodsResponse.storedPaymentMethods
      ? {
          storedPaymentMethods: getPaymentMethodsResponse.storedPaymentMethods,
        }
      : {}),
  };
}

async function createAdyenDropin({ shopperLocale }: { shopperLocale?: string } = {}) {
  let getPaymentMethodsResponse = await sdk.adyen.getPaymentMethods({ shopperLocale });
  let newIsPartialPayment = Boolean(getPaymentMethodsResponse.order);
  let balanceCheckResponse: BalanceCheckResponse | null;

  async function didCancelPartialPaymentFromAnotherTab() {
    if (!newIsPartialPayment) {
      return false;
    }
    const { me } = await sdk.commerce.getMe();
    if (!me.activeCart) {
      return undefined;
    }
    const adyenOrderDataField = me.activeCart?.custom?.customFieldsRaw?.find(
      (field) => field.name === 'adyenOrderData',
    );
    if (!adyenOrderDataField) {
      return undefined;
    }
    return adyenOrderDataField.value.length === 0;
  }

  async function handleResult(
    checkout: { update: Function },
    result: MakePaymentResponse | SubmitDetailsResponse,
    dropin: { unmount: Function; handleAction: Function },
  ) {
    if (['Refused', 'Cancelled', 'Error'].includes(result.resultCode!)) {
      // Handling negative result codes and unmounting the Adyen Drop-in
      // To allow the user to try again by recreating session and component
      dropin.unmount();
      // Show some meaningful error message
      return await createAdyenDropin();
    } else if ('action' in result && result.action) {
      return dropin.handleAction(result.action);
    } else if (result.order && result.order?.remainingAmount?.value && result.order?.remainingAmount?.value > 0) {
      getPaymentMethodsResponse = await sdk.adyen.getPaymentMethods({ shopperLocale });

      checkout.update({
        paymentMethodsResponse: mapPaymentMethodsResponse(getPaymentMethodsResponse),
        order: getPaymentMethodsResponse.order,
        amount: result.order.remainingAmount,
      });
      return result;
    } else {
      // Here put a code to place an order and redirect to success page.
      console.log('success', result);
    }
  }

  function createOnOrderCancel(checkout: { update: Function }) {
    return async function onOrderCancel(order: CancelOrderParams) {
      await sdk.adyen.cancelOrder(order);
      newIsPartialPayment = Boolean(getPaymentMethodsResponse.order);
      getPaymentMethodsResponse = await sdk.adyen.getPaymentMethods({ shopperLocale });
      checkout.update({
        paymentMethodsResponse: mapPaymentMethodsResponse(getPaymentMethodsResponse),
        amount: {
          value: getPaymentMethodsResponse.payment.amountPlanned.centAmount,
          currency: getPaymentMethodsResponse.payment.amountPlanned.currencyCode,
        },
        order: undefined,
      });
    };
  }

  const { checkout, dropinComponent } = await sdk.adyen.mountPaymentElement({
    getPaymentMethodsResponse,
    locale: 'en-US',
    order: getPaymentMethodsResponse.order,
    paymentDOMElement: `#${DROPIN_CONTAINER_ID}`,
    adyenConfiguration: {
      countryCode,
      locale,
      onPaymentMethodsRequest: async (data, { resolve, reject }) => {
        getPaymentMethodsResponse = await sdk.adyen.getPaymentMethods({
          shopperLocale: data.locale,
        });
     
        resolve(getPaymentMethodsResponse as PaymentMethodsResponse);
      },
      async onSubmit(state, dropin) {
        if (await didCancelPartialPaymentFromAnotherTab()) {
          window.location.reload();
          return;
        }
        const result = await sdk.adyen.makePayment({
          paymentId: getPaymentMethodsResponse.payment.id,
          componentData: state.data,
          ...(balanceCheckResponse?.resultCode === 'NotEnoughBalance'
            ? {
                balance: balanceCheckResponse.balance,
              }
            : {}),
        });
        if (state.data.order) {
          newIsPartialPayment = true;
        }
        balanceCheckResponse = null;
        await handleResult(checkout, result, dropin);
      },
      async onAdditionalDetails(state, dropin) {
        if (await didCancelPartialPaymentFromAnotherTab()) {
          window.location.reload();
          return;
        }
        const result = await sdk.adyen.submitDetails({
          paymentId: getPaymentMethodsResponse.payment.id,
          componentData: state.data,
        });
        await handleResult(checkout, result, dropin);
      },
    },
    dropinConfiguration: {
      showRemovePaymentMethodButton: true,
      async onOrderCancel(order) {
        const onOrderCancel = createOnOrderCancel(checkout);
        await onOrderCancel(order);
      },
      async onDisableStoredPaymentMethod(recurringDetailReference, resolve, reject) {
        sdk.adyen.removeCard({ recurringDetailReference }).then(resolve).catch(reject);
      },
      paymentMethodsConfiguration: {
        card: {
          enableStoreDetails: true
        },
        giftcard: {
          async onBalanceCheck(resolve, reject, data) {
            balanceCheckResponse = await sdk.adyen.balanceCheck({
              ...data,
              paymentId: getPaymentMethodsResponse.payment.id,
            });
            resolve(balanceCheckResponse);
          },
          async onOrderRequest(resolve, reject, data) {
            sdk.adyen
              .createOrder({
                ...data,
                riskData: data.riskData ?? {},
                paymentId: getPaymentMethodsResponse.payment.id,
              })
              .then((response) => resolve({
                ...response,
                pspReference: response.pspReference ?? '',
              }))
              .catch(reject);
          },
        },
      },
    },
  });
}

// Call it client-side, e.g. in Nuxt it would be inside onMounted
const shopperLocale = 'en-US'; // https://docs.adyen.com/api-explorer/Checkout/71/post/paymentMethods#request-shopperLocale
await createAdyenDropin({ shopperLocale });

Changes in the aforementioned boilerplate are mostly related to switch to advanced flow and support for Partial Payments.

AdyenRedirectBack page and route

The AdyenRedirectBack page is no longer needed, so it can be removed.

BuildCustomPaymentAttributesParams type changed

export type BuildCustomPaymentAttributesParams = {
  payload: CreateSessionRequestPayload;
  payment: PaymentWithFields;
  cart: Cart;
+ origin: string;
- shopperLocale?: string;
  customerId?: string;
+ balance?: Balance;
};

interface Balance {
  value: number;
  currency: string;
}

If you are using the buildCustomPaymentAttributes middleware config property, you will have to adjust it to match the new type.

The signature of the buildCustomPaymentAttributes function changed and it's not used to extend creating session payload but one sent to the POST /payments. It's similiar but might require some additional adjustments.

Extend commercetools cart/order type

In commercetools, a cart and an order share a single type because at some point cart is transformed to the order. The name of the shared type is order. We are going to extend this shared type because...

Adyen creates a wrapper for payment partials. To make it accessible after refresh of website or outside of user's session, we had to store it somewhere. Our pick is an extended commercetools' order type.

Fortunately, you do not need to create every cart using this type, we will convert type of the cart when it's necessary (making partial payment).

In order to register extended order type, use the commercetools API directly or the HTTP API Playground in the Merchant Center:

  1. Open the Merchant Center and navigate to your project,
  2. Go to Settings → Developer settings → HTTP API Playground,
  3. Set the request to POST against the Types endpoint, specify command Create Type and use the following payload:
{
  "key": "ctcart-adyen-integration",
  "name": {
    "en": "commercetools Adyen integration cart custom type to support partial payments"
  },
  "resourceTypeIds": ["order"],
  "fieldDefinitions": [
    {
      "name": "adyenOrderData",
      "label": {
        "en": "adyenOrderData"
      },
      "type": {
        "name": "String"
      },
      "inputHint": "SingleLine",
      "required": false
    },
    {
      "name": "adyenOrderPspReference",
      "label": {
        "en": "adyenOrderPspReference"
      },
      "type": {
        "name": "String"
      },
      "inputHint": "SingleLine",
      "required": false
    },
    {
      "name": "adyenOrderRemainingAmountValue",
      "label": {
        "en": "adyenOrderRemainingAmountValue"
      },
      "type": {
        "name": "Number"
      },
      "inputHint": "SingleLine",
      "required": false
    },
    {
      "name": "adyenOrderRemainingAmountCurrency",
      "label": {
        "en": "adyenOrderRemainingAmountCurrency"
      },
      "type": {
        "name": "String"
      },
      "inputHint": "SingleLine",
      "required": false
    },
    {
      "name": "adyenOrderTotalAmountValue",
      "label": {
        "en": "adyenOrderTotalAmountValue"
      },
      "type": {
        "name": "Number"
      },
      "inputHint": "SingleLine",
      "required": false
    },
    {
      "name": "adyenOrderTotalAmountCurrency",
      "label": {
        "en": "adyenOrderTotalAmountCurrency"
      },
      "type": {
        "name": "String"
      },
      "inputHint": "SingleLine",
      "required": false
    }
  ]
}
  1. Send the request.

Did you already extended commercetools' order type?

It is possible that your application is already extending order type and you cannot stop using it. In that case, we recommend adding fields from our type and add them to your custom type. Then in middleware config add type's key as a value of customOrderType optional property.

adyen: {
  location: '@vsf-enterprise/adyen-commercetools-api/server',
  configuration: {
    // ...
    customOrderType: 'keyOfMyCustomType'
  }
},

Setup webhook

Unfinished partial payments expire after some time. To elegantly handle it, we need to setup webhook to shoot in adyen integration's endpoint prepared for it.

Start from registering new webhook in Adyen's dashboard. URL should be equal:

https://my-alokai-frontend.local/api/adyen/webhookCancelOrder

Where adyen is equal a value of integrationName property from middleware config.

Method - JSON.
Encryption protocol - TLSv1.3.
Events - select only ORDER_CANCEL.
Additional settings - select only Include a success boolean for the payments listed in an ORDER_CLOSED event.

Save configuration.

Verifying HMAC signature

In order to prevent fraud requests to the endpoint, it is recommended to enable verifying HMAC signature. To do that, edit created webhook in Adyen's dashboard, Generate a new HMAC key, save configuration and copy generated key, as we will need it soon.

Then adjust a middleware config:

// ...
adyen: {
  location: '@vsf-enterprise/adyen-commercetools-api/server',
  configuration: {
    // ...
    enableHmacSignature: true,
    secretHmacKey: '<PASTE_HMAC_KEY_COPIED_FROM_ADYEN_DASHBOARD>'
  }
},
// ...