fix: update stripe wallets to use payment intent (#54668)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2024-06-15 09:15:10 +03:00
committed by GitHub
parent c132ef80f4
commit 7e23b0d69c
11 changed files with 234 additions and 165 deletions

View File

@@ -1,11 +1,15 @@
import debug from 'debug';
import Stripe from 'stripe';
import { donationSubscriptionConfig } from '../../../../shared/config/donation-settings';
import {
donationSubscriptionConfig,
allStripeProductIdsArray
} from '../../../../shared/config/donation-settings';
import keys from '../../../config/secrets';
import {
createStripeCardDonation,
handleStripeCardUpdateSession
handleStripeCardUpdateSession,
inLastFiveMinutes
} from '../utils/donation';
import { validStripeForm } from '../utils/stripeHelpers';
@@ -42,117 +46,82 @@ export default function donateBoot(app, done) {
});
}
function createStripeDonation(req, res) {
const { user, body } = req;
async function createStripeDonation(req, res) {
const { body } = req;
const { amount, duration, email, subscriptionId } = body;
try {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const isSubscriptionActive = subscription.status === 'active';
const productId = subscription.items.data[0].plan.product;
const isStartedRecently = inLastFiveMinutes(
subscription.current_period_start
);
const isProductIdValid = allStripeProductIdsArray.includes(productId);
const {
amount,
duration,
token: { id },
email,
name
} = body;
if (isSubscriptionActive && isProductIdValid && isStartedRecently) {
const [donatingUser] = await User.findOrCreate(
{ where: { email } },
{ email }
);
const donation = {
email,
amount,
duration,
provider: 'stripe',
subscriptionId,
customerId: subscription.customer,
startDate: new Date().toISOString()
};
await donatingUser.createDonation(donation);
return res.status(200).send({ isDonating: true });
} else {
throw new Error('Donation failed due to a server error.');
}
} catch (err) {
return res
.status(500)
.send({ error: 'Donation failed due to a server error.' });
}
}
async function createStripePaymentIntent(req, res) {
const { body } = req;
const { amount, duration, email, name } = body;
if (!validStripeForm(amount, duration, email)) {
return res.status(500).send({
return res.status(400).send({
error: 'The donation form had invalid values for this submission.'
});
}
const fccUser = user
? Promise.resolve(user)
: new Promise((resolve, reject) =>
User.findOrCreate(
{ where: { email } },
{ email },
(err, instance) => {
if (err) {
return reject(err);
}
return resolve(instance);
}
)
);
let donatingUser = {};
let donation = {
email,
amount,
duration,
provider: 'stripe',
startDate: new Date(Date.now()).toISOString()
};
const createCustomer = async user => {
let customer;
donatingUser = user;
try {
customer = await stripe.customers.create({
email,
card: id,
name
});
} catch (err) {
throw new Error('Error creating stripe customer');
}
log(`Stripe customer with id ${customer.id} created`);
return customer;
};
const createSubscription = async customer => {
donation.customerId = customer.id;
let sub;
try {
sub = await stripe.subscriptions.create({
customer: customer.id,
items: [
{
plan: `${donationSubscriptionConfig.duration[
duration
].toLowerCase()}-donation-${amount}`
}
]
});
} catch (err) {
throw new Error('Error creating stripe subscription');
}
return sub;
};
const createAsyncUserDonation = () => {
donatingUser
.createDonation(donation)
.toPromise()
.catch(err => {
throw new Error(err);
});
};
return Promise.resolve(fccUser)
.then(nonDonatingUser => {
// the logic is removed since users can donate without an account
return nonDonatingUser;
})
.then(createCustomer)
.then(customer => {
return createSubscription(customer).then(subscription => {
log(`Stripe subscription with id ${subscription.id} created`);
donation.subscriptionId = subscription.id;
return res.send(subscription);
});
})
.then(createAsyncUserDonation)
.catch(err => {
if (
err.type === 'StripeCardError' ||
err.type === 'AlreadyDonatingError'
) {
return res.status(402).send({ error: err.message });
}
return res
.status(500)
.send({ error: 'Donation failed due to a server error.' });
try {
const stripeCustomer = await stripe.customers.create({
email,
name
});
const stripeSubscription = await stripe.subscriptions.create({
customer: stripeCustomer.id,
items: [
{
plan: `${donationSubscriptionConfig.duration[duration]}-donation-${amount}`
}
],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent']
});
res.status(200).send({
subscriptionId: stripeSubscription.id,
clientSecret:
stripeSubscription.latest_invoice.payment_intent.client_secret
});
} catch (err) {
return res
.status(500)
.send({ error: 'Donation failed due to a server error.' });
}
}
function addDonation(req, res) {
@@ -208,6 +177,7 @@ export default function donateBoot(app, done) {
} else {
api.post('/charge-stripe', createStripeDonation);
api.post('/charge-stripe-card', handleStripeCardDonation);
api.post('/create-stripe-payment-intent', createStripePaymentIntent);
api.put('/update-stripe-card', handleStripeCardUpdate);
api.post('/add-donation', addDonation);
donateRouter.use('/donate', api);

View File

@@ -14,7 +14,9 @@ export default function getCsurf() {
const { path } = req;
if (
// eslint-disable-next-line max-len
/^\/donate\/charge-stripe$|^\/coderoad-challenge-completed$/.test(path)
/^\/donate\/charge-stripe$|^\/donate\/create-stripe-payment-intent$|^\/coderoad-challenge-completed$/.test(
path
)
) {
next();
} else {

View File

@@ -24,6 +24,7 @@ const unsubscribedRE = /^\/unsubscribed\//;
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
// note: this would be replaced by webhooks later
const donateRE = /^\/donate\/charge-stripe$/;
const paymentIntentRE = /^\/donate\/create-stripe-payment-intent$/;
const submitCoderoadChallengeRE = /^\/coderoad-challenge-completed$/;
const mobileLoginRE = /^\/mobile-login\/?$/;
@@ -40,6 +41,7 @@ const _pathsAllowedREs = [
unsubscribedRE,
unsubscribeRE,
donateRE,
paymentIntentRE,
submitCoderoadChallengeRE,
mobileLoginRE
];

View File

@@ -173,3 +173,9 @@ export async function handleStripeCardUpdateSession(req, app, stripe) {
});
return { sessionId: session.id };
}
export function inLastFiveMinutes(unixTimestamp) {
const currentTimestamp = Math.floor(Date.now() / 1000);
const timeDifference = currentTimestamp - unixTimestamp;
return timeDifference <= 300; // 300 seconds is 5 minutes
}

View File

@@ -1,15 +1,10 @@
import { isEmail, isNumeric } from 'validator';
import {
durationKeysConfig,
donationOneTimeConfig,
donationSubscriptionConfig
} from '../../../../shared/config/donation-settings';
import { donationSubscriptionConfig } from '../../../../shared/config/donation-settings';
export function validStripeForm(amount, duration, email) {
return isEmail('' + email) &&
return (
isEmail('' + email) &&
isNumeric('' + amount) &&
durationKeysConfig.includes(duration) &&
duration === 'one-time'
? donationOneTimeConfig.includes(amount)
: donationSubscriptionConfig.plans[duration];
donationSubscriptionConfig.plans[duration].includes(amount)
);
}

View File

@@ -575,6 +575,7 @@
"other-ways": "Here are many <0>other ways you can support our charity's mission</0>.",
"if-support-further": "If you want to support our charity further, please consider <0>making a one-time donation</0>, <1>sending us a check</1>, or <2>learning about other ways you could support our charity.</2>",
"failed-pay": "Uh - oh. It looks like your transaction didn't go through. Could you please try again?",
"try-another-method": "Uh - oh. It looks like your transaction didn't go through. Could you please try another payment method?",
"try-again": "Please try again.",
"card-number": "Your Card Number:",
"expiration": "Expiration Date:",
@@ -787,7 +788,6 @@
"cert-claim-success": "@{{username}}, you have successfully claimed the {{name}} Certification! Congratulations on behalf of the freeCodeCamp.org team!",
"wrong-name": "Something went wrong with the verification of {{name}}, please try again. If you continue to receive this error, you can send a message to support@freeCodeCamp.org to get help.",
"error-claiming": "Error claiming {{certName}}",
"refresh-needed": "You can only use the PaymentRequest button once. Refresh the page to start over.",
"username-not-found": "We could not find a user with the username \"{{username}}\"",
"add-name": "This user needs to add their name to their account in order for others to be able to view their certification.",
"not-eligible": "This user is not eligible for freeCodeCamp.org certifications at this time.",

View File

@@ -56,6 +56,7 @@ type PostCharge = (data: {
name?: string | undefined;
paymentMethodId?: string;
handleAuthentication?: HandleAuthentication;
subscriptionId?: string;
}) => void;
type DonateFormProps = {
@@ -162,9 +163,9 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
data,
payerEmail,
payerName,
token,
paymentMethodId,
handleAuthentication
handleAuthentication,
subscriptionId
}: PostPayment): void => {
const { donationAmount, donationDuration: duration } = this.state;
const { paymentContext, email, selectedDonationAmount } = this.props;
@@ -176,11 +177,11 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
amount,
duration,
data,
token,
email: email || payerEmail,
name: payerName,
paymentMethodId,
handleAuthentication
handleAuthentication,
subscriptionId
});
if (this.props.handleProcessing) this.props.handleProcessing();
};
@@ -254,11 +255,11 @@ class DonateForm extends Component<DonateFormProps, DonateFormComponentState> {
{loading.stripe && loading.paypal && <PaymentButtonsLoader />}
<WalletsWrapper
amount={donationAmount}
duration={donationDuration}
handlePaymentButtonLoad={this.handlePaymentButtonLoad}
label={walletlabel}
onDonationStateChange={this.onDonationStateChange}
postPayment={this.postPayment}
refreshErrorMessage={t('donate.refresh-needed')}
theme={priorityTheme}
/>
<PaypalButton

View File

@@ -1,4 +1,4 @@
import type { Token, PaymentIntentResult } from '@stripe/stripe-js';
import type { PaymentIntentResult } from '@stripe/stripe-js';
export type PaymentContext = 'modal' | 'donate page' | 'certificate';
export type PaymentProvider = 'patreon' | 'paypal' | 'stripe' | 'stripe card';
@@ -11,11 +11,11 @@ export type HandleAuthentication = (
export interface PostPayment {
paymentProvider: PaymentProvider;
data?: DonationApprovalData;
token?: Token;
payerEmail?: string | undefined;
payerName?: string | undefined;
paymentMethodId?: string;
handleAuthentication?: HandleAuthentication;
subscriptionId?: string;
}
export interface DonationApprovalData {

View File

@@ -2,20 +2,25 @@ import {
PaymentRequestButtonElement,
ElementsConsumer
} from '@stripe/react-stripe-js';
import type { Token, PaymentRequest, Stripe } from '@stripe/stripe-js';
import React, { useState, useEffect } from 'react';
import type { PaymentRequest, Stripe } from '@stripe/stripe-js';
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Themes } from '../settings/theme';
import { PaymentProvider } from '../../../../shared/config/donation-settings';
import {
PaymentProvider,
DonationDuration
} from '../../../../shared/config/donation-settings';
import { createStripePaymentIntent } from '../../utils/ajax';
import { DonationApprovalData, PostPayment } from './types';
interface WrapperProps {
label: string;
amount: number;
theme: Themes;
duration: DonationDuration;
postPayment: (arg0: PostPayment) => void;
onDonationStateChange: (donationState: DonationApprovalData) => void;
refreshErrorMessage: string;
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
}
interface WalletsButtonProps extends WrapperProps {
@@ -27,16 +32,27 @@ const WalletsButton = ({
label,
amount,
theme,
refreshErrorMessage,
duration,
postPayment,
onDonationStateChange,
handlePaymentButtonLoad
}: WalletsButtonProps) => {
const [token, setToken] = useState<Token | null>(null);
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest | null>(
null
);
const [canMakePayment, checkPaymentPossibility] = useState(false);
const { t } = useTranslation();
const displayError = useCallback(
(errorMessage: string): void => {
onDonationStateChange({
redirecting: false,
processing: false,
success: false,
error: errorMessage
});
},
[onDonationStateChange]
);
useEffect(() => {
if (!stripe) {
@@ -52,51 +68,78 @@ const WalletsButton = ({
disableWallets: ['browserCard']
});
pr.on('token', event => {
const { token, payerEmail, payerName } = event;
setToken(token);
event.complete('success');
postPayment({
paymentProvider: PaymentProvider.Stripe,
token,
pr.on('paymentmethod', async event => {
const {
payerEmail,
payerName
payerName,
paymentMethod: { id: paymentMethodId }
} = event;
//create payment intent
const {
data: { clientSecret, subscriptionId, error }
} = await createStripePaymentIntent({
email: payerEmail,
name: payerName,
amount,
duration
});
if (error) {
event.complete('fail');
displayError(t('donate.try-another-method'));
} else if (clientSecret) {
// confirm payment intent
const { paymentIntent, error: confirmError } =
await stripe.confirmCardPayment(
clientSecret,
{ payment_method: event.paymentMethod.id },
{ handleActions: false }
);
if (confirmError) {
event.complete('fail');
displayError(t('donate.try-another-method'));
} else {
event.complete('success');
if (paymentIntent.status === 'requires_action') {
const { error } = await stripe.confirmCardPayment(clientSecret);
if (error) {
return displayError(t('donate.try-another-method'));
}
}
postPayment({
paymentProvider: PaymentProvider.Stripe,
paymentMethodId,
payerEmail,
payerName,
subscriptionId
});
}
}
});
void pr.canMakePayment().then(canMakePaymentRes => {
if (canMakePaymentRes) {
setPaymentRequest(pr);
checkPaymentPossibility(true);
} else {
checkPaymentPossibility(false);
}
void pr.canMakePayment().then(result => {
if (result) setPaymentRequest(pr);
});
return () => {
setPaymentRequest(null);
checkPaymentPossibility(false);
};
}, [label, amount, stripe, postPayment, handlePaymentButtonLoad]);
const displayRefreshError = (): void => {
onDonationStateChange({
redirecting: false,
processing: false,
success: false,
error: refreshErrorMessage
});
};
}, [
label,
amount,
stripe,
postPayment,
handlePaymentButtonLoad,
duration,
displayError,
t
]);
return (
<form className='wallets-form'>
{canMakePayment && paymentRequest && (
{paymentRequest && (
<PaymentRequestButtonElement
onClick={() => {
if (token) {
displayRefreshError();
}
}}
onReady={() => handlePaymentButtonLoad('stripe')}
options={{
style: {

View File

@@ -11,6 +11,7 @@ import type {
SurveyResults,
User
} from '../redux/prop-types';
import { DonationDuration } from '../../../shared/config/donation-settings';
const { apiLocation } = envData;
@@ -256,6 +257,31 @@ export function postChargeStripeCard(
): Promise<ResponseWithData<void>> {
return post('/donate/charge-stripe-card', body);
}
type PaymentIntentResponse = Promise<
ResponseWithData<
| {
clientSecret?: never;
subscriptionId?: never;
error: string;
}
| {
clientSecret: string;
subscriptionId: string;
error?: never;
}
>
>;
export function createStripePaymentIntent(body: {
email: string | undefined;
name: string | undefined;
amount: number;
duration: DonationDuration;
}): PaymentIntentResponse {
return post('/donate/create-stripe-payment-intent', body);
}
interface Report {
username: string;
reportDescription: string;

View File

@@ -38,10 +38,10 @@ export const durationKeysConfig = ['month', 'one-time'];
export const donationOneTimeConfig = [100000, 25000, 6000];
export const donationSubscriptionConfig = {
duration: {
month: 'Monthly'
month: 'monthly'
},
plans: {
month: [25000, 3500, 500]
month: subscriptionAmounts
}
};
@@ -123,3 +123,27 @@ export enum PaymentProvider {
Stripe = 'stripe',
StripeCard = 'stripe card'
}
const stripeProductIds = {
live: {
month: {
500: 'prod_Cc9bIxB2NvjpLy',
1000: 'prod_BuiSxWk7jGSFlJ',
2000: 'prod_IElpZVK7kOn6Fe',
4000: 'prod_IElq1foW39g3Cx'
}
},
staging: {
month: {
500: 'prod_GD1GGbJsqQaupl',
1000: 'prod_GD1IzNEXfSCGgy',
2000: 'prod_IEkNp8M03xvsuB',
4000: 'prod_IEkPebxS63mVbs'
}
}
};
export const allStripeProductIdsArray = [
...Object.values(stripeProductIds['live']['month']),
...Object.values(stripeProductIds['staging']['month'])
];