feat(api): add POST /donate/charge-stripe-card (#51348)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2023-10-06 15:56:26 +03:00
committed by GitHub
parent e2a0bc30ad
commit 0060e78715
6 changed files with 281 additions and 6 deletions
+1
View File
@@ -30,6 +30,7 @@
"nodemailer": "6.9.5", "nodemailer": "6.9.5",
"nodemon": "2.0.22", "nodemon": "2.0.22",
"pino-pretty": "10.2.0", "pino-pretty": "10.2.0",
"stripe": "8.205.0",
"query-string": "7.1.3" "query-string": "7.1.3"
}, },
"description": "The freeCodeCamp.org open-source codebase and curriculum", "description": "The freeCodeCamp.org open-source codebase and curriculum",
+122 -5
View File
@@ -1,5 +1,45 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { devLogin, setupServer, superRequest } from '../../jest.utils'; import { devLogin, setupServer, superRequest } from '../../jest.utils';
const chargeStripeCardReqBody = {
paymentMethodId: 'UID',
amount: 500,
duration: 'month'
};
const mockSubCreate = jest.fn();
const generateMockSubCreate = (status: string) => () =>
Promise.resolve({
id: 'cust_111',
latest_invoice: {
payment_intent: {
client_secret: 'superSecret',
status
}
}
});
const defaultError = () =>
Promise.reject(new Error('Stripe encountered an error'));
jest.mock('stripe', () => {
return jest.fn().mockImplementation(() => {
return {
customers: {
create: jest.fn(() =>
Promise.resolve({
id: 'cust_111',
name: 'Jest_User',
currency: 'sgd',
description: 'Jest User Account created'
})
)
},
subscriptions: {
create: mockSubCreate
}
};
});
});
describe('Donate', () => { describe('Donate', () => {
setupServer(); setupServer();
@@ -10,6 +50,86 @@ describe('Donate', () => {
setCookies = await devLogin(); setCookies = await devLogin();
}); });
describe('POST /donate/charge-stripe-card', () => {
it('should return 200 and update the user', async () => {
mockSubCreate.mockImplementationOnce(
generateMockSubCreate('we only care about specific error cases')
);
const response = await superRequest('/donate/charge-stripe-card', {
method: 'POST',
setCookies
}).send(chargeStripeCardReqBody);
expect(response.body).toEqual({ isDonating: true, type: 'success' });
expect(response.status).toBe(200);
});
it('should return 402 with client_secret if subscription status requires source action', async () => {
mockSubCreate.mockImplementationOnce(
generateMockSubCreate('requires_source_action')
);
const response = await superRequest('/donate/charge-stripe-card', {
method: 'POST',
setCookies
}).send(chargeStripeCardReqBody);
expect(response.body).toEqual({
type: 'UserActionRequired',
message: 'Payment requires user action',
client_secret: 'superSecret'
});
expect(response.status).toBe(402);
});
it('should return 402 if subscription status requires source', async () => {
mockSubCreate.mockImplementationOnce(
generateMockSubCreate('requires_source')
);
const response = await superRequest('/donate/charge-stripe-card', {
method: 'POST',
setCookies
}).send(chargeStripeCardReqBody);
expect(response.body).toEqual({
type: 'PaymentMethodRequired',
message: 'Card has been declined'
});
expect(response.status).toBe(402);
});
it('should return 400 if the user is already donating', async () => {
mockSubCreate.mockImplementationOnce(
generateMockSubCreate('still does not matter')
);
const successResponse = await superRequest(
'/donate/charge-stripe-card',
{
method: 'POST',
setCookies
}
).send(chargeStripeCardReqBody);
expect(successResponse.status).toBe(200);
const failResponse = await superRequest('/donate/charge-stripe-card', {
method: 'POST',
setCookies
}).send(chargeStripeCardReqBody);
expect(failResponse.status).toBe(400);
});
it('should return 500 if Stripe encountes an error', async () => {
mockSubCreate.mockImplementationOnce(defaultError);
const response = await superRequest('/donate/charge-stripe-card', {
method: 'POST',
setCookies
}).send(chargeStripeCardReqBody);
expect(response.status).toBe(500);
expect(response.body).toEqual({
type: 'danger',
message: 'Donation failed due to a server error.'
});
});
});
describe('POST /donate/add-donation', () => { describe('POST /donate/add-donation', () => {
it('should return 200 and update the user', async () => { it('should return 200 and update the user', async () => {
const response = await superRequest('/donate/add-donation', { const response = await superRequest('/donate/add-donation', {
@@ -31,14 +151,11 @@ describe('Donate', () => {
method: 'POST', method: 'POST',
setCookies setCookies
}).send({}); }).send({});
expect(successResponse.status).toBe(200); expect(successResponse.status).toBe(200);
const failResponse = await superRequest('/donate/add-donation', { const failResponse = await superRequest('/donate/add-donation', {
method: 'POST', method: 'POST',
setCookies setCookies
}).send({}); }).send({});
expect(failResponse.status).toBe(400); expect(failResponse.status).toBe(400);
}); });
}); });
@@ -52,9 +169,9 @@ describe('Donate', () => {
setCookies = res.get('Set-Cookie'); setCookies = res.get('Set-Cookie');
}); });
const endpoints: { path: string; method: 'POST' }[] = [ const endpoints: { path: string; method: 'POST' }[] = [
{ path: '/donate/add-donation', method: 'POST' } { path: '/donate/add-donation', method: 'POST' },
{ path: '/donate/charge-stripe-card', method: 'POST' }
]; ];
endpoints.forEach(({ path, method }) => { endpoints.forEach(({ path, method }) => {
test(`${method} ${path} returns 401 status code with error message`, async () => { test(`${method} ${path} returns 401 status code with error message`, async () => {
const response = await superRequest(path, { const response = await superRequest(path, {
+121 -1
View File
@@ -1,7 +1,13 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { import {
Type, Type,
type FastifyPluginCallbackTypebox type FastifyPluginCallbackTypebox
} from '@fastify/type-provider-typebox'; } from '@fastify/type-provider-typebox';
import Stripe from 'stripe';
import { donationSubscriptionConfig } from '../../../shared/config/donation-settings';
import { schemas } from '../schemas';
import { STRIPE_SECRET_KEY } from '../utils/env';
/** /**
* Plugin for the donation endpoints. * Plugin for the donation endpoints.
@@ -15,12 +21,17 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
_options, _options,
done done
) => { ) => {
// Stripe plugin
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: '2020-08-27',
typescript: true
});
// The order matters here, since we want to reject invalid cross site requests // The order matters here, since we want to reject invalid cross site requests
// before checking if the user is authenticated. // before checking if the user is authenticated.
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection); fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.authenticateSession); fastify.addHook('onRequest', fastify.authenticateSession);
fastify.post( fastify.post(
'/donate/add-donation', '/donate/add-donation',
{ {
@@ -76,5 +87,114 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
} }
); );
fastify.post(
'/donate/charge-stripe-card',
{
schema: schemas.chargeStripeCard
},
async (req, reply) => {
try {
const { paymentMethodId, amount, duration } = req.body;
const { id } = req.session.user;
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id }
});
const { email, name } = user;
if (user.isDonating) {
void reply.code(400);
return {
type: 'info',
message: 'User is already donating.'
} as const;
}
// Create Stripe Customer
const { id: customerId } = await stripe.customers.create({
email,
payment_method: paymentMethodId,
invoice_settings: { default_payment_method: paymentMethodId },
...(name && { name })
});
// //Create Stripe Subscription
const plan = `${donationSubscriptionConfig.duration[
duration
].toLowerCase()}-donation-${amount}`;
const {
id: subscriptionId,
latest_invoice: {
// For older api versions, @ts-ignore is recommended by Stripe. More info: https://github.com/stripe/stripe-node/blob/fe81d1f28064c9b468c7b380ab09f7a93054ba63/README.md?plain=1#L91
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore stripe-version-2019-10-17
payment_intent: { client_secret, status }
}
} = await stripe.subscriptions.create({
customer: customerId,
payment_behavior: 'allow_incomplete',
items: [{ plan }],
expand: ['latest_invoice.payment_intent']
});
if (status === 'requires_source_action') {
void reply.code(402);
return {
type: 'UserActionRequired',
message: 'Payment requires user action',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
client_secret
} as const;
} else if (status === 'requires_source') {
void reply.code(402);
return {
type: 'PaymentMethodRequired',
message: 'Card has been declined'
} as const;
}
// update record in database
const donation = {
userId: id,
email,
amount,
duration,
provider: 'stripe',
subscriptionId,
customerId: id,
// TODO(Post-MVP) migrate to startDate: new Date()
startDate: {
date: new Date().toISOString(),
when: new Date().toISOString().replace(/.$/, '+00:00')
}
};
await fastify.prisma.donation.create({
data: donation
});
await fastify.prisma.user.update({
where: { id },
data: {
isDonating: true
}
});
return {
type: 'success',
isDonating: true
} as const;
} catch (error) {
fastify.log.error(error);
void reply.code(500);
return {
type: 'danger',
message: 'Donation failed due to a server error.'
} as const;
}
}
);
done(); done();
}; };
+27
View File
@@ -463,6 +463,33 @@ export const schemas = {
}) })
} }
}, },
chargeStripeCard: {
body: Type.Object({
paymentMethodId: Type.String(),
amount: Type.Number(),
duration: Type.Literal('month')
}),
response: {
200: Type.Object({
isDonating: Type.Boolean(),
type: Type.Literal('success')
}),
400: Type.Object({
message: Type.String(),
type: Type.Literal('info')
}),
402: Type.Object({
message: Type.String(),
type: Type.String(),
// eslint-disable-next-line @typescript-eslint/naming-convention
client_secret: Type.Optional(Type.String())
}),
500: Type.Object({
message: Type.String(),
type: Type.Literal('danger')
})
}
},
modernChallengeCompleted: { modernChallengeCompleted: {
body: Type.Object({ body: Type.Object({
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }), id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
+7
View File
@@ -38,6 +38,7 @@ assert.ok(process.env.SESSION_SECRET);
assert.ok(process.env.FCC_ENABLE_SWAGGER_UI); assert.ok(process.env.FCC_ENABLE_SWAGGER_UI);
assert.ok(process.env.FCC_ENABLE_DEV_LOGIN_MODE); assert.ok(process.env.FCC_ENABLE_DEV_LOGIN_MODE);
assert.ok(process.env.JWT_SECRET); assert.ok(process.env.JWT_SECRET);
assert.ok(process.env.STRIPE_SECRET_KEY);
if (process.env.FREECODECAMP_NODE_ENV !== 'development') { if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
assert.ok(process.env.SES_ID); assert.ok(process.env.SES_ID);
@@ -77,6 +78,11 @@ if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
process.env.EMAIL_PROVIDER === 'ses', process.env.EMAIL_PROVIDER === 'ses',
'SES MUST be used in production.' 'SES MUST be used in production.'
); );
assert.notEqual(
process.env.STRIPE_SECRET_KEY,
'sk_from_stripe_dashboard',
'The Stripe secret should be changed from the default value.'
);
} }
export const HOME_LOCATION = process.env.HOME_LOCATION; export const HOME_LOCATION = process.env.HOME_LOCATION;
@@ -103,3 +109,4 @@ export const SES_ID = process.env.SES_ID;
export const SES_SECRET = process.env.SES_SECRET; export const SES_SECRET = process.env.SES_SECRET;
export const SES_REGION = process.env.SES_REGION; export const SES_REGION = process.env.SES_REGION;
export const EMAIL_PROVIDER = process.env.EMAIL_PROVIDER; export const EMAIL_PROVIDER = process.env.EMAIL_PROVIDER;
export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
+3
View File
@@ -252,6 +252,9 @@ importers:
query-string: query-string:
specifier: 7.1.3 specifier: 7.1.3
version: 7.1.3 version: 7.1.3
stripe:
specifier: 8.205.0
version: 8.205.0
devDependencies: devDependencies:
'@total-typescript/ts-reset': '@total-typescript/ts-reset':
specifier: 0.5.1 specifier: 0.5.1