feat: add update-stripe-card route (#52389)

Co-authored-by: Naomi Carrigan <nhcarrigan@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2023-12-12 22:49:52 +03:00
committed by GitHub
parent d3ac96c204
commit 575aa172ad
17 changed files with 465 additions and 12 deletions

View File

@@ -8,7 +8,8 @@ import {
verifyWebHook,
updateUser,
verifyWebHookType,
createStripeCardDonation
createStripeCardDonation,
handleStripeCardUpdateSession
} from '../utils/donation';
import { validStripeForm } from '../utils/stripeHelpers';
@@ -181,6 +182,19 @@ export default function donateBoot(app, done) {
});
}
async function handleStripeCardUpdate(req, res, next) {
try {
const sessionIdObj = await handleStripeCardUpdateSession(
req,
app,
stripe
);
return res.status(200).json(sessionIdObj);
} catch (err) {
return next(err);
}
}
function updatePaypal(req, res) {
const { headers, body } = req;
return Promise.resolve(req)
@@ -220,6 +234,7 @@ export default function donateBoot(app, done) {
} else {
api.post('/charge-stripe', createStripeDonation);
api.post('/charge-stripe-card', handleStripeCardDonation);
api.put('/update-stripe-card', handleStripeCardUpdate);
api.post('/add-donation', addDonation);
hooks.post('/update-paypal', updatePaypal);
donateRouter.use('/donate', api);

View File

@@ -314,3 +314,40 @@ export async function createStripeCardDonation(req, res, stripe) {
await createAsyncUserDonation(user, donation);
return res.status(200).json({ isDonating: true });
}
export async function handleStripeCardUpdateSession(req, app, stripe) {
const {
user: { id }
} = req;
const { Donation } = app.models;
log('Updating stripe card for user: ', id);
// multiple donations support should be added
const donation = await Donation.findOne({
where: { userId: id, provider: 'stripe' }
});
if (!donation) throw Error('Stripe donation record not found');
const { customerId, subscriptionId } = donation;
log(subscriptionId);
// Create a Stripe checkout session
// updating customer payment method is handled by webhook handler
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'setup',
customer: customerId,
setup_intent_data: {
metadata: {
customer_id: customerId,
subscription_id: subscriptionId
}
},
success_url: `${process.env.HOME_LOCATION}/update-stripe-card?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.HOME_LOCATION}/update-stripe-card`
});
return { sessionId: session.id };
}

View File

@@ -1,5 +1,7 @@
/* eslint-disable camelcase */
import axios from 'axios';
import stripe from 'stripe';
import { ObjectId } from 'mongodb';
import keys from '../../../config/secrets';
import {
mockApp,
@@ -14,10 +16,18 @@ import {
verifyWebHook,
updateUser,
capitalizeKeys,
createDonationObj
createDonationObj,
handleStripeCardUpdateSession
} from './donation';
jest.mock('axios');
jest.mock('stripe', () => ({
checkout: {
sessions: {
create: jest.fn()
}
}
}));
const verificationUrl = `https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature`;
const tokenUrl = `https://api.sandbox.paypal.com/v1/oauth2/token`;
@@ -155,4 +165,49 @@ describe('donation', () => {
expect(updateUserAttr).not.toHaveBeenCalled();
});
});
describe('handleStripeCardUpdateSession', () => {
const mockUserId = ObjectId('507f1f77bcf86cd799439011');
const mockDonation = {
customerId: 'customer_123',
subscriptionId: 'sub_123'
};
const req = { user: { id: mockUserId } };
const app = {
models: {
Donation: { findOne: jest.fn().mockResolvedValue(mockDonation) }
}
};
stripe.checkout.sessions.create.mockResolvedValue({ id: 'session_123' });
it('creates a session successfully', async () => {
const result = await handleStripeCardUpdateSession(req, app, stripe);
expect(app.models.Donation.findOne).toHaveBeenCalledWith({
where: { userId: mockUserId, provider: 'stripe' }
});
expect(stripe.checkout.sessions.create).toHaveBeenCalled();
expect(result).toEqual({ sessionId: 'session_123' });
});
it('throws an error when donation not found', async () => {
const app = {
models: { Donation: { findOne: jest.fn().mockResolvedValue(null) } }
};
await expect(
handleStripeCardUpdateSession(req, app, stripe)
).rejects.toThrow('Stripe donation record not found');
});
it('handles stripe session creation failure', async () => {
stripe.checkout.sessions.create.mockRejectedValue(
new Error('Stripe error')
);
await expect(
handleStripeCardUpdateSession(req, app, stripe)
).rejects.toThrow('Stripe error');
});
});
});

