mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-13 22:00:19 -04:00
fix: update stripe wallets to use payment intent (#54668)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'])
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user