Skip to content

Commit

Permalink
feat: Add Stripe Payments Intents API SCA-compliant Payment Component
Browse files Browse the repository at this point in the history
Signed-off-by: Janus Reith <mail@janusreith.de>
  • Loading branch information
janus-reith committed Jun 17, 2021
1 parent b1c1ae0 commit 30fa912
Show file tree
Hide file tree
Showing 10 changed files with 366 additions and 10 deletions.
41 changes: 41 additions & 0 deletions components/StripeCard/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.DS_Store
.fileStorage/
.vscode
.idea
.c9
.env*
!.env.example*
!.env.prod*
*.csv
*.dat
*.gz
*.log
*.out
*.pid
*.seed
*.sublime-project
*.sublime-workspace
browser.config.js

lib-cov
logs
node_modules
npm-debug.log
pids
results
allure-results
package-lock.json

.reaction/config.json

.next/*
src/.next/*
build
/reports

docker-compose.override.yml

# Yalc
.yalc/
yalc.lock
yalc-packages
221 changes: 221 additions & 0 deletions components/StripeCard/StripeCard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import React, { forwardRef, Fragment, useCallback, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { CardCvcElement, CardExpiryElement, CardNumberElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { Box, Grid, TextField } from "@material-ui/core";
import { makeStyles } from "@material-ui/core/styles";
import Alert from "@material-ui/lab/Alert";
import useStripePaymentIntent from "./hooks/useStripePaymentIntent";
import StripeInput from "./StripeInput";

const useStyles = makeStyles(() => ({
stripeForm: {
display: "flex",
flexDirection: "column",
},
}));

function SplitForm(
{
isSaving,
onSubmit,
onReadyForSaveChange,
stripeCardNumberInputLabel = "Card Number",
stripeCardExpirationDateInputLabel = "Exp.",
stripeCardCVCInputLabel = "CVC",
},
ref
) {
const classes = useStyles();
const stripe = useStripe();
const elements = useElements();
const options = useMemo(
() => ({
showIcon: true,
style: {
base: {
fontSize: "18px",
},
},
}),
[]
);
const [error, setError] = useState();

const [formCompletionState, setFormCompletionState] = useState({
cardNumber: false,
cardExpiry: false,
cardCvc: false,
});

const [isConfirmationInFlight, setIsConfirmationInFlight] = useState(false);

const [createStripePaymentIntent] = useStripePaymentIntent();

const isReady = useMemo(() => {
const { cardNumber, cardExpiry, cardCvc } = formCompletionState;

if (!isSaving && !isConfirmationInFlight && cardNumber && cardExpiry && cardCvc) return true;

return false;
}, [formCompletionState, isSaving, isConfirmationInFlight]);

useEffect(() => {
onReadyForSaveChange(isReady);
}, [onReadyForSaveChange, isReady]);

const onInputChange = useCallback(
({ elementType, complete }) => {
if (formCompletionState[elementType] !== complete) {
setFormCompletionState({
...formCompletionState,
[elementType]: complete,
});
}
},
[formCompletionState, setFormCompletionState]
);

const handleSubmit = useCallback(
async (event) => {
if (event) {
event.preventDefault();
}

if (!stripe || !elements || isSaving || isConfirmationInFlight) {
// Stripe.js has not loaded yet, saving is in progress or card payment confirmation is in-flight.
return;
}

setError();
setIsConfirmationInFlight(true);

// Await the server secret here
const { paymentIntentClientSecret } = await createStripePaymentIntent();

const result = await stripe.confirmCardPayment(paymentIntentClientSecret, {
// eslint-disable-next-line camelcase
payment_method: {
card: elements.getElement(CardNumberElement),
},
});

setIsConfirmationInFlight(false);

if (result.error) {
// Show error to your customer (e.g., insufficient funds)
console.error(result.error.message); // eslint-disable-line
setError(result.error.message);
} else {
// The payment has been processed!
if (result.paymentIntent.status === "succeeded" || result.paymentIntent.status === "requires_capture") {
// Show a success message to your customer
// There's a risk of the customer closing the window before callback
// execution. Set up a webhook or plugin to listen for the
// payment_intent.succeeded event that handles any business critical
// post-payment actions.

const { amount, id } = result.paymentIntent;

onSubmit({
amount: amount ? parseFloat(amount / 100) : null,
data: { stripePaymentIntentId: id },
displayName: "Stripe Payment",
});
} else {
console.error("Payment was not successful"); // eslint-disable-line
setError("Payment was not successful");
}
}
},
[createStripePaymentIntent, onSubmit, stripe, setError, isConfirmationInFlight, setIsConfirmationInFlight]
);

useImperativeHandle(ref, () => ({
submit() {
handleSubmit();
},
}));

return (
<Fragment>
<Box my={2}>
<Grid container spacing={2}>
{error && (
<Grid item xs={12}>
<Alert severity="error">{error}</Alert>
</Grid>
)}
<Grid item xs={12}>
<form onSubmit={handleSubmit} className={classes.stripeForm}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label={stripeCardNumberInputLabel}
name="ccnumber"
variant="outlined"
fullWidth
InputProps={{
inputComponent: StripeInput,
inputProps: {
component: CardNumberElement,
options,
},
}}
InputLabelProps={{
shrink: true,
}}
onChange={onInputChange}
required
/>
</Grid>

<Grid item xs={6}>
<TextField
label={stripeCardExpirationDateInputLabel}
name="ccexp"
variant="outlined"
fullWidth
InputProps={{
inputComponent: StripeInput,
inputProps: {
component: CardExpiryElement,
options,
},
}}
InputLabelProps={{
shrink: true,
}}
onChange={onInputChange}
required
/>
</Grid>

<Grid item xs={6}>
<TextField
label={stripeCardCVCInputLabel}
name="cvc"
variant="outlined"
fullWidth
InputProps={{
inputComponent: StripeInput,
inputProps: {
component: CardCvcElement,
options,
},
}}
InputLabelProps={{
shrink: true,
}}
onChange={onInputChange}
required
/>
</Grid>
</Grid>
</form>
</Grid>
</Grid>
</Box>
</Fragment>
);
}

export default forwardRef(SplitForm);
18 changes: 18 additions & 0 deletions components/StripeCard/StripeInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React, { useImperativeHandle,useRef } from "react";

function StripeInput({ component: Component, inputRef, ...props }) {
const elementRef = useRef();
useImperativeHandle(inputRef, () => ({
focus: () => elementRef.current.focus
}));
return (
<Component
onReady={(element) => {
elementRef.current = element;
}}
{...props}
/>
);
}

export default StripeInput;
5 changes: 5 additions & 0 deletions components/StripeCard/hooks/createStripePaymentIntent.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mutation createStripePaymentIntent($input: CreateStripePaymentIntentInput!) {
createStripePaymentIntent(input: $input) {
paymentIntentClientSecret
}
}
28 changes: 28 additions & 0 deletions components/StripeCard/hooks/useStripePaymentIntent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useMutation } from "@apollo/client";
import useCartStore from "hooks/globalStores/useCartStore";
import useShop from "hooks/shop/useShop";

import createStripePaymentIntentMutation from "./createStripePaymentIntent.gql";

export default function useStripePaymentIntent() {
const shop = useShop();
const { accountCartId, anonymousCartId, anonymousCartToken } = useCartStore();

const [createStripePaymentIntentFunc, { loading }] = useMutation(createStripePaymentIntentMutation);

const createStripePaymentIntent = async () => {
const { data } = await createStripePaymentIntentFunc({
variables: {
input: {
cartId: anonymousCartId || accountCartId,
shopId: shop?._id,
cartToken: anonymousCartToken,
},
},
});

return data?.createStripePaymentIntent;
};

return [createStripePaymentIntent, loading];
}
4 changes: 4 additions & 0 deletions components/StripeCard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import StripeWrapper from "./provider/StripeWrapper";
export { default } from "./StripeCard";

export { StripeWrapper };
15 changes: 15 additions & 0 deletions components/StripeCard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "reaction-stripe-sca-react",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Janus Reith",
"license": "Apache-2.0",
"dependencies": {
"@stripe/react-stripe-js": "^1.4.1",
"@stripe/stripe-js": "^1.15.0"
}
}
10 changes: 10 additions & 0 deletions components/StripeCard/provider/StripeWrapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";

const stripePromise = loadStripe(process.env.STRIPE_PUBLIC_API_KEY);

function StripeWrapper({ children }) {
return <Elements stripe={stripePromise}>{children}</Elements>;
}

export default StripeWrapper;
14 changes: 12 additions & 2 deletions custom/paymentMethods.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import ExampleIOUPaymentForm from "@reactioncommerce/components/ExampleIOUPaymentForm/v1";
import StripePaymentInput from "@reactioncommerce/components/StripePaymentInput/v1";
// import ExampleIOUPaymentForm from "@reactioncommerce/components/ExampleIOUPaymentForm/v1";
// import StripePaymentInput from "@reactioncommerce/components/StripePaymentInput/v1";

import StripeCard from "components/StripeCard";

const paymentMethods = [
/*
{
displayName: "Credit Card",
InputComponent: StripePaymentInput,
Expand All @@ -14,6 +17,13 @@ const paymentMethods = [
name: "iou_example",
shouldCollectBillingAddress: true
}
*/
{
displayName: "Credit Card",
InputComponent: StripeCard,
name: "stripe_payment_intent",
shouldCollectBillingAddress: true,
},
];

export default paymentMethods;
Loading

0 comments on commit 30fa912

Please sign in to comment.