— 10 min read
Edit post on GitHubBy 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:
export const CHECKOUT_STEP = {
SHIPPING_ADDRESS: 1,
SHIPPING_METHOD: 2,
PAYMENT: 3,
REVIEW: 4
};
PaymentInformation/paymentInformation.js
The checkout page will display the component on the checkout step PAYMENT.
It is responsible for:
LoadingIndicator/indicator.js
on loadPaymentInformation/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!
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);
PaymentInformation/summary.js. Renders information for the ReviewStep
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.
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.
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.
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
}
}
}
}
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
stateData
cc_type
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
}
}
}
}
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.
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;
/**
* 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.
/**
* 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']),
});
};
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`
);
}
{
"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:
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'
})
);
}
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.
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) };
};
}
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
};
};
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