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

@@ -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",

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 { 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());

View File

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

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",
"@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",

View File

@@ -54,6 +54,11 @@ export default [
disabled: false,
href: '/preferences/items',
},
{
text: 'Integrations',
disabled: false,
href: '/preferences/integrations'
},
// {
// text: <T id={'sms_integration.label'} />,
// 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')),
exact: true,
},
{
path: `${BASE_URL}/integrations`,
component: lazy(
() => import('@/containers/Preferences/Integrations/IntegrationsPage'),
),
exact: true,
},
{
path: `${BASE_URL}/`,
component: lazy(() => import('../containers/Preferences/DefaultRoute')),