Teaser for Demystified Magento PWA Studio Checkout

Demystified Magento PWA Studio Checkout

10 min read

Edit post on GitHub

By default, PWAStudio ships with Braintree Credit card and Check MO (Money Order) as a Payment Method. I would say this is good starting but for your project, maybe not enough. Before we learn to build a Payment Method, it is definitely better to understand how the Checkout Process works in PWA Studio. So basically, this is a foundation which components exist and how we utilize them for our payment integration.

CheckoutPage.js

This component is the main entry point for the one-step Checkout. It renders sections according to the state created by useCheckoutPage.

Steps Number for section state:

peregrine/lib/talons/useCheckoutPage.js
export const CHECKOUT_STEP = {
    SHIPPING_ADDRESS: 1,
    SHIPPING_METHOD: 2,
    PAYMENT: 3,
    REVIEW: 4
};

GitHub Corefile

PaymentInformation/paymentInformation.js

The checkout page will display the component on the checkout step PAYMENT.

It is responsible for:

  • LoadingIndicator/indicator.js on load
  • PaymentInformation/summary.js if doneEditing=true
  • PaymentInformation/paymentMethods.js if doneEditing=false

PaymentInformation/paymentMethods.js

Loads all available Payments via usePaymentMethods hook and compares them with paymentMethodCollection.js, built via Magento Target.

It only renders components that both lists contain!

PaymentInformation/paymentMethods.js
const radios = availablePaymentMethods
    .map(({ code, title }) => {
        // If we don't have an implementation for a method type, ignore it.
        if (!Object.keys(payments).includes(code)) {
            return;
        }
        const isSelected = currentSelectedPaymentMethod === code;
        const PaymentMethodComponent = payments[code];
        const renderedComponent = isSelected ? (
            <PaymentMethodComponent
                onPaymentSuccess={onPaymentSuccess}
                onPaymentError={onPaymentError}
                resetShouldSubmit={resetShouldSubmit}
                shouldSubmit={shouldSubmit}
            />
        ) : null;
        return (
            <div key={code} className={classes.payment_method}>
                <Radio
                    label={title}
                    value={code}
                    classes={{
                        label: classes.radio_label
                    }}
                    checked={isSelected}
                />
                {renderedComponent}
            </div>
        );
    })
    .filter(paymentMethod => !!paymentMethod);

GitHub Corefile

PaymentInformation/summary.js. Renders information for the ReviewStep

PWA Studio Checkout Payment Workflow Diagram

Checkout Payment Workflow Diagram

Checkout Payment Extension point

To extend the Checkout with a new Payment Method, we can use a venia target for the paymentMethodCollection.js.

If you are not familiar with targets and how that works, Getting started with PWA Studio Extensibility describes the foundation concept.

Image that show PWA Studio Checkout

In the Image, we can see the billing address is by design part of the payment methods to allow special implementation.

Note: In default Magento, you need to make sure that the billing address is set by before you can complete the order.

For most of all Payment Methods, the billing address form would be identical. In the current develop branch, you can find @magento/venia-ui/lib/components/BillingAddress. You can copy it to start already building your Payment Method.

intercept.js
module.exports = (targets) => {
  const { specialFeatures } = targets.of("@magento/pwa-buildpack");
    /**
     *  Wee need to activate esModules, cssModules and GQL Queries to allow build pack to load our extension
     * {@link https://magento.github.io/pwa-studio/pwa-buildpack/reference/configure-webpack/#special-flags}.
     */
  specialFeatures.tap((flags) => {
    flags[targets.name] = {
      esModules: true,
      cssModules: true,
      graphqlQueries: true
    };
  });
 
   /** Registers our Payment **/
  const { checkoutPagePaymentTypes } = targets.of("@magento/venia-ui");
  checkoutPagePaymentTypes.tap((payments) =>
    payments.add({
      paymentCode: "payment-code",
      importPath: "@your-namespace/components/payment.js"
    })
  );
};

Your payment component gets the following props injected by the parent.

<YourPaymentMethodComponent
  onPaymentSuccess={onPaymentSuccess}
  onPaymentError={onPaymentError}
  resetShouldSubmit={resetShouldSubmit}
  shouldSubmit={shouldSubmit}
/>
  • onPaymentSuccess: Callback Methode should call to proceed to review step. If the Payment method needs a token (nonce), you need to take that was generated successfully before calling this method.

  • onPaymentError: Callback to invoke when the payment component throws an errors

  • resetShouldSubmit: Callback to reset the review order button flag

  • shouldSubmit (read-only): It will change to true if the review button gets summited.

There are many payment methods first can split them in to offline and online. In option there are two main types of Context / Dropin and Redirect/ Hosted payment page.

Before you select an PSP you check if they support a properer GraphQL Backend API if not you will spend hours creating them.

Here you find which workflow Adobe Commerce supports by default.

Workflow for Adyen Credit Card Payment 8.2.0

With the last Module version, Adyen released a ready to use GraphQL API for Adobe Commerce.

