From 2d9f3a405e360396dfde3ff165a7bbf62674ae99 Mon Sep 17 00:00:00 2001 From: "Andrew Revinsky (DART)" Date: Wed, 14 Jul 2021 03:31:47 +0300 Subject: [PATCH] #11: Implement a Checkout page - created a simple payment server endpoint to confirm payment (moved logic from client to server) (ref: #22) - added e2e-assisting values to the selectors dictionary - placed these e2e-assisting properties into their corresponding elements on the checkout page --- server/src/api/index.js | 36 ++---- server/src/api/payment.js | 126 +++++++++++++++++++ src/features/actions/api.js | 9 +- src/features/cart/cartSlice.js | 25 +++- src/testability/selectors.js | 19 +++ src/ui/pages/CheckoutPage/cardElement.js | 57 +++------ src/ui/pages/CheckoutPage/checkoutForm.js | 16 ++- src/ui/pages/CheckoutPage/index.js | 5 +- src/ui/pages/CheckoutPage/paymentModal.js | 6 +- src/ui/pages/RestaurantPage/yourTrayItems.js | 9 +- 10 files changed, 225 insertions(+), 83 deletions(-) create mode 100644 server/src/api/payment.js diff --git a/server/src/api/index.js b/server/src/api/index.js index e4ec6ed..df8b198 100755 --- a/server/src/api/index.js +++ b/server/src/api/index.js @@ -3,20 +3,9 @@ import { Router } from 'express'; import addresses from './address'; import restaurants from './restaurant'; import cart from './cart'; -import { paymentIntentFakeStripeResponse } from './paymentIntentResponse'; -//import stripeDefault from 'stripe'; +import { postPaymentConfirmHandler, postPaymentIntentHandler } from './payment'; -//const stripe = stripeDefault(process.env.STRIPE_SK_KEY); - -const calculateOrderAmount = items => { - // Replace this constant with a calculation of the order's amount - // Calculate the order total on the server to prevent - // people from directly manipulating the amount on the client - console.log(`[calculateOrderAmount]`, JSON.stringify(items, null, 2)); - return 1400; -}; - -export default ({ config, db }) => { +const defaultExport = ({ config, db }) => { let api = Router(); // mount the facets resource @@ -24,21 +13,8 @@ export default ({ config, db }) => { api.use('/cart', cart({ config, db })); api.use('/restaurants', restaurants({ config, db })); - api.post('/payment/intent', async (req, res) => { - const { items } = req.body; - // Create a PaymentIntent with the order amount and currency - const paymentIntent = paymentIntentFakeStripeResponse; - // await stripe.paymentIntents.create({ - // amount: calculateOrderAmount(items), - // currency: "usd" - // }); - - console.log('[paymentIntent]', paymentIntent); - - res.send({ - clientSecret: paymentIntent.client_secret - }); - }); + api.post('/payment/intent', postPaymentIntentHandler); + api.post('/payment/confirm', postPaymentConfirmHandler); // perhaps expose some API metadata at the root @@ -47,4 +23,6 @@ export default ({ config, db }) => { }); return api; -} +}; + +export default defaultExport; diff --git a/server/src/api/payment.js b/server/src/api/payment.js new file mode 100644 index 0000000..7886071 --- /dev/null +++ b/server/src/api/payment.js @@ -0,0 +1,126 @@ +import { paymentIntentFakeStripeResponse } from './paymentIntentResponse'; + +//import stripeDefault from 'stripe'; + +//const stripe = stripeDefault(process.env.STRIPE_SK_KEY); + +const paymentIntent = paymentIntentFakeStripeResponse; + +export const postPaymentIntentHandler = (req, res) => { + const { items } = req.body; + + // Create a PaymentIntent with the order amount and currency + // await stripe.paymentIntents.create({ + // amount: calculateOrderAmount(items), + // currency: "usd" + // }); + + console.log('[paymentIntent]', paymentIntent); + + res.send({ + clientSecret: paymentIntent.client_secret, + amount: calculateOrderAmount(items) + }); + +}; + +export const postPaymentConfirmHandler = (req, res) => { + + const [ err, clientSecret, card ] = safelyDestructure(req.body, + ({ + clientSecret, + paymentMethod: { + card + } + }) => ([ clientSecret, card ])); + + if (err) { + console.log(err); + return res.status(400).json({ error: { message: 'invalid parameters in the request' } }); + } + + console.log('[stripe-ish.confirmCardPayment]', clientSecret, card); + + if (clientSecret !== paymentIntent.client_secret) { + console.log('payment intent client secret mismatch'); + return res.status(400).json({ error: { message: 'payment intent client secret mismatch' } }); + } + + const aGeneralCCCardNumberPattern = /^(?:4[0-9]{12}(?:[0-9]{3})?|[25][1-7][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})$/; + + if (!aGeneralCCCardNumberPattern.test(card?.card_number ?? '')) { + return res.status(400).json({ error: { message: 'Invalid card number' }, errors: { card_number: 'Invalid' } }); + } + + if (!card?.exp_year || !card?.exp_month) { + return res.json({ + error: { message: 'Expiration (MM/YY) is required' }, errors: { + ...(!card?.exp_year ? { exp_year: 'Required' } : {}), + ...(!card?.exp_month ? { exp_month: 'Required' } : {}) + } + }).status(400); + } + + if ((Number(card?.exp_year ?? '00') < (new Date().getFullYear() % 100)) || ( + (Number(card?.exp_year ?? '00') === (new Date().getFullYear() % 100)) && + (Number(card?.exp_month ?? '00') < (new Date().getMonth() % 12 + 1)) + )) { + return res.json({ + error: { message: 'Card is expired.' }, + errors: { exp_month: 'Invalid', exp_year: 'Invalid' } + }).status(400); + } + + if (!card?.cvv) { + return res.json({ error: { message: 'CVV is required' }, errors: { cvv: 'Required' } }).status(400); + } + + + // 4242 4242 4242 4242 - Payment succeeds + // 4000 0025 0000 3155 - Payment requires authentication + // 4000 0000 0000 9995 - Payment is declined + + if (/^4242\s*4242\s*4242\s*4242$/.test(card?.card_number ?? '')) { + return res.status(200).json({ success: true }); + + } else if (/^4000\s*0025\s*0000\s*3155$/.test(card?.card_number ?? '')) { + const isOdd = (new Date().getTime() % 2) === 0; + return setTimeout(() => { + if (isOdd) { + return res.json({ + error: { message: 'Payment requires authentication. The odds now are for simulating an error. Try again for successful payment.' }, + errors: { card_number: 'Bank requested authentication.' } + }).status(400); + } else { + return res.status(200).json({ success: true }); + } + }, 3500); + } else if (/^4000\s*0000\s*0000\s*9995$/.test(card?.card_number ?? '')) { + return setTimeout(() => { + return res.status(400).json({ error: { message: 'Payment is declined' } }); + }, 2000); + } else { + return setTimeout(() => { + return res.status(200).json({ success: true }); + }, 1000); + } + +}; + + +function safelyDestructure(source, destructor) { + try { + return [ null, ...destructor(source) ]; + } catch (err) { + return [ err ]; + } +} + + +const calculateOrderAmount = items => { + // Replace this constant with a calculation of the order's amount + // Calculate the order total on the server to prevent + // people from directly manipulating the amount on the client + console.log(`[calculateOrderAmount]`, JSON.stringify(items, null, 2)); + return 1400; +}; diff --git a/src/features/actions/api.js b/src/features/actions/api.js index a5102b6..63e8539 100644 --- a/src/features/actions/api.js +++ b/src/features/actions/api.js @@ -20,9 +20,16 @@ const apiRoutes = prepareRoutesForFetch({ `POST /payment/intent`, (items) => ({ items }) ], + postConfirmPayment: [ + `POST /payment/confirm`, + (clientSecret, card) => ({ clientSecret, paymentMethod: { card } }) + ] }, urlResolver); -export const { postAddressObtainRestaurants, getRestaurantById, putUpdateCartWithItem, getCart, postCreatePaymentIntent } = apiRoutes; +export const { + postAddressObtainRestaurants, getRestaurantById, putUpdateCartWithItem, + getCart, postCreatePaymentIntent, postConfirmPayment +} = apiRoutes; function prepareRoutesForFetch(routes, urlResolver) { return Object.fromEntries(Array.from(Object.entries(routes), ([ k, v ]) => { diff --git a/src/features/cart/cartSlice.js b/src/features/cart/cartSlice.js index f0bfa14..c485ff5 100644 --- a/src/features/cart/cartSlice.js +++ b/src/features/cart/cartSlice.js @@ -1,5 +1,5 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { getCart, postCreatePaymentIntent, putUpdateCartWithItem } from '../actions/api'; +import { getCart, postConfirmPayment, postCreatePaymentIntent, putUpdateCartWithItem } from '../actions/api'; const ns = 'cart'; @@ -29,6 +29,18 @@ export const postCreatePaymentIntentAsyncThunk = createAsyncThunk( } ); +export const postConfirmPaymentAsyncThunk = createAsyncThunk( + 'payment/confirm', + async (data, { rejectWithValue }) => { + try { + const { clientSecret, card } = data; + return await postConfirmPayment(clientSecret, card); + } catch (ex) { + return rejectWithValue(ex); + } + } +); + const initialState = { id: '123', subTotal: 0, @@ -49,7 +61,8 @@ export const cartSlice = createSlice({ resetCart: () => Object.assign({}, initialState, { items: [] }), paymentSuccessful: (state) => { debugger; - state.paymentSuccessful = true; }, + state.paymentSuccessful = true; + }, resetPaymentSuccessful: (state) => Object.assign({}, initialState, { items: [] }) }, extraReducers: builder => builder @@ -128,8 +141,14 @@ export const cartSlice = createSlice({ }) .addCase(postCreatePaymentIntentAsyncThunk.pending, (state, { payload, meta }) => state) - .addCase(postCreatePaymentIntentAsyncThunk.fulfilled, (state, { payload, meta }) => state) + .addCase(postCreatePaymentIntentAsyncThunk.fulfilled, (state, { payload, meta }) => { + // TODO: get amount sum from the response and set it here + return state; + }) .addCase(postCreatePaymentIntentAsyncThunk.rejected, (state, { payload, meta }) => state) + .addCase(postConfirmPaymentAsyncThunk.pending, (state, { payload, meta }) => state) + .addCase(postConfirmPaymentAsyncThunk.fulfilled, (state, { payload, meta }) => state) + .addCase(postConfirmPaymentAsyncThunk.rejected, (state, { payload, meta }) => state) }); export const { resetCart, paymentSuccessful, resetPaymentSuccessful } = cartSlice.actions; diff --git a/src/testability/selectors.js b/src/testability/selectors.js index 1aaf0a8..434369c 100644 --- a/src/testability/selectors.js +++ b/src/testability/selectors.js @@ -38,4 +38,23 @@ export const selectors = defineTestIdDictionary((testId, testIdRest) => ({ PAGE_CHECKOUT: testId('page', 'checkout'), + MODAL_PAYMENT: testId('modal', 'payment'), + BTN_MODAL_PAYMENT_DISMISS_FN: testIdRest('button', 'dismiss payment modal'), + BTN_MODAL_PAYMENT_DISMISS: testId('button', 'dismiss payment modal', 'dismiss'), + BTN_MODAL_PAYMENT_CANCEL: testId('button', 'dismiss payment modal', 'cancel'), + + FORM_PAYMENT: testId('form', 'payment'), + BTN_FORM_PAYMENT_SUBMIT: testId('button', 'submit payment form'), + TEXT_FORM_PAYMENT_ERRORS: testId('text', 'payment form errors'), + TEXT_FORM_PAYMENT_SUCCESS: testId('text', 'payment form success'), + + FLD_FORM_PAYMENT_FN: testIdRest('field', 'payment form'), + + BTN_INVOKE_PAYMENT_MODAL: testId('button', 'invoke payment modal'), + BTN_CHECKOUT_MODIFY_CART: testId('button', 'modify cart', 'checkout page'), + BTN_CHECKOUT_REMOVE_ITEM_FN: testIdRest('button', 'remove item', 'checkout page'), + + CARD_CHECKOUT_ITEM_FN: testIdRest('card', 'item', 'checkout page'), + + })); diff --git a/src/ui/pages/CheckoutPage/cardElement.js b/src/ui/pages/CheckoutPage/cardElement.js index fec8012..6fb2bb2 100644 --- a/src/ui/pages/CheckoutPage/cardElement.js +++ b/src/ui/pages/CheckoutPage/cardElement.js @@ -1,9 +1,10 @@ import { Input, InputGroup, InputGroupText } from 'reactstrap'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import curry from 'lodash-es/curry'; import { accessCardValue, resetCard, updateCardValue } from '../../../features/card/cardSlice'; import { useDispatch, useSelector } from 'react-redux'; -import { forTimeout } from '../../../shared/promises'; +import { postConfirmPaymentAsyncThunk } from '../../../features/cart/cartSlice'; +import { e2eAssist } from '../../../testability'; export const useElements = () => { const cardValue = useSelector(accessCardValue()); @@ -14,8 +15,10 @@ export const useElements = () => { }; export const useStripe = () => { + const dispatch = useDispatch(); + return useMemo(() => ({ - confirmCardPayment(clientSecret, data) { + async confirmCardPayment(clientSecret, data) { const { payment_method: { card //: elements.getElement(CardElement) @@ -24,39 +27,12 @@ export const useStripe = () => { console.log('[stripe.confirmCardPayment]', clientSecret, card); - - // 4242 4242 4242 4242 - Payment succeeds - // 4000 0025 0000 3155 - Payment requires authentication - // 4000 0000 0000 9995 - Payment is declined - - - if (/^4242\s*4242\s*4242\s*4242$/.test(card?.card_number ?? '')) { - return forTimeout(1000, { - error: false - }); - } - else if (/^4000\s*0025\s*0000\s*3155$/.test(card?.card_number ?? '')) { - const isOdd = (new Date().getTime() % 2) === 0; - return forTimeout(3500, { - error: isOdd && { - message: 'Payment requires authentication. The odds now are for simulating an error. Try again for successful payment.' - } - }); - } - else if (/^4000\s*0000\s*0000\s*9995$/.test(card?.card_number ?? '')) { - return forTimeout(3000, { - error: { - message: 'Payment is declined' - } - }); - } else { - return forTimeout(1000, { - error: false - }); - } - + const response = await dispatch(postConfirmPaymentAsyncThunk({ clientSecret, card })); + const { error, payload } = response; + console.log(error, payload); + return payload; } - }), []); + }), [ dispatch ]); }; export const CardElement = ({ errors, onChange, options = {} }) => { @@ -105,35 +81,40 @@ export const CardElement = ({ errors, onChange, options = {} }) => { /
Try using these values for the card: -
+      
         4242 4242 4242 4242 - Payment succeeds
