diff --git a/.env b/.env index 9b15b303f..f9766565d 100644 --- a/.env +++ b/.env @@ -21,6 +21,8 @@ REFRESH_ACCESS_TOKEN_ENDPOINT=null SEGMENT_KEY=null SITE_NAME=null USER_INFO_COOKIE_NAME=null +USER_LOCATION_COOKIE_NAME=null +LOCATION_OVERRIDE_COOKIE=null CURRENCY_COOKIE_NAME=null SUPPORT_URL=null CYBERSOURCE_URL=null diff --git a/.env.development b/.env.development index 3ed3f90cb..a239c4371 100644 --- a/.env.development +++ b/.env.development @@ -19,6 +19,8 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' SEGMENT_KEY='VMsX2obE9Xveo4se3c6CmfdG0LZVc7qI' SITE_NAME='edX' USER_INFO_COOKIE_NAME='edx-user-info' +USER_LOCATION_COOKIE_NAME='prod-edx-cf-loc' +LOCATION_OVERRIDE_COOKIE='location-override' CURRENCY_COOKIE_NAME='edx-price-l10n' SUPPORT_URL='http://localhost:18000/support' CYBERSOURCE_URL='https://testsecureacceptance.cybersource.com/silent/pay' diff --git a/.env.development-stage b/.env.development-stage index fc6c6f0ed..4e600db6a 100644 --- a/.env.development-stage +++ b/.env.development-stage @@ -19,6 +19,8 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='/proxy/lms/login_refresh' SEGMENT_KEY=null SITE_NAME='edX' USER_INFO_COOKIE_NAME='npmstage-edx-user-info' +USER_LOCATION_COOKIE_NAME='prod-edx-cf-loc' +LOCATION_OVERRIDE_COOKIE='location-override' CURRENCY_COOKIE_NAME='edx-price-l10n' SUPPORT_URL='/proxy/lms/support' CYBERSOURCE_URL='https://testsecureacceptance.cybersource.com/silent/pay' diff --git a/.env.test b/.env.test index 529f66f27..eba51b57f 100644 --- a/.env.test +++ b/.env.test @@ -17,6 +17,8 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' SEGMENT_KEY=null SITE_NAME='edX' USER_INFO_COOKIE_NAME='edx-user-info' +USER_LOCATION_COOKIE_NAME='prod-edx-cf-loc' +LOCATION_OVERRIDE_COOKIE='location-override' # App specific CURRENCY_COOKIE_NAME='edx-price-l10n' diff --git a/src/components/formatted-alert-list/FormattedAlertList.jsx b/src/components/formatted-alert-list/FormattedAlertList.jsx index 2a483b162..8728d0b95 100644 --- a/src/components/formatted-alert-list/FormattedAlertList.jsx +++ b/src/components/formatted-alert-list/FormattedAlertList.jsx @@ -8,6 +8,7 @@ import { EnrollmentCodeQuantityUpdated, TransactionDeclined, BasketChangedError, + DynamicPaymentMethodsNotCompatibleError, CaptureKeyTimeoutTwoMinutes, CaptureKeyTimeoutOneMinute, } from '../../payment/AlertCodeMessages'; @@ -47,6 +48,9 @@ export const FormattedAlertList = (props) => { 'basket-changed-error-message': ( ), + 'dynamic-payment-methods-country-not-compatible': ( + + ), 'capture-key-2mins-message': ( ), diff --git a/src/feedback/data/sagas.js b/src/feedback/data/sagas.js index 6d26ce835..3a65e1cb6 100644 --- a/src/feedback/data/sagas.js +++ b/src/feedback/data/sagas.js @@ -19,7 +19,7 @@ export function* handleErrors(e, clearExistingMessages) { if (e.errors !== undefined) { for (let i = 0; i < e.errors.length; i++) { // eslint-disable-line no-plusplus const error = e.errors[i]; - if (error.code === 'basket-changed-error-message') { + if (error.code === 'basket-changed-error-message' || error.code === 'dynamic-payment-methods-country-not-compatible') { yield put(addMessage(error.code, error.userMessage, {}, MESSAGE_TYPES.ERROR)); } else if (error.data === undefined && error.messageType === null) { yield put(addMessage('transaction-declined-message', error.userMessage, {}, MESSAGE_TYPES.ERROR)); diff --git a/src/payment/AlertCodeMessages.jsx b/src/payment/AlertCodeMessages.jsx index 87c64b45c..e9f4145a5 100644 --- a/src/payment/AlertCodeMessages.jsx +++ b/src/payment/AlertCodeMessages.jsx @@ -73,6 +73,14 @@ export const TransactionDeclined = () => ( /> ); +export const DynamicPaymentMethodsNotCompatibleError = () => ( + +); + export const BasketChangedError = () => ( { expect(tree).toMatchSnapshot(); }); }); + +describe('DynamicPaymentMethodsNotCompatibleError', () => { + it('should render with values', () => { + const component = ( + + + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('BasketChangedError', () => { + it('should render with values', () => { + const component = ( + + + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/payment/PaymentPage.jsx b/src/payment/PaymentPage.jsx index 597ed74b9..9bdda4042 100644 --- a/src/payment/PaymentPage.jsx +++ b/src/payment/PaymentPage.jsx @@ -1,11 +1,20 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { loadStripe } from '@stripe/stripe-js'; +import { getConfig } from '@edx/frontend-platform'; +import { + FormattedMessage, + getLocale, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; +import { logInfo } from '@edx/frontend-platform/logging'; import { AppContext } from '@edx/frontend-platform/react'; import { sendPageEvent } from '@edx/frontend-platform/analytics'; import messages from './PaymentPage.messages'; +import { handleApiError } from './data/handleRequestError'; // Actions import { fetchBasket } from './data/actions'; @@ -38,6 +47,9 @@ class PaymentPage extends React.Component { REV1045Experiment, isTransparentPricingExperiment, enrollmentCountData, + stripe: null, + paymentStatus: null, // only available when redirected back to payment.edx.org from dynamic payment methods + orderNumber: null, // order number associated with the Payment Intent from dynamic payment methods }; } @@ -46,15 +58,74 @@ class PaymentPage extends React.Component { this.props.fetchBasket(); } + componentDidUpdate(prevProps) { + const { enableStripePaymentProcessor } = this.props; + if (!prevProps.enableStripePaymentProcessor && enableStripePaymentProcessor) { + this.initializeStripe(); + } + } + + initializeStripe = async () => { + const stripePromise = await loadStripe(process.env.STRIPE_PUBLISHABLE_KEY, { + betas: [process.env.STRIPE_BETA_FLAG], + apiVersion: process.env.STRIPE_API_VERSION, + locale: getLocale(), + }); + this.setState({ stripe: stripePromise }, () => { + this.retrievePaymentIntentInfo(); + }); + }; + + retrievePaymentIntentInfo = async () => { + // Get Payment Intent to retrieve the payment status and order number associated with this DPM payment. + // If this is not a Stripe dynamic payment methods (BNPL), URL will not contain any params + // and should not retrieve the Payment Intent. + const searchParams = new URLSearchParams(global.location.search); + const clientSecretId = searchParams.get('payment_intent_client_secret'); + if (clientSecretId) { + const { paymentIntent, error } = await this.state.stripe.retrievePaymentIntent(clientSecretId); + if (error) { handleApiError(error); } + this.setState({ orderNumber: paymentIntent.description }); + this.setState({ paymentStatus: paymentIntent.status }); + } + }; + + redirectToReceiptPage(orderNumber) { + logInfo(`Payment succeeded for edX order number ${orderNumber}, redirecting to ecommerce receipt page.`); + const queryParams = `order_number=${orderNumber}&disable_back_button=${Number(true)}&dpm_enabled=${true}`; + if (getConfig().ENVIRONMENT !== 'test') { + /* istanbul ignore next */ + global.location.assign(`${getConfig().ECOMMERCE_BASE_URL}/checkout/receipt/?${queryParams}`); + } + } + renderContent() { - const { isEmpty, isRedirect } = this.props; + const { isEmpty, isRedirect, isPaymentRedirect } = this.props; + const { isNumEnrolledExperiment, REV1045Experiment, isTransparentPricingExperiment, enrollmentCountData, + paymentStatus, + stripe, + orderNumber, } = this.state; + // If this is a redirect from Stripe Dynamic Payment Methods with a successful payment, redirect to the receipt page + if (paymentStatus === 'succeeded') { + this.redirectToReceiptPage(orderNumber); + } + + // If this is a redirect from Stripe Dynamic Payment Methods, show loading icon until getPaymentStatus is done. + if (isPaymentRedirect && (paymentStatus !== 'requires_payment_method' || paymentStatus !== 'canceled')) { + return ( + + ); + } + // If we're going to be redirecting to another page instead of showing the user the interface, // show a minimal spinner while the redirect is happening. In other cases we want to show the // page skeleton, but in this case that would be misleading. @@ -98,7 +169,7 @@ class PaymentPage extends React.Component { />
- + { stripe ? : }
); @@ -123,6 +194,8 @@ PaymentPage.propTypes = { intl: intlShape.isRequired, isEmpty: PropTypes.bool, isRedirect: PropTypes.bool, + isPaymentRedirect: PropTypes.bool, + enableStripePaymentProcessor: PropTypes.bool, fetchBasket: PropTypes.func.isRequired, summaryQuantity: PropTypes.number, summarySubtotal: PropTypes.number, @@ -131,6 +204,8 @@ PaymentPage.propTypes = { PaymentPage.defaultProps = { isEmpty: false, isRedirect: false, + isPaymentRedirect: false, + enableStripePaymentProcessor: false, summaryQuantity: undefined, summarySubtotal: undefined, }; diff --git a/src/payment/__snapshots__/AlertCodeMessages.test.jsx.snap b/src/payment/__snapshots__/AlertCodeMessages.test.jsx.snap index 09d6ffd77..39ca5ab23 100644 --- a/src/payment/__snapshots__/AlertCodeMessages.test.jsx.snap +++ b/src/payment/__snapshots__/AlertCodeMessages.test.jsx.snap @@ -1,5 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`BasketChangedError should render with values 1`] = ` +
+ Your cart has changed since navigating to this page. Please reload the page and verify the product you are purchasing. +
+`; + +exports[`DynamicPaymentMethodsNotCompatibleError should render with values 1`] = ` +
+ The payment method you have selected is not available in your country. Please select another payment method. +
+`; + exports[`EnrollmentCodeQuantityUpdated should render with values 1`] = `
diff --git a/src/payment/checkout/Checkout.jsx b/src/payment/checkout/Checkout.jsx index 7831f459a..03c3ceec6 100644 --- a/src/payment/checkout/Checkout.jsx +++ b/src/payment/checkout/Checkout.jsx @@ -2,10 +2,8 @@ import React from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { loadStripe } from '@stripe/stripe-js'; import { Elements } from '@stripe/react-stripe-js'; import { - getLocale, FormattedMessage, injectIntl, intlShape, @@ -95,13 +93,13 @@ class Checkout extends React.Component { this.props.submitPayment({ method: 'stripe', ...formData }); }; - handleSubmitStripeButtonClick = () => { + handleSubmitStripeButtonClick = (stripeSelectedPaymentMethod) => { sendTrackEvent( 'edx.bi.ecommerce.basket.payment_selected', { type: 'click', category: 'checkout', - paymentMethod: 'Credit Card - Stripe', + paymentMethod: stripeSelectedPaymentMethod === 'affirm' ? 'Affirm - Stripe' : 'Credit Card - Stripe', checkoutType: 'client_side', stripeEnabled: this.props.enableStripePaymentProcessor, }, @@ -157,6 +155,7 @@ class Checkout extends React.Component { paymentMethod, submitting, orderType, + stripe, } = this.props; const submissionDisabled = loading || isBasketProcessing; const isBulkOrder = orderType === ORDER_TYPES.BULK_ENROLLMENT; @@ -223,16 +222,6 @@ class Checkout extends React.Component { const shouldDisplayStripePaymentForm = !loading && enableStripePaymentProcessor && options.clientSecret; const shouldDisplayCyberSourcePaymentForm = !loading && !enableStripePaymentProcessor; - // Doing this within the Checkout component so locale is configured and available - let stripePromise; - if (shouldDisplayStripePaymentForm) { - stripePromise = loadStripe(process.env.STRIPE_PUBLISHABLE_KEY, { - betas: [process.env.STRIPE_BETA_FLAG], - apiVersion: process.env.STRIPE_API_VERSION, - locale: getLocale(), - }); - } - return ( <>
@@ -269,7 +258,7 @@ class Checkout extends React.Component { since the flag will not be needed when we remove CyberSource. This is not needed in CyberSource form component since the default is set to false. */} {shouldDisplayStripePaymentForm ? ( - + ', () => { fireEvent.change(screen.getByLabelText('Country (required)'), { target: { value: 'US' } }); expect(getCountryStatesMap).toHaveBeenCalledWith('US'); - expect(isPostalCodeRequired).toHaveBeenCalledWith('US'); + expect(isPostalCodeRequired).toHaveBeenCalledWith('US', false); // DPM enabled added to the call }); }); describe('purchasedForOrganization field', () => { diff --git a/src/payment/checkout/payment-form/PlaceOrderButton.jsx b/src/payment/checkout/payment-form/PlaceOrderButton.jsx index fec4a883d..22215bac9 100644 --- a/src/payment/checkout/payment-form/PlaceOrderButton.jsx +++ b/src/payment/checkout/payment-form/PlaceOrderButton.jsx @@ -4,7 +4,7 @@ import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { StatefulButton } from '@openedx/paragon'; const PlaceOrderButton = ({ - showLoadingButton, onSubmitButtonClick, disabled, isProcessing, + showLoadingButton, onSubmitButtonClick, stripeSelectedPaymentMethod, disabled, isProcessing, }) => { let submitButtonState = 'default'; // istanbul ignore if @@ -26,7 +26,7 @@ const PlaceOrderButton = ({ size="lg" block state={submitButtonState} - onClick={onSubmitButtonClick} + onClick={() => onSubmitButtonClick(stripeSelectedPaymentMethod)} labels={{ default: ( { + // Check payment type before submitting since BNPL requires state/zip code and Afterpay requires a shipping address. + // This is also used for analytics on "Place Order" click event. + const [stripeSelectedPaymentMethod, setStripeSelectedPaymentMethod] = useState(null); const stripe = useStripe(); const elements = useElements(); const context = useContext(AppContext); @@ -53,9 +59,24 @@ const StripePaymentForm = ({ const checkoutDetails = useSelector(paymentDataSelector); const { - enableStripePaymentProcessor, loading, submitting, products, + enableStripePaymentProcessor, + loading, + submitting, + products, + isDynamicPaymentMethodsEnabled, + currency, + locationCountryCode, + orderTotal, } = checkoutDetails; + // Check if should show PaymentMethodMessagingElement, as it only renders + // for specific countries, if country code and currency are known, and they must match + const userLocationCountryCode = new Cookies().get(getConfig().LOCATION_OVERRIDE_COOKIE) + || new Cookies().get(getConfig().USER_LOCATION_COOKIE_NAME); + const shouldDisplayPaymentMethodMessagingElement = ( + (!!userLocationCountryCode || !!locationCountryCode) && !!orderTotal && !!currency + ); + // Loading button should appear when: basket and stripe elements are loading, quantity is updating and not submitting // isQuantityUpdating is true when isBasketProcessing is true when there is an update in the quantity for // bulk purchases but also happens on submit, when the 'processing' button state should show instead @@ -127,10 +148,16 @@ const StripePaymentForm = ({ } onSubmitPayment({ - skus, elements, stripe, context, values, + skus, elements, stripe, context, values, stripeSelectedPaymentMethod, }); }; + const handleStripeElementOnChange = (event) => { + if (event.value) { + setStripeSelectedPaymentMethod(event.value.type); + } + }; + const stripeElementsOnReady = () => { setIsStripeElementLoading(false); markPerformanceIfAble('Stripe Elements component rendered'); @@ -162,6 +189,7 @@ const StripePaymentForm = ({ showBulkEnrollmentFields={isBulkOrder} disabled={submitting} enableStripePaymentProcessor={enableStripePaymentProcessor} + isDynamicPaymentMethodsEnabled={isDynamicPaymentMethodsEnabled} />
+ {shouldDisplayPaymentMethodMessagingElement ? ( + + ) : null } {isSubscription ? ( <> @@ -189,6 +227,7 @@ const StripePaymentForm = ({ ) : ( { }; // eslint-disable-next-line import/prefer-default-export -export function isPostalCodeRequired(selectedCountry) { - const countryListRequiredPostalCode = ['CA', 'GB', 'US']; +export function isPostalCodeRequired(selectedCountry, isDynamicPaymentMethodsEnabled) { + // Stripe recommends to have state and zip code since it can have a material effect on + // our card authorization rates and fees that the card networks and issuers charge. + // 'CA', 'GB' and 'US' were alreay required prior to implementing Dynamic Payment Methods. + // The Stripe API also requires state and zip code for BNPL options (Affirm, Afterpay, Klarna) + // for the countries that these payment methods are compatible with. + let countryListRequiredPostalCode = []; + if (isDynamicPaymentMethodsEnabled) { + countryListRequiredPostalCode = [ + 'CA', // Affirm, Afterpay, Klarna + 'GB', // Afterpay, Klarna + 'US', // Affirm, Afterpay, Klarna + 'AU', // Afterpay, Klarna + 'AT', // Klarna + 'BE', // Klarna + 'CH', // Klarna + 'CZ', // Klarna + 'DE', // Klarna + 'DK', // Klarna + 'ES', // Klarna + 'FI', // Klarna + 'FR', // Klarna + 'GR', // Klarna + 'IE', // Klarna + 'IT', // Klarna + 'NL', // Klarna + 'NO', // Klarna + 'NZ', // Afterpay, Klarna + 'PL', // Klarna + 'PT', // Klarna + 'SE', // Klarna + ]; + } else { + countryListRequiredPostalCode = ['CA', 'GB', 'US']; + } + const postalCodeRequired = countryListRequiredPostalCode.includes(selectedCountry); return postalCodeRequired; diff --git a/src/payment/data/handleRequestError.js b/src/payment/data/handleRequestError.js index df23c4ebd..5a2a0a9bd 100644 --- a/src/payment/data/handleRequestError.js +++ b/src/payment/data/handleRequestError.js @@ -84,7 +84,20 @@ export default function handleRequestError(error) { ]); } - // Basket already purchased + // Country not DPM compatible + if (error.type === 'invalid_request_error' && ( + error.param === 'payment_method_data[billing_details][address][country]' || error.param === 'billing_details[address][state]' || error.param === 'billing_details[address][postal_code]' + )) { + logInfo('Dynamic Payment Method Country Error', error.param); + handleApiErrors([ + { + error_code: 'dynamic-payment-methods-country-not-compatible', + user_message: 'error', + }, + ]); + } + + // For a Payment Intent to be confirmable, it must be in requires_payment_method or requires_confirmation if (error.code === 'payment_intent_unexpected_state' && error.type === 'invalid_request_error') { logInfo('Basket Changed Error', error.code); handleApiErrors([ diff --git a/src/payment/data/redux.test.js b/src/payment/data/redux.test.js index 05862e558..356ed36d5 100644 --- a/src/payment/data/redux.test.js +++ b/src/payment/data/redux.test.js @@ -38,6 +38,7 @@ describe('redux tests', () => { expect(result).toEqual({ currencyCode: undefined, conversionRate: undefined, + locationCountryCode: undefined, showAsLocalizedCurrency: false, }); }); @@ -46,12 +47,14 @@ describe('redux tests', () => { Cookies.result = { code: 'USD', rate: 1, + countryCode: 'US', }; const result = localizedCurrencySelector(); expect(result).toEqual({ currencyCode: 'USD', conversionRate: 1, + locationCountryCode: 'US', showAsLocalizedCurrency: false, }); }); @@ -60,12 +63,14 @@ describe('redux tests', () => { Cookies.result = { code: 'EUR', rate: 1.5, + countryCode: 'FR', }; const result = localizedCurrencySelector(); expect(result).toEqual({ currencyCode: 'EUR', conversionRate: 1.5, + locationCountryCode: 'FR', showAsLocalizedCurrency: true, }); }); @@ -107,6 +112,7 @@ describe('redux tests', () => { isCouponRedeemRedirect: false, isBasketProcessing: false, isEmpty: false, + isPaymentRedirect: false, isRedirect: false, }); }); @@ -127,9 +133,31 @@ describe('redux tests', () => { isCouponRedeemRedirect: true, // this is now true isBasketProcessing: false, isEmpty: false, + isPaymentRedirect: false, isRedirect: true, // this is also now true. }); }); + + it('is a Stripe dynamic payment methods redirect', () => { + global.history.pushState({}, '', '?payment_intent=pi_123dummy'); + store = createStore(combineReducers({ + payment: reducer, + })); + + const result = paymentSelector(store.getState()); + expect(result).toEqual({ + loading: true, + loaded: false, + submitting: false, + redirect: false, // This is a different kind of redirect, so still false. + products: [], + isCouponRedeemRedirect: false, + isBasketProcessing: false, + isEmpty: false, + isPaymentRedirect: true, // this is now true + isRedirect: false, + }); + }); }); }); diff --git a/src/payment/data/selectors.js b/src/payment/data/selectors.js index 5b1ae792b..f4d329693 100644 --- a/src/payment/data/selectors.js +++ b/src/payment/data/selectors.js @@ -37,9 +37,12 @@ export const paymentSelector = createSelector( (basket, queryParams) => { const isCouponRedeemRedirect = !!queryParams && queryParams.coupon_redeem_redirect == 1; // eslint-disable-line eqeqeq + const isPaymentRedirect = !!queryParams + && Boolean(queryParams.payment_intent); // Only klarna has redirect_status on URL return { ...basket, isCouponRedeemRedirect, + isPaymentRedirect, isEmpty: basket.loaded && !basket.redirect && (!basket.products || basket.products.length === 0), isRedirect: diff --git a/src/payment/data/utils.js b/src/payment/data/utils.js index 85675331f..0ce13e80c 100644 --- a/src/payment/data/utils.js +++ b/src/payment/data/utils.js @@ -193,10 +193,12 @@ export const localizedCurrencySelector = () => { const cookie = new Cookies().get(getConfig().CURRENCY_COOKIE_NAME); let currencyCode; let conversionRate; + let locationCountryCode; if (cookie && typeof cookie.code === 'string' && typeof cookie.rate === 'number') { currencyCode = cookie.code; conversionRate = cookie.rate; + locationCountryCode = cookie.countryCode; } const showAsLocalizedCurrency = typeof currencyCode === 'string' ? currencyCode !== 'USD' : false; @@ -204,6 +206,7 @@ export const localizedCurrencySelector = () => { return { currencyCode, conversionRate, + locationCountryCode, showAsLocalizedCurrency, }; }; diff --git a/src/payment/payment-methods/stripe/service.js b/src/payment/payment-methods/stripe/service.js index e20369080..62e59c0ef 100644 --- a/src/payment/payment-methods/stripe/service.js +++ b/src/payment/payment-methods/stripe/service.js @@ -18,7 +18,7 @@ ensureConfig(['ECOMMERCE_BASE_URL', 'STRIPE_RESPONSE_URL'], 'Stripe API service' export default async function checkout( basket, { - skus, elements, stripe, context, values, + skus, elements, stripe, context, values, stripeSelectedPaymentMethod, }, setLocation = href => { global.location.href = href; }, // HACK: allow tests to mock setting location ) { @@ -35,6 +35,21 @@ export default async function checkout( purchasedForOrganization, } = values; + let shippingAddress; + if (stripeSelectedPaymentMethod === 'afterpay_clearpay') { + shippingAddress = { + address: { + city, + country, + line1: address, + line2: unit || '', + postal_code: postalCode || '', + state: state || '', + }, + name: `${firstName} ${lastName}`, + }; + } + const result = await stripe.updatePaymentIntent({ elements, params: { @@ -56,10 +71,12 @@ export default async function checkout( purchased_for_organization: purchasedForOrganization, }, }, + // Shipping is required for processing Afterpay payments + shipping: shippingAddress, }, }); - if (result.error?.code === 'payment_intent_unexpected_state' && result.error?.type === 'invalid_request_error') { + if (result.error) { handleApiError(result.error); } @@ -67,6 +84,7 @@ export default async function checkout( const postData = formurlencoded({ payment_intent_id: result.paymentIntent.id, skus, + dynamic_payment_methods_enabled: basket.isDynamicPaymentMethodsEnabled, }); await getAuthenticatedHttpClient() .post( @@ -76,8 +94,27 @@ export default async function checkout( headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }, ) - .then(response => { - setLocation(response.data.receipt_page_url); + .then(async response => { + // If response contains receipt_page_url, it's not a DPM payment + if (response.data.receipt_page_url) { + setLocation(response.data.receipt_page_url); + } + if (response.data.status === 'requires_action') { + const { error } = await stripe.handleNextAction({ + clientSecret: response.data.confirmation_client_secret, + }); + + if (error) { + // Log error and tell user. + logError(error, { + messagePrefix: 'Stripe Submit Error', + paymentMethod: 'Stripe', + paymentErrorType: 'Submit Error', + basketId, + }); + handleApiError(error); + } + } }) .catch(error => { const errorData = error.response ? error.response.data : null;