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}}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",
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