-        
+
4000 0025 0000 3155 - Payment requires authentication -
+
4000 0000 0000 9995 - Payment is declined
diff --git a/src/ui/pages/CheckoutPage/checkoutForm.js b/src/ui/pages/CheckoutPage/checkoutForm.js index 2e8e4b2..a68d7f2 100644 --- a/src/ui/pages/CheckoutPage/checkoutForm.js +++ b/src/ui/pages/CheckoutPage/checkoutForm.js @@ -9,12 +9,14 @@ import { import { LoadingSpinner } from '../../elements/Loading'; import './checkoutForm.scss'; import { safelyExecuteSync } from '../../../shared/promises'; +import { e2eAssist } from '../../../testability'; export function CheckoutForm() { const [ succeeded, setSucceeded ] = useState(false); const [ error, setError ] = useState(null); + const [ errors, setErrors ] = useState(null); const [ processing, setProcessing ] = useState(''); const [ disabled, setDisabled ] = useState(true); const [ clientSecret, setClientSecret ] = useState(''); @@ -79,11 +81,16 @@ export function CheckoutForm() { } }); + console.log(payload); + debugger; + if (payload.error) { setError(`${ payload.error.message }`); + payload.errors ? setErrors(payload.errors) : setErrors(null); setProcessing(false); } else { setError(null); + setErrors(null); setProcessing(false); setSucceeded(true); dispatch(paymentSuccessful()); @@ -95,11 +102,12 @@ export function CheckoutForm() { } return ( -
- + + {/* Show any error that happens when processing the payment */ } { error && ( -
+
Payment failed: { error }
) } {/* Show a success message upon completion */ } -

+

Payment succeeded!

diff --git a/src/ui/pages/CheckoutPage/index.js b/src/ui/pages/CheckoutPage/index.js index 5cf3cca..4f8c79f 100644 --- a/src/ui/pages/CheckoutPage/index.js +++ b/src/ui/pages/CheckoutPage/index.js @@ -109,7 +109,7 @@ const CheckoutPage = () => { - + @@ -120,7 +120,8 @@ const CheckoutPage = () => { - + diff --git a/src/ui/pages/CheckoutPage/paymentModal.js b/src/ui/pages/CheckoutPage/paymentModal.js index 72dabcf..193e4da 100644 --- a/src/ui/pages/CheckoutPage/paymentModal.js +++ b/src/ui/pages/CheckoutPage/paymentModal.js @@ -1,6 +1,7 @@ import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import React from 'react'; import { CheckoutForm } from './checkoutForm'; +import { e2eAssist } from '../../../testability'; //import { Elements } from '@stripe/react-stripe-js'; //import { loadStripe } from '@stripe/stripe-js'; @@ -8,13 +9,14 @@ import { CheckoutForm } from './checkoutForm'; export const PaymentModal = ({ show, toggle, showDismiss }) => { // return - return + return Payment Details: - + ; // ; diff --git a/src/ui/pages/RestaurantPage/yourTrayItems.js b/src/ui/pages/RestaurantPage/yourTrayItems.js index 6ff14bb..59f5069 100644 --- a/src/ui/pages/RestaurantPage/yourTrayItems.js +++ b/src/ui/pages/RestaurantPage/yourTrayItems.js @@ -1,7 +1,7 @@ import { useSelector } from 'react-redux'; import { accessCart, accessCartItems, accessCartStatus } from '../../../features/cart/cartSlice'; import { createMap, useUpdateCartHandler } from './hooks'; -import { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Button, ButtonGroup, Card, CardBody, CardTitle } from 'reactstrap'; import { IconMinus, IconPlus, IconTrash } from '../../elements/icons'; import { PaginatedTable } from '../../elements/paginatedTable'; @@ -15,7 +15,6 @@ export function YourTrayItems({ checkout }) { const cartItemsMap = useMemo(() => createMap(cartItems || [], i => i.id), [ cartItems ]); const handleAddToCart = useUpdateCartHandler(cartId, cartItemsMap, undefined); - const actionColumnFormatter = useCallback((cellContent, row, rowIdx, cartId) => { const disabled = !cartId || typeof row.oldCount !== 'undefined'; return @@ -59,11 +58,13 @@ export function YourTrayItems({ checkout }) { } if (checkout) { - return cartItems.map((item, idx) => ( + return cartItems.map((item, idx) => ( { item.name }
{ actionColumnFormatter(null, item, idx, cartId) }
-
+
+ +
${ item.price * item.count }