A very good starting point is using their web drop in render this in the payment section. To simplify your application flow, you should set payment method code already in the payment section.

mutation setAdyenPaymentOnCartWithAdditionalData(
	$cartId: String!
	$paymentMethod: String!
) {
	setPaymentMethodOnCart(
		input: { cart_id: $cartId, payment_method: { code: $paymentMethod } }
	) {
		cart {
			id
			selected_payment_method {
				code
				title
			}
		}
	}
}
Attention !!

I recommend to create a new checkout context and store the result from the drop in memory on client side. In terms of PCI compliance Adyen dont store the encrypted data see setPaymentMethodAndPlaceOrder.

Necessary Data to store

  • current CartId
  • Json encoded stateData
  • cc_type
Place Order Muatation
mutation setPaymentMethodAndPlaceOrder(
    $cartId: String!
    $stateData: String!
) {
    setPaymentMethodOnCart(
        input: {
            cart_id: $cartId
            payment_method: {
                code: "adyen_cc"
                adyen_additional_data_cc: {
                    cc_type: "VI"
                    stateData: $stateData
                }
            }
        }
    ) {
        cart {
            selected_payment_method {
                code
                title
            }
        }
    }
 
    placeOrder(
        input: {
            cart_id: $cartId
        }
    ) {
        order {
            order_id
            adyen_payment_status {
                isFinal
                resultCode
                additionalData
                action
            }
        }
    }
}
PWA Studio Checkout Payment Workflow Ayden Credit Card

Replace PWAStudio place order button

Some Payment method needs additional APIs call instead of using the default custom button.

In terms of implementing PayPal express, you want to replace the default place order with a custom one.

@custom/checkout/src/components/PlaceOrderButton
import React from 'react';
import LoadingIndicator from '@magento/venia-ui/lib/components/LoadingIndicator';
 
import { usePlaceOrderButton } from '../talons/usePlaceOrderButton';
import placeOrderButtonCollection from './placeOrderButtonCollection';
 
/**
 * Replace the default place order button with a custom one.
 * @see @custom/checkout/src/target/extend-intercept.js
 *
 * @param {object} props
 * @param {React.ReactElement} props.originalPlaceOrderButton
 * @param {function} props.handlePlaceOrder
 * @returns {React.ReactElement}
 */
const PlaceOrderButton = props => {
    const { originalPlaceOrderButton, handlePlaceOrder } = props;
 
    // should fetch the current payment method from cart via a query or context
    const { paymentMethod, loading } = usePlaceOrderButton();
 
 
    if (loading && !paymentMethod) {
        return (
            <LoadingIndicator/>
        );
    }
 
    const PlaceOrderButton = placeOrderButtonCollection[paymentMethod] || null;
 
    if (PlaceOrderButton) {
        // custom place order button if payment method matches with a collection
        return <PlaceOrderButton handlePlaceOrder={handlePlaceOrder} />;
    }
 
    return originalPlaceOrderButton;
};
 
export default PlaceOrderButton;
@custom/checkout/src/components/placeOrderButtonCollection
/**
* This will be populated via webpack and target from a pwa studio
*/
export default {};

We required to create new extensions point via intercept and declaring files. The pwa-studio.targets.intercept and pwa-studio.targets.declare definitions in your package.json need to path to these files.

@custom/checkout/src/target/declare.js
/**
 * These targets are available for interception to modules which depend on `@custom/checkout`.
 *
 * Their implementations can found in `./intercept.js`.
 *
 */
module.exports = targets => {
    targets.declare({
        orderButtonTypes: new targets.types.Sync(['orderButtonTypes']),
    });
};
@custom/checkout/src/target/intercept.js
const { Targetables } = require('@magento/pwa-buildpack');
const OrderButtonTypes = require("./OrderButtonTypes");
 
module.exports = targets => {
    targets.of('@magento/pwa-buildpack').specialFeatures.tap(flags => {
        /**
         *  Wee need to activated esModules and cssModules to allow build pack to load our extension
         * {@link https://magento.github.io/pwa-studio/pwa-buildpack/reference/configure-webpack/#special-flags}.
         */
        flags[targets.name] = {
            esModules: true,
            graphqlQueries: true,
        };
    });
 
    // Make payment button collection extendable
    const venia = Targetables.using(targets);
    new OrderButtonTypes(venia);
 
    const CheckoutPageComponent = venia.reactComponent(
        '@magento/venia-ui/lib/components/checkoutPage'
    );
 
    /**
     * Replace the place order button to the checkout page
     */
    const placeOrderButton = '{placeOrderButton}';
    CheckoutPageComponent.insertBeforeSource(
        placeOrderButton,
        '{customPlaceOrderButton}',
        {
            remove: placeOrderButton.length
        }
    );
 
    const CustomPlaceOrder = CheckoutPageComponent.addImport(
        "CustomPlaceOrder from '@custom/checkout/src/components/placeOrderButton'"
    );
 
    /**
     * Define the custom place order button
     */
    CheckoutPageComponent.insertBeforeSource(
        'const orderSummary',
        `const customPlaceOrderButton = checkoutStep === CHECKOUT_STEP.REVIEW ? (<${CustomPlaceOrder} originalPlaceOrderButton={placeOrderButton} handlePlaceOrder={handlePlaceOrder}></${CustomPlaceOrder}>): null;\n`
    );
}
@custom/checkout/package.json
{
  "name": "@custom/checkout",
  "version": "0.1.0",
  "main": "src/targets/intercept.js",
 
  "pwa-studio": {
    "targets": {
      "intercept": "src/targets/intercept.js"
    }
  }
}