View File

@@ -89,7 +89,8 @@
"submit-exam-results": "Submit my results",
"verify-trophy": "Verify Trophy",
"link-account": "Link Account",
"unlink-account": "Unlink Account"
"unlink-account": "Unlink Account",
"update-card": "Update your card"
},
"landing": {
"big-heading-1": "Learn to code — for free.",
@@ -378,6 +379,10 @@
"reset-warn-2": "This cannot be undone",
"scrimba-tip": "Tip: If the mini-browser is covering the code, click and drag to move it. Also, feel free to stop and edit the code in the video at any time.",
"chal-preview": "Challenge Preview",
"donation-record-not-found": "Your donation record has not been found.",
"sign-in-card-update": "Sign in to update your card",
"card-has-been-updated": "Your card has been updated successfully.",
"contact-support-mistake": "If you think there has been a mistake, please contact us at donors@freecodecamp.org",
"cert-map-estimates": {
"certs": "{{title}} Certification"
},
@@ -465,10 +470,13 @@
"redirecting": "Redirecting...",
"thanks": "Thanks for donating",
"thank-you": "Thank you for being a supporter.",
"success-card-update": "Your card has been updated successfully.",
"additional": "You can make an additional one-time donation of any amount using this link: <0>{{url}}</0>",
"help-more": "Help us do more",
"error": "Something went wrong with your donation.",
"error-card-update": "Something went wrong with updating your card.",
"error-2": "Something is not right. Please contact donors@freecodecamp.org",
"error-3": "Please try again or contact donors@freecodecamp.org",
"free-tech": "Your donations will support free technology education for people all over the world.",
"no-halo": "If you don't see a gold halo around your profile picture, contact donors@freecodecamp.org.",
"gift-frequency": "Select gift frequency:",
@@ -596,6 +604,7 @@
"update-email-2": "Update your email address here:",
"email": "Email",
"and": "and",
"update-your-card": "Update your card",
"change-theme": "Sign in to change theme.",
"translation-pending": "Help us translate",
"certification-project": "Certification Project",

View File

@@ -0,0 +1,63 @@
import { Alert, Button } from '@freecodecamp/react-bootstrap';
import React from 'react';
import { useTranslation } from 'react-i18next';
import Spinner from 'react-spinkit';
type CardUpdateAlertHandlerProps = {
error: string | null;
redirecting: boolean;
reset: () => void;
success: boolean;
};
function CardUpdateAlertHandler({
reset,
success,
redirecting,
error = null
}: CardUpdateAlertHandlerProps): JSX.Element {
const { t } = useTranslation();
const style = redirecting ? 'info' : success ? 'success' : 'danger';
const heading = redirecting
? `${t('donate.redirecting')}`
: success
? `${t('donate.success-card-update')}`
: `${t('donate.error-card-update')}`;
return (
<Alert
bsStyle={style}
className='donation-completion'
closeLabel={t('buttons.close')}
>
<h4>
<b>{heading}</b>
</h4>
<div className='donation-completion-body'>
{redirecting && (
<Spinner
className='user-state-spinner'
color='#0a0a23'
fadeIn='none'
name='line-scale'
/>
)}
{error && <p>{error}</p>}
</div>
<div className='donation-completion-buttons'>
{error && (
<div>
<Button bsStyle='primary' onClick={reset}>
{t('buttons.try-again')}
</Button>
</div>
)}
</div>
</Alert>
);
}
CardUpdateAlertHandler.displayName = 'CardUpdateAlertHandler';
export default CardUpdateAlertHandler;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import Login from '../../Header/components/login';
const BigCallToAction = (): JSX.Element => {
const BigCallToAction = ({ text }: { text?: string }): JSX.Element => {
const { t } = useTranslation();
return (
@@ -11,7 +11,7 @@ const BigCallToAction = (): JSX.Element => {
data-test-label='landing-big-cta'
data-playwright-test-label='landing-big-cta'
>
{t('buttons.logged-in-cta-btn')}
{text ? text : t('buttons.logged-in-cta-btn')}
</Login>
);
};

View File

@@ -27,6 +27,10 @@ body {
height: 100%;
}
.page-wrapper-80 {
min-height: 80vh;
}
.codeally-frame {
display: block;
height: calc(100vh - var(--header-height));

View File

@@ -0,0 +1,151 @@
import { Row, Col, Button } from '@freecodecamp/react-bootstrap';
import { useLocation } from '@reach/router';
import React, { type FormEvent, useEffect } from 'react';
import Helmet from 'react-helmet';
import { withTranslation, useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Container } from '@freecodecamp/ui';
import BigCallToAction from '../components/landing/components/big-call-to-action';
import { Spacer } from '../components/helpers';
import './update-email.css';
import {
isSignedInSelector,
isDonatingSelector,
updateCardStateSelector
} from '../redux/selectors';
import { updateCard, updateCardComplete } from '../redux/actions';
import { UpdateCardState } from '../redux/types';
import CardUpdateAlertHandler from '../components/Donation/card-update-alert-handler';
interface UpdateStripeCardProps {
isNewEmail: boolean;
resetDonationFormState: () => void;
isSignedIn: boolean;
isDonating: boolean;
updateCardState: UpdateCardState;
updateCard: () => void;
updateCardComplete: () => void;
}
const mapStateToProps = createSelector(
isSignedInSelector,
isDonatingSelector,
updateCardStateSelector,
(
isSignedIn: boolean,
isDonating: boolean,
updateCardState: UpdateCardState
) => ({
isSignedIn,
isDonating,
updateCardState
})
);
const mapDispatchToProps = { updateCard, updateCardComplete };
function ConditionalContent({
isSignedIn,
isDonating,
handleClick,
updateCardState
}: {
isSignedIn: boolean;
isDonating: boolean;
handleClick: (e?: FormEvent) => void;
updateCardState: UpdateCardState;
}) {
const { t } = useTranslation();
if (isSignedIn && !isDonating) {
return (
<>
<h1 className='text-center'>{t('learn.donation-record-not-found')}</h1>
<Spacer size='medium' />
<p className='text-center'>{t('learn.contact-support-mistake')}</p>
</>
);
} else if (isSignedIn && isDonating) {
const { success, error, redirecting } = updateCardState;
if (redirecting || error || success) {
return (
<CardUpdateAlertHandler
success={success}
error={error}
redirecting={redirecting}
reset={handleClick}
/>
);
} else
return (
<>
<Spacer size='medium' />
<Button block={true} onClick={handleClick}>
{t('buttons.update-card')}
</Button>
</>
);
} else
return (
<>
<h1 className='text-center'>{t('learn.sign-in-card-update')}</h1>
<Spacer size='medium' />
<BigCallToAction text={t('buttons.sign-in')} />
</>
);
}
function UpdateStripeCard({
isSignedIn,
isDonating,
updateCardState,
updateCard,
updateCardComplete
}: UpdateStripeCardProps) {
function handleClick(event?: FormEvent) {
updateCard();
if (event) event.preventDefault();
}
const { t } = useTranslation();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const isUpdateSuccessful = searchParams.get('session_id') !== null;
useEffect(() => {
if (isUpdateSuccessful) {
updateCardComplete();
}
}, [isUpdateSuccessful, updateCardComplete]);
return (
<>
<Helmet>
<title>{t('misc.update-your-card')} | freeCodeCamp.org</title>
</Helmet>
<Container className='page-wrapper-80'>
<Row>
<Col sm={6} smOffset={3}>
<Spacer size='large' />
<ConditionalContent
isSignedIn={isSignedIn}
isDonating={isDonating}
handleClick={handleClick}
updateCardState={updateCardState}
/>
<Spacer size='large' />
</Col>
</Row>
</Container>
</>
);
}
UpdateStripeCard.displayName = 'Update-Stripe-Card';
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(UpdateStripeCard));

View File

@@ -39,6 +39,8 @@ export const actionTypes = createTypes(
'updateUserToken',
'postChargeProcessing',
'updateAllChallengesInfo',
'updateCardRedirecting',
...createAsyncTypes('updateCard'),
...createAsyncTypes('fetchUser'),
...createAsyncTypes('postCharge'),
...createAsyncTypes('fetchProfileForUser'),

View File

@@ -68,6 +68,13 @@ export const postChargeProcessing = createAction(
export const postChargeComplete = createAction(actionTypes.postChargeComplete);
export const postChargeError = createAction(actionTypes.postChargeError);
export const updateCard = createAction(actionTypes.updateCard);
export const updateCardError = createAction(actionTypes.updateCardError);
export const updateCardComplete = createAction(actionTypes.updateCardComplete);
export const updateCardRedirecting = createAction(
actionTypes.updateCardRedirecting
);
export const fetchProfileForUser = createAction(
actionTypes.fetchProfileForUser
);

View File

@@ -9,10 +9,14 @@ import {
takeLeading
} from 'redux-saga/effects';
import { loadStripe } from '@stripe/stripe-js';
import envData from '../../config/env.json';
import {
addDonation,
postChargeStripe,
postChargeStripeCard
postChargeStripeCard,
updateStripeCard
} from '../utils/ajax';
import { stringifyDonationEvents } from '../utils/analytics-strings';
import { PaymentProvider } from '../../../shared/config/donation-settings';
@@ -24,7 +28,9 @@ import {
postChargeError,
preventBlockDonationRequests,
setCompletionCountWhenShownProgressModal,
executeGA
executeGA,
updateCardError,
updateCardRedirecting
} from './actions';
import {
isDonatingSelector,
@@ -34,6 +40,9 @@ import {
} from './selectors';
const defaultDonationErrorMessage = i18next.t('donate.error-2');
const updateCardErrorMessage = i18next.t('donate.error-3');
const { stripePublicKey } = envData;
function* showDonateModalSaga() {
let shouldRequestDonation = yield select(shouldRequestDonationSelector);
@@ -155,10 +164,26 @@ export function* setDonationCookie() {
}
}
export function* updateCardSaga() {
yield put(updateCardRedirecting());
try {
const {
data: { sessionId }
} = yield call(updateStripeCard);
if (!sessionId) throw new Error('No sessionId');
const stripe = yield call(loadStripe, stripePublicKey);
stripe.redirectToCheckout({ sessionId });
} catch (error) {
yield put(updateCardError(updateCardErrorMessage));
}
}
export function createDonationSaga(types) {
return [
takeEvery(types.tryToShowDonationModal, showDonateModalSaga),
takeLeading(types.postCharge, postChargeSaga),
takeEvery(types.fetchUserComplete, setDonationCookie)
takeEvery(types.fetchUserComplete, setDonationCookie),
takeLeading(types.updateCard, updateCardSaga)
];
}

View File

@@ -2,10 +2,21 @@ import { expectSaga } from 'redux-saga-test-plan';
import {
postChargeStripe,
postChargeStripeCard,
addDonation
addDonation,
updateStripeCard
} from '../utils/ajax';
import { postChargeSaga, setDonationCookie } from './donation-saga.js';
import { postChargeComplete, postChargeProcessing, executeGA } from './actions';
import {
postChargeSaga,
setDonationCookie,
updateCardSaga
} from './donation-saga.js';
import {
postChargeComplete,
postChargeProcessing,
executeGA,
updateCardRedirecting,
updateCardError
} from './actions';
jest.mock('../utils/ajax');
jest.mock('../analytics');
@@ -121,4 +132,28 @@ describe('donation-saga', () => {
.put(executeGA(patreonAnalyticsDataMock))
.run();
});
it('handles successful card update', () => {
updateStripeCard.mockResolvedValue({
data: { sessionId: 'expected data' }
});
return expectSaga(updateCardSaga)
.put(updateCardRedirecting())
.call(updateStripeCard)
.not.put(updateCardError())
.run();
});
it('handles errors correctly for card update', () => {
updateStripeCard.mockResolvedValue({
data: 'unexpected data'
});
return expectSaga(updateCardSaga)
.put(updateCardRedirecting())
.call(updateStripeCard)
.put(updateCardError())
.run();
});
});

View File

@@ -32,6 +32,12 @@ const defaultFetchState = {
error: null
};
const updateCardDefaultState = {
redirecting: false,
success: false,
error: null
};
export const defaultDonationFormState = {
redirecting: false,
processing: false,
@@ -76,6 +82,9 @@ const initialState = {
renderStartTime: null,
donationFormState: {
...defaultDonationFormState
},
updateCardState: {
...updateCardDefaultState
}
};
@@ -144,6 +153,18 @@ export const reducer = handleActions(
renderStartTime: payload
};
},
[actionTypes.updateCardError]: (state, { payload }) => ({
...state,
updateCardState: { ...updateCardDefaultState, error: payload }
}),
[actionTypes.updateCardRedirecting]: state => ({
...state,
updateCardState: { ...updateCardDefaultState, redirecting: true }
}),
[actionTypes.updateCardComplete]: state => ({
...state,
updateCardState: { ...updateCardDefaultState, success: true }
}),
[actionTypes.updateDonationFormState]: (state, { payload }) => ({
...state,
donationFormState: { ...state.donationFormState, ...payload }

View File

@@ -28,6 +28,7 @@ export const recentlyClaimedBlockSelector = state =>
state[MainApp].recentlyClaimedBlock;
export const donationFormStateSelector = state =>
state[MainApp].donationFormState;
export const updateCardStateSelector = state => state[MainApp].updateCardState;
export const signInLoadingSelector = state =>
userFetchStateSelector(state).pending;
export const showCertSelector = state => state[MainApp].showCert;

View File

@@ -58,3 +58,9 @@ export interface DonateFormState {
paypal: boolean;
};
}
export interface UpdateCardState {
redirecting: boolean;
success: boolean;
error: string;
}

View File

@@ -55,7 +55,7 @@ export function post<T = void>(
function put<T = void>(
path: string,
body: unknown
body?: unknown
): Promise<ResponseWithData<T>> {
return request('PUT', path, body);
}
@@ -238,6 +238,10 @@ export function addDonation(body: Donation): Promise<ResponseWithData<void>> {
return post('/donate/add-donation', body);
}
export function updateStripeCard() {
return put('/donate/update-stripe-card');
}
export function postChargeStripe(
body: Donation
): Promise<ResponseWithData<void>> {

View File

@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
import translations from '../client/i18n/locales/english/translations.json';
test.describe('Update Card Page for Non-Donor Authenticated User', () => {
test.use({ storageState: 'playwright/.auth/certified-user.json' });
test('should render correctly', async ({ page }) => {
await page.goto('/update-stripe-card');
await expect(page).toHaveTitle(
`${translations.misc['update-your-card']} | freeCodeCamp.org`
);
const h1 = page.locator('h1');
await expect(h1).toHaveText(
`${translations.learn['donation-record-not-found']}`
);
});
});
// Unauthrorized, and donor user states should be added here in upcoming PRs