Merge pull request #864 from Daniel15/patch-3

fix: Stripe integration
This commit is contained in:
Ahmed Bouhuolia
2025-12-02 01:41:04 +02:00
committed by GitHub
12 changed files with 165 additions and 30 deletions

View File

@@ -15,7 +15,7 @@ global.__views_dirname = path.join(global.__static_dirname, '/views');
global.__images_dirname = path.join(global.__static_dirname, '/images'); global.__images_dirname = path.join(global.__static_dirname, '/images');
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule, { rawBody: true });
app.setGlobalPrefix('/api'); app.setGlobalPrefix('/api');
// create and mount the middleware manually here // create and mount the middleware manually here

View File

@@ -1,5 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { Controller, Get, Param, Res } from '@nestjs/common'; import { Controller, Get, Param, Post, Res } from '@nestjs/common';
import { PaymentLinksApplication } from './PaymentLinksApplication'; import { PaymentLinksApplication } from './PaymentLinksApplication';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders'; import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@@ -40,7 +40,7 @@ export class PaymentLinksController {
return { data }; return { data };
} }
@Get('/:paymentLinkId/stripe_checkout_session') @Post('/:paymentLinkId/stripe_checkout_session')
@ApiOperation({ @ApiOperation({
summary: 'Create Stripe checkout session', summary: 'Create Stripe checkout session',
description: description:

View File

@@ -15,7 +15,7 @@ export class GetPaymentMethodsStateService {
private readonly paymentIntegrationModel: TenantModelProxy< private readonly paymentIntegrationModel: TenantModelProxy<
typeof PaymentIntegration typeof PaymentIntegration
>, >,
) {} ) { }
/** /**
* Retrieves the payment state provising state. * Retrieves the payment state provising state.

View File

@@ -1,5 +1,6 @@
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { SaleInvoice } from '../models/SaleInvoice'; import { SaleInvoice } from '../models/SaleInvoice';
import { SaleInvoiceTransformer } from './SaleInvoice.transformer'; import { SaleInvoiceTransformer } from './SaleInvoice.transformer';
@@ -17,7 +18,7 @@ export class GetSaleInvoice {
@Inject(SaleInvoice.name) @Inject(SaleInvoice.name)
private saleInvoiceModel: TenantModelProxy<typeof SaleInvoice>, private saleInvoiceModel: TenantModelProxy<typeof SaleInvoice>,
) {} ) { }
/** /**
* Retrieve sale invoice with associated entries. * Retrieve sale invoice with associated entries.
@@ -27,9 +28,10 @@ export class GetSaleInvoice {
*/ */
public async getSaleInvoice( public async getSaleInvoice(
saleInvoiceId: number, saleInvoiceId: number,
trx?: Knex.Transaction,
): Promise<SaleInvoiceResponseDto> { ): Promise<SaleInvoiceResponseDto> {
const saleInvoice = await this.saleInvoiceModel() const saleInvoice = await this.saleInvoiceModel()
.query() .query(trx)
.findById(saleInvoiceId) .findById(saleInvoiceId)
.withGraphFetched('entries.item') .withGraphFetched('entries.item')
.withGraphFetched('entries.tax') .withGraphFetched('entries.tax')

View File

@@ -12,7 +12,7 @@ export class CreatePaymentReceiveStripePayment {
private readonly createPaymentReceivedService: CreatePaymentReceivedService, private readonly createPaymentReceivedService: CreatePaymentReceivedService,
private readonly uow: UnitOfWork, private readonly uow: UnitOfWork,
private readonly accountRepository: AccountRepository, private readonly accountRepository: AccountRepository,
) {} ) { }
/** /**
* Creates a payment received transaction associated to the given invoice. * Creates a payment received transaction associated to the given invoice.
@@ -28,7 +28,7 @@ export class CreatePaymentReceiveStripePayment {
// Retrieves the given invoice to create payment transaction associated to it. // Retrieves the given invoice to create payment transaction associated to it.
const invoice = const invoice =
await this.getSaleInvoiceService.getSaleInvoice(saleInvoiceId); await this.getSaleInvoiceService.getSaleInvoice(saleInvoiceId, trx);
const paymentReceivedDTO = { const paymentReceivedDTO = {
customerId: invoice.customerId, customerId: invoice.customerId,
@@ -38,6 +38,7 @@ export class CreatePaymentReceiveStripePayment {
referenceNo: '', referenceNo: '',
statement: '', statement: '',
depositAccountId: stripeClearingAccount.id, depositAccountId: stripeClearingAccount.id,
branchId: invoice.branchId,
entries: [{ invoiceId: saleInvoiceId, paymentAmount: paidAmount }], entries: [{ invoiceId: saleInvoiceId, paymentAmount: paidAmount }],
}; };
// Create a payment received transaction associated to the given invoice. // Create a payment received transaction associated to the given invoice.

View File

@@ -7,7 +7,7 @@ export class GetStripeAuthorizationLinkService {
public getStripeAuthLink() { public getStripeAuthLink() {
const clientId = this.config.get('stripePayment.clientId'); const clientId = this.config.get('stripePayment.clientId');
const redirectUrl = this.config.get('stripePayment.redirectTo'); const redirectUrl = this.config.get('stripePayment.redirectUrl');
const authorizationUri = `https://connect.stripe.com/oauth/v2/authorize?response_type=code&client_id=${clientId}&scope=read_write&redirect_uri=${redirectUrl}`; const authorizationUri = `https://connect.stripe.com/oauth/v2/authorize?response_type=code&client_id=${clientId}&scope=read_write&redirect_uri=${redirectUrl}`;

View File

@@ -3,12 +3,11 @@ import { CreateStripeAccountLinkService } from './CreateStripeAccountLink';
import { CreateStripeAccountService } from './CreateStripeAccountService'; import { CreateStripeAccountService } from './CreateStripeAccountService';
import { StripePaymentApplication } from './StripePaymentApplication'; import { StripePaymentApplication } from './StripePaymentApplication';
import { ExchangeStripeOAuthTokenService } from './ExchangeStripeOauthToken'; import { ExchangeStripeOAuthTokenService } from './ExchangeStripeOauthToken';
import { PaymentIntegration } from './models/PaymentIntegration.model';
import { SeedStripeAccountsOnOAuthGrantedSubscriber } from './subscribers/SeedStripeAccounts'; import { SeedStripeAccountsOnOAuthGrantedSubscriber } from './subscribers/SeedStripeAccounts';
import { StripeWebhooksSubscriber } from './subscribers/StripeWebhooksSubscriber'; import { StripeWebhooksSubscriber } from './subscribers/StripeWebhooksSubscriber';
import { StripeIntegrationController } from './StripePayment.controller'; import { StripeIntegrationController } from './StripePayment.controller';
import { StripePaymentWebhooksController } from './StripePaymentWebhooks.controller';
import { StripePaymentService } from './StripePaymentService'; import { StripePaymentService } from './StripePaymentService';
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
import { GetStripeAuthorizationLinkService } from './GetStripeAuthorizationLink'; import { GetStripeAuthorizationLinkService } from './GetStripeAuthorizationLink';
import { AccountsModule } from '../Accounts/Accounts.module'; import { AccountsModule } from '../Accounts/Accounts.module';
import { CreatePaymentReceiveStripePayment } from './CreatePaymentReceivedStripePayment'; import { CreatePaymentReceiveStripePayment } from './CreatePaymentReceivedStripePayment';
@@ -16,8 +15,6 @@ import { SaleInvoicesModule } from '../SaleInvoices/SaleInvoices.module';
import { PaymentsReceivedModule } from '../PaymentReceived/PaymentsReceived.module'; import { PaymentsReceivedModule } from '../PaymentReceived/PaymentsReceived.module';
import { TenancyContext } from '../Tenancy/TenancyContext.service'; import { TenancyContext } from '../Tenancy/TenancyContext.service';
const models = [InjectSystemModel(PaymentIntegration)];
@Module({ @Module({
imports: [ imports: [
AccountsModule, AccountsModule,
@@ -25,7 +22,6 @@ const models = [InjectSystemModel(PaymentIntegration)];
forwardRef(() => SaleInvoicesModule), forwardRef(() => SaleInvoicesModule),
], ],
providers: [ providers: [
...models,
StripePaymentService, StripePaymentService,
GetStripeAuthorizationLinkService, GetStripeAuthorizationLinkService,
CreateStripeAccountLinkService, CreateStripeAccountLinkService,
@@ -38,6 +34,6 @@ const models = [InjectSystemModel(PaymentIntegration)];
TenancyContext, TenancyContext,
], ],
exports: [StripePaymentService, GetStripeAuthorizationLinkService], exports: [StripePaymentService, GetStripeAuthorizationLinkService],
controllers: [StripeIntegrationController], controllers: [StripeIntegrationController, StripePaymentWebhooksController],
}) })
export class StripePaymentModule {} export class StripePaymentModule { }

View File

@@ -0,0 +1,118 @@
import {
Controller,
Headers,
HttpCode,
HttpException,
HttpStatus,
Post,
RawBodyRequest,
Req,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ConfigService } from '@nestjs/config';
import { StripePaymentService } from './StripePaymentService';
import { events } from '@/common/events/events';
import {
StripeCheckoutSessionCompletedEventPayload,
StripeWebhookEventPayload,
} from './StripePayment.types';
import { PublicRoute } from '../Auth/guards/jwt.guard';
@Controller('/webhooks/stripe')
@ApiTags('stripe')
@PublicRoute()
export class StripePaymentWebhooksController {
constructor(
private readonly stripePaymentService: StripePaymentService,
private readonly eventEmitter: EventEmitter2,
private readonly configService: ConfigService,
) { }
/**
* Handles incoming Stripe webhook events.
* Verifies the webhook signature, processes the event based on its type,
* and triggers appropriate actions or events in the system.
* @param {Request} req - The Express request object containing the webhook payload.
* @param {Response} res - The Express response object.
* @returns {Promise<Response>}
*/
@Post('/')
@HttpCode(200)
@ApiOperation({ summary: 'Listen to Stripe webhooks' })
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string,
) {
try {
const rawBody = req.rawBody || req.body;
const webhooksSecret = this.configService.get(
'stripePayment.webhooksSecret',
);
if (!webhooksSecret) {
throw new HttpException(
'Stripe webhook secret is not configured',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (!signature) {
throw new HttpException(
'Stripe signature header is missing',
HttpStatus.BAD_REQUEST,
);
}
let event;
// Verify webhook signature and extract the event.
// See https://stripe.com/docs/webhooks#verify-events for more information.
try {
event = this.stripePaymentService.stripe.webhooks.constructEvent(
rawBody,
signature,
webhooksSecret,
);
} catch (err) {
throw new HttpException(
`Webhook Error: ${err.message}`,
HttpStatus.BAD_REQUEST,
);
}
// Handle the event based on its type
switch (event.type) {
case 'checkout.session.completed':
// Triggers `onStripeCheckoutSessionCompleted` event.
await this.eventEmitter.emitAsync(
events.stripeWebhooks.onCheckoutSessionCompleted,
{
event,
} as StripeCheckoutSessionCompletedEventPayload,
);
break;
case 'account.updated':
// Triggers `onStripeAccountUpdated` event.
await this.eventEmitter.emitAsync(
events.stripeWebhooks.onAccountUpdated,
{
event,
} as StripeWebhookEventPayload,
);
break;
// Add more cases as needed
default:
console.log(`Unhandled event type ${event.type}`);
}
return { received: true };
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
error.message || 'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@@ -1,21 +1,25 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ClsService } from 'nestjs-cls';
import { CreatePaymentReceiveStripePayment } from '../CreatePaymentReceivedStripePayment'; import { CreatePaymentReceiveStripePayment } from '../CreatePaymentReceivedStripePayment';
import { import {
StripeCheckoutSessionCompletedEventPayload, StripeCheckoutSessionCompletedEventPayload,
StripeWebhookEventPayload, StripeWebhookEventPayload,
} from '../StripePayment.types'; } from '../StripePayment.types';
// import { initalizeTenantServices } from '@/api/middleware/TenantDependencyInjection';
// import { initializeTenantSettings } from '@/api/middleware/SettingsMiddleware';
import { OnEvent } from '@nestjs/event-emitter';
import { Inject, Injectable } from '@nestjs/common';
import { events } from '@/common/events/events'; import { events } from '@/common/events/events';
import { PaymentIntegration } from '../models/PaymentIntegration.model'; import { PaymentIntegration } from '../models/PaymentIntegration.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { TenantModel } from '@/modules/System/models/TenantModel'; import { TenantModel } from '@/modules/System/models/TenantModel';
import { SystemUser } from '@/modules/System/models/SystemUser';
@Injectable() @Injectable()
export class StripeWebhooksSubscriber { export class StripeWebhooksSubscriber {
constructor( constructor(
private readonly createPaymentReceiveStripePayment: CreatePaymentReceiveStripePayment, private readonly createPaymentReceiveStripePayment: CreatePaymentReceiveStripePayment,
private readonly clsService: ClsService,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
@Inject(PaymentIntegration.name) @Inject(PaymentIntegration.name)
private readonly paymentIntegrationModel: TenantModelProxy< private readonly paymentIntegrationModel: TenantModelProxy<
@@ -23,8 +27,8 @@ export class StripeWebhooksSubscriber {
>, >,
@Inject(TenantModel.name) @Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel private readonly tenantModel: typeof TenantModel,
) {} ) { }
/** /**
* Handles the checkout session completed webhook event. * Handles the checkout session completed webhook event.
@@ -38,10 +42,22 @@ export class StripeWebhooksSubscriber {
const tenantId = parseInt(metadata.tenantId, 10); const tenantId = parseInt(metadata.tenantId, 10);
const saleInvoiceId = parseInt(metadata.saleInvoiceId, 10); const saleInvoiceId = parseInt(metadata.saleInvoiceId, 10);
const tenant = await this.tenantModel
.query()
.findOne({ id: tenantId })
.throwIfNotFound();
const user = await this.systemUserModel
.query()
.findOne({
tenantId: tenant.id,
})
.modify('active')
.throwIfNotFound();
// await initalizeTenantServices(tenantId); this.clsService.set('organizationId', tenant.organizationId);
// await initializeTenantSettings(tenantId); this.clsService.set('userId', user.id);
this.clsService.set('tenantId', tenant.id);
// Get the amount from the event // Get the amount from the event
const amount = event.data.object.amount_total; const amount = event.data.object.amount_total;

View File

@@ -26,6 +26,7 @@ import { BillLandedCostEntry } from '@/modules/BillLandedCosts/models/BillLanded
import { BillLandedCost } from '@/modules/BillLandedCosts/models/BillLandedCost'; import { BillLandedCost } from '@/modules/BillLandedCosts/models/BillLandedCost';
import { VendorCreditAppliedBill } from '@/modules/VendorCreditsApplyBills/models/VendorCreditAppliedBill'; import { VendorCreditAppliedBill } from '@/modules/VendorCreditsApplyBills/models/VendorCreditAppliedBill';
import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice'; import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice';
import { PaymentIntegration } from '@/modules/StripePayment/models/PaymentIntegration.model';
import { PaymentReceivedEntry } from '@/modules/PaymentReceived/models/PaymentReceivedEntry'; import { PaymentReceivedEntry } from '@/modules/PaymentReceived/models/PaymentReceivedEntry';
import { CreditNoteAppliedInvoice } from '@/modules/CreditNotesApplyInvoice/models/CreditNoteAppliedInvoice'; import { CreditNoteAppliedInvoice } from '@/modules/CreditNotesApplyInvoice/models/CreditNoteAppliedInvoice';
import { CreditNote } from '@/modules/CreditNotes/models/CreditNote'; import { CreditNote } from '@/modules/CreditNotes/models/CreditNote';
@@ -75,6 +76,7 @@ const models = [
VendorCredit, VendorCredit,
VendorCreditAppliedBill, VendorCreditAppliedBill,
RefundVendorCredit, RefundVendorCredit,
PaymentIntegration,
PaymentReceived, PaymentReceived,
PaymentReceivedEntry, PaymentReceivedEntry,
TenantUser, TenantUser,

View File

@@ -243,7 +243,7 @@ const transformPaymentMethodsToRequest = (
paymentMethods: Record<string, { enable: boolean }>, paymentMethods: Record<string, { enable: boolean }>,
): Array<{ payment_integration_id: string; enable: boolean }> => { ): Array<{ payment_integration_id: string; enable: boolean }> => {
return Object.entries(paymentMethods).map(([paymentMethodId, method]) => ({ return Object.entries(paymentMethods).map(([paymentMethodId, method]) => ({
payment_integration_id: paymentMethodId, payment_integration_id: +paymentMethodId,
enable: method.enable, enable: method.enable,
})); }));
}; };

View File

@@ -37,7 +37,7 @@ export const useCreateStripeAccountLink = (
return useMutation( return useMutation(
(values: StripeAccountLinkValues) => { (values: StripeAccountLinkValues) => {
return apiRequest return apiRequest
.post('/stripe_integration/account_link', { .post('/stripe/account_link', {
stripe_account_id: values?.stripeAccountId, stripe_account_id: values?.stripeAccountId,
}) })
.then((res) => transformToCamelCase(res.data)); .then((res) => transformToCamelCase(res.data));
@@ -72,7 +72,7 @@ export const useCreateStripeAccountSession = (
return useMutation( return useMutation(
(values: AccountSessionValues) => { (values: AccountSessionValues) => {
return apiRequest return apiRequest
.post('/stripe_integration/account_session', { .post('/stripe/account_session', {
account: values?.connectedAccountId, account: values?.connectedAccountId,
}) })
.then((res) => res.data); .then((res) => res.data);
@@ -100,7 +100,7 @@ export const useCreateStripeAccount = (
return useMutation( return useMutation(
(values: CreateStripeAccountValues) => { (values: CreateStripeAccountValues) => {
return apiRequest return apiRequest
.post('/stripe_integration/account') .post('/stripe/account')
.then((res) => res.data); .then((res) => res.data);
}, },
{ ...options }, { ...options },
@@ -131,7 +131,7 @@ export const useGetStripeAccountLink = (
'getStripeAccountLink', 'getStripeAccountLink',
() => { () => {
return apiRequest return apiRequest
.get('/stripe_integration/link') .get('/stripe/link')
.then((res) => transformToCamelCase(res.data)); .then((res) => transformToCamelCase(res.data));
}, },
{ ...options }, { ...options },
@@ -163,7 +163,7 @@ export const useSetStripeAccountCallback = (
return useMutation( return useMutation(
(values: StripeAccountCallbackMutationValues) => { (values: StripeAccountCallbackMutationValues) => {
return apiRequest return apiRequest
.post(`/stripe_integration/callback`, values) .post(`/stripe/callback`, values)
.then( .then(
(res) => (res) =>
transformToCamelCase( transformToCamelCase(