feat: Onboard accounts to Stripe Connect

This commit is contained in:
Ahmed Bouhuolia
2024-09-08 11:42:26 +02:00
parent 6d24474162
commit a183666df6
14 changed files with 360 additions and 3 deletions

View File

@@ -92,4 +92,8 @@ S3_BUCKET=
# PostHog # PostHog
POSTHOG_API_KEY= POSTHOG_API_KEY=
POSTHOG_HOST= POSTHOG_HOST=
# Stripe Payment
STRIPE_PAYMENT_SECRET_KEY=
STRIPE_PAYMENT_PUBLISHABLE_KEY=

View File

@@ -109,6 +109,7 @@
"rtl-detect": "^1.0.4", "rtl-detect": "^1.0.4",
"socket.io": "^4.7.4", "socket.io": "^4.7.4",
"source-map-loader": "^4.0.1", "source-map-loader": "^4.0.1",
"stripe": "^16.10.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"ts-transformer-keys": "^0.4.2", "ts-transformer-keys": "^0.4.2",
"tsyringe": "^4.3.0", "tsyringe": "^4.3.0",

View File

@@ -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);
}
}
}

View File

@@ -64,6 +64,7 @@ import { Webhooks } from './controllers/Webhooks/Webhooks';
import { ExportController } from './controllers/Export/ExportController'; import { ExportController } from './controllers/Export/ExportController';
import { AttachmentsController } from './controllers/Attachments/AttachmentsController'; import { AttachmentsController } from './controllers/Attachments/AttachmentsController';
import { OneClickDemoController } from './controllers/OneClickDemo/OneClickDemoController'; import { OneClickDemoController } from './controllers/OneClickDemo/OneClickDemoController';
import { StripeIntegrationController } from './controllers/StripeIntegration/StripeIntegrationController';
export default () => { export default () => {
const app = Router(); const app = Router();
@@ -147,6 +148,7 @@ export default () => {
dashboard.use('/import', Container.get(ImportController).router()); dashboard.use('/import', Container.get(ImportController).router());
dashboard.use('/export', Container.get(ExportController).router()); dashboard.use('/export', Container.get(ExportController).router());
dashboard.use('/attachments', Container.get(AttachmentsController).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(ProjectTasksController).router());
dashboard.use('/', Container.get(ProjectTimesController).router()); dashboard.use('/', Container.get(ProjectTimesController).router());

View File

@@ -259,6 +259,14 @@ module.exports = {
*/ */
posthog: { posthog: {
apiKey: process.env.POSTHOG_API_KEY, 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 || '',
},
}; };

View File

@@ -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'
);
}
}
}

View File

@@ -17,6 +17,8 @@
"@casl/react": "^2.3.0", "@casl/react": "^2.3.0",
"@craco/craco": "^5.9.0", "@craco/craco": "^5.9.0",
"@reduxjs/toolkit": "^1.2.5", "@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/jest-dom": "^4.2.4",
"@testing-library/react": "^9.4.0", "@testing-library/react": "^9.4.0",
"@testing-library/user-event": "^7.2.1", "@testing-library/user-event": "^7.2.1",

View File

@@ -54,6 +54,11 @@ export default [
disabled: false, disabled: false,
href: '/preferences/items', href: '/preferences/items',
}, },
{
text: 'Integrations',
disabled: false,
href: '/preferences/integrations'
},
// { // {
// text: <T id={'sms_integration.label'} />, // text: <T id={'sms_integration.label'} />,
// disabled: false, // disabled: false,

View File

@@ -0,0 +1,5 @@
import { StripeIntegration } from '@/containers/StripePayment/StripeIntegration';
export default function IntegrationsPage() {
return <StripeIntegration />
}

View File