How to use the created target in your module:

@my-payment/src/target/intercept.js
module.exports = targets => {
    targets.of('@magento/pwa-buildpack').specialFeatures.tap(flags => {
        /**
         *  Wee need to activated esModules and cssModules to allow build pack to load our extension
         * {@link https://magento.github.io/pwa-studio/pwa-buildpack/reference/configure-webpack/#special-flags}.
         */
        flags[targets.name] = {
            esModules: true,
            cssModules: true,
            graphqlQueries: true,
            i18n: true
        };
    });
    const { orderButtonTypes } = targets.of('@custom/checkout');
    orderButtonTypes.tap(buttons =>
        buttons.add({
            paymentCode: 'payment_method_code',
            importPath: '@my-payment/src/components/placeOrderButtonExpress.js'
        })
    );
}

Replace PWAStudio handlePlaceOrder mutation / action

If your PSP provider needs a different place order like ayden, then you are required to wrap useCheckout.js.

For this, we can already use building in a mechanism from peregrine use. When Peregrine talons are invoked, and/or to modify the behavior and output of those talons. It is like the around plugin concept known as the interceptor pattern of Magento.

You must also follow here the limitation of React that all React Hooks must be called in the exact same order in every component render. Currently there is no eslint error if you try to early bailout this lead to that your app have memory leaks or is not stable!!! If your lucky React will punch a errors in the console that a useEffect is called conditionally.

intercept.js
module.exports = targets => {
    targets.of('@magento/pwa-buildpack').specialFeatures.tap(flags => {
        /**
         *  Wee need to activated esModules and cssModules to allow build pack to load our extension
         * {@link https://magento.github.io/pwa-studio/pwa-buildpack/reference/configure-webpack/#special-flags}.
         */
        flags[targets.name] = {
            esModules: true,
            cssModules: true,
            graphqlQueries: true,
            i18n: true
        };
    });
    const { orderButtonTypes } = targets.of('@custom/checkout');
    orderButtonTypes.tap(buttons =>
        buttons.add({
            paymentCode: 'payment_method_code',
            importPath: '@my-payment/src/components/placeOrderButtonExpress.js'
        })
    );
 
 targets.of('@magento/peregrine').talons.tap(talons => {
        talons.CheckoutPage.useCheckoutPage.wrapWith(
            `@my-payment/src/wrapers/uwCheckout`
        );
    });
}
import { useCheckoutFlow } from "../talons/useCheckoutFlow";
 
 
/**
 * Allow extending useCheckout to overwrite function of the hook
 *
 * @param origUseCheckout
 * @returns {function(*): *&{orderNumber: *, placeOrderLoading: boolean, clearCart: ((function(): Promise<void>)|*), orderDetailsLoading: boolean, handlePlaceOrder: ((function(): void)|*), adyenCheckoutAction: *, orderDetailsData: *}}
 */
export default function wrapUseCheckout(origUseCheckout) {
    return function(props) {
        // we cloud also overwrite some props before we execute the useCheckout function.
        const originalReturn = origUseCheckout(props);
        // we partly overwrite the result and extend it
        return { ...originalReturn,...useCheckoutFlow(originalReturn) };
    };
}
talons/useCheckoutFlow.js
export const useCheckoutFlow = (props) => {
  const handleAdyenPlaceOrder = useCallback(() => {
    async function placeOrderAndCleanup() {
      await getOrderDetails({
        variables: {
          cartId,
        },
      });
    }
 
    placeOrderAndCleanup();
  }, [cartId, getOrderDetails]);
 
  /**
   * @param methodeCode string
   * @returns {*}
   */
  const isMyPaymentFlow = (methodeCode) => {
    return methodeCode.startsWith('my_payment');
  };
 
  if (isMyPaymentFlow(checkoutState.method) === false) {
    return {
      ...props,
      adyenCheckoutAction,
      orderDetailsLoading,
      clearCart
    };
  }
 
  return {
    orderNumber,
    orderDetailsData,
    orderDetailsLoading,
    handlePlaceOrder: handleAdyenPlaceOrder,
    placeOrderLoading,
    adyenCheckoutAction,
    clearCart
  };
};
Profile

Lars Roettig is a Senior Software Engineer at TechDivision GmbH. digital agency focused on Adobe Commerce and modern web development. My personal goal is to teach you how to write stable software with quality.

Learn more about Lars