From 0e761b662c990b2c1cb85f6f631239e27069a0a9 Mon Sep 17 00:00:00 2001 From: Jeremy Milledge Date: Sun, 1 Dec 2024 19:05:40 +1100 Subject: [PATCH] feat: Optionally, do not throw if payment intent doesnt have vendure metadata --- .../e2e/stripe-payment.e2e-spec.ts | 46 ++++++++++++++++++- .../src/stripe/stripe-utils.ts | 14 ++++++ .../src/stripe/stripe.controller.ts | 26 +++++++++-- packages/payments-plugin/src/stripe/types.ts | 8 +++- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts b/packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts index a4bfe62d5d..d0a64940fb 100644 --- a/packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts +++ b/packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts @@ -16,7 +16,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'; @@ -390,6 +390,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; diff --git a/packages/payments-plugin/src/stripe/stripe-utils.ts b/packages/payments-plugin/src/stripe/stripe-utils.ts index 328afe251f..bce28cc8ab 100644 --- a/packages/payments-plugin/src/stripe/stripe-utils.ts +++ b/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; +} diff --git a/packages/payments-plugin/src/stripe/stripe.controller.ts b/packages/payments-plugin/src/stripe/stripe.controller.ts index 2b59cc7c5f..4c472f727b 100644 --- a/packages/payments-plugin/src/stripe/stripe.controller.ts +++ b/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) => { diff --git a/packages/payments-plugin/src/stripe/types.ts b/packages/payments-plugin/src/stripe/types.ts index 6c5f77a366..853289b1f3 100644 --- a/packages/payments-plugin/src/stripe/types.ts +++ b/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'; @@ -151,6 +151,12 @@ export interface StripePluginOptions { ctx: RequestContext, order: Order, ) => AdditionalCustomerCreateParams | Promise; + /** + * @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 {