Skip to content

Commit a1c1284

Browse files
feat(api): add update-stripe-card endpoint (freeCodeCamp#55548)
Co-authored-by: Oliver Eyton-Williams <[email protected]>
1 parent 65dfc04 commit a1c1284

File tree

5 files changed

+109
-7
lines changed

5 files changed

+109
-7
lines changed

api/src/routes/donate.test.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
devLogin,
55
setupServer,
66
superRequest,
7-
defaultUserEmail
7+
defaultUserEmail,
8+
defaultUserId
89
} from '../../jest.utils';
910
import { createUserInput } from '../utils/create-user';
1011

@@ -42,6 +43,21 @@ const userWithProgress: Prisma.userCreateInput = {
4243
}
4344
]
4445
};
46+
const donationMock = {
47+
endDate: null,
48+
startDate: {
49+
date: '2024-07-17T10:20:56.076Z',
50+
when: '2024-07-17T10:20:56.076+00:00'
51+
},
52+
id: '66979a414748aa2f3ba36d41',
53+
amount: 500,
54+
customerId: 'cust_test_id',
55+
duration: 'month',
56+
57+
provider: 'stripe',
58+
subscriptionId: 'sub_test_id',
59+
userId: defaultUserId
60+
};
4561
const sharedDonationReqBody = {
4662
amount: 500,
4763
duration: 'month'
@@ -93,6 +109,9 @@ const mockSubRetrieveObj = {
93109
status: 'active'
94110
};
95111
const mockSubRetrieve = jest.fn(() => Promise.resolve(mockSubRetrieveObj));
112+
const mockCheckoutSessionCreate = jest.fn(() =>
113+
Promise.resolve({ id: 'checkout_session_id' })
114+
);
96115
const mockCustomerUpdate = jest.fn();
97116
const generateMockSubCreate = (status: string) => () =>
98117
Promise.resolve({
@@ -120,15 +139,22 @@ jest.mock('stripe', () => {
120139
subscriptions: {
121140
create: mockSubCreate,
122141
retrieve: mockSubRetrieve
142+
},
143+
checkout: {
144+
sessions: {
145+
create: mockCheckoutSessionCreate
146+
}
123147
}
124148
};
125149
});
126150
});
127151

