From f5a1d68c5205eee60dd251db69845808ab45db5f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 19 Sep 2024 22:24:07 +0200 Subject: [PATCH] feat: Stripe payment checkout session --- .../StripeIntegrationController.ts | 36 +++++++ .../server/src/interfaces/StripePayment.ts | 7 ++ .../CreateInvoiceCheckoutSession.ts | 94 +++++++++++++++++++ .../StripePayment/StripePaymentApplication.ts | 21 +++++ .../PaymentPortal/PaymentPortal.tsx | 29 +++++- .../PaymentPortal/PaymentPortalBoot.tsx | 2 + .../StripePayment/StripeIntegration.tsx | 1 - .../src/hooks/query/stripe-integration.ts | 40 ++++++++ 8 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/services/StripePayment/CreateInvoiceCheckoutSession.ts diff --git a/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts b/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts index bf4919d6d..df1aea63a 100644 --- a/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts +++ b/packages/server/src/api/controllers/StripeIntegration/StripeIntegrationController.ts @@ -3,6 +3,7 @@ import { Service, Inject } from 'typedi'; import { StripePaymentService } from '@/services/StripePayment/StripePaymentService'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import { StripeIntegrationApplication } from './StripeIntegrationApplication'; +import { StripePaymentApplication } from '@/services/StripePayment/StripePaymentApplication'; @Service() export class StripeIntegrationController { @@ -12,6 +13,9 @@ export class StripeIntegrationController { @Inject() private stripeIntegrationApp: StripeIntegrationApplication; + @Inject() + private stripePaymentApp: StripePaymentApplication; + router() { const router = Router(); @@ -20,9 +24,41 @@ export class StripeIntegrationController { '/account_session', asyncMiddleware(this.createAccountSession.bind(this)) ); + router.post( + '/:linkId/create_checkout_session', + this.createCheckoutSession.bind(this) + ); + return router; } + /** + * Creates a Stripe checkout session for the given payment link id. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + public async createCheckoutSession( + req: Request<{ linkId: number }>, + res: Response, + next: NextFunction + ) { + const { linkId } = req.params; + const { tenantId } = req; + + try { + const session = + await this.stripePaymentApp.createSaleInvoiceCheckoutSession( + tenantId, + linkId + ); + return res.status(200).send(session); + } catch (error) { + next(error); + } + } + /** * Creates a new Stripe account. * @param {Request} req - The Express request object. diff --git a/packages/server/src/interfaces/StripePayment.ts b/packages/server/src/interfaces/StripePayment.ts index 003d00b05..a2d4ac6b4 100644 --- a/packages/server/src/interfaces/StripePayment.ts +++ b/packages/server/src/interfaces/StripePayment.ts @@ -8,3 +8,10 @@ export interface StripePaymentLinkCreatedEventPayload { export interface StripeCheckoutSessionCompletedEventPayload { event: any; } + + +export interface StripeInvoiceCheckoutSessionPOJO { + sessionId: string; + publishableKey: string; + redirectTo: string; +} \ No newline at end of file diff --git a/packages/server/src/services/StripePayment/CreateInvoiceCheckoutSession.ts b/packages/server/src/services/StripePayment/CreateInvoiceCheckoutSession.ts new file mode 100644 index 000000000..1fff02684 --- /dev/null +++ b/packages/server/src/services/StripePayment/CreateInvoiceCheckoutSession.ts @@ -0,0 +1,94 @@ +import config from '@/config'; +import { StripePaymentService } from './StripePaymentService'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { ISaleInvoice } from '@/interfaces'; +import { Inject, Service } from 'typedi'; +import { StripeInvoiceCheckoutSessionPOJO } from '@/interfaces/StripePayment'; +import { PaymentLink } from '@/system/models'; + +const origin = 'http://localhost'; + +@Service() +export class CreateInvoiceCheckoutSession { + @Inject() + private stripePaymentService: StripePaymentService; + + @Inject() + private tenancy: HasTenancyService; + + /** + * Creates a new Stripe checkout session from the given sale invoice. + * @param {number} tenantId + * @param {number} saleInvoiceId - Sale invoice id. + * @returns {Promise} + */ + async createInvoiceCheckoutSession( + tenantId: number, + publicPaymentLinkId: number + ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + // Retrieves the payment link from the given id. + const paymentLink = await PaymentLink.query() + .findOne('linkId', publicPaymentLinkId) + .where('resourceType', 'SaleInvoice') + .throwIfNotFound(); + + // Retrieves the invoice from associated payment link. + const invoice = await SaleInvoice.query() + .findById(paymentLink.resourceId) + .withGraphFetched('paymentMethods') + .throwIfNotFound(); + + // It will be only one Stripe payment method associated to the invoice. + const stripePaymentMethod = invoice.paymentMethods?.find( + (method) => method.paymentIntegration?.service === 'Stripe' + ); + const stripeAccountId = stripePaymentMethod?.paymentIntegration?.accountId; + const paymentIntegrationId = stripePaymentMethod?.paymentIntegration?.id; + + const session = await this.createCheckoutSession(invoice, stripeAccountId); + + return { + sessionId: session.id, + publishableKey: config.stripePayment.publishableKey, + redirectTo: session.url, + }; + } + + /** + * Creates a new Stripe checkout session for the given sale invoice. + * @param {ISaleInvoice} invoice - The sale invoice for which the checkout session is created. + * @param {string} stripeAccountId - The Stripe account ID associated with the payment method. + * @returns {Promise} - The created Stripe checkout session. + */ + private createCheckoutSession( + invoice: ISaleInvoice, + stripeAccountId: string + ) { + return this.stripePaymentService.stripe.checkout.sessions.create( + { + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: invoice.currencyCode, + product_data: { + name: invoice.invoiceNo, + }, + unit_amount: invoice.total * 100, // Amount in cents + }, + quantity: 1, + }, + ], + mode: 'payment', + success_url: `${origin}/success`, + cancel_url: `${origin}/cancel`, + }, + { + stripeAccount: stripeAccountId, + // stripeAccount: 'acct_1Q0nE7ESY7RfeebE', + } + ); + } +} diff --git a/packages/server/src/services/StripePayment/StripePaymentApplication.ts b/packages/server/src/services/StripePayment/StripePaymentApplication.ts index c92552bfb..54930ac6a 100644 --- a/packages/server/src/services/StripePayment/StripePaymentApplication.ts +++ b/packages/server/src/services/StripePayment/StripePaymentApplication.ts @@ -4,6 +4,8 @@ import { CreateStripeAccountService } from '@/api/controllers/StripeIntegration/ import { CreateStripeAccountDTO } from '@/api/controllers/StripeIntegration/types'; import { SaleInvoiceStripePaymentLink } from './SaleInvoiceStripePaymentLink'; import { DeleteStripePaymentLinkInvoice } from './DeleteStripePaymentLinkInvoice'; +import { CreateInvoiceCheckoutSession } from './CreateInvoiceCheckoutSession'; +import { StripeInvoiceCheckoutSessionPOJO } from '@/interfaces/StripePayment'; export class StripePaymentApplication { @Inject() @@ -15,6 +17,9 @@ export class StripePaymentApplication { @Inject() private deleteStripePaymentLinkInvoice: DeleteStripePaymentLinkInvoice; + @Inject() + private createSaleInvoiceCheckoutSessionService: CreateInvoiceCheckoutSession; + /** * Creates a new Stripe account for Bigcapital. * @param {number} tenantId @@ -49,6 +54,22 @@ export class StripePaymentApplication { ); } + /** + * Creates the Stripe checkout session from the given sale invoice. + * @param {number} tenantId + * @param {string} paymentLinkId + * @returns {Promise} + */ + public createSaleInvoiceCheckoutSession( + tenantId: number, + paymentLinkId: number + ): Promise { + return this.createSaleInvoiceCheckoutSessionService.createInvoiceCheckoutSession( + tenantId, + paymentLinkId + ); + } + /** * Deletes the Stripe payment link associated with the given sale invoice. * @param {number} tenantId - Tenant id. diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx index ae0dd24d1..8b1666858 100644 --- a/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx +++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortal.tsx @@ -1,18 +1,37 @@ import { Text, Classes, Button, Intent } from '@blueprintjs/core'; import clsx from 'classnames'; -import { Box, Group, Stack } from '@/components'; -import styles from './PaymentPortal.module.scss'; +import { AppToaster, Box, Group, Stack } from '@/components'; import { usePaymentPortalBoot } from './PaymentPortalBoot'; import { useDrawerActions } from '@/hooks/state'; +import { useCreateStripeCheckoutSession } from '@/hooks/query/stripe-integration'; import { DRAWERS } from '@/constants/drawers'; +import styles from './PaymentPortal.module.scss'; export function PaymentPortal() { const { openDrawer } = useDrawerActions(); - const { sharableLinkMeta } = usePaymentPortalBoot(); + const { sharableLinkMeta, linkId } = usePaymentPortalBoot(); + const { + mutateAsync: createStripeCheckoutSession, + isLoading: isStripeCheckoutLoading, + } = useCreateStripeCheckoutSession(); + // Handles invoice preview button click. const handleInvoicePreviewBtnClick = () => { openDrawer(DRAWERS.PAYMENT_INVOICE_PREVIEW); }; + // handles the pay button click. + const handlePayButtonClick = () => { + createStripeCheckoutSession({ linkId }) + .then((session) => { + window.open(session.redirectTo); + }) + .catch(() => { + AppToaster.show({ + intent: Intent.DANGER, + message: 'Something went wrong.', + }); + }); + }; return ( @@ -91,15 +110,19 @@ export function PaymentPortal() { > Download Invoice + + diff --git a/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx b/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx index aa7c446a3..42ae1fbbe 100644 --- a/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx +++ b/packages/webapp/src/containers/PaymentPortal/PaymentPortalBoot.tsx @@ -6,6 +6,7 @@ import { import { Spinner } from '@blueprintjs/core'; interface PaymentPortalContextType { + linkId: string; sharableLinkMeta: GetSharableLinkMetaResponse | undefined; isSharableLinkMetaLoading: boolean; } @@ -27,6 +28,7 @@ export const PaymentPortalBoot: React.FC = ({ useGetSharableLinkMeta(linkId); const value = { + linkId, sharableLinkMeta, isSharableLinkMetaLoading, }; diff --git a/packages/webapp/src/containers/StripePayment/StripeIntegration.tsx b/packages/webapp/src/containers/StripePayment/StripeIntegration.tsx index dc3058b88..520f75442 100644 --- a/packages/webapp/src/containers/StripePayment/StripeIntegration.tsx +++ b/packages/webapp/src/containers/StripePayment/StripeIntegration.tsx @@ -15,7 +15,6 @@ export function StripeIntegration() { null, ); const stripeConnectInstance = useStripeConnect(connectedAccountId || ''); - const { mutateAsync: createAccount } = useCreateStripeAccount(); const handleSignupBtnClick = () => { diff --git a/packages/webapp/src/hooks/query/stripe-integration.ts b/packages/webapp/src/hooks/query/stripe-integration.ts index 84e40742d..3a510c4e5 100644 --- a/packages/webapp/src/hooks/query/stripe-integration.ts +++ b/packages/webapp/src/hooks/query/stripe-integration.ts @@ -5,6 +5,7 @@ import { UseMutationResult, } from 'react-query'; import useApiRequest from '../useRequest'; +import { transformToCamelCase } from '@/utils'; interface AccountSessionValues { connectedAccountId?: string; @@ -57,3 +58,42 @@ export const useCreateStripeAccount = ( { ...options }, ); }; + +interface CreateCheckoutSessionValues { + linkId: string; +} + +interface CreateCheckoutSessionResponse { + sessionId: string; + publishableKey: string; + redirectTo: string; +} + +export const useCreateStripeCheckoutSession = ( + options?: UseMutationOptions< + CreateCheckoutSessionResponse, + Error, + CreateCheckoutSessionValues + >, +): UseMutationResult< + CreateCheckoutSessionResponse, + Error, + CreateCheckoutSessionValues +> => { + const apiRequest = useApiRequest(); + + return useMutation( + (values: CreateCheckoutSessionValues) => { + return apiRequest + .post( + `/stripe_integration/${values.linkId}/create_checkout_session`, + values, + ) + .then( + (res) => + transformToCamelCase(res.data) as CreateCheckoutSessionResponse, + ); + }, + { ...options }, + ); +};