Просмотр исходного кода

feat(payments-plugin): Add option to StripePlugin to handle payment intent that doesn't have Vendure metadata (#3250)

Jeremy Milledge 1 год назад
Родитель
Сommit
ec934dd74e

+ 45 - 1
packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts

@@ -17,7 +17,7 @@ import { Stripe } from 'stripe';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 import { StripePlugin } from '../src/stripe';
 import { stripePaymentMethodHandler } from '../src/stripe/stripe.handler';
 
@@ -431,6 +431,50 @@ describe('Stripe payments', () => {
         expect(result.status).toEqual(200);
     });
 
+    // https://github.com/vendure-ecommerce/vendure/issues/3249
+    it('Should skip events without expected metadata, when the plugin option is set', async () => {
+        StripePlugin.options.skipPaymentIntentsWithoutExpectedMetadata = true;
+
+        const MOCKED_WEBHOOK_PAYLOAD = {
+            id: 'evt_0',
+            object: 'event',
+            api_version: '2022-11-15',
+            data: {
+                object: {
+                    id: 'pi_0',
+                    currency: 'usd',
+                    metadata: {
+                        dummy: 'not a vendure payload',
+                    },
+                    amount_received: 10000,
+                    status: 'succeeded',
+                },
+            },
+            livemode: false,
+            pending_webhooks: 1,
+            request: {
+                id: 'req_0',
+                idempotency_key: '00000000-0000-0000-0000-000000000000',
+            },
+            type: 'payment_intent.succeeded',
+        };
+
+        const payloadString = JSON.stringify(MOCKED_WEBHOOK_PAYLOAD, null, 2);
+        const stripeWebhooks = new Stripe('test-api-secret', { apiVersion: '2023-08-16' }).webhooks;
+        const header = stripeWebhooks.generateTestHeaderString({
+            payload: payloadString,
+            secret: 'test-signing-secret',
+        });
+
+        const result = await fetch(`http://localhost:${serverPort}/payments/stripe`, {
+            method: 'post',
+            body: payloadString,
+            headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header },
+        });
+
+        expect(result.status).toEqual(200);
+    });
+
     // https://github.com/vendure-ecommerce/vendure/issues/1630
     describe('currencies with no fractional units', () => {
         let japanProductId: string;

+ 14 - 0
packages/payments-plugin/src/stripe/stripe-utils.ts

@@ -1,4 +1,5 @@
 import { CurrencyCode, Order } from '@vendure/core';
+import Stripe from 'stripe';
 
 /**
  * @description
@@ -35,3 +36,16 @@ function currencyHasFractionPart(currencyCode: CurrencyCode): boolean {
 
     return !!parts.find(p => p.type === 'fraction');
 }
+
+/**
+ *
+ * @description
+ * Ensures that the payment intent metadata object contains the expected properties, as defined by the plugin.
+ */
+export function isExpectedVendureStripeEventMetadata(metadata: Stripe.Metadata): metadata is {
+    channelToken: string;
+    orderCode: string;
+    orderId: string;
+} {
+    return !!metadata.channelToken && !!metadata.orderCode && !!metadata.orderId;
+}

+ 21 - 5
packages/payments-plugin/src/stripe/stripe.controller.ts

@@ -1,7 +1,7 @@
-import { Controller, Headers, HttpStatus, Post, Req, Res } from '@nestjs/common';
+import { Controller, Headers, HttpStatus, Inject, Post, Req, Res } from '@nestjs/common';
 import type { PaymentMethod, RequestContext } from '@vendure/core';
-import { ChannelService } from '@vendure/core';
 import {
+    ChannelService,
     InternalServerError,
     LanguageCode,
     Logger,
@@ -15,18 +15,21 @@ import { OrderStateTransitionError } from '@vendure/core/dist/common/error/gener
 import type { Response } from 'express';
 import type Stripe from 'stripe';
 
-import { loggerCtx } from './constants';
+import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
+import { isExpectedVendureStripeEventMetadata } from './stripe-utils';
 import { stripePaymentMethodHandler } from './stripe.handler';
 import { StripeService } from './stripe.service';
-import { RequestWithRawBody } from './types';
+import { RequestWithRawBody, StripePluginOptions } from './types';
 
 const missingHeaderErrorMessage = 'Missing stripe-signature header';
 const signatureErrorMessage = 'Error verifying Stripe webhook signature';
 const noPaymentIntentErrorMessage = 'No payment intent in the event payload';
+const ignorePaymentIntentEvent = 'Event has no Vendure metadata, skipped.';
 
 @Controller('payments')
 export class StripeController {
     constructor(
+        @Inject(STRIPE_PLUGIN_OPTIONS) private options: StripePluginOptions,
         private paymentMethodService: PaymentMethodService,
         private orderService: OrderService,
         private stripeService: StripeService,
@@ -56,7 +59,20 @@ export class StripeController {
             return;
         }
 
-        const { metadata: { channelToken, orderCode, orderId } = {} } = paymentIntent;
+        const { metadata } = paymentIntent;
+
+        if (!isExpectedVendureStripeEventMetadata(metadata)) {
+            if (this.options.skipPaymentIntentsWithoutExpectedMetadata) {
+                response.status(HttpStatus.OK).send(ignorePaymentIntentEvent);
+                return;
+            }
+            throw new Error(
+                `Missing expected payment intent metadata, unable to settle payment ${paymentIntent.id}!`,
+            );
+        }
+
+        const { channelToken, orderCode, orderId } = metadata;
+
         const outerCtx = await this.createContext(channelToken, request);
 
         await this.connection.withTransaction(outerCtx, async (ctx: RequestContext) => {

+ 7 - 1
packages/payments-plugin/src/stripe/types.ts

@@ -1,5 +1,5 @@
-import '@vendure/core/dist/entity/custom-entity-fields';
 import type { Injector, Order, RequestContext } from '@vendure/core';
+import '@vendure/core/dist/entity/custom-entity-fields';
 import type { Request } from 'express';
 import type Stripe from 'stripe';
 
@@ -188,6 +188,12 @@ export interface StripePluginOptions {
         ctx: RequestContext,
         order: Order,
     ) => AdditionalCustomerCreateParams | Promise<AdditionalCustomerCreateParams>;
+    /**
+     * @description
+     * If your Stripe account also generates payment intents which are independent of Vendure orders, you can set this
+     * to `true` to skip processing those payment intents.
+     */
+    skipPaymentIntentsWithoutExpectedMetadata?: boolean;
 }
 
 export interface RequestWithRawBody extends Request {