In this article we're going to focus on setting up a front-end application to handle credit card payment with Stripe and achieve a seamless online shopping experience.
Software prerequisites:
- Magento Open Source 2.4.x,
- Stripe Payments 4.x - module for Magento,
- React application (mine was based on Next.js 14.x).
What you already did:
- configured your Stripe account,
- installed and configured Stripe Payments module for your Magento instance.
Initialize the Cart
Before an user adds a product to the cart, we need to create the cart first in Magento. e.g. right after we initialize the application.
POST /rest/V1/guest-carts
The endpoint is going to return the cart ID. We need to store it somewhere to make it persistent, e.g. in localStorage. There are a couple of state-management libraries which let you store persistent state in browser's inbuilt storages (e.g. zustand).
💡 In Magento cart and quote are called interchangeably and they're basically the same object
Then the user can add products to the cart.
POST /rest/V1/guest-carts/${cartId}/items
{
cartItem: {
quote_id: cartId,
sku,
qty,
product_option, // For composite products
}
}
Checkout component
To put it simply, your checkout probably consists of steps and looks more-less like below. For the needs of this article, the following four-step component illustrates what actions an user needs to take to reach credit card details form.
const Checkout: React.FC = () => (
<>
<main>
<Steps>
<ShippingMethod />
<ShippingDetails />
<BillingDetails />
<Payment />
</Steps>
</main>
<aside>
<Cart />
</aside>
</>
);
💡 This article is mostly about Stripe Payments. I skip some obvious logic to keep the article short and simple e.g. I skip logic related to showing processing state indicator, validating or disabling the forms when making asynchronous requests
Shipping Method and Shipping Address
As the user already added something to cart, let's fetch available shipping methods for the country.
POST /rest/${storeCode}/V1/guest-carts/${cartId}/estimate-shipping-methods
{
address: {
countryId
}
}
As we collected the shipping method and the shipping address, we can store them in Magento.
POST /rest/${storeCode}/V1/guest-carts/${cartId}/shipping-information
{
addressInformation: {
shippingAddress,
shipping_carrier_code,
shipping_method_code,
}
}
You should receive available payment methods in response from the above endpoint.
💡 If your store allows to choose from multiple countries for the delivery, you can do the other way around - first collect the shipping address and then fetch available shipping methods
Billing Address and Payment
At this point, the user already added a product to the cart, selected a shipping method and filled in the shipping address. In this step he or she is going to choose one from available payment methods (Credit Card in this case), fill in the billing address and credit card details.
GET /rest/V1/guest-carts/${cartId}/totals
Make sure you have got recent totals information - you can use the same data which got fetched for presentation purposes in the Cart component. We need to pass the grand total to Stripe.
Payment elements
At this point the user already filled in the billing address and selected online payment as the payment method. Stripe offers multiple ways to handle the payment UI - in this guide I will be using @stripe/react-stripe-js package.
import type React from 'react';
import { Elements, useElements, useStripe } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { stripePublicApiKey, locale } from '@adam/StripeConfig';
import StripePaymentHandler from '@adam/StripePaymentHandler';
type StripePaymentProps = {
grandTotal: number;
billingName: string;
currency: string;
};
// Make sure to call `loadStripe` outside of a component's render to avoid
// recreating the `Stripe` object on every render.
const stripePromise = loadStripe(stripePublicApiKey, { locale });
const StripePayment: React.FC<StripePaymentProps> = ({ grandTotal, billingName, currency }) => (
<Elements
stripe={stripePromise}
options={{
mode: 'payment',
currency,
// Amount must be lowest currency unit, e.g. $100 = 10000
amount: Math.ceil(grandTotal * 100),
locale,
paymentMethodCreation: 'manual',
payment_method_types: ['card'],
// You can add "appearance" property to change look and feel of the credit card form
}}
>
<StripePaymentHandler billingName={billingName} />
</Elements>
);
export default StripePayment;
💡 It's important to round up the result of grandTotal * 100, because in some cases we may get floating point numbers from multiplication. Sounds interesting? Do a research to know why 18335.44 * 100 = 1833543.9999999998 🙂
import { useState } from 'react';
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';
import type { PaymentIntentOrSetupIntentResult, StripeError } from '@stripe/stripe-js';
import type { AxiosError } from 'axios';
import Message from '@adam/Message';
import Button from '@adam/Button';
import { useCheckoutContext } from '@adam/checkout/CheckoutContext';
export type NextActionHandler = (
error: AxiosError<{ message: string }>
) => Promise<PaymentIntentOrSetupIntentResult>;
type StripePaymentHandlerProps = {
billingName: string;
};
const StripePaymentHandler: React.FC<StripePaymentHandlerProps> = ({ billingName }) => {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const { placeOrder } = useCheckoutContext();
const handleAuthentication: NextActionHandler = (error) => {
if (
error.response?.status !== 400 ||
!error.response.data.message.startsWith('Authentication Required: ')
) {
// No auth required, continue normally
return Promise.reject(error);
}
if (!stripe) {
// Should never happen
return Promise.reject(new Error('Stripe is not initialized'));
}
return stripe
.handleNextAction({
clientSecret: error.response.data.message.split(': ')[1],
})
.then((result) => {
if (result.error) {
// Promise resolves with an error
return Promise.reject(result.error);
}
return result;
});
};
const createPaymentMethod = () => {
if (!stripe) return;
stripe.createPaymentMethod({
elements,
params: {
billing_details: {
name: billingName,
},
},
}).then((result) => {
if (result && result.paymentMethod) {
setError(null);
const poData = {
paymentMethod: { additional_data: { payment_method: result.paymentMethod.id } }
};
placeOrder(poData)
.then((res) => {
// Order successful
})
.catch((error) => {
// Additional auth required - e.g. 3D Secure
handleAuthentication(error)
.then(() =>
// Auth successful, try to place an order again
placeOrder(poData)
.then((res) => {
// Order successful
})
.catch((error) =>
setError(error.message || "Unknown error")
)
)
.catch((error) => setError(error.message || 'Unknown error'));
});
}
}).catch((error: StripeError) =>
setError(error.message || 'Unknown payment error')
);
};
const submitElements = () => {
if (elements) elements.submit().then(() => createPaymentMethod);
};
return (
<>
<PaymentElement />
{error && <Message>error</Message>}
<Button onClick={submitElements}>Place Order</Button>
</>
);
};
export default StripePaymentHandler;
Place order
As you already noticed in the above component I use placeOrder
function from useCheckoutContext
hook. This function should be a chain of two promises - setting payment information and placing the order.
POST /rest/${storeCode}/V1/guest-carts/${cartId}/set-payment-information
{
paymentMethod: {
method,
additional_data,
},
billingAddress,
email,
}
If setting the payment information is successful make an attempt to place the order. Under the hood Magento will talk with Stripe service (server to server) and place the order if the payment gets approved.
PUT /rest/${storeCode}/V1/guest-carts/${cartId}/order
Payment promise scenarios
What will happen next in the order placement process can be described as four scenarios which we want to handle in our application's checkout logic:
- Payment gets accepted and the order gets placed. Then we want to display the order confirmation page.
- Payment gets declined. Stripe reported an error after receiving initial payment information. Then we want to display a message.
- Order gets declined. Stripe accepted initial payment information, but Magento reported an error. Then we want to display a message.
- Payment requires additional authentication. Then we want to display a modal to perform the authentication.
Few words about scenarios
Payment gets accepted and the order gets placed
Magento talks with Stripe and there's everything fine about the payment. Magento stores the order and returns the order ID, so we can notify the user about successfully completing the process.
Payment gets declined
Magento talks with Stripe and there's something wrong about the payment. Magento returns an error message which we can show to the user.
Order gets declined
Something is wrong about the order itself. Payment process gets interrupted.
Payment requires additional authentication
In my opinion it's the most interesting scenario. To handle it is a responsibility of the handleAuthentication
function. It could be the case when e.g. there's a 3-D Secure Authentication required. When Magento returns a 400
status code response with the message Authentication Required: client-secret-goes-here
we know we need some additional action. Let's pass the client secret to Stripe's handleNextAction
function and it will know what to do next, e.g. it will display an authentication popup.
Summary
Stripe is a good quality choice and when integrated with SPA, it gives us a smooth and modern shopping experience. It's also relatively simple to set it up.
- "People shopping online" photo by John Schnobrich on Unsplash
- "Credit card" photo by CardMapr.nl on Unsplash