Integrating Stripe Payment Intent in NestJS with Webhook Handling

Published on Nov 11, 2024 | Reading time: 4 min


In this article, we’ll walk through how to implement Stripe payment processing in a NestJS application, focusing on creating a payment intent for bookings and handling webhook events from Stripe. We’ll cover the controller, service, and the business logic involved in interacting with Stripe’s API, including updating the booking status based on Stripe’s payment events.

1. Setup Nest Application With Express

To properly handle Stripe webhooks in a NestJS application, one key aspect to ensure smooth processing is retaining the raw request body. This is particularly important for webhook signature verification. To prevent NestJS from parsing the body, you must set rawBody: true in the application setup. This ensures the request body remains in its raw form, allowing Stripe’s signature verification process to function properly.

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule, {
    rawBody: true,  // Retain raw request body for Stripe webhook verification
  });


    // ...
}

2. Setting Up the Controller

The controller handles incoming HTTP requests related to payment processing. We define two routes: one for retrieving a payment intent based on a booking ID and another for handling webhook events sent by Stripe.

@ApiTags("Payment Receive")
@Controller("PaymentReceive")
export class PaymentReceiveController {
  constructor(private readonly paymentService: PaymentReceiveService) {}

  @Get("GetIntentByBookingId/:DocId")
  @ApiResponse({ status: 200, type: SuccessResponseDto })
  getPaymentIntent(
    @AuthUserId() { userId }: ITokenPayload,
    @Param() { DocId }: DocIdQueryDto,
  ) {
    return this.paymentService.getPaymentIntent(DocId, userId);
  }

  @Post("StripeWebhook")
  @HttpCode(200)
  @IsPublic()
  @ApiExcludeEndpoint()
  async handleStripeWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Headers("stripe-signature") signature: string,
  ): Promise<void> {
    await this.paymentService.handleStripeWebhook(req, signature);
  }
}

Explanation:

3. The Payment Receive Service

This service handles the business logic of creating and retrieving payment intents from Stripe, as well as processing the events sent via Stripe webhooks. The key logic revolves around managing payment statuses and updating related entities in the database.

@Injectable()
export class StripePaymentService {
  private readonly logger: Logger = new Logger(StripePaymentService.name);
  private readonly stripeService: Stripe;

  private readonly stripeWebhookSecret: string;
  private readonly stripePublishableKey: string;

  constructor(
    private readonly configService: ConfigService,
  ) {
    const stripeSecretKey = this.configService.get<string>("STRIPE_SECRET_KEY", "");
    this.stripePublishableKey = this.configService.get<string>("STRIPE_PUBLISHABLE_KEY", "");
    this.stripeWebhookSecret = this.configService.get<string>("STRIPE_WEBHOOK_SECRET", "");

    this.stripeService = new Stripe(stripeSecretKey, {});
  }

  async getPaymentIntent(
    bookingId: string,
    auditUserId: string,
  ): Promise<SuccessResponseDto> {
    try {
      // Here you retrieve the booking by its DocId (e.g., bookingId)
      // If booking is not found, throw an exception (e.g., NotFoundException)
      
      let stripeIntent: Stripe.PaymentIntent;

      // Here, check if a payment intent already exists for the booking
      // If not, create a new payment intent via Stripe

      const intentMetadata: StripeIntentMetadata = {
        bookingCode: "bookingCode", // Example: Booking code should be passed dynamically
        bookingId: bookingId,
        paymentReceiveId: "paymentReceiveId", // Save paymentReceiveId dynamically
      };

      // Create a payment intent with Stripe
      stripeIntent = await this.stripeService.paymentIntents.create({
        amount: 1000, // Example: amount should be the booking total price in cents
        currency: "usd", // Set currency
        metadata: intentMetadata,
      });

      // Here you save the payment intent details (e.g., stripeIntent) to your database

      const paymentIntentResponse = new PaymentIntentResponseDto();
      paymentIntentResponse.stripeKey = this.stripePublishableKey;
      paymentIntentResponse.stripeSecret = stripeIntent.client_secret || "";
      paymentIntentResponse.status = stripeIntent.status;
      paymentIntentResponse.currency = stripeIntent.currency;

      return new SuccessResponseDto(
        "Payment intent retrieved successfully",
        paymentIntentResponse,
      );
    } catch (error) {
      this.logger.error("Error in getPaymentIntent:", error);
      throw new BadRequestException("Failed to get payment intent");
    }
  }

  async handleStripeWebhook(
    request: RawBodyRequest<Request>,
    signature: string,
  ) {
    try {
      if (!this.stripeWebhookSecret) {
        throw new Error("Stripe webhook secret not found in the configuration.");
      }

      const event = this.stripeService.webhooks.constructEvent(
        request.rawBody as Buffer,
        signature,
        this.stripeWebhookSecret,
      );

      // Handle the different event types sent by Stripe
      switch (event.type) {
        case "payment_intent.created":
          // Here you handle the payment intent created event
          // Example: Update booking status to 'Payment Created'
          break;

        case "payment_intent.succeeded":
          // Here you handle the successful payment event
          // Example: Update booking status to 'Payment Completed'
          break;

        case "payment_intent.payment_failed":
          // Here you handle the failed payment event
          // Example: Update booking status to 'Payment Failed'
          break;

        default:
          this.logger.log("Unhandled event type: " + event.type);
      }

      // Here you update the database based on the event
      // For example, update payment status in your records

      return { received: true };
    } catch (error) {
      this.logger.error("Error handling Stripe webhook event:", error);
      throw new BadRequestException("Error in webhook event processing");
    }
  }
}

Explanation:

4. The PaymentReceiveModule

In a typical NestJS application, we would include the necessary modules for the database connection and business logic. However, the imports section for the PaymentReceiveModule has been omitted here for clarity, as the focus is on the core logic of payment intent and webhook handling.

@Module({
  controllers: [PaymentReceiveController],
  providers: [PaymentReceiveService, PaymentReceiveRepository],
})
export class PaymentReceiveModule {}

Key Notes:

Conclusion

This implementation provides a clean, professional way to handle Stripe payments in a NestJS application. By managing payment intents and handling webhooks efficiently, we ensure that users can process payments securely, and booking statuses remain synchronized with payment updates.