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",
|
"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",
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Generated
+3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user