From 7e23b0d69ce0282afdaaa6489b8b0ed0e62e59c8 Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Sat, 15 Jun 2024 09:15:10 +0300 Subject: [PATCH] fix: update stripe wallets to use payment intent (#54668) Co-authored-by: Oliver Eyton-Williams --- api-server/src/server/boot/donate.js | 180 ++++++++---------- api-server/src/server/middlewares/csurf.js | 4 +- .../middlewares/request-authorization.js | 2 + api-server/src/server/utils/donation.js | 6 + api-server/src/server/utils/stripeHelpers.js | 15 +- client/i18n/locales/english/translations.json | 2 +- .../src/components/Donation/donate-form.tsx | 11 +- client/src/components/Donation/types.ts | 4 +- .../components/Donation/wallets-button.tsx | 121 ++++++++---- client/src/utils/ajax.ts | 26 +++ shared/config/donation-settings.ts | 28 ++- 11 files changed, 234 insertions(+), 165 deletions(-) diff --git a/api-server/src/server/boot/donate.js b/api-server/src/server/boot/donate.js index 13507296b67..05aee1ac93e 100644 --- a/api-server/src/server/boot/donate.js +++ b/api-server/src/server/boot/donate.js @@ -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); diff --git a/api-server/src/server/middlewares/csurf.js b/api-server/src/server/middlewares/csurf.js index 46b1c0fafa7..c9287af705e 100644 --- a/api-server/src/server/middlewares/csurf.js +++ b/api-server/src/server/middlewares/csurf.js @@ -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 { diff --git a/api-server/src/server/middlewares/request-authorization.js b/api-server/src/server/middlewares/request-authorization.js index 8895e1670a9..aeb6edf1b07 100644 --- a/api-server/src/server/middlewares/request-authorization.js +++ b/api-server/src/server/middlewares/request-authorization.js @@ -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 ]; diff --git a/api-server/src/server/utils/donation.js b/api-server/src/server/utils/donation.js index 5500d445a50..a8d9208ffe1 100644 --- a/api-server/src/server/utils/donation.js +++ b/api-server/src/server/utils/donation.js @@ -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 +} diff --git a/api-server/src/server/utils/stripeHelpers.js b/api-server/src/server/utils/stripeHelpers.js index b8494cb60ff..4c5f30889b1 100644 --- a/api-server/src/server/utils/stripeHelpers.js +++ b/api-server/src/server/utils/stripeHelpers.js @@ -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) + ); } diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 342d309482e..e0783d1a15b 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -575,6 +575,7 @@ "other-ways": "Here are many <0>other ways you can support our charity's mission.", "if-support-further": "If you want to support our charity further, please consider <0>making a one-time donation, <1>sending us a check, or <2>learning about other ways you could support our charity.", "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.", diff --git a/client/src/components/Donation/donate-form.tsx b/client/src/components/Donation/donate-form.tsx index 232ec025c0f..05accc0cba1 100644 --- a/client/src/components/Donation/donate-form.tsx +++ b/client/src/components/Donation/donate-form.tsx @@ -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 { 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 { 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 { {loading.stripe && loading.paypal && } 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(null); const [paymentRequest, setPaymentRequest] = useState( 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 (
- {canMakePayment && paymentRequest && ( + {paymentRequest && ( { - if (token) { - displayRefreshError(); - } - }} onReady={() => handlePaymentButtonLoad('stripe')} options={{ style: { diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 520dc8d9f34..07bb22ae5c5 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -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> { 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; diff --git a/shared/config/donation-settings.ts b/shared/config/donation-settings.ts index 1474e4467b7..b938173336c 100644 --- a/shared/config/donation-settings.ts +++ b/shared/config/donation-settings.ts @@ -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']) +];