@@ -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<boolean>(false);
const [onboardingExited, setOnboardingExited] = useState<boolean>(false);
const [error, setError] = useState<boolean>(false);
const [connectedAccountId, setConnectedAccountId] = useState<string | null>(
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 (
<div className="container">
<div className="banner">
<h2>Bigcapital Technology, Inc.</h2>
</div>
<div className="content">
{!connectedAccountId && <h2>Get ready for take off</h2>}
{connectedAccountId && !stripeConnectInstance && (
<h2>Add information to start accepting money</h2>
)}
{!connectedAccountId && (
<p>
Bigcapital Technology, Inc. is the world's leading air travel
platform: join our team of pilots to help people travel faster.
</p>
)}
{!accountCreatePending && !connectedAccountId && (
<div>
<button onClick={handleSignupBtnClick}>Sign up</button>
</div>
)}
{stripeConnectInstance && (
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
<ConnectAccountOnboarding
onExit={() => setOnboardingExited(true)}
/>
</ConnectComponentsProvider>
)}
{error && <p className="error">Something went wrong!</p>}
{(connectedAccountId || accountCreatePending || onboardingExited) && (
<div className="dev-callout">
{connectedAccountId && (
<p>
Your connected account ID is:{' '}
<code className="bold">{connectedAccountId}</code>
</p>
)}
{accountCreatePending && <p>Creating a connected account...</p>}
{onboardingExited && (
<p>The Account Onboarding component has exited</p>
)}
</div>
)}
<div className="info-callout">
<p>
This is a sample app for Connect onboarding using the Account
Onboarding embedded component.{' '}
<a
href="https://docs.stripe.com/connect/onboarding/quickstart?connect-onboarding-surface=embedded"
target="_blank"
rel="noopener noreferrer"
>
View docs
</a>
</p>
</div>
</div>
</div>
);
}

View File

@@ -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<StripeConnectInstance | null>();
const { mutateAsync: createAccountSession } = useCreateStripeAccountSession();
useEffect(() => {
if (connectedAccountId) {
const fetchClientSecret = async (): Promise<string> => {
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;
};

View File

@@ -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<AccountSessionResponse, Error, AccountSessionValues> => {
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 },
);
};

View File

@@ -103,6 +103,13 @@ export const getPreferenceRoutes = () => [
component: lazy(() => import('@/containers/Subscriptions/BillingPage')), component: lazy(() => import('@/containers/Subscriptions/BillingPage')),
exact: true, exact: true,
}, },
{
path: `${BASE_URL}/integrations`,
component: lazy(
() => import('@/containers/Preferences/Integrations/IntegrationsPage'),
),
exact: true,
},
{ {
path: `${BASE_URL}/`, path: `${BASE_URL}/`,
component: lazy(() => import('../containers/Preferences/DefaultRoute')), component: lazy(() => import('../containers/Preferences/DefaultRoute')),

33
pnpm-lock.yaml generated
View File

@@ -302,6 +302,9 @@ importers:
source-map-loader: source-map-loader:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.2(webpack@5.91.0) version: 4.0.2(webpack@5.91.0)
stripe:
specifier: ^16.10.0
version: 16.10.0
tmp-promise: tmp-promise:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -507,6 +510,12 @@ importers:
'@reduxjs/toolkit': '@reduxjs/toolkit':
specifier: ^1.2.5 specifier: ^1.2.5
version: 1.9.7(react-redux@7.2.9)(react@18.3.1) 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': '@testing-library/jest-dom':
specifier: ^4.2.4 specifier: ^4.2.4
version: 4.2.4 version: 4.2.4
@@ -5710,6 +5719,22 @@ packages:
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
dev: false 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: /@supercharge/promise-pool@3.2.0:
resolution: {integrity: sha512-pj0cAALblTZBPtMltWOlZTQSLT07jIaFNeM8TWoJD1cQMgDB9mcMlVMoetiB35OzNJpqQ2b+QEtwiR9f20mADg==} resolution: {integrity: sha512-pj0cAALblTZBPtMltWOlZTQSLT07jIaFNeM8TWoJD1cQMgDB9mcMlVMoetiB35OzNJpqQ2b+QEtwiR9f20mADg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -24010,6 +24035,14 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'} 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: /strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
dev: false dev: false