1
0

stripe.controller.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import { Controller, Headers, HttpStatus, Inject, Post, Req, Res } from '@nestjs/common';
  2. import type { PaymentMethod, RequestContext } from '@vendure/core';
  3. import {
  4. ChannelService,
  5. InternalServerError,
  6. LanguageCode,
  7. Logger,
  8. Order,
  9. OrderService,
  10. PaymentMethodService,
  11. RequestContextService,
  12. TransactionalConnection,
  13. } from '@vendure/core';
  14. import { OrderStateTransitionError } from '@vendure/core/dist/common/error/generated-graphql-shop-errors';
  15. import type { Response } from 'express';
  16. import type Stripe from 'stripe';
  17. import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
  18. import { isExpectedVendureStripeEventMetadata } from './stripe-utils';
  19. import { stripePaymentMethodHandler } from './stripe.handler';
  20. import { StripeService } from './stripe.service';
  21. import { RequestWithRawBody, StripePluginOptions } from './types';
  22. const missingHeaderErrorMessage = 'Missing stripe-signature header';
  23. const signatureErrorMessage = 'Error verifying Stripe webhook signature';
  24. const noPaymentIntentErrorMessage = 'No payment intent in the event payload';
  25. const ignorePaymentIntentEvent = 'Event has no Vendure metadata, skipped.';
  26. @Controller('payments')
  27. export class StripeController {
  28. constructor(
  29. @Inject(STRIPE_PLUGIN_OPTIONS) private options: StripePluginOptions,
  30. private paymentMethodService: PaymentMethodService,
  31. private orderService: OrderService,
  32. private stripeService: StripeService,
  33. private requestContextService: RequestContextService,
  34. private connection: TransactionalConnection,
  35. private channelService: ChannelService,
  36. ) {}
  37. @Post('stripe')
  38. async webhook(
  39. @Headers('stripe-signature') signature: string | undefined,
  40. @Req() request: RequestWithRawBody,
  41. @Res() response: Response,
  42. ): Promise<void> {
  43. if (!signature) {
  44. Logger.error(missingHeaderErrorMessage, loggerCtx);
  45. response.status(HttpStatus.BAD_REQUEST).send(missingHeaderErrorMessage);
  46. return;
  47. }
  48. const event = JSON.parse(request.body.toString()) as Stripe.Event;
  49. const paymentIntent = event.data.object as Stripe.PaymentIntent;
  50. if (!paymentIntent) {
  51. Logger.error(noPaymentIntentErrorMessage, loggerCtx);
  52. response.status(HttpStatus.BAD_REQUEST).send(noPaymentIntentErrorMessage);
  53. return;
  54. }
  55. const { metadata } = paymentIntent;
  56. if (!isExpectedVendureStripeEventMetadata(metadata)) {
  57. if (this.options.skipPaymentIntentsWithoutExpectedMetadata) {
  58. response.status(HttpStatus.OK).send(ignorePaymentIntentEvent);
  59. return;
  60. }
  61. throw new Error(
  62. `Missing expected payment intent metadata, unable to settle payment ${paymentIntent.id}!`,
  63. );
  64. }
  65. const { channelToken, orderCode, orderId, languageCode } = metadata;
  66. const outerCtx = await this.createContext(channelToken, languageCode, request);
  67. await this.connection.withTransaction(outerCtx, async (ctx: RequestContext) => {
  68. const order = await this.orderService.findOneByCode(ctx, orderCode);
  69. if (!order) {
  70. throw new Error(
  71. `Unable to find order ${orderCode}, unable to settle payment ${paymentIntent.id}!`,
  72. );
  73. }
  74. try {
  75. // Throws an error if the signature is invalid
  76. await this.stripeService.constructEventFromPayload(ctx, order, request.rawBody, signature);
  77. } catch (e: any) {
  78. Logger.error(`${signatureErrorMessage} ${signature}: ${(e as Error)?.message}`, loggerCtx);
  79. response.status(HttpStatus.BAD_REQUEST).send(signatureErrorMessage);
  80. return;
  81. }
  82. if (event.type === 'payment_intent.payment_failed') {
  83. const message = paymentIntent.last_payment_error?.message ?? 'unknown error';
  84. Logger.warn(`Payment for order ${orderCode} failed: ${message}`, loggerCtx);
  85. response.status(HttpStatus.OK).send('Ok');
  86. return;
  87. }
  88. if (event.type !== 'payment_intent.succeeded') {
  89. // This should never happen as the webhook is configured to receive
  90. // payment_intent.succeeded and payment_intent.payment_failed events only
  91. Logger.info(`Received ${event.type} status update for order ${orderCode}`, loggerCtx);
  92. return;
  93. }
  94. if (order.state !== 'ArrangingPayment') {
  95. // Orders can switch channels (e.g., global to UK store), causing lookups by the original
  96. // channel to fail. Using a default channel avoids "entity-with-id-not-found" errors.
  97. // See https://github.com/vendure-ecommerce/vendure/issues/3072
  98. const defaultChannel = await this.channelService.getDefaultChannel(ctx);
  99. const ctxWithDefaultChannel = await this.createContext(defaultChannel.token, languageCode, request);
  100. const transitionToStateResult = await this.orderService.transitionToState(
  101. ctxWithDefaultChannel,
  102. orderId,
  103. 'ArrangingPayment',
  104. );
  105. if (transitionToStateResult instanceof OrderStateTransitionError) {
  106. Logger.error(
  107. `Error transitioning order ${orderCode} to ArrangingPayment state: ${transitionToStateResult.message}`,
  108. loggerCtx,
  109. );
  110. return;
  111. }
  112. }
  113. const paymentMethod = await this.getPaymentMethod(ctx);
  114. const addPaymentToOrderResult = await this.orderService.addPaymentToOrder(ctx, orderId, {
  115. method: paymentMethod.code,
  116. metadata: {
  117. paymentIntentAmountReceived: paymentIntent.amount_received,
  118. paymentIntentId: paymentIntent.id,
  119. },
  120. });
  121. if (!(addPaymentToOrderResult instanceof Order)) {
  122. Logger.error(
  123. `Error adding payment to order ${orderCode}: ${addPaymentToOrderResult.message}`,
  124. loggerCtx,
  125. );
  126. return;
  127. }
  128. // The payment intent ID is added to the order only if we can reach this point.
  129. Logger.info(
  130. `Stripe payment intent id ${paymentIntent.id} added to order ${orderCode}`,
  131. loggerCtx,
  132. );
  133. });
  134. // Send the response status only if we didn't sent anything yet.
  135. if (!response.headersSent) {
  136. response.status(HttpStatus.OK).send('Ok');
  137. }
  138. }
  139. private async createContext(channelToken: string, languageCode: LanguageCode, req: RequestWithRawBody): Promise<RequestContext> {
  140. return this.requestContextService.create({
  141. apiType: 'admin',
  142. channelOrToken: channelToken,
  143. req,
  144. languageCode,
  145. });
  146. }
  147. private async getPaymentMethod(ctx: RequestContext): Promise<PaymentMethod> {
  148. const method = (await this.paymentMethodService.findAll(ctx)).items.find(
  149. m => m.handler.code === stripePaymentMethodHandler.code,
  150. );
  151. if (!method) {
  152. throw new InternalServerError(`[${loggerCtx}] Could not find Stripe PaymentMethod`);
  153. }
  154. return method;
  155. }
  156. }