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 = {} }) => {
+4242 4242 4242 4242 - Payment succeeds -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 ( -
+
4000 0025 0000 3155 - Payment requires authentication -
+
4000 0000 0000 9995 - Payment is declined