Skip to content

Commit

Permalink
#11: Implement a Checkout page
Browse files Browse the repository at this point in the history
 - 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
  • Loading branch information
dartandrevinsky committed Jul 14, 2021
1 parent 2ccab17 commit 2d9f3a4
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 83 deletions.
36 changes: 7 additions & 29 deletions server/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,18 @@ 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
api.use('/cart/address', addresses({ 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
Expand All @@ -47,4 +23,6 @@ export default ({ config, db }) => {
});

return api;
}
};

export default defaultExport;
126 changes: 126 additions & 0 deletions server/src/api/payment.js
Original file line number Diff line number Diff line change
@@ -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;
};
9 changes: 8 additions & 1 deletion src/features/actions/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]) => {
Expand Down
25 changes: 22 additions & 3 deletions src/features/cart/cartSlice.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions src/testability/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),


}));
57 changes: 19 additions & 38 deletions src/ui/pages/CheckoutPage/cardElement.js
Original file line number Diff line number Diff line change
@@ -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());
Expand All @@ -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)
Expand All @@ -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 = {} }) => {
Expand Down Expand Up @@ -105,35 +81,40 @@ export const CardElement = ({ errors, onChange, options = {} }) => {
<Input className="mb-1" placeholder="Card Number" name="card_number" type="number" value={ ccNumber } onChange={ onChangeHandler(setCCNumber) }
{ ...(errors?.card_number ? { invalid: true } : {}) }
style={ Object.assign({}, baseStyle, errors?.card_number ? invalidStyle : {}) }
{ ...e2eAssist.FLD_FORM_PAYMENT_FN('card_number', ...(errors?.card_number ? [ 'invalid' ] : [])) }
/>
<InputGroup className="mb-1">
<Input placeholder="Expires Month" name="exp_month" type="number" value={ expMonth } onChange={ onChangeHandler(setExpMonth) }
{ ...(errors?.exp_month ? { invalid: true } : {}) }
style={ Object.assign({}, baseStyle, errors?.exp_month ? invalidStyle : {}) }
{ ...e2eAssist.FLD_FORM_PAYMENT_FN('exp_month', ...(errors?.exp_month ? [ 'invalid' ] : [])) }
/>
<InputGroupText>/</InputGroupText>
<Input placeholder="Year" name="exp_year" type="number" value={ expYear } onChange={ onChangeHandler(setExpYear) }
{ ...(errors?.exp_year ? { invalid: true } : {}) }
style={ Object.assign({}, baseStyle, errors?.exp_year ? invalidStyle : {}) }
{ ...e2eAssist.FLD_FORM_PAYMENT_FN('exp_year', ...(errors?.exp_year ? [ 'invalid' ] : [])) }
/>
</InputGroup>
<InputGroup className="mb-1">
<Input placeholder="CVV" name="cvv" type="number" value={ cvv } onChange={ onChangeHandler(setCvv) }
{ ...(errors?.cvv ? { invalid: true } : {}) }
style={ Object.assign({}, baseStyle, errors?.cvv ? invalidStyle : {}) }
{ ...e2eAssist.FLD_FORM_PAYMENT_FN('cvv', ...(errors?.cvv ? [ 'invalid' ] : [])) }
/>
<Input placeholder="ZIP" name="zip" type="number" value={ zip } onChange={ onChangeHandler(setZip) }
{ ...(errors?.zip ? { invalid: true } : {}) }
style={ Object.assign({}, baseStyle, errors?.zip ? invalidStyle : {}) }
{ ...e2eAssist.FLD_FORM_PAYMENT_FN('zip', ...(errors?.zip ? [ 'invalid' ] : [])) }
/>
</InputGroup>
<div className="mb-1 text-muted">
Try using these values for the card:
<pre className="d-block">
<pre className="d-block text-muted">
4242 4242 4242 4242 - Payment succeeds
<br/>
<br />
4000 0025 0000 3155 - Payment requires authentication
<br/>
<br />

4000 0000 0000 9995 - Payment is declined
</pre>
Expand Down
Loading

0 comments on commit 2d9f3a4

Please sign in to comment.