From be0a1090644adf9a943da2df6fdfc0bf4fbf33b8 Mon Sep 17 00:00:00 2001 From: Ijemma Onwuzulike Date: Mon, 15 Apr 2024 00:33:54 -0400 Subject: [PATCH] feat: support stripe checkout and portal sessions --- __mocks__/stripe.ts | 23 ++++++++++++ __tests__/shared/fixtures.ts | 3 +- functions/package.json | 1 + package.json | 1 + src/app.ts | 5 ++- src/controllers/__tests__/stripe.test.ts | 21 +++++++++++ src/controllers/stripe.ts | 46 ++++++++++++++++++++++++ src/pages/dashboard/credentials.page.tsx | 3 +- src/pages/dashboard/dashboard.tsx | 3 +- src/pages/dashboard/layout.tsx | 3 +- src/pages/dashboard/profile.page.tsx | 3 +- src/routers/index.ts | 3 +- src/routers/router.ts | 4 +-- src/routers/routerV2.ts | 13 +++++-- src/routers/siteRouter.ts | 4 +-- src/routers/stripeRouter.ts | 9 +++++ src/routers/testRouter.ts | 4 +-- 17 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 __mocks__/stripe.ts create mode 100644 src/controllers/__tests__/stripe.test.ts create mode 100644 src/controllers/stripe.ts create mode 100644 src/routers/stripeRouter.ts diff --git a/__mocks__/stripe.ts b/__mocks__/stripe.ts new file mode 100644 index 00000000..6974fe35 --- /dev/null +++ b/__mocks__/stripe.ts @@ -0,0 +1,23 @@ +class Stripe { + apiKey = ''; + prices = { + list: jest.fn(() => ({ data: [{ id: 'price_id' }] })), + }; + checkout = { + sessions: { + create: jest.fn(() => ({ url: 'checkout_session_url' })), + retrieve: jest.fn(() => ({ customer: 'checkout_session_customer' })), + }, + }; + billingPortal = { + sessions: { + create: jest.fn(() => ({ url: 'portal_session_url' })), + }, + }; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } +} + +export default Stripe; diff --git a/__tests__/shared/fixtures.ts b/__tests__/shared/fixtures.ts index 7afe108b..6c9dabc1 100644 --- a/__tests__/shared/fixtures.ts +++ b/__tests__/shared/fixtures.ts @@ -74,7 +74,7 @@ export const requestFixture = ( body?: { [key: string]: string }, params?: { [key: string]: string }, headers?: { [key: string]: string }, - }, + } = {}, options?: RequestOptions ) => ({ body, @@ -87,5 +87,6 @@ export const statusSendMock = jest.fn(); export const responseFixture = () => ({ status: jest.fn(() => ({ send: statusSendMock })), send: jest.fn(), + redirect: jest.fn(), }); export const nextFunctionFixture = () => jest.fn(); diff --git a/functions/package.json b/functions/package.json index 08ab97f6..bf9d4e7b 100644 --- a/functions/package.json +++ b/functions/package.json @@ -73,6 +73,7 @@ "shelljs": "^0.8.4", "shx": "^0.3.3", "string-similarity": "^4.0.2", + "stripe": "^15.1.0", "tailwindcss": "3", "typescript": "^4.0.3", "unicharadata": "^9.0.0-alpha.6", diff --git a/package.json b/package.json index 6751aecf..51f2adf4 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "shelljs": "^0.8.4", "shx": "^0.3.3", "string-similarity": "^4.0.2", + "stripe": "^15.1.0", "tailwindcss": "3", "typescript": "^4.0.3", "unicharadata": "^9.0.0-alpha.6", diff --git a/src/app.ts b/src/app.ts index 1f846ceb..2d39d006 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,7 +5,7 @@ import bodyParser from 'body-parser'; import morgan from 'morgan'; import compression from 'compression'; import './shared/utils/wrapConsole'; -import { router, routerV2, siteRouter, testRouter } from './routers'; +import { router, routerV2, siteRouter, stripeRouter, testRouter } from './routers'; import cache from './middleware/cache'; import logger from './middleware/logger'; import errorHandler from './middleware/errorHandler'; @@ -35,6 +35,9 @@ app.use('/assets', cache(), express.static('./dist/assets')); app.use('/fonts', cache(), express.static('./dist/fonts')); app.use('/services', cache(), express.static('./services')); +/* Stripe */ +app.use('/stripe', stripeRouter); + /* Grabs data from MongoDB */ app.use(`/api/${Version.VERSION_1}`, cache(86400, 172800), router); app.use(`/api/${Version.VERSION_2}`, cache(86400, 172800), routerV2); diff --git a/src/controllers/__tests__/stripe.test.ts b/src/controllers/__tests__/stripe.test.ts new file mode 100644 index 00000000..9e32680f --- /dev/null +++ b/src/controllers/__tests__/stripe.test.ts @@ -0,0 +1,21 @@ +jest.mock('stripe'); +import { requestFixture, responseFixture } from '../../../__tests__/shared/fixtures'; +import { postCheckoutSession, postPortalSession } from '../stripe'; + +describe('Credentials', () => { + it('creates a new checkout session', async () => { + const res = responseFixture(); + // @ts-expect-error Request fixture + await postCheckoutSession(requestFixture(), res); + + expect(res.redirect).toHaveBeenCalledWith(303, 'checkout_session_url'); + }); + + it('creates a new portal session', async () => { + const res = responseFixture(); + // @ts-expect-error Request fixture + await postPortalSession(requestFixture(), res); + + expect(res.redirect).toHaveBeenCalledWith(303, 'portal_session_url'); + }); +}); diff --git a/src/controllers/stripe.ts b/src/controllers/stripe.ts new file mode 100644 index 00000000..b22199fc --- /dev/null +++ b/src/controllers/stripe.ts @@ -0,0 +1,46 @@ +import { Request, Response } from 'express'; +import Stripe from 'stripe'; +import { API_ROUTE } from '../config'; + +const STRIPE_SECRET_KEY = 'sk_test_hpwuITjteocLizB8Afq7H3cV00FEEViC1s'; +const stripe = new Stripe(STRIPE_SECRET_KEY); + +export const postCheckoutSession = async (req: Request, res: Response) => { + const prices = await stripe.prices.list({ + lookup_keys: [req.body.lookup_key], + expand: ['data.product'], + }); + + const session = await stripe.checkout.sessions.create({ + billing_address_collection: 'auto', + line_items: [ + { + price: prices.data[0].id, + quantity: 1, + }, + ], + mode: 'subscription', + success_url: `${API_ROUTE}/?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${API_ROUTE}/?canceled=true`, + }); + + return res.redirect(303, session.url || '/'); +}; + +export const postPortalSession = async (req: Request, res: Response) => { + const { sessionId } = req.body; + const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId); + + if (!checkoutSession.customer) { + throw new Error('No associated customer with checkout session'); + } + + const returnUrl = API_ROUTE; + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: `${checkoutSession.customer}`, + return_url: returnUrl, + }); + + return res.redirect(303, portalSession.url); +}; diff --git a/src/pages/dashboard/credentials.page.tsx b/src/pages/dashboard/credentials.page.tsx index 071ff818..ccc797e0 100644 --- a/src/pages/dashboard/credentials.page.tsx +++ b/src/pages/dashboard/credentials.page.tsx @@ -3,7 +3,6 @@ import { Box, Heading, IconButton, Input, Text, Tooltip } from '@chakra-ui/react import { FiEye, FiEyeOff, FiCopy } from 'react-icons/fi'; import DashboardLayout from './layout'; -import { Developer } from '../../types'; const Credentials = () => { const [isApiKeyVisible, setIsApiKeyVisible] = useState(false); @@ -19,7 +18,7 @@ const Credentials = () => { return ( - {({ developer }: { developer: Developer }) => ( + {({ developer }) => ( <> API Keys diff --git a/src/pages/dashboard/dashboard.tsx b/src/pages/dashboard/dashboard.tsx index a6f43742..375c5ba5 100644 --- a/src/pages/dashboard/dashboard.tsx +++ b/src/pages/dashboard/dashboard.tsx @@ -11,11 +11,10 @@ import { } from '@chakra-ui/react'; import moment from 'moment'; import DashboardLayout from './layout'; -import { Developer } from '../../types'; const Dashboard = () => ( - {({ developer }: { developer: Developer }) => ( + {({ developer }) => ( Home diff --git a/src/pages/dashboard/layout.tsx b/src/pages/dashboard/layout.tsx index 90b2ab09..ba35ea6c 100644 --- a/src/pages/dashboard/layout.tsx +++ b/src/pages/dashboard/layout.tsx @@ -7,8 +7,9 @@ import DashboardSideMenu from './components/DashboardSideMenu'; import { getDeveloper } from '../APIs/DevelopersAPI'; import { auth } from '../../services/firebase'; import { developerAtom } from '../atoms/dashboard'; +import { Developer } from '../../types'; -const DashboardLayout = ({ children }: { children: any }) => { +const DashboardLayout = ({ children }: { children: ({ developer } : { developer: Developer}) => any }) => { const [developer, setDeveloper] = useAtom(developerAtom); if (auth.currentUser && !developer) { diff --git a/src/pages/dashboard/profile.page.tsx b/src/pages/dashboard/profile.page.tsx index fb5b855b..21c02fff 100644 --- a/src/pages/dashboard/profile.page.tsx +++ b/src/pages/dashboard/profile.page.tsx @@ -1,11 +1,10 @@ import React from 'react'; import { Avatar, Box, Heading, Text, Badge } from '@chakra-ui/react'; import DashboardLayout from './layout'; -import { Developer } from '../../types'; const Profile = () => ( - {({ developer }: { developer: Developer }) => ( + {({ developer }) => ( Profile diff --git a/src/routers/index.ts b/src/routers/index.ts index 6623f15c..62e5b225 100644 --- a/src/routers/index.ts +++ b/src/routers/index.ts @@ -1,6 +1,7 @@ import router from './router'; import routerV2 from './routerV2'; import siteRouter from './siteRouter'; +import stripeRouter from './stripeRouter'; import testRouter from './testRouter'; -export { router, routerV2, siteRouter, testRouter }; +export { router, routerV2, siteRouter, stripeRouter, testRouter }; diff --git a/src/routers/router.ts b/src/routers/router.ts index 09e986f7..cc57e945 100644 --- a/src/routers/router.ts +++ b/src/routers/router.ts @@ -1,4 +1,4 @@ -import express from 'express'; +import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import { MiddleWare } from '../types'; import { getWords, getWord } from '../controllers/words'; @@ -14,7 +14,7 @@ import attachRedisClient from '../middleware/attachRedisClient'; import analytics from '../middleware/analytics'; import developerAuthorization from '../middleware/developerAuthorization'; -const router = express.Router(); +const router = Router(); const FIFTEEN_MINUTES = 15 * 60 * 1000; const REQUESTS_PER_MS = 20; diff --git a/src/routers/routerV2.ts b/src/routers/routerV2.ts index 56cd2f36..5a96bc91 100644 --- a/src/routers/routerV2.ts +++ b/src/routers/routerV2.ts @@ -1,4 +1,4 @@ -import express from 'express'; +import { Router } from 'express'; import { getWords, getWord } from '../controllers/words'; import { getExample, getExamples } from '../controllers/examples'; import { getNsibidiCharacter, getNsibidiCharacters } from '../controllers/nsibidi'; @@ -7,14 +7,21 @@ import validateApiKey from '../middleware/validateApiKey'; import analytics from '../middleware/analytics'; import attachRedisClient from '../middleware/attachRedisClient'; -const routerV2 = express.Router(); +const routerV2 = Router(); routerV2.get('/words', analytics, validateApiKey, attachRedisClient, getWords); routerV2.get('/words/:id', analytics, validateApiKey, validId, attachRedisClient, getWord); routerV2.get('/examples', analytics, validateApiKey, attachRedisClient, getExamples); routerV2.get('/examples/:id', analytics, validateApiKey, validId, attachRedisClient, getExample); routerV2.get('/nsibidi', analytics, validateApiKey, attachRedisClient, getNsibidiCharacters); -routerV2.get('/nsibidi/:id', analytics, validateApiKey, validId, attachRedisClient, getNsibidiCharacter); +routerV2.get( + '/nsibidi/:id', + analytics, + validateApiKey, + validId, + attachRedisClient, + getNsibidiCharacter +); // Redirects to V1 routerV2.post('/developers', (_, res) => res.redirect('/api/v1/developers')); diff --git a/src/routers/siteRouter.ts b/src/routers/siteRouter.ts index ed2c3be7..d3f61f9b 100644 --- a/src/routers/siteRouter.ts +++ b/src/routers/siteRouter.ts @@ -1,4 +1,4 @@ -import express from 'express'; +import { Router } from 'express'; import nextjs from 'next'; import compact from 'lodash/compact'; import { parse } from 'url'; @@ -8,7 +8,7 @@ const handle = nextApp.getRequestHandler(); const routes = compact([/^\/$/]); -const siteRouter = express.Router(); +const siteRouter = Router(); siteRouter.use(async (req, res, next) => { try { diff --git a/src/routers/stripeRouter.ts b/src/routers/stripeRouter.ts new file mode 100644 index 00000000..f2959d18 --- /dev/null +++ b/src/routers/stripeRouter.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { postCheckoutSession, postPortalSession } from '../controllers/stripe'; + +const router = Router(); + +router.post('/checkout', postCheckoutSession); +router.post('/portal', postPortalSession); + +export default router; diff --git a/src/routers/testRouter.ts b/src/routers/testRouter.ts index 6072d048..97cf7f54 100644 --- a/src/routers/testRouter.ts +++ b/src/routers/testRouter.ts @@ -1,8 +1,8 @@ -import express from 'express'; +import { Router } from 'express'; import { getWordData } from '../controllers/words'; import { seedDatabase } from '../dictionaries/seed'; -const testRouter = express.Router(); +const testRouter = Router(); testRouter.get('/', (_, res) => { res.send('Welcome to the Igbo English Dictionary API');