128152
describe('Donate', () => {
153+
let setCookies: string[];
129154
setupServer();
130155
describe('Authenticated User', () => {
131156
let superPost: ReturnType<typeof createSuperRequest>;
157+
let superPut: ReturnType<typeof createSuperRequest>;
132158
const verifyUpdatedUserAndNewDonation = async (email: string) => {
133159
const user = await fastifyTestInstance.prisma.user.findFirst({
134160
where: { email }
@@ -162,8 +188,9 @@ describe('Donate', () => {
162188
};
163189

164190
beforeEach(async () => {
165-
const setCookies = await devLogin();
191+
setCookies = await devLogin();
166192
superPost = createSuperRequest({ method: 'POST', setCookies });
193+
superPut = createSuperRequest({ method: 'PUT', setCookies });
167194
await fastifyTestInstance.prisma.user.updateMany({
168195
where: { email: userWithProgress.email },
169196
data: userWithProgress
@@ -302,6 +329,39 @@ describe('Donate', () => {
302329
});
303330
});
304331

332+
describe('PUT /donate/update-stripe-card', () => {
333+
it('should return 200 and return session id', async () => {
334+
await fastifyTestInstance.prisma.donation.create({
335+
data: donationMock
336+
});
337+
const response = await superPut('/donate/update-stripe-card').send({});
338+
expect(mockCheckoutSessionCreate).toHaveBeenCalledWith({
339+
cancel_url: 'http://localhost:8000/update-stripe-card',
340+
customer: 'cust_test_id',
341+
mode: 'setup',
342+
payment_method_types: ['card'],
343+
setup_intent_data: {
344+
metadata: {
345+
customer_id: 'cust_test_id',
346+
subscription_id: 'sub_test_id'
347+
}
348+
},
349+
success_url:
350+
'http://localhost:8000/update-stripe-card?session_id={CHECKOUT_SESSION_ID}'
351+
});
352+
expect(response.body).toEqual({ sessionId: 'checkout_session_id' });
353+
expect(response.status).toBe(200);
354+
});
355+
it('should return 500 if there is no donation record', async () => {
356+
const response = await superPut('/donate/update-stripe-card').send({});
357+
expect(response.body).toEqual({
358+
message: 'flash.generic-error',
359+
type: 'danger'
360+
});
361+
expect(response.status).toBe(500);
362+
});
363+
});
364+
305365
describe('POST /donate/create-stripe-payment-intent', () => {
306366
it('should return 200 and call stripe api properly', async () => {
307367
mockSubCreate.mockImplementationOnce(
@@ -432,16 +492,16 @@ describe('Donate', () => {
432492
});
433493

434494
describe('Unauthenticated User', () => {
435-
let setCookies: string[];
436495
// Get the CSRF cookies from an unprotected route
437496
beforeAll(async () => {
438497
const res = await superRequest('/status/ping', { method: 'GET' });
439498
setCookies = res.get('Set-Cookie');
440499
});
441500

442-
const endpoints: { path: string; method: 'POST' }[] = [
501+
const endpoints: { path: string; method: 'POST' | 'PUT' }[] = [
443502
{ path: '/donate/add-donation', method: 'POST' },
444-
{ path: '/donate/charge-stripe-card', method: 'POST' }
503+
{ path: '/donate/charge-stripe-card', method: 'POST' },
504+
{ path: '/donate/update-stripe-card', method: 'PUT' }
445505
];
446506

447507
endpoints.forEach(({ path, method }) => {

api/src/routes/donate.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
allStripeProductIdsArray
99
} from '../../../shared/config/donation-settings';
1010
import * as schemas from '../schemas';
11-
import { STRIPE_SECRET_KEY } from '../utils/env';
11+
import { STRIPE_SECRET_KEY, HOME_LOCATION } from '../utils/env';
1212
import { inLastFiveMinutes } from '../utils/validate-donation';
1313
import { findOrCreateUser } from './helpers/auth-helpers';
1414

@@ -30,6 +30,35 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
3030
typescript: true
3131
});
3232

33+
fastify.put(
34+
'/donate/update-stripe-card',
35+
{
36+
schema: schemas.updateStripeCard
37+
},
38+
async req => {
39+
const donation = await fastify.prisma.donation.findFirst({
40+
where: { userId: req.user?.id, provider: 'stripe' }
41+
});
42+
if (!donation)
43+
throw Error(`Stripe donation record not found: ${req.user?.id}`);
44+
const { customerId, subscriptionId } = donation;
45+
const session = await stripe.checkout.sessions.create({
46+
payment_method_types: ['card'],
47+
mode: 'setup',
48+
customer: customerId,
49+
setup_intent_data: {
50+
metadata: {
51+
customer_id: customerId,
52+
subscription_id: subscriptionId
53+
}
54+
},
55+
success_url: `${HOME_LOCATION}/update-stripe-card?session_id={CHECKOUT_SESSION_ID}`,
56+
cancel_url: `${HOME_LOCATION}/update-stripe-card`
57+
});
58+
return { sessionId: session.id } as const;
59+
}
60+
);
61+
3362
fastify.post(
3463
'/donate/add-donation',
3564
{

api/src/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export { deprecatedEndpoints } from './schemas/deprecated';
1414
export { chargeStripeCard } from './schemas/donate/charge-stripe-card';
1515
export { chargeStripe } from './schemas/donate/charge-stripe';
1616
export { createStripePaymentIntent } from './schemas/donate/create-stripe-payment-intent';
17+
export { updateStripeCard } from './schemas/donate/update-stripe-card';
1718
export { resubscribe } from './schemas/email-subscription/resubscribe';
1819
export { unsubscribe } from './schemas/email-subscription/unsubscribe';
1920
export { updateMyAbout } from './schemas/settings/update-my-about';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Type } from '@fastify/type-provider-typebox';
2+
import { genericError } from '../types';
3+
4+
export const updateStripeCard = {
5+
body: Type.Object({}),
6+
response: {
7+
200: Type.Object({
8+
sessionId: Type.String()
9+
}),
10+
default: genericError
11+
}
12+
};

client/src/utils/ajax.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ export function addDonation(body: Donation): Promise<ResponseWithData<void>> {
243243
}
244244

245245
export function updateStripeCard() {
246-
return put('/donate/update-stripe-card');
246+
return put('/donate/update-stripe-card', {});
247247
}
248248

249249
export function postChargeStripe(

0 commit comments

Comments
 (0)