From 9179b2fc5522aeeb19a5659584d1b69d0f8ff64a Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Wed, 3 Nov 2021 20:32:03 +0300 Subject: [PATCH] feat: add stripe authentication support (#44060) * feat: add stripe authentication support Co-authored-by: Nicholas Carrigan (he/him) Co-authored-by: Oliver Eyton-Williams --- api-server/src/server/boot/donate.js | 7 ++- api-server/src/server/utils/donation.js | 54 +++++++++++++------ client/src/components/Donation/DonateForm.tsx | 15 ++++-- .../components/Donation/stripe-card-form.tsx | 35 ++++++++++-- client/src/redux/donation-saga.js | 39 ++++++++++++-- 5 files changed, 120 insertions(+), 30 deletions(-) diff --git a/api-server/src/server/boot/donate.js b/api-server/src/server/boot/donate.js index 29268230ff..b702943053 100644 --- a/api-server/src/server/boot/donate.js +++ b/api-server/src/server/boot/donate.js @@ -30,8 +30,13 @@ export default function donateBoot(app, done) { async function handleStripeCardDonation(req, res) { return createStripeCardDonation(req, res, stripe, app).catch(err => { - if (err.type === 'AlreadyDonatingError') + if ( + err.type === 'AlreadyDonatingError' || + err.type === 'UserActionRequired' || + err.type === 'PaymentMethodRequired' + ) { return res.status(402).send({ error: err }); + } if (err.type === 'InvalidRequest') return res.status(400).send({ error: err }); return res.status(500).send({ diff --git a/api-server/src/server/utils/donation.js b/api-server/src/server/utils/donation.js index 269cf6c412..978d187a18 100644 --- a/api-server/src/server/utils/donation.js +++ b/api-server/src/server/utils/donation.js @@ -179,16 +179,12 @@ export async function updateUser(body, app) { export async function createStripeCardDonation(req, res, stripe) { const { - body: { - token: { id: tokenId }, - amount, - duration - }, + body: { paymentMethodId, amount, duration }, user: { name, id: userId, email }, user } = req; - if (!tokenId || !amount || !duration || !userId || !email) { + if (!paymentMethodId || !amount || !duration || !userId || !email) { throw { message: 'Request is not valid', type: 'InvalidRequest' @@ -210,7 +206,8 @@ export async function createStripeCardDonation(req, res, stripe) { try { const customer = await stripe.customers.create({ email, - card: tokenId, + payment_method: paymentMethodId, + invoice_settings: { default_payment_method: paymentMethodId }, ...(name && { name }) }); customerId = customer?.id; @@ -221,26 +218,51 @@ export async function createStripeCardDonation(req, res, stripe) { }; } log(`Stripe customer with id ${customerId} created`); - // log creation of Stripe customer event + let subscriptionId; try { - const subscription = await stripe.subscriptions.create({ + const { + id: subscription_id, + latest_invoice: { + payment_intent: { client_secret, status: intent_status } + } + } = await stripe.subscriptions.create({ // create Stripe subscription customer: customerId, + payment_behavior: 'allow_incomplete', items: [ { plan: `${donationSubscriptionConfig.duration[ duration ].toLowerCase()}-donation-${amount}` } - ] + ], + expand: ['latest_invoice.payment_intent'] }); - subscriptionId = subscription?.id; - } catch { - throw { - type: 'subscriptionCreationFailed', - message: 'Failed to create stripe subscription' - }; + + if (intent_status === 'requires_source_action') + throw { + type: 'UserActionRequired', + message: 'Payment requires user action', + client_secret + }; + else if (intent_status === 'requires_source') + throw { + type: 'PaymentMethodRequired', + message: 'Card has been declined' + }; + subscriptionId = subscription_id; + } catch (err) { + if ( + err.type === 'UserActionRequired' || + err.type === 'PaymentMethodRequired' + ) + throw err; + else + throw { + type: 'SubscriptionCreationFailed', + message: 'Failed to create stripe subscription' + }; } log(`Stripe subscription with id ${subscriptionId} created`); diff --git a/client/src/components/Donation/DonateForm.tsx b/client/src/components/Donation/DonateForm.tsx index e6fe05569b..15f35f024d 100644 --- a/client/src/components/Donation/DonateForm.tsx +++ b/client/src/components/Donation/DonateForm.tsx @@ -29,7 +29,7 @@ import Spacer from '../helpers/spacer'; import DonateCompletion from './DonateCompletion'; import type { AddDonationData } from './PaypalButton'; import PaypalButton from './PaypalButton'; -import StripeCardForm from './stripe-card-form'; +import StripeCardForm, { HandleAuthentication } from './stripe-card-form'; import WalletsWrapper from './walletsButton'; import './Donation.css'; @@ -57,9 +57,10 @@ type DonateFormProps = { addDonation: (data: unknown) => unknown; postChargeStripe: (data: unknown) => unknown; postChargeStripeCard: (data: { - token: Token; + paymentMethodId: string; amount: number; duration: string; + handleAuthentication: HandleAuthentication; }) => void; defaultTheme?: string; email: string; @@ -221,7 +222,10 @@ class DonateForm extends Component { }); } - postStripeCardDonation(token: Token) { + postStripeCardDonation( + paymentMethodId: string, + handleAuthentication: HandleAuthentication + ) { const { donationAmount: amount, donationDuration: duration } = this.state; this.props.handleProcessing( duration, @@ -229,9 +233,10 @@ class DonateForm extends Component { 'Stripe card payment submission' ); this.props.postChargeStripeCard({ - token, + paymentMethodId, amount, - duration + duration, + handleAuthentication }); } diff --git a/client/src/components/Donation/stripe-card-form.tsx b/client/src/components/Donation/stripe-card-form.tsx index fea859ff15..22da886572 100644 --- a/client/src/components/Donation/stripe-card-form.tsx +++ b/client/src/components/Donation/stripe-card-form.tsx @@ -9,9 +9,9 @@ import { } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import type { - Token, StripeCardNumberElementChangeEvent, - StripeCardExpiryElementChangeEvent + StripeCardExpiryElementChangeEvent, + PaymentIntentResult } from '@stripe/stripe-js'; import React, { useState } from 'react'; @@ -19,9 +19,18 @@ import envData from '../../../../config/env.json'; import { AddDonationData } from './PaypalButton'; const { stripePublicKey }: { stripePublicKey: string | null } = envData; + +export type HandleAuthentication = ( + clientSecret: string, + paymentMethod: string +) => Promise; + interface FormPropTypes { onDonationStateChange: (donationState: AddDonationData) => void; - postStripeCardDonation: (token: Token) => void; + postStripeCardDonation: ( + paymentMethodId: string, + handleAuthentication: HandleAuthentication + ) => void; t: (label: string) => string; theme: string; processing: boolean; @@ -100,7 +109,10 @@ const StripeCardForm = ({ const cardElement = elements.getElement(CardNumberElement); if (cardElement) { setTokenizing(true); - const { error, token } = await stripe.createToken(cardElement); + const { paymentMethod, error } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement + }); if (error) { onDonationStateChange({ redirecting: false, @@ -108,11 +120,24 @@ const StripeCardForm = ({ success: false, error: t('donate.went-wrong') }); - } else if (token) postStripeCardDonation(token); + } else if (paymentMethod) + postStripeCardDonation(paymentMethod.id, handleAuthentication); } } return setTokenizing(false); }; + const handleAuthentication = async ( + clientSecret: string, + paymentMethod: string + ) => { + if (stripe) { + return stripe.confirmCardPayment(clientSecret, { + // eslint-disable-next-line camelcase + payment_method: paymentMethod + }); + } + return { error: { type: 'StripeNotLoaded' } }; + }; return (
diff --git a/client/src/redux/donation-saga.js b/client/src/redux/donation-saga.js index ac89dd211b..d24fa9038c 100644 --- a/client/src/redux/donation-saga.js +++ b/client/src/redux/donation-saga.js @@ -7,6 +7,7 @@ import { call, take } from 'redux-saga/effects'; + import { addDonation, postChargeStripe, @@ -73,10 +74,42 @@ function* postChargeStripeSaga({ payload }) { } } -function* postChargeStripeCardSaga({ payload }) { +function* stripeCardErrorHandler( + error, + handleAuthentication, + clientSecret, + paymentMethodId +) { + if (error.type === 'UserActionRequired' && clientSecret) { + yield handleAuthentication(clientSecret, paymentMethodId) + .then(result => { + if (result?.paymentIntent?.status !== 'succeeded') + throw result.error || { type: 'StripeAuthorizationFailed' }; + }) + .catch(error => { + throw error; + }); + } else { + throw error; + } +} + +function* postChargeStripeCardSaga({ + payload: { paymentMethodId, amount, duration, handleAuthentication } +}) { try { - const { error } = yield call(postChargeStripeCard, payload); - if (error) throw error; + const optimizedPayload = { paymentMethodId, amount, duration }; + const { error } = yield call(postChargeStripeCard, optimizedPayload); + if (error) { + yield stripeCardErrorHandler( + error, + handleAuthentication, + error.client_secret, + paymentMethodId, + optimizedPayload + ); + } + yield call(addDonation, optimizedPayload); yield put(postChargeStripeCardComplete()); } catch (error) { const errorMessage = error.message || defaultDonationErrorMessage;