mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): add POST /donate/charge-stripe-card (#51348)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
"nodemailer": "6.9.5",
|
||||
"nodemon": "2.0.22",
|
||||
"pino-pretty": "10.2.0",
|
||||
"stripe": "8.205.0",
|
||||
"query-string": "7.1.3"
|
||||
},
|
||||
"description": "The freeCodeCamp.org open-source codebase and curriculum",
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
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', () => {
|
||||
setupServer();
|
||||
|
||||
@@ -10,6 +50,86 @@ describe('Donate', () => {
|
||||
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', () => {
|
||||
it('should return 200 and update the user', async () => {
|
||||
const response = await superRequest('/donate/add-donation', {
|
||||
@@ -31,14 +151,11 @@ describe('Donate', () => {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({});
|
||||
|
||||
expect(successResponse.status).toBe(200);
|
||||
|
||||
const failResponse = await superRequest('/donate/add-donation', {
|
||||
method: 'POST',
|
||||
setCookies
|
||||
}).send({});
|
||||
|
||||
expect(failResponse.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -52,9 +169,9 @@ describe('Donate', () => {
|
||||
setCookies = res.get('Set-Cookie');
|
||||
});
|
||||
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 }) => {
|
||||
test(`${method} ${path} returns 401 status code with error message`, async () => {
|
||||
const response = await superRequest(path, {
|
||||
|
||||
+121
-1
@@ -1,7 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import {
|
||||
Type,
|
||||
type FastifyPluginCallbackTypebox
|
||||
} 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.
|
||||
@@ -15,12 +21,17 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
_options,
|
||||
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
|
||||
// before checking if the user is authenticated.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||
|
||||
fastify.post(
|
||||
'/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();
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
body: Type.Object({
|
||||
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
|
||||
|
||||
@@ -38,6 +38,7 @@ assert.ok(process.env.SESSION_SECRET);
|
||||
assert.ok(process.env.FCC_ENABLE_SWAGGER_UI);
|
||||
assert.ok(process.env.FCC_ENABLE_DEV_LOGIN_MODE);
|
||||
assert.ok(process.env.JWT_SECRET);
|
||||
assert.ok(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
||||
assert.ok(process.env.SES_ID);
|
||||
@@ -77,6 +78,11 @@ if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
||||
process.env.EMAIL_PROVIDER === 'ses',
|
||||
'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;
|
||||
@@ -103,3 +109,4 @@ export const SES_ID = process.env.SES_ID;
|
||||
export const SES_SECRET = process.env.SES_SECRET;
|
||||
export const SES_REGION = process.env.SES_REGION;
|
||||
export const EMAIL_PROVIDER = process.env.EMAIL_PROVIDER;
|
||||
export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
Generated
+3
@@ -252,6 +252,9 @@ importers:
|
||||
query-string:
|
||||
specifier: 7.1.3
|
||||
version: 7.1.3
|
||||
stripe:
|
||||
specifier: 8.205.0
|
||||
version: 8.205.0
|
||||
devDependencies:
|
||||
'@total-typescript/ts-reset':
|
||||
specifier: 0.5.1
|
||||
|
||||
Reference in New Issue
Block a user