feat: add stripe authentication support (#44060)
* feat: add stripe authentication support Co-authored-by: Nicholas Carrigan (he/him) <nhcarrigan@gmail.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@ -30,8 +30,13 @@ export default function donateBoot(app, done) {
|
|||||||
|
|
||||||
async function handleStripeCardDonation(req, res) {
|
async function handleStripeCardDonation(req, res) {
|
||||||
return createStripeCardDonation(req, res, stripe, app).catch(err => {
|
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 });
|
return res.status(402).send({ error: err });
|
||||||
|
}
|
||||||
if (err.type === 'InvalidRequest')
|
if (err.type === 'InvalidRequest')
|
||||||
return res.status(400).send({ error: err });
|
return res.status(400).send({ error: err });
|
||||||
return res.status(500).send({
|
return res.status(500).send({
|
||||||
|
@ -179,16 +179,12 @@ export async function updateUser(body, app) {
|
|||||||
|
|
||||||
export async function createStripeCardDonation(req, res, stripe) {
|
export async function createStripeCardDonation(req, res, stripe) {
|
||||||
const {
|
const {
|
||||||
body: {
|
body: { paymentMethodId, amount, duration },
|
||||||
token: { id: tokenId },
|
|
||||||
amount,
|
|
||||||
duration
|
|
||||||
},
|
|
||||||
user: { name, id: userId, email },
|
user: { name, id: userId, email },
|
||||||
user
|
user
|
||||||
} = req;
|
} = req;
|
||||||
|
|
||||||
if (!tokenId || !amount || !duration || !userId || !email) {
|
if (!paymentMethodId || !amount || !duration || !userId || !email) {
|
||||||
throw {
|
throw {
|
||||||
message: 'Request is not valid',
|
message: 'Request is not valid',
|
||||||
type: 'InvalidRequest'
|
type: 'InvalidRequest'
|
||||||
@ -210,7 +206,8 @@ export async function createStripeCardDonation(req, res, stripe) {
|
|||||||
try {
|
try {
|
||||||
const customer = await stripe.customers.create({
|
const customer = await stripe.customers.create({
|
||||||
email,
|
email,
|
||||||
card: tokenId,
|
payment_method: paymentMethodId,
|
||||||
|
invoice_settings: { default_payment_method: paymentMethodId },
|
||||||
...(name && { name })
|
...(name && { name })
|
||||||
});
|
});
|
||||||
customerId = customer?.id;
|
customerId = customer?.id;
|
||||||
@ -221,24 +218,49 @@ export async function createStripeCardDonation(req, res, stripe) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
log(`Stripe customer with id ${customerId} created`);
|
log(`Stripe customer with id ${customerId} created`);
|
||||||
// log creation of Stripe customer event
|
|
||||||
let subscriptionId;
|
let subscriptionId;
|
||||||
try {
|
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
|
// create Stripe subscription
|
||||||
customer: customerId,
|
customer: customerId,
|
||||||
|
payment_behavior: 'allow_incomplete',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
plan: `${donationSubscriptionConfig.duration[
|
plan: `${donationSubscriptionConfig.duration[
|
||||||
duration
|
duration
|
||||||
].toLowerCase()}-donation-${amount}`
|
].toLowerCase()}-donation-${amount}`
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
expand: ['latest_invoice.payment_intent']
|
||||||
});
|
});
|
||||||
subscriptionId = subscription?.id;
|
|
||||||
} catch {
|
if (intent_status === 'requires_source_action')
|
||||||
throw {
|
throw {
|
||||||
type: 'subscriptionCreationFailed',
|
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'
|
message: 'Failed to create stripe subscription'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ import Spacer from '../helpers/spacer';
|
|||||||
import DonateCompletion from './DonateCompletion';
|
import DonateCompletion from './DonateCompletion';
|
||||||
import type { AddDonationData } from './PaypalButton';
|
import type { AddDonationData } from './PaypalButton';
|
||||||
import PaypalButton from './PaypalButton';
|
import PaypalButton from './PaypalButton';
|
||||||
import StripeCardForm from './stripe-card-form';
|
import StripeCardForm, { HandleAuthentication } from './stripe-card-form';
|
||||||
import WalletsWrapper from './walletsButton';
|
import WalletsWrapper from './walletsButton';
|
||||||
|
|
||||||
import './Donation.css';
|
import './Donation.css';
|
||||||
@ -57,9 +57,10 @@ type DonateFormProps = {
|
|||||||
addDonation: (data: unknown) => unknown;
|
addDonation: (data: unknown) => unknown;
|
||||||
postChargeStripe: (data: unknown) => unknown;
|
postChargeStripe: (data: unknown) => unknown;
|
||||||
postChargeStripeCard: (data: {
|
postChargeStripeCard: (data: {
|
||||||
token: Token;
|
paymentMethodId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
duration: string;
|
duration: string;
|
||||||
|
handleAuthentication: HandleAuthentication;
|
||||||
}) => void;
|
}) => void;
|
||||||
defaultTheme?: string;
|
defaultTheme?: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -221,7 +222,10 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
postStripeCardDonation(token: Token) {
|
postStripeCardDonation(
|
||||||
|
paymentMethodId: string,
|
||||||
|
handleAuthentication: HandleAuthentication
|
||||||
|
) {
|
||||||
const { donationAmount: amount, donationDuration: duration } = this.state;
|
const { donationAmount: amount, donationDuration: duration } = this.state;
|
||||||
this.props.handleProcessing(
|
this.props.handleProcessing(
|
||||||
duration,
|
duration,
|
||||||
@ -229,9 +233,10 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
|
|||||||
'Stripe card payment submission'
|
'Stripe card payment submission'
|
||||||
);
|
);
|
||||||
this.props.postChargeStripeCard({
|
this.props.postChargeStripeCard({
|
||||||
token,
|
paymentMethodId,
|
||||||
amount,
|
amount,
|
||||||
duration
|
duration,
|
||||||
|
handleAuthentication
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,9 +9,9 @@ import {
|
|||||||
} from '@stripe/react-stripe-js';
|
} from '@stripe/react-stripe-js';
|
||||||
import { loadStripe } from '@stripe/stripe-js';
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
import type {
|
import type {
|
||||||
Token,
|
|
||||||
StripeCardNumberElementChangeEvent,
|
StripeCardNumberElementChangeEvent,
|
||||||
StripeCardExpiryElementChangeEvent
|
StripeCardExpiryElementChangeEvent,
|
||||||
|
PaymentIntentResult
|
||||||
} from '@stripe/stripe-js';
|
} from '@stripe/stripe-js';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
@ -19,9 +19,18 @@ import envData from '../../../../config/env.json';
|
|||||||
import { AddDonationData } from './PaypalButton';
|
import { AddDonationData } from './PaypalButton';
|
||||||
|
|
||||||
const { stripePublicKey }: { stripePublicKey: string | null } = envData;
|
const { stripePublicKey }: { stripePublicKey: string | null } = envData;
|
||||||
|
|
||||||
|
export type HandleAuthentication = (
|
||||||
|
clientSecret: string,
|
||||||
|
paymentMethod: string
|
||||||
|
) => Promise<PaymentIntentResult | { error: { type: string } }>;
|
||||||
|
|
||||||
interface FormPropTypes {
|
interface FormPropTypes {
|
||||||
onDonationStateChange: (donationState: AddDonationData) => void;
|
onDonationStateChange: (donationState: AddDonationData) => void;
|
||||||
postStripeCardDonation: (token: Token) => void;
|
postStripeCardDonation: (
|
||||||
|
paymentMethodId: string,
|
||||||
|
handleAuthentication: HandleAuthentication
|
||||||
|
) => void;
|
||||||
t: (label: string) => string;
|
t: (label: string) => string;
|
||||||
theme: string;
|
theme: string;
|
||||||
processing: boolean;
|
processing: boolean;
|
||||||
@ -100,7 +109,10 @@ const StripeCardForm = ({
|
|||||||
const cardElement = elements.getElement(CardNumberElement);
|
const cardElement = elements.getElement(CardNumberElement);
|
||||||
if (cardElement) {
|
if (cardElement) {
|
||||||
setTokenizing(true);
|
setTokenizing(true);
|
||||||
const { error, token } = await stripe.createToken(cardElement);
|
const { paymentMethod, error } = await stripe.createPaymentMethod({
|
||||||
|
type: 'card',
|
||||||
|
card: cardElement
|
||||||
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
onDonationStateChange({
|
onDonationStateChange({
|
||||||
redirecting: false,
|
redirecting: false,
|
||||||
@ -108,11 +120,24 @@ const StripeCardForm = ({
|
|||||||
success: false,
|
success: false,
|
||||||
error: t('donate.went-wrong')
|
error: t('donate.went-wrong')
|
||||||
});
|
});
|
||||||
} else if (token) postStripeCardDonation(token);
|
} else if (paymentMethod)
|
||||||
|
postStripeCardDonation(paymentMethod.id, handleAuthentication);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return setTokenizing(false);
|
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 (
|
return (
|
||||||
<Form className='donation-form' onSubmit={handleSubmit}>
|
<Form className='donation-form' onSubmit={handleSubmit}>
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
call,
|
call,
|
||||||
take
|
take
|
||||||
} from 'redux-saga/effects';
|
} from 'redux-saga/effects';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addDonation,
|
addDonation,
|
||||||
postChargeStripe,
|
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 {
|
try {
|
||||||
const { error } = yield call(postChargeStripeCard, payload);
|
const optimizedPayload = { paymentMethodId, amount, duration };
|
||||||
if (error) throw error;
|
const { error } = yield call(postChargeStripeCard, optimizedPayload);
|
||||||
|
if (error) {
|
||||||
|
yield stripeCardErrorHandler(
|
||||||
|
error,
|
||||||
|
handleAuthentication,
|
||||||
|
error.client_secret,
|
||||||
|
paymentMethodId,
|
||||||
|
optimizedPayload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
yield call(addDonation, optimizedPayload);
|
||||||
yield put(postChargeStripeCardComplete());
|
yield put(postChargeStripeCardComplete());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error.message || defaultDonationErrorMessage;
|
const errorMessage = error.message || defaultDonationErrorMessage;
|
||||||
|
Reference in New Issue
Block a user