diff --git a/api-server/src/server/boot/donate.js b/api-server/src/server/boot/donate.js index a0ea4d89c24..c01f56bbadd 100644 --- a/api-server/src/server/boot/donate.js +++ b/api-server/src/server/boot/donate.js @@ -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); diff --git a/api-server/src/server/utils/donation.js b/api-server/src/server/utils/donation.js index fc9921d4486..abb2fe063fa 100644 --- a/api-server/src/server/utils/donation.js +++ b/api-server/src/server/utils/donation.js @@ -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 }; +} diff --git a/api-server/src/server/utils/donation.test.js b/api-server/src/server/utils/donation.test.js index f601ead7ced..33ae2edef55 100644 --- a/api-server/src/server/utils/donation.test.js +++ b/api-server/src/server/utils/donation.test.js @@ -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'); + }); + }); }); diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 5153e7a2dfc..f11602d4256 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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}}", "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", diff --git a/client/src/components/Donation/card-update-alert-handler.tsx b/client/src/components/Donation/card-update-alert-handler.tsx new file mode 100644 index 00000000000..26198423444 --- /dev/null +++ b/client/src/components/Donation/card-update-alert-handler.tsx @@ -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 ( + +

+ {heading} +

+
+ {redirecting && ( + + )} + {error &&

{error}

} +
+
+ {error && ( +
+ +
+ )} +
+
+ ); +} + +CardUpdateAlertHandler.displayName = 'CardUpdateAlertHandler'; + +export default CardUpdateAlertHandler; diff --git a/client/src/components/landing/components/big-call-to-action.tsx b/client/src/components/landing/components/big-call-to-action.tsx index 05e7b7f1aa3..9aabb749cb7 100644 --- a/client/src/components/landing/components/big-call-to-action.tsx +++ b/client/src/components/landing/components/big-call-to-action.tsx @@ -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')} ); }; diff --git a/client/src/components/layouts/global.css b/client/src/components/layouts/global.css index 4024a3a02e6..23470be4965 100644 --- a/client/src/components/layouts/global.css +++ b/client/src/components/layouts/global.css @@ -27,6 +27,10 @@ body { height: 100%; } +.page-wrapper-80 { + min-height: 80vh; +} + .codeally-frame { display: block; height: calc(100vh - var(--header-height)); diff --git a/client/src/pages/update-stripe-card.tsx b/client/src/pages/update-stripe-card.tsx new file mode 100644 index 00000000000..1b3e5b2e80c --- /dev/null +++ b/client/src/pages/update-stripe-card.tsx @@ -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 ( + <> +

{t('learn.donation-record-not-found')}

+ +

{t('learn.contact-support-mistake')}

+ + ); + } else if (isSignedIn && isDonating) { + const { success, error, redirecting } = updateCardState; + if (redirecting || error || success) { + return ( + + ); + } else + return ( + <> + + + + ); + } else + return ( + <> +

{t('learn.sign-in-card-update')}

+ + + + ); +} + +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 ( + <> + + {t('misc.update-your-card')} | freeCodeCamp.org + + + + + + + + + + + + ); +} + +UpdateStripeCard.displayName = 'Update-Stripe-Card'; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(withTranslation()(UpdateStripeCard)); diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index 3856fe515b8..2af6ca3e20d 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -39,6 +39,8 @@ export const actionTypes = createTypes( 'updateUserToken', 'postChargeProcessing', 'updateAllChallengesInfo', + 'updateCardRedirecting', + ...createAsyncTypes('updateCard'), ...createAsyncTypes('fetchUser'), ...createAsyncTypes('postCharge'), ...createAsyncTypes('fetchProfileForUser'), diff --git a/client/src/redux/actions.js b/client/src/redux/actions.js index 8184f8ba569..6f2042b6781 100644 --- a/client/src/redux/actions.js +++ b/client/src/redux/actions.js @@ -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 ); diff --git a/client/src/redux/donation-saga.js b/client/src/redux/donation-saga.js index 0ab4aa0e3ab..aaba86609ae 100644 --- a/client/src/redux/donation-saga.js +++ b/client/src/redux/donation-saga.js @@ -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) ]; } diff --git a/client/src/redux/donation-saga.test.js b/client/src/redux/donation-saga.test.js index b7d50e6307c..c230254a6ab 100644 --- a/client/src/redux/donation-saga.test.js +++ b/client/src/redux/donation-saga.test.js @@ -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(); + }); }); diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 97e85a524ce..592c312196b 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -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 } diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js index f5af6242cde..498480c7372 100644 --- a/client/src/redux/selectors.js +++ b/client/src/redux/selectors.js @@ -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; diff --git a/client/src/redux/types.ts b/client/src/redux/types.ts index 2a0177fb1e3..244fde89314 100644 --- a/client/src/redux/types.ts +++ b/client/src/redux/types.ts @@ -58,3 +58,9 @@ export interface DonateFormState { paypal: boolean; }; } + +export interface UpdateCardState { + redirecting: boolean; + success: boolean; + error: string; +} diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index ca592d6bc29..709103570a2 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -55,7 +55,7 @@ export function post( function put( path: string, - body: unknown + body?: unknown ): Promise> { return request('PUT', path, body); } @@ -238,6 +238,10 @@ export function addDonation(body: Donation): Promise> { return post('/donate/add-donation', body); } +export function updateStripeCard() { + return put('/donate/update-stripe-card'); +} + export function postChargeStripe( body: Donation ): Promise> { diff --git a/e2e/update-stripe-card-default.spec.ts b/e2e/update-stripe-card-default.spec.ts new file mode 100644 index 00000000000..a37a70e8e11 --- /dev/null +++ b/e2e/update-stripe-card-default.spec.ts @@ -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