diff --git a/api/package.json b/api/package.json index efaad945780..b458537f9c8 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/routes/donate.test.ts b/api/src/routes/donate.test.ts index 9f24c67d47c..299e88e2ea1 100644 --- a/api/src/routes/donate.test.ts +++ b/api/src/routes/donate.test.ts @@ -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, { diff --git a/api/src/routes/donate.ts b/api/src/routes/donate.ts index 10d2f233af4..2eeb2035d74 100644 --- a/api/src/routes/donate.ts +++ b/api/src/routes/donate.ts @@ -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(); }; diff --git a/api/src/schemas.ts b/api/src/schemas.ts index fe2b5f8a73d..069dd1ff8fd 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -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 }), diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index 8d7a3e1b2d2..d1fe7eb2e00 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52fcf982baa..706ae6ccb8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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