mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-28 12:43:11 +10:00
Update CheckoutPage container:
Combine initiateOrder and initiateOrderAfterEnquiry thunk functions Use PaymentIntents flow: initiateOrder (SDK), handleCardPayment(Stripe), confirmPayment (SDK), sendMessage (SDK)
This commit is contained in:
parent
441ecd9759
commit
5854ff0ecc
5 changed files with 431 additions and 170 deletions
|
|
@ -2,7 +2,11 @@ import pick from 'lodash/pick';
|
|||
import config from '../../config';
|
||||
import { denormalisedResponseEntities } from '../../util/data';
|
||||
import { storableError } from '../../util/errors';
|
||||
import { TRANSITION_REQUEST, TRANSITION_REQUEST_AFTER_ENQUIRY } from '../../util/transaction';
|
||||
import {
|
||||
TRANSITION_REQUEST_PAYMENT,
|
||||
TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY,
|
||||
TRANSITION_CONFIRM_PAYMENT,
|
||||
} from '../../util/transaction';
|
||||
import * as log from '../../util/log';
|
||||
import { fetchCurrentUserHasOrdersSuccess } from '../../ducks/user.duck';
|
||||
|
||||
|
|
@ -14,6 +18,10 @@ export const INITIATE_ORDER_REQUEST = 'app/CheckoutPage/INITIATE_ORDER_REQUEST';
|
|||
export const INITIATE_ORDER_SUCCESS = 'app/CheckoutPage/INITIATE_ORDER_SUCCESS';
|
||||
export const INITIATE_ORDER_ERROR = 'app/CheckoutPage/INITIATE_ORDER_ERROR';
|
||||
|
||||
export const CONFIRM_PAYMENT_REQUEST = 'app/CheckoutPage/CONFIRM_PAYMENT_REQUEST';
|
||||
export const CONFIRM_PAYMENT_SUCCESS = 'app/CheckoutPage/CONFIRM_PAYMENT_SUCCESS';
|
||||
export const CONFIRM_PAYMENT_ERROR = 'app/CheckoutPage/CONFIRM_PAYMENT_ERROR';
|
||||
|
||||
export const SPECULATE_TRANSACTION_REQUEST = 'app/ListingPage/SPECULATE_TRANSACTION_REQUEST';
|
||||
export const SPECULATE_TRANSACTION_SUCCESS = 'app/ListingPage/SPECULATE_TRANSACTION_SUCCESS';
|
||||
export const SPECULATE_TRANSACTION_ERROR = 'app/ListingPage/SPECULATE_TRANSACTION_ERROR';
|
||||
|
|
@ -27,8 +35,9 @@ const initialState = {
|
|||
speculateTransactionInProgress: false,
|
||||
speculateTransactionError: null,
|
||||
speculatedTransaction: null,
|
||||
enquiredTransaction: null,
|
||||
transaction: null,
|
||||
initiateOrderError: null,
|
||||
confirmPaymentError: null,
|
||||
};
|
||||
|
||||
export default function checkoutPageReducer(state = initialState, action = {}) {
|
||||
|
|
@ -61,10 +70,19 @@ export default function checkoutPageReducer(state = initialState, action = {}) {
|
|||
case INITIATE_ORDER_REQUEST:
|
||||
return { ...state, initiateOrderError: null };
|
||||
case INITIATE_ORDER_SUCCESS:
|
||||
return state;
|
||||
return { ...state, transaction: payload };
|
||||
case INITIATE_ORDER_ERROR:
|
||||
console.error(payload); // eslint-disable-line no-console
|
||||
return { ...state, initiateOrderError: payload };
|
||||
|
||||
case CONFIRM_PAYMENT_REQUEST:
|
||||
return { ...state, confirmPaymentError: null };
|
||||
case CONFIRM_PAYMENT_SUCCESS:
|
||||
return state;
|
||||
case CONFIRM_PAYMENT_ERROR:
|
||||
console.error(payload); // eslint-disable-line no-console
|
||||
return { ...state, confirmPaymentError: payload };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -81,9 +99,9 @@ export const setInitialValues = initialValues => ({
|
|||
|
||||
const initiateOrderRequest = () => ({ type: INITIATE_ORDER_REQUEST });
|
||||
|
||||
const initiateOrderSuccess = orderId => ({
|
||||
const initiateOrderSuccess = order => ({
|
||||
type: INITIATE_ORDER_SUCCESS,
|
||||
payload: orderId,
|
||||
payload: order,
|
||||
});
|
||||
|
||||
const initiateOrderError = e => ({
|
||||
|
|
@ -92,6 +110,19 @@ const initiateOrderError = e => ({
|
|||
payload: e,
|
||||
});
|
||||
|
||||
const confirmPaymentRequest = () => ({ type: CONFIRM_PAYMENT_REQUEST });
|
||||
|
||||
const confirmPaymentSuccess = orderId => ({
|
||||
type: CONFIRM_PAYMENT_SUCCESS,
|
||||
payload: orderId,
|
||||
});
|
||||
|
||||
const confirmPaymentError = e => ({
|
||||
type: CONFIRM_PAYMENT_ERROR,
|
||||
error: true,
|
||||
payload: e,
|
||||
});
|
||||
|
||||
export const speculateTransactionRequest = () => ({ type: SPECULATE_TRANSACTION_REQUEST });
|
||||
|
||||
export const speculateTransactionSuccess = transaction => ({
|
||||
|
|
@ -107,37 +138,39 @@ export const speculateTransactionError = e => ({
|
|||
|
||||
/* ================ Thunks ================ */
|
||||
|
||||
export const initiateOrder = (orderParams, initialMessage) => (dispatch, getState, sdk) => {
|
||||
export const initiateOrder = (orderParams, transactionId) => (dispatch, getState, sdk) => {
|
||||
dispatch(initiateOrderRequest());
|
||||
const bodyParams = {
|
||||
transition: TRANSITION_REQUEST,
|
||||
processAlias: config.bookingProcessAlias,
|
||||
params: orderParams,
|
||||
};
|
||||
return sdk.transactions
|
||||
.initiate(bodyParams)
|
||||
.then(response => {
|
||||
const orderId = response.data.data.id;
|
||||
dispatch(initiateOrderSuccess(orderId));
|
||||
dispatch(fetchCurrentUserHasOrdersSuccess(true));
|
||||
|
||||
if (initialMessage) {
|
||||
return sdk.messages
|
||||
.send({ transactionId: orderId, content: initialMessage })
|
||||
.then(() => {
|
||||
return { orderId, initialMessageSuccess: true };
|
||||
})
|
||||
.catch(e => {
|
||||
log.error(e, 'initial-message-send-failed', { txId: orderId });
|
||||
return { orderId, initialMessageSuccess: false };
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve({ orderId, initialMessageSuccess: true });
|
||||
const bodyParams = transactionId
|
||||
? {
|
||||
id: transactionId,
|
||||
transition: TRANSITION_REQUEST_PAYMENT_AFTER_ENQUIRY,
|
||||
params: orderParams,
|
||||
}
|
||||
: {
|
||||
processAlias: config.bookingProcessAlias,
|
||||
transition: TRANSITION_REQUEST_PAYMENT,
|
||||
params: orderParams,
|
||||
};
|
||||
const queryParams = {
|
||||
include: ['booking', 'provider'],
|
||||
expand: true,
|
||||
};
|
||||
|
||||
const createOrder = transactionId ? sdk.transactions.transition : sdk.transactions.initiate;
|
||||
|
||||
return createOrder(bodyParams, queryParams)
|
||||
.then(response => {
|
||||
const entities = denormalisedResponseEntities(response);
|
||||
const order = entities[0];
|
||||
dispatch(initiateOrderSuccess(order));
|
||||
dispatch(fetchCurrentUserHasOrdersSuccess(true));
|
||||
return order;
|
||||
})
|
||||
.catch(e => {
|
||||
dispatch(initiateOrderError(storableError(e)));
|
||||
const transactionIdMaybe = transactionId ? { transactionId: transactionId.uuid } : {};
|
||||
log.error(e, 'initiate-order-failed', {
|
||||
...transactionIdMaybe,
|
||||
listingId: orderParams.listingId.uuid,
|
||||
bookingStart: orderParams.bookingStart,
|
||||
bookingEnd: orderParams.bookingEnd,
|
||||
|
|
@ -146,43 +179,53 @@ export const initiateOrder = (orderParams, initialMessage) => (dispatch, getStat
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate an order after an enquiry. Transitions previously created transaction.
|
||||
*/
|
||||
export const initiateOrderAfterEnquiry = (transactionId, orderParams) => (
|
||||
dispatch,
|
||||
getState,
|
||||
sdk
|
||||
) => {
|
||||
dispatch(initiateOrderRequest());
|
||||
export const confirmPayment = orderParams => (dispatch, getState, sdk) => {
|
||||
dispatch(confirmPaymentRequest());
|
||||
|
||||
const bodyParams = {
|
||||
id: transactionId,
|
||||
transition: TRANSITION_REQUEST_AFTER_ENQUIRY,
|
||||
params: orderParams,
|
||||
id: orderParams.transactionId,
|
||||
transition: TRANSITION_CONFIRM_PAYMENT,
|
||||
params: {},
|
||||
};
|
||||
|
||||
return sdk.transactions
|
||||
.transition(bodyParams)
|
||||
.then(response => {
|
||||
const orderId = response.data.data.id;
|
||||
dispatch(initiateOrderSuccess(orderId));
|
||||
dispatch(fetchCurrentUserHasOrdersSuccess(true));
|
||||
// set initialMessageSuccess to true to unify promise handling with initiateOrder
|
||||
return Promise.resolve({ orderId, initialMessageSuccess: true });
|
||||
const order = response.data.data;
|
||||
dispatch(confirmPaymentSuccess(order.id));
|
||||
return order;
|
||||
})
|
||||
.catch(e => {
|
||||
dispatch(initiateOrderError(storableError(e)));
|
||||
dispatch(confirmPaymentError(storableError(e)));
|
||||
const transactionIdMaybe = orderParams.transactionId
|
||||
? { transactionId: orderParams.transactionId.uuid }
|
||||
: {};
|
||||
log.error(e, 'initiate-order-failed', {
|
||||
transactionId: transactionId.uuid,
|
||||
listingId: orderParams.listingId.uuid,
|
||||
bookingStart: orderParams.bookingStart,
|
||||
bookingEnd: orderParams.bookingEnd,
|
||||
...transactionIdMaybe,
|
||||
});
|
||||
throw e;
|
||||
});
|
||||
};
|
||||
|
||||
export const sendMessage = params => (dispatch, getState, sdk) => {
|
||||
const message = params.message;
|
||||
const orderId = params.id;
|
||||
|
||||
if (message) {
|
||||
return sdk.messages
|
||||
.send({ transactionId: orderId, content: message })
|
||||
.then(() => {
|
||||
return { orderId, messageSuccess: true };
|
||||
})
|
||||
.catch(e => {
|
||||
log.error(e, 'initial-message-send-failed', { txId: orderId });
|
||||
return { orderId, messageSuccess: false };
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve({ orderId, messageSuccess: true });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate the speculative transaction with the given booking details
|
||||
*
|
||||
|
|
@ -198,7 +241,7 @@ export const initiateOrderAfterEnquiry = (transactionId, orderParams) => (
|
|||
export const speculateTransaction = params => (dispatch, getState, sdk) => {
|
||||
dispatch(speculateTransactionRequest());
|
||||
const bodyParams = {
|
||||
transition: TRANSITION_REQUEST,
|
||||
transition: TRANSITION_REQUEST_PAYMENT,
|
||||
processAlias: config.bookingProcessAlias,
|
||||
params: {
|
||||
...params,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { Component } from 'react';
|
||||
import { bool, func, instanceOf, object, shape, string } from 'prop-types';
|
||||
import { bool, func, instanceOf, object, oneOfType, shape, string } from 'prop-types';
|
||||
import { compose } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
||||
|
|
@ -9,8 +9,14 @@ import config from '../../config';
|
|||
import routeConfiguration from '../../routeConfiguration';
|
||||
import { pathByRouteName, findRouteByRouteName } from '../../util/routes';
|
||||
import { propTypes, LINE_ITEM_NIGHT, LINE_ITEM_DAY } from '../../util/types';
|
||||
import { ensureListing, ensureUser, ensureTransaction, ensureBooking } from '../../util/data';
|
||||
import { dateFromLocalToAPI } from '../../util/dates';
|
||||
import {
|
||||
ensureListing,
|
||||
ensureCurrentUser,
|
||||
ensureUser,
|
||||
ensureTransaction,
|
||||
ensureBooking,
|
||||
} from '../../util/data';
|
||||
import { dateFromLocalToAPI, minutesBetween } from '../../util/dates';
|
||||
import { createSlug } from '../../util/urlHelpers';
|
||||
import {
|
||||
isTransactionInitiateAmountTooLowError,
|
||||
|
|
@ -22,6 +28,7 @@ import {
|
|||
transactionInitiateOrderStripeErrors,
|
||||
} from '../../util/errors';
|
||||
import { formatMoney } from '../../util/currency';
|
||||
import { TRANSITION_ENQUIRE, txIsPaymentPending, txIsPaymentExpired } from '../../util/transaction';
|
||||
import {
|
||||
AvatarMedium,
|
||||
BookingBreakdown,
|
||||
|
|
@ -33,20 +40,40 @@ import {
|
|||
} from '../../components';
|
||||
import { StripePaymentForm } from '../../forms';
|
||||
import { isScrollingDisabled } from '../../ducks/UI.duck';
|
||||
import { createStripePaymentToken, clearStripePaymentToken } from '../../ducks/stripe.duck.js';
|
||||
import { handleCardPayment, retrievePaymentIntent } from '../../ducks/stripe.duck.js';
|
||||
|
||||
import {
|
||||
initiateOrder,
|
||||
initiateOrderAfterEnquiry,
|
||||
setInitialValues,
|
||||
speculateTransaction,
|
||||
confirmPayment,
|
||||
sendMessage,
|
||||
} from './CheckoutPage.duck';
|
||||
|
||||
import { storeData, storedData, clearData } from './CheckoutPageSessionHelpers';
|
||||
import css from './CheckoutPage.css';
|
||||
|
||||
const STORAGE_KEY = 'CheckoutPage';
|
||||
|
||||
// Stripe PaymentIntent statuses, where user actions are already completed
|
||||
// https://stripe.com/docs/payments/payment-intents/status
|
||||
const STRIPE_PI_USER_ACTIONS_DONE_STATUSES = ['processing', 'requires_capture', 'succeeded'];
|
||||
|
||||
const initializeOrderPage = (initialValues, routes, dispatch) => {
|
||||
const OrderPage = findRouteByRouteName('OrderDetailsPage', routes);
|
||||
|
||||
// Transaction is already created, but if the initial message
|
||||
// sending failed, we tell it to the OrderDetailsPage.
|
||||
dispatch(OrderPage.setInitialValues(initialValues));
|
||||
};
|
||||
|
||||
const checkIsPaymentExpired = existingTransaction => {
|
||||
return txIsPaymentExpired(existingTransaction)
|
||||
? true
|
||||
: txIsPaymentPending(existingTransaction)
|
||||
? minutesBetween(existingTransaction.attributes.lastTransitionedAt, new Date()) >= 15
|
||||
: false;
|
||||
};
|
||||
|
||||
export class CheckoutPageComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
|
@ -56,8 +83,11 @@ export class CheckoutPageComponent extends Component {
|
|||
dataLoaded: false,
|
||||
submitting: false,
|
||||
};
|
||||
this.stripe = null;
|
||||
|
||||
this.onStripeInitialized = this.onStripeInitialized.bind(this);
|
||||
this.loadInitialData = this.loadInitialData.bind(this);
|
||||
this.handlePaymentIntent = this.handlePaymentIntent.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +118,7 @@ export class CheckoutPageComponent extends Component {
|
|||
bookingData,
|
||||
bookingDates,
|
||||
listing,
|
||||
enquiredTransaction,
|
||||
transaction,
|
||||
fetchSpeculatedTransaction,
|
||||
history,
|
||||
} = this.props;
|
||||
|
|
@ -101,24 +131,29 @@ export class CheckoutPageComponent extends Component {
|
|||
const hasDataInProps = !!(bookingData && bookingDates && listing) && hasNavigatedThroughLink;
|
||||
if (hasDataInProps) {
|
||||
// Store data only if data is passed through props and user has navigated through a link.
|
||||
storeData(bookingData, bookingDates, listing, enquiredTransaction, STORAGE_KEY);
|
||||
storeData(bookingData, bookingDates, listing, transaction, STORAGE_KEY);
|
||||
}
|
||||
|
||||
// NOTE: stored data can be empty if user has already successfully completed transaction.
|
||||
const pageData = hasDataInProps
|
||||
? { bookingData, bookingDates, listing, enquiredTransaction }
|
||||
? { bookingData, bookingDates, listing, transaction }
|
||||
: storedData(STORAGE_KEY);
|
||||
|
||||
const hasData =
|
||||
// Check if a booking is already created according to stored data.
|
||||
const tx = pageData ? pageData.transaction : null;
|
||||
const isBookingCreated = tx && tx.booking && tx.booking.id;
|
||||
|
||||
const shouldFetchSpeculatedTransaction =
|
||||
pageData &&
|
||||
pageData.listing &&
|
||||
pageData.listing.id &&
|
||||
pageData.bookingData &&
|
||||
pageData.bookingDates &&
|
||||
pageData.bookingDates.bookingStart &&
|
||||
pageData.bookingDates.bookingEnd;
|
||||
pageData.bookingDates.bookingEnd &&
|
||||
!isBookingCreated;
|
||||
|
||||
if (hasData) {
|
||||
if (shouldFetchSpeculatedTransaction) {
|
||||
const listingId = pageData.listing.id;
|
||||
const { bookingStart, bookingEnd } = pageData.bookingDates;
|
||||
|
||||
|
|
@ -140,81 +175,209 @@ export class CheckoutPageComponent extends Component {
|
|||
this.setState({ pageData: pageData || {}, dataLoaded: true });
|
||||
}
|
||||
|
||||
handlePaymentIntent(handlePaymentParams) {
|
||||
const { onInitiateOrder, onHandleCardPayment, onConfirmPayment, onSendMessage } = this.props;
|
||||
const { pageData, speculatedTransaction, message } = handlePaymentParams;
|
||||
const storedTx = ensureTransaction(pageData.transaction);
|
||||
|
||||
// Step 1: initiate order by requesting payment from Marketplace API
|
||||
const fnRequestPayment = fnParams => {
|
||||
// fnParams should be { listingId, bookingStart, bookingEnd }
|
||||
const hasPaymentIntents =
|
||||
storedTx.attributes.protectedData && storedTx.attributes.protectedData.stripePaymentIntents;
|
||||
|
||||
// If paymentIntent exists, order has been initiated previously.
|
||||
return hasPaymentIntents ? Promise.resolve(storedTx) : onInitiateOrder(fnParams, storedTx.id);
|
||||
};
|
||||
|
||||
// Step 2: pay using Stripe SDK
|
||||
const fnHandleCardPayment = fnParams => {
|
||||
// fnParams should be returned transaction entity
|
||||
|
||||
const order = ensureTransaction(fnParams);
|
||||
if (order.id) {
|
||||
// Store order.
|
||||
const { bookingData, bookingDates, listing } = pageData;
|
||||
storeData(bookingData, bookingDates, listing, order, STORAGE_KEY);
|
||||
this.setState({ pageData: { ...pageData, transaction: order } });
|
||||
}
|
||||
|
||||
const hasPaymentIntents =
|
||||
order.attributes.protectedData && order.attributes.protectedData.stripePaymentIntents;
|
||||
|
||||
if (!hasPaymentIntents) {
|
||||
throw new Error(
|
||||
`Missing StripePaymentIntents key in transaction's protectedData. Check that your transaction process is configured to use payment intents.`
|
||||
);
|
||||
}
|
||||
|
||||
const { stripePaymentIntentClientSecret } = hasPaymentIntents
|
||||
? order.attributes.protectedData.stripePaymentIntents.default
|
||||
: null;
|
||||
|
||||
const { stripe, card, billingDetails, paymentIntent } = handlePaymentParams;
|
||||
|
||||
const params = {
|
||||
stripePaymentIntentClientSecret,
|
||||
orderId: order.id,
|
||||
stripe,
|
||||
card,
|
||||
paymentParams: {
|
||||
payment_method_data: {
|
||||
billing_details: billingDetails,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// If paymentIntent status is not waiting user action,
|
||||
// handleCardPayment has been called previously.
|
||||
const hasPaymentIntentUserActionsDone =
|
||||
paymentIntent && STRIPE_PI_USER_ACTIONS_DONE_STATUSES.includes(paymentIntent.status);
|
||||
return hasPaymentIntentUserActionsDone
|
||||
? Promise.resolve({ transactionId: order.id, paymentIntent })
|
||||
: onHandleCardPayment(params);
|
||||
};
|
||||
|
||||
// Step 3: complete order by confirming payment to Marketplace API
|
||||
// Parameter should contain { paymentIntent, transactionId } returned in step 2
|
||||
const fnConfirmPayment = onConfirmPayment;
|
||||
|
||||
// Step 4: send initial message
|
||||
const fnSendMessage = fnParams => {
|
||||
return onSendMessage({ ...fnParams, message });
|
||||
};
|
||||
|
||||
// Here we create promise calls in sequence
|
||||
// This is pretty much the same as:
|
||||
// fnRequestPayment({...initialParams})
|
||||
// .then(result => fnHandleCardPayment({...result}))
|
||||
// .then(result => fnConfirmPayment({...result}))
|
||||
const applyAsync = (acc, val) => acc.then(val);
|
||||
const composeAsync = (...funcs) => x => funcs.reduce(applyAsync, Promise.resolve(x));
|
||||
const handlePaymentIntentCreation = composeAsync(
|
||||
fnRequestPayment,
|
||||
fnHandleCardPayment,
|
||||
fnConfirmPayment,
|
||||
fnSendMessage
|
||||
);
|
||||
|
||||
// Create order aka transaction
|
||||
// NOTE: if unit type is line-item/units, quantity needs to be added.
|
||||
// The way to pass it to checkout page is through pageData.bookingData
|
||||
const tx = speculatedTransaction ? speculatedTransaction : storedTx;
|
||||
const orderParams = {
|
||||
listingId: pageData.listing.id,
|
||||
bookingStart: tx.booking.attributes.start,
|
||||
bookingEnd: tx.booking.attributes.end,
|
||||
};
|
||||
|
||||
return handlePaymentIntentCreation(orderParams);
|
||||
}
|
||||
|
||||
handleSubmit(values) {
|
||||
if (this.state.submitting) {
|
||||
return;
|
||||
}
|
||||
this.setState({ submitting: true });
|
||||
|
||||
const cardToken = values.token;
|
||||
const initialMessage = values.message;
|
||||
const {
|
||||
history,
|
||||
sendOrderRequest,
|
||||
sendOrderRequestAfterEnquiry,
|
||||
speculatedTransaction,
|
||||
onClearStripePaymentToken,
|
||||
dispatch,
|
||||
} = this.props;
|
||||
const { history, speculatedTransaction, currentUser, paymentIntent, dispatch } = this.props;
|
||||
const { card, message, formValues } = values;
|
||||
const { name, addressLine1, addressLine2, postal, city, state, country } = formValues;
|
||||
|
||||
// Create order aka transaction
|
||||
// NOTE: if unit type is line-item/units, quantity needs to be added.
|
||||
// The way to pass it to checkout page is through pageData.bookingData
|
||||
const requestParams = {
|
||||
listingId: this.state.pageData.listing.id,
|
||||
cardToken,
|
||||
bookingStart: speculatedTransaction.booking.attributes.start,
|
||||
bookingEnd: speculatedTransaction.booking.attributes.end,
|
||||
// Billing address is recommended.
|
||||
// However, let's not assume that <StripePaymentAddress> data is among formValues.
|
||||
// Read more about this from Stripe's docs
|
||||
// https://stripe.com/docs/stripe-js/reference#stripe-handle-card-payment-no-element
|
||||
const addressMaybe =
|
||||
addressLine1 && postal
|
||||
? {
|
||||
address: {
|
||||
city: city,
|
||||
country: country,
|
||||
line1: addressLine1,
|
||||
line2: addressLine2,
|
||||
postal_code: postal,
|
||||
state: state,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const billingDetails = {
|
||||
name,
|
||||
email: ensureCurrentUser(currentUser).attributes.email,
|
||||
...addressMaybe,
|
||||
};
|
||||
|
||||
const enquiredTransaction = this.state.pageData.enquiredTransaction;
|
||||
const requestPaymentParams = {
|
||||
pageData: this.state.pageData,
|
||||
speculatedTransaction,
|
||||
stripe: this.stripe,
|
||||
card,
|
||||
billingDetails,
|
||||
message,
|
||||
paymentIntent,
|
||||
};
|
||||
|
||||
// if an enquired transaction is available, use that as basis
|
||||
// otherwise initiate a new transaction
|
||||
const initiateRequest = enquiredTransaction
|
||||
? sendOrderRequestAfterEnquiry(enquiredTransaction.id, requestParams)
|
||||
: sendOrderRequest(requestParams, initialMessage);
|
||||
|
||||
initiateRequest
|
||||
.then(values => {
|
||||
const { orderId, initialMessageSuccess } = values;
|
||||
this.handlePaymentIntent(requestPaymentParams)
|
||||
.then(res => {
|
||||
const { orderId, messageSuccess } = res;
|
||||
this.setState({ submitting: false });
|
||||
const routes = routeConfiguration();
|
||||
const OrderPage = findRouteByRouteName('OrderDetailsPage', routes);
|
||||
|
||||
// Transaction is already created, but if the initial message
|
||||
// sending failed, we tell it to the OrderDetailsPage.
|
||||
dispatch(
|
||||
OrderPage.setInitialValues({
|
||||
initialMessageFailedToTransaction: initialMessageSuccess ? null : orderId,
|
||||
})
|
||||
);
|
||||
const orderDetailsPath = pathByRouteName('OrderDetailsPage', routes, {
|
||||
id: orderId.uuid,
|
||||
});
|
||||
onClearStripePaymentToken();
|
||||
const routes = routeConfiguration();
|
||||
const initialMessageFailedToTransaction = messageSuccess ? null : orderId;
|
||||
const orderDetailsPath = pathByRouteName('OrderDetailsPage', routes, { id: orderId.uuid });
|
||||
|
||||
initializeOrderPage({ initialMessageFailedToTransaction }, routes, dispatch);
|
||||
clearData(STORAGE_KEY);
|
||||
history.push(orderDetailsPath);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
this.setState({ submitting: false });
|
||||
});
|
||||
}
|
||||
|
||||
onStripeInitialized(stripe) {
|
||||
this.stripe = stripe;
|
||||
|
||||
const { paymentIntent, onRetrievePaymentIntent } = this.props;
|
||||
const tx = this.state.pageData ? this.state.pageData.transaction : null;
|
||||
|
||||
// We need to get up to date PI, if booking is created but payment is not expired.
|
||||
const shouldFetchPaymentIntent =
|
||||
this.stripe &&
|
||||
!paymentIntent &&
|
||||
tx &&
|
||||
tx.id &&
|
||||
tx.booking &&
|
||||
tx.booking.id &&
|
||||
txIsPaymentPending(tx) &&
|
||||
!checkIsPaymentExpired(tx);
|
||||
|
||||
if (shouldFetchPaymentIntent) {
|
||||
const { stripePaymentIntentClientSecret } =
|
||||
tx.attributes.protectedData && tx.attributes.protectedData.stripePaymentIntents
|
||||
? tx.attributes.protectedData.stripePaymentIntents.default
|
||||
: {};
|
||||
|
||||
// Fetch up to date PaymentIntent from Stripe
|
||||
onRetrievePaymentIntent({ stripe, stripePaymentIntentClientSecret });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
scrollingDisabled,
|
||||
speculateTransactionInProgress,
|
||||
speculateTransactionError,
|
||||
speculatedTransaction,
|
||||
speculatedTransaction: speculatedTransactionMaybe,
|
||||
initiateOrderError,
|
||||
confirmPaymentError,
|
||||
intl,
|
||||
params,
|
||||
currentUser,
|
||||
onCreateStripePaymentToken,
|
||||
stripePaymentTokenInProgress,
|
||||
stripePaymentTokenError,
|
||||
stripePaymentToken,
|
||||
handleCardPaymentError,
|
||||
paymentIntent,
|
||||
retrievePaymentIntentError,
|
||||
} = this.props;
|
||||
|
||||
// Since the listing data is already given from the ListingPage
|
||||
|
|
@ -229,9 +392,9 @@ export class CheckoutPageComponent extends Component {
|
|||
|
||||
const isLoading = !this.state.dataLoaded || speculateTransactionInProgress;
|
||||
|
||||
const { listing, bookingDates, enquiredTransaction } = this.state.pageData;
|
||||
const currentTransaction = ensureTransaction(speculatedTransaction, {}, null);
|
||||
const currentBooking = ensureBooking(currentTransaction.booking);
|
||||
const { listing, bookingDates, transaction } = this.state.pageData;
|
||||
const existingTransaction = ensureTransaction(transaction);
|
||||
const speculatedTransaction = ensureTransaction(speculatedTransactionMaybe, {}, null);
|
||||
const currentListing = ensureListing(listing);
|
||||
const currentAuthor = ensureUser(currentListing.author);
|
||||
|
||||
|
|
@ -257,26 +420,30 @@ export class CheckoutPageComponent extends Component {
|
|||
if (shouldRedirect) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Missing or invalid data for checkout, redirecting back to listing page.', {
|
||||
transaction: currentTransaction,
|
||||
transaction: speculatedTransaction,
|
||||
bookingDates,
|
||||
listing,
|
||||
});
|
||||
return <NamedRedirect name="ListingPage" params={params} />;
|
||||
}
|
||||
|
||||
// Show breakdown only when transaction and booking are loaded
|
||||
// Show breakdown only when speculated transaction and booking are loaded
|
||||
// (i.e. have an id)
|
||||
const tx = existingTransaction.booking ? existingTransaction : speculatedTransaction;
|
||||
const txBooking = ensureBooking(tx.booking);
|
||||
const breakdown =
|
||||
currentTransaction.id && currentBooking.id ? (
|
||||
tx.id && txBooking.id ? (
|
||||
<BookingBreakdown
|
||||
className={css.bookingBreakdown}
|
||||
userRole="customer"
|
||||
unitType={config.bookingUnitType}
|
||||
transaction={currentTransaction}
|
||||
booking={currentBooking}
|
||||
transaction={tx}
|
||||
booking={txBooking}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const isPaymentExpired = checkIsPaymentExpired(existingTransaction);
|
||||
|
||||
// Allow showing page when currentUser is still being downloaded,
|
||||
// but show payment form only when user info is loaded.
|
||||
const showPaymentForm = !!(
|
||||
|
|
@ -284,7 +451,9 @@ export class CheckoutPageComponent extends Component {
|
|||
hasRequiredData &&
|
||||
!listingNotFound &&
|
||||
!initiateOrderError &&
|
||||
!speculateTransactionError
|
||||
!speculateTransactionError &&
|
||||
!retrievePaymentIntentError &&
|
||||
!isPaymentExpired
|
||||
);
|
||||
|
||||
const listingTitle = currentListing.attributes.title;
|
||||
|
|
@ -293,11 +462,6 @@ export class CheckoutPageComponent extends Component {
|
|||
const firstImage =
|
||||
currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null;
|
||||
|
||||
const listingNotFoundErrorMessage = listingNotFound ? (
|
||||
<p className={css.notFoundError}>
|
||||
<FormattedMessage id="CheckoutPage.listingNotFoundError" />
|
||||
</p>
|
||||
) : null;
|
||||
const listingLink = (
|
||||
<NamedLink
|
||||
name="ListingPage"
|
||||
|
|
@ -315,26 +479,33 @@ export class CheckoutPageComponent extends Component {
|
|||
const stripeErrors = transactionInitiateOrderStripeErrors(initiateOrderError);
|
||||
|
||||
let initiateOrderErrorMessage = null;
|
||||
let listingNotFoundErrorMessage = null;
|
||||
|
||||
if (!listingNotFound && isAmountTooLowError) {
|
||||
if (listingNotFound) {
|
||||
listingNotFoundErrorMessage = (
|
||||
<p className={css.notFoundError}>
|
||||
<FormattedMessage id="CheckoutPage.listingNotFoundError" />
|
||||
</p>
|
||||
);
|
||||
} else if (isAmountTooLowError) {
|
||||
initiateOrderErrorMessage = (
|
||||
<p className={css.orderError}>
|
||||
<FormattedMessage id="CheckoutPage.initiateOrderAmountTooLow" />
|
||||
</p>
|
||||
);
|
||||
} else if (!listingNotFound && isBookingTimeNotAvailableError) {
|
||||
} else if (isBookingTimeNotAvailableError) {
|
||||
initiateOrderErrorMessage = (
|
||||
<p className={css.orderError}>
|
||||
<FormattedMessage id="CheckoutPage.bookingTimeNotAvailableMessage" />
|
||||
</p>
|
||||
);
|
||||
} else if (!listingNotFound && isChargeDisabledError) {
|
||||
} else if (isChargeDisabledError) {
|
||||
initiateOrderErrorMessage = (
|
||||
<p className={css.orderError}>
|
||||
<FormattedMessage id="CheckoutPage.chargeDisabledMessage" />
|
||||
</p>
|
||||
);
|
||||
} else if (!listingNotFound && stripeErrors && stripeErrors.length > 0) {
|
||||
} else if (stripeErrors && stripeErrors.length > 0) {
|
||||
// NOTE: Error messages from Stripes are not part of translations.
|
||||
// By default they are in English.
|
||||
const stripeErrorsAsString = stripeErrors.join(', ');
|
||||
|
|
@ -346,7 +517,8 @@ export class CheckoutPageComponent extends Component {
|
|||
/>
|
||||
</p>
|
||||
);
|
||||
} else if (!listingNotFound && initiateOrderError) {
|
||||
} else if (initiateOrderError) {
|
||||
// Generic initiate order error
|
||||
initiateOrderErrorMessage = (
|
||||
<p className={css.orderError}>
|
||||
<FormattedMessage id="CheckoutPage.initiateOrderError" values={{ listingLink }} />
|
||||
|
|
@ -418,7 +590,9 @@ export class CheckoutPageComponent extends Component {
|
|||
const formattedPrice = formatMoney(intl, price);
|
||||
const detailsSubTitle = `${formattedPrice} ${intl.formatMessage({ id: unitTranslationKey })}`;
|
||||
|
||||
const showInitialMessageInput = !enquiredTransaction;
|
||||
const showInitialMessageInput = !(
|
||||
existingTransaction && existingTransaction.attributes.lastTransition === TRANSITION_ENQUIRE
|
||||
);
|
||||
|
||||
const pageProps = { title, scrollingDisabled };
|
||||
|
||||
|
|
@ -433,6 +607,22 @@ export class CheckoutPageComponent extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
// Get first and last name of the current user and use it in the StripePaymentForm to autofill the name field
|
||||
const userName =
|
||||
currentUser && currentUser.attributes
|
||||
? `${currentUser.attributes.profile.firstName} ${currentUser.attributes.profile.lastName}`
|
||||
: null;
|
||||
|
||||
// If paymentIntent status is not waiting user action,
|
||||
// handleCardPayment has been called previously.
|
||||
const hasPaymentIntentUserActionsDone =
|
||||
paymentIntent && STRIPE_PI_USER_ACTIONS_DONE_STATUSES.includes(paymentIntent.status);
|
||||
|
||||
// If your marketplace works mostly in one country you can use initial values to select country automatically
|
||||
// e.g. {country: 'FI'}
|
||||
|
||||
const initalValuesForStripePayment = { name: userName };
|
||||
|
||||
return (
|
||||
<Page {...pageProps}>
|
||||
{topbar}
|
||||
|
|
@ -471,6 +661,14 @@ export class CheckoutPageComponent extends Component {
|
|||
{initiateOrderErrorMessage}
|
||||
{listingNotFoundErrorMessage}
|
||||
{speculateErrorMessage}
|
||||
{retrievePaymentIntentError ? (
|
||||
<p className={css.orderError}>
|
||||
<FormattedMessage
|
||||
id="CheckoutPage.retrievingStripePaymentIntentFailed"
|
||||
values={{ listingLink }}
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
{showPaymentForm ? (
|
||||
<StripePaymentForm
|
||||
className={css.paymentForm}
|
||||
|
|
@ -480,12 +678,23 @@ export class CheckoutPageComponent extends Component {
|
|||
paymentInfo={intl.formatMessage({ id: 'CheckoutPage.paymentInfo' })}
|
||||
authorDisplayName={currentAuthor.attributes.profile.displayName}
|
||||
showInitialMessageInput={showInitialMessageInput}
|
||||
onCreateStripePaymentToken={onCreateStripePaymentToken}
|
||||
stripePaymentTokenInProgress={stripePaymentTokenInProgress}
|
||||
stripePaymentTokenError={stripePaymentTokenError}
|
||||
stripePaymentToken={stripePaymentToken}
|
||||
initialValues={initalValuesForStripePayment}
|
||||
initiateOrderError={initiateOrderError}
|
||||
handleCardPaymentError={handleCardPaymentError}
|
||||
confirmPaymentError={confirmPaymentError}
|
||||
hasHandledCardPayment={hasPaymentIntentUserActionsDone}
|
||||
paymentIntent={paymentIntent}
|
||||
onStripeInitialized={this.onStripeInitialized}
|
||||
/>
|
||||
) : null}
|
||||
{isPaymentExpired ? (
|
||||
<p className={css.orderError}>
|
||||
<FormattedMessage
|
||||
id="CheckoutPage.paymentExpiredMessage"
|
||||
values={{ listingLink }}
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
|
@ -519,16 +728,15 @@ export class CheckoutPageComponent extends Component {
|
|||
|
||||
CheckoutPageComponent.defaultProps = {
|
||||
initiateOrderError: null,
|
||||
confirmPaymentError: null,
|
||||
listing: null,
|
||||
bookingData: {},
|
||||
bookingDates: null,
|
||||
speculateTransactionError: null,
|
||||
speculatedTransaction: null,
|
||||
enquiredTransaction: null,
|
||||
transaction: null,
|
||||
currentUser: null,
|
||||
stripePaymentToken: null,
|
||||
stripePaymentTokenInProgress: false,
|
||||
stripePaymentTokenError: null,
|
||||
paymentIntent: null,
|
||||
};
|
||||
|
||||
CheckoutPageComponent.propTypes = {
|
||||
|
|
@ -543,18 +751,20 @@ CheckoutPageComponent.propTypes = {
|
|||
speculateTransactionInProgress: bool.isRequired,
|
||||
speculateTransactionError: propTypes.error,
|
||||
speculatedTransaction: propTypes.transaction,
|
||||
enquiredTransaction: propTypes.transaction,
|
||||
initiateOrderError: propTypes.error,
|
||||
transaction: propTypes.transaction,
|
||||
currentUser: propTypes.currentUser,
|
||||
params: shape({
|
||||
id: string,
|
||||
slug: string,
|
||||
}).isRequired,
|
||||
sendOrderRequest: func.isRequired,
|
||||
onCreateStripePaymentToken: func.isRequired,
|
||||
stripePaymentTokenInProgress: bool,
|
||||
stripePaymentTokenError: propTypes.error,
|
||||
stripePaymentToken: object,
|
||||
onInitiateOrder: func.isRequired,
|
||||
onHandleCardPayment: func.isRequired,
|
||||
onRetrievePaymentIntent: func.isRequired,
|
||||
initiateOrderError: propTypes.error,
|
||||
confirmPaymentError: propTypes.error,
|
||||
// handleCardPaymentError comes from Stripe so that's why we can't expect it to be in a specific form
|
||||
handleCardPaymentError: oneOfType([propTypes.error, object]),
|
||||
paymentIntent: object,
|
||||
|
||||
// from connect
|
||||
dispatch: func.isRequired,
|
||||
|
|
@ -576,15 +786,12 @@ const mapStateToProps = state => {
|
|||
speculateTransactionInProgress,
|
||||
speculateTransactionError,
|
||||
speculatedTransaction,
|
||||
enquiredTransaction,
|
||||
transaction,
|
||||
initiateOrderError,
|
||||
confirmPaymentError,
|
||||
} = state.CheckoutPage;
|
||||
const { currentUser } = state.user;
|
||||
const {
|
||||
stripePaymentTokenInProgress,
|
||||
stripePaymentTokenError,
|
||||
stripePaymentToken,
|
||||
} = state.stripe;
|
||||
const { handleCardPaymentError, paymentIntent, retrievePaymentIntentError } = state.stripe;
|
||||
return {
|
||||
scrollingDisabled: isScrollingDisabled(state),
|
||||
currentUser,
|
||||
|
|
@ -593,23 +800,24 @@ const mapStateToProps = state => {
|
|||
speculateTransactionInProgress,
|
||||
speculateTransactionError,
|
||||
speculatedTransaction,
|
||||
enquiredTransaction,
|
||||
transaction,
|
||||
listing,
|
||||
initiateOrderError,
|
||||
stripePaymentTokenInProgress,
|
||||
stripePaymentTokenError,
|
||||
stripePaymentToken,
|
||||
handleCardPaymentError,
|
||||
confirmPaymentError,
|
||||
paymentIntent,
|
||||
retrievePaymentIntentError,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
dispatch,
|
||||
sendOrderRequest: (params, initialMessage) => dispatch(initiateOrder(params, initialMessage)),
|
||||
sendOrderRequestAfterEnquiry: (transactionId, params) =>
|
||||
dispatch(initiateOrderAfterEnquiry(transactionId, params)),
|
||||
onInitiateOrder: (params, transactionId) => dispatch(initiateOrder(params, transactionId)),
|
||||
fetchSpeculatedTransaction: params => dispatch(speculateTransaction(params)),
|
||||
onCreateStripePaymentToken: params => dispatch(createStripePaymentToken(params)),
|
||||
onClearStripePaymentToken: () => dispatch(clearStripePaymentToken()),
|
||||
onRetrievePaymentIntent: params => dispatch(retrievePaymentIntent(params)),
|
||||
onHandleCardPayment: params => dispatch(handleCardPayment(params)),
|
||||
onConfirmPayment: params => dispatch(confirmPayment(params)),
|
||||
onSendMessage: params => dispatch(sendMessage(params)),
|
||||
});
|
||||
|
||||
const CheckoutPage = compose(
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ describe('CheckoutPage', () => {
|
|||
fetchSpeculatedTransaction: noop,
|
||||
speculateTransactionInProgress: false,
|
||||
scrollingDisabled: false,
|
||||
onCreateStripePaymentToken: noop,
|
||||
stripePaymentTokenInProgress: false,
|
||||
stripePaymentTokenError: null,
|
||||
onHandleCardPayment: noop,
|
||||
onInitiateOrder: noop,
|
||||
onRetrievePaymentIntent: noop,
|
||||
handleCardPaymentInProgress: false,
|
||||
};
|
||||
const tree = renderShallow(<CheckoutPageComponent {...props} />);
|
||||
expect(tree).toMatchSnapshot();
|
||||
|
|
@ -62,7 +63,8 @@ describe('CheckoutPage', () => {
|
|||
speculateTransactionError: null,
|
||||
speculateTransactionInProgress: false,
|
||||
speculatedTransaction: null,
|
||||
enquiredTransaction: null,
|
||||
transaction: null,
|
||||
confirmPaymentError: null,
|
||||
};
|
||||
|
||||
it('should return the initial state', () => {
|
||||
|
|
|
|||
|
|
@ -88,15 +88,21 @@ exports[`CheckoutPage matches snapshot 1`] = `
|
|||
<section>
|
||||
<InjectIntl(StripePaymentForm)
|
||||
authorDisplayName="author display name"
|
||||
confirmPaymentError={null}
|
||||
formId="CheckoutPagePaymentForm"
|
||||
hasHandledCardPayment={null}
|
||||
inProgress={false}
|
||||
onCreateStripePaymentToken={[Function]}
|
||||
initialValues={
|
||||
Object {
|
||||
"name": "currentUser first name currentUser last name",
|
||||
}
|
||||
}
|
||||
initiateOrderError={null}
|
||||
onStripeInitialized={[Function]}
|
||||
onSubmit={[Function]}
|
||||
paymentInfo="CheckoutPage.paymentInfo"
|
||||
paymentIntent={null}
|
||||
showInitialMessageInput={true}
|
||||
stripePaymentToken={null}
|
||||
stripePaymentTokenError={null}
|
||||
stripePaymentTokenInProgress={false}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -88,12 +88,14 @@
|
|||
"CheckoutPage.initiateOrderStripeError": "The payment processor gave the following errors: {stripeErrors}",
|
||||
"CheckoutPage.listingNotFoundError": "Unfortunately, the listing is no longer available.",
|
||||
"CheckoutPage.loadingData": "Loading checkout data…",
|
||||
"CheckoutPage.paymentExpiredMessage": "Payment was not completed in 15 minutes. Please go back to {listingLink} and try again.",
|
||||
"CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.",
|
||||
"CheckoutPage.perDay": "per day",
|
||||
"CheckoutPage.perNight": "per night",
|
||||
"CheckoutPage.perUnit": "per unit",
|
||||
"CheckoutPage.priceBreakdownTitle": "Booking breakdown",
|
||||
"CheckoutPage.providerStripeAccountMissingError": "The listing author has not added their payment information and the listing cannot be booked at the moment.",
|
||||
"CheckoutPage.retrievingStripePaymentIntentFailed": "Oops, something went wrong. Please refresh the page. If the error persists, go back to {listingLink} and try again after 15 minutes",
|
||||
"CheckoutPage.speculateFailedMessage": "Oops, something went wrong. Please refresh the page and try again.",
|
||||
"CheckoutPage.speculateTransactionError": "Failed to fetch breakdown information.",
|
||||
"CheckoutPage.title": "Book {listingTitle}",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue