diff --git a/.env.example b/.env.example index 99a2cae8d..047da670b 100644 --- a/.env.example +++ b/.env.example @@ -92,4 +92,8 @@ S3_BUCKET= # PostHog POSTHOG_API_KEY= -POSTHOG_HOST= \ No newline at end of file +POSTHOG_HOST= + +# Stripe Payment +STRIPE_PAYMENT_SECRET_KEY= +STRIPE_PAYMENT_PUBLISHABLE_KEY= \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index b07d5694b..065700a0b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -109,6 +109,7 @@ "rtl-detect": "^1.0.4", "socket.io": "^4.7.4", "source-map-loader": "^4.0.1", + "stripe": "^16.10.0", "tmp-promise": "^3.0.3", "ts-transformer-keys": "^0.4.2", "tsyringe": "^4.3.0", diff --git a/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts b/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts new file mode 100644 index 000000000..345e6c062 --- /dev/null +++ b/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts @@ -0,0 +1,46 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import { Service, Inject } from 'typedi'; +import { StripePaymentService } from '@/services/StripePayment/StripePaymentService'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; + +@Service() +export class StripeIntegrationController { + @Inject() + private stripePaymentService: StripePaymentService; + + router() { + const router = Router(); + + router.post('/account', asyncMiddleware(this.createAccount.bind(this))); + router.post( + '/account_session', + asyncMiddleware(this.createAccountSession.bind(this)) + ); + return router; + } + + public async createAccount(req: Request, res: Response, next: NextFunction) { + try { + const accountId = await this.stripePaymentService.createAccount(); + res.status(201).json({ accountId }); + } catch (error) { + next(error); + } + } + + public async createAccountSession( + req: Request, + res: Response, + next: NextFunction + ) { + const { account } = req.body; + try { + const clientSecret = await this.stripePaymentService.createAccountSession( + account + ); + res.status(200).json({ clientSecret }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index ce4d2093f..38ca5a357 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -64,6 +64,7 @@ import { Webhooks } from './controllers/Webhooks/Webhooks'; import { ExportController } from './controllers/Export/ExportController'; import { AttachmentsController } from './controllers/Attachments/AttachmentsController'; import { OneClickDemoController } from './controllers/OneClickDemo/OneClickDemoController'; +import { StripeIntegrationController } from './controllers/StripeIntegration/StripeIntegrationController'; export default () => { const app = Router(); @@ -147,6 +148,7 @@ export default () => { dashboard.use('/import', Container.get(ImportController).router()); dashboard.use('/export', Container.get(ExportController).router()); dashboard.use('/attachments', Container.get(AttachmentsController).router()); + dashboard.use('/stripe_integration', Container.get(StripeIntegrationController).router()); dashboard.use('/', Container.get(ProjectTasksController).router()); dashboard.use('/', Container.get(ProjectTimesController).router()); diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index a806fe7d7..f79a36e54 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -259,6 +259,14 @@ module.exports = { */ posthog: { apiKey: process.env.POSTHOG_API_KEY, - host: process.env.POSTHOG_HOST - } + host: process.env.POSTHOG_HOST, + }, + + /** + * Stripe Payment Integration. + */ + stripePayment: { + secretKey: process.env.STRIPE_PAYMENT_SECRET_KEY || '', + publishableKey: process.env.STRIPE_PAYMENT_PUBLISHABLE_KEY || '', + }, }; diff --git a/packages/server/src/services/StripePayment/StripePaymentService.ts b/packages/server/src/services/StripePayment/StripePaymentService.ts new file mode 100644 index 000000000..254298ed1 --- /dev/null +++ b/packages/server/src/services/StripePayment/StripePaymentService.ts @@ -0,0 +1,42 @@ +import { Service } from 'typedi'; +import stripe from 'stripe'; +import config from '@/config'; + +@Service() +export class StripePaymentService { + private stripe; + + constructor() { + this.stripe = new stripe(config.stripePayment.secretKey, { + apiVersion: '2023-10-16', + }); + } + + public async createAccountSession(accountId: string) { + try { + const accountSession = await this.stripe.accountSessions.create({ + account: accountId, + components: { + account_onboarding: { enabled: true }, + }, + }); + return accountSession.client_secret; + } catch (error) { + throw new Error( + 'An error occurred when calling the Stripe API to create an account session' + ); + } + } + + public async createAccount() { + try { + const account = await this.stripe.accounts.create({}); + + return account.id; + } catch (error) { + throw new Error( + 'An error occurred when calling the Stripe API to create an account' + ); + } + } +} diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 38c1a8c0c..47990d1e2 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -17,6 +17,8 @@ "@casl/react": "^2.3.0", "@craco/craco": "^5.9.0", "@reduxjs/toolkit": "^1.2.5", + "@stripe/connect-js": "^3.3.12", + "@stripe/react-connect-js": "^3.3.13", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", diff --git a/packages/webapp/src/constants/preferencesMenu.tsx b/packages/webapp/src/constants/preferencesMenu.tsx index 1fc17eff2..ef53ccdf9 100644 --- a/packages/webapp/src/constants/preferencesMenu.tsx +++ b/packages/webapp/src/constants/preferencesMenu.tsx @@ -54,6 +54,11 @@ export default [ disabled: false, href: '/preferences/items', }, + { + text: 'Integrations', + disabled: false, + href: '/preferences/integrations' + }, // { // text: , // disabled: false, diff --git a/packages/webapp/src/containers/Preferences/Integrations/IntegrationsPage.tsx b/packages/webapp/src/containers/Preferences/Integrations/IntegrationsPage.tsx new file mode 100644 index 000000000..cc7434825 --- /dev/null +++ b/packages/webapp/src/containers/Preferences/Integrations/IntegrationsPage.tsx @@ -0,0 +1,5 @@ +import { StripeIntegration } from '@/containers/StripePayment/StripeIntegration'; + +export default function IntegrationsPage() { + return +} diff --git a/packages/webapp/src/containers/StripePayment/StripeIntegration.tsx b/packages/webapp/src/containers/StripePayment/StripeIntegration.tsx new file mode 100644 index 000000000..dc3058b88 --- /dev/null +++ b/packages/webapp/src/containers/StripePayment/StripeIntegration.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { + ConnectAccountOnboarding, + ConnectComponentsProvider, +} from '@stripe/react-connect-js'; +import { useStripeConnect } from './use-stripe-connect'; +import { useCreateStripeAccount } from '@/hooks/query/stripe-integration'; + +export function StripeIntegration() { + const [accountCreatePending, setAccountCreatePending] = + useState(false); + const [onboardingExited, setOnboardingExited] = useState(false); + const [error, setError] = useState(false); + const [connectedAccountId, setConnectedAccountId] = useState( + null, + ); + const stripeConnectInstance = useStripeConnect(connectedAccountId || ''); + + const { mutateAsync: createAccount } = useCreateStripeAccount(); + + const handleSignupBtnClick = () => { + setAccountCreatePending(true); + setError(false); + + createAccount({}) + .then((account) => { + setConnectedAccountId(account.account_id); + }) + .catch(() => { + setError(true); + }) + .finally(() => { + setAccountCreatePending(false); + }); + }; + + return ( +
+
+

Bigcapital Technology, Inc.

+
+
+ {!connectedAccountId &&

Get ready for take off

} + {connectedAccountId && !stripeConnectInstance && ( +

Add information to start accepting money

+ )} + {!connectedAccountId && ( +

+ Bigcapital Technology, Inc. is the world's leading air travel + platform: join our team of pilots to help people travel faster. +

+ )} + {!accountCreatePending && !connectedAccountId && ( +
+ +
+ )} + {stripeConnectInstance && ( + + setOnboardingExited(true)} + /> + + )} + {error &&

Something went wrong!

} + {(connectedAccountId || accountCreatePending || onboardingExited) && ( +
+ {connectedAccountId && ( +

+ Your connected account ID is:{' '} + {connectedAccountId} +

+ )} + {accountCreatePending &&

Creating a connected account...

} + {onboardingExited && ( +

The Account Onboarding component has exited

+ )} +
+ )} +
+

+ This is a sample app for Connect onboarding using the Account + Onboarding embedded component.{' '} + + View docs + +

+
+
+
+ ); +} diff --git a/packages/webapp/src/containers/StripePayment/use-stripe-connect.ts b/packages/webapp/src/containers/StripePayment/use-stripe-connect.ts new file mode 100644 index 000000000..44cbd5ab6 --- /dev/null +++ b/packages/webapp/src/containers/StripePayment/use-stripe-connect.ts @@ -0,0 +1,47 @@ +import { useState, useEffect } from 'react'; +import { + loadConnectAndInitialize, + StripeConnectInstance, +} from '@stripe/connect-js'; +import { useCreateStripeAccountSession } from '@/hooks/query/stripe-integration'; + +export const useStripeConnect = (connectedAccountId?: string) => { + const [stripeConnectInstance, setStripeConnectInstance] = + useState(); + const { mutateAsync: createAccountSession } = useCreateStripeAccountSession(); + + useEffect(() => { + if (connectedAccountId) { + const fetchClientSecret = async (): Promise => { + try { + const clientSecret = await createAccountSession({ + connectedAccountId, + }); + return clientSecret?.client_secret as string; + } catch (error) { + // Handle errors on the client side here + if (error instanceof Error) { + throw new Error(`An error occurred: ${error.message}`); + } else { + throw new Error('An unknown error occurred'); + } + } + }; + + setStripeConnectInstance( + loadConnectAndInitialize({ + publishableKey: 'pk_test_51PRck9BW396nDn7gxEw1uvkoGwl5BXDWnrhntQIWReiDnH2Zdm7uL0RSvzKN6SR6ELHDK99dF9UbVEumgTu8k0oN00pP0J91Lx', + fetchClientSecret, + appearance: { + overlays: 'dialog', + variables: { + colorPrimary: '#ffffff', + }, + }, + }), + ); + } + }, [connectedAccountId, createAccountSession]); + + return stripeConnectInstance; +}; diff --git a/packages/webapp/src/hooks/query/stripe-integration.ts b/packages/webapp/src/hooks/query/stripe-integration.ts new file mode 100644 index 000000000..84e40742d --- /dev/null +++ b/packages/webapp/src/hooks/query/stripe-integration.ts @@ -0,0 +1,59 @@ +// @ts-nocheck +import { + useMutation, + UseMutationOptions, + UseMutationResult, +} from 'react-query'; +import useApiRequest from '../useRequest'; + +interface AccountSessionValues { + connectedAccountId?: string; +} +interface AccountSessionResponse { + client_secret: string; +} + +export const useCreateStripeAccountSession = ( + options?: UseMutationOptions< + AccountSessionResponse, + Error, + AccountSessionValues + >, +): UseMutationResult => { + const apiRequest = useApiRequest(); + + return useMutation( + (values: AccountSessionValues) => { + return apiRequest + .post('/stripe_integration/account_session', { + account: values?.connectedAccountId, + }) + .then((res) => res.data); + }, + { ...options }, + ); +}; + +interface CreateStripeAccountValues {} +interface CreateStripeAccountResponse { + account_id: string; +} + +export const useCreateStripeAccount = ( + options?: UseMutationOptions< + CreateStripeAccountResponse, + Error, + CreateStripeAccountValues + >, +) => { + const apiRequest = useApiRequest(); + + return useMutation( + (values: CreateStripeAccountValues) => { + return apiRequest + .post('/stripe_integration/account') + .then((res) => res.data); + }, + { ...options }, + ); +}; diff --git a/packages/webapp/src/routes/preferences.tsx b/packages/webapp/src/routes/preferences.tsx index 5a9795c4d..a540ebd5a 100644 --- a/packages/webapp/src/routes/preferences.tsx +++ b/packages/webapp/src/routes/preferences.tsx @@ -103,6 +103,13 @@ export const getPreferenceRoutes = () => [ component: lazy(() => import('@/containers/Subscriptions/BillingPage')), exact: true, }, + { + path: `${BASE_URL}/integrations`, + component: lazy( + () => import('@/containers/Preferences/Integrations/IntegrationsPage'), + ), + exact: true, + }, { path: `${BASE_URL}/`, component: lazy(() => import('../containers/Preferences/DefaultRoute')), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f26282a66..4dd88c77a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: source-map-loader: specifier: ^4.0.1 version: 4.0.2(webpack@5.91.0) + stripe: + specifier: ^16.10.0 + version: 16.10.0 tmp-promise: specifier: ^3.0.3 version: 3.0.3 @@ -507,6 +510,12 @@ importers: '@reduxjs/toolkit': specifier: ^1.2.5 version: 1.9.7(react-redux@7.2.9)(react@18.3.1) + '@stripe/connect-js': + specifier: ^3.3.12 + version: 3.3.12 + '@stripe/react-connect-js': + specifier: ^3.3.13 + version: 3.3.13(@stripe/connect-js@3.3.12)(react-dom@18.3.1)(react@18.3.1) '@testing-library/jest-dom': specifier: ^4.2.4 version: 4.2.4 @@ -5710,6 +5719,22 @@ packages: resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} dev: false + /@stripe/connect-js@3.3.12: + resolution: {integrity: sha512-hXbgvGq9Lb6BYgsb8lcbjL76Yqsxr0yAj6T9ZFTfUK0O4otI5GSEWum9do9rf/E5OfYy6fR1FG/77Jve2w1o6Q==} + dev: false + + /@stripe/react-connect-js@3.3.13(@stripe/connect-js@3.3.12)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-kMxYjeQUcl/ixu/mSeX5QGIr/MuP+YxFSEBdb8j6w+tbK82tmcjyFDgoQTQwVXNqUV6jI66Kks3XcfpPRfeiJA==} + peerDependencies: + '@stripe/connect-js': '>=3.3.11' + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@stripe/connect-js': 3.3.12 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@supercharge/promise-pool@3.2.0: resolution: {integrity: sha512-pj0cAALblTZBPtMltWOlZTQSLT07jIaFNeM8TWoJD1cQMgDB9mcMlVMoetiB35OzNJpqQ2b+QEtwiR9f20mADg==} engines: {node: '>=8'} @@ -24010,6 +24035,14 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + /stripe@16.10.0: + resolution: {integrity: sha512-H0qeSCkZVvk4fVchUbg0rNNviwOyw3Rsr9X6MKe84ajBeMz4ogEOZykaUcb/n0GSdvWlXAtbnB1gxl3xOlH+ZA==} + engines: {node: '>=12.*'} + dependencies: + '@types/node': 14.18.63 + qs: 6.12.1 + dev: false + /strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} dev: false