@@ -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;