Skip to content

Commit ec934dd

Browse files
authored
feat(payments-plugin): Add option to StripePlugin to handle payment intent that doesn't have Vendure metadata (#3250)
1 parent f56ce3a commit ec934dd

File tree

4 files changed

+87
-7
lines changed

4 files changed

+87
-7
lines changed

packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts

+45-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { Stripe } from 'stripe';
1717
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
1818

1919
import { initialData } from '../../../e2e-common/e2e-initial-data';
20-
import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
20+
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
2121
import { StripePlugin } from '../src/stripe';
2222
import { stripePaymentMethodHandler } from '../src/stripe/stripe.handler';
2323

@@ -431,6 +431,50 @@ describe('Stripe payments', () => {
431431
expect(result.status).toEqual(200);
432432
});
433433

434+
// https://github.com/vendure-ecommerce/vendure/issues/3249
435+
it('Should skip events without expected metadata, when the plugin option is set', async () => {
436+
StripePlugin.options.skipPaymentIntentsWithoutExpectedMetadata = true;
437+
438+
const MOCKED_WEBHOOK_PAYLOAD = {
439+
id: 'evt_0',
440+
object: 'event',
441+
api_version: '2022-11-15',
442+
data: {
443+
object: {
444+
id: 'pi_0',
445+
currency: 'usd',
446+
metadata: {
447+
dummy: 'not a vendure payload',
448+
},
449+
amount_received: 10000,
450+
status: 'succeeded',
451+
},
452+
},
453+
livemode: false,
454+
pending_webhooks: 1,
455+
request: {
456+
id: 'req_0',
457+
idempotency_key: '00000000-0000-0000-0000-000000000000',
458+
},
459+
type: 'payment_intent.succeeded',
460+
};
461+
462+
const payloadString = JSON.stringify(MOCKED_WEBHOOK_PAYLOAD, null, 2);
463+
const stripeWebhooks = new Stripe('test-api-secret', { apiVersion: '2023-08-16' }).webhooks;
464+
const header = stripeWebhooks.generateTestHeaderString({
465+
payload: payloadString,
466+
secret: 'test-signing-secret',
467+
});
468+
469+
const result = await fetch(`http://localhost:${serverPort}/payments/stripe`, {
470+
method: 'post',
471+
body: payloadString,
472+
headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header },
473+
});
474+
475+
expect(result.status).toEqual(200);
476+
});
477+
434478
// https://github.com/vendure-ecommerce/vendure/issues/1630
435479
describe('currencies with no fractional units', () => {
436480
let japanProductId: string;

packages/payments-plugin/src/stripe/stripe-utils.ts

+14
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CurrencyCode, Order } from '@vendure/core';
2+
import Stripe from 'stripe';
23

34
/**
45
* @description
@@ -35,3 +36,16 @@ function currencyHasFractionPart(currencyCode: CurrencyCode): boolean {
3536

3637
return !!parts.find(p => p.type === 'fraction');
3738
}
39+
40+
/**
41+
*
42+
* @description
43+
* Ensures that the payment intent metadata object contains the expected properties, as defined by the plugin.
44+
*/
45+
export function isExpectedVendureStripeEventMetadata(metadata: Stripe.Metadata): metadata is {
46+
channelToken: string;
47+
orderCode: string;
48+
orderId: string;
49+
} {
50+
return !!metadata.channelToken && !!metadata.orderCode && !!metadata.orderId;
51+
}

packages/payments-plugin/src/stripe/stripe.controller.ts

+21-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Controller, Headers, HttpStatus, Post, Req, Res } from '@nestjs/common';
1+
import { Controller, Headers, HttpStatus, Inject, Post, Req, Res } from '@nestjs/common';
22
import type { PaymentMethod, RequestContext } from '@vendure/core';
3-
import { ChannelService } from '@vendure/core';
43
import {
4+
ChannelService,
55
InternalServerError,
66
LanguageCode,
77
Logger,
@@ -15,18 +15,21 @@ import { OrderStateTransitionError } from '@vendure/core/dist/common/error/gener
1515
import type { Response } from 'express';
1616
import type Stripe from 'stripe';
1717

18-
import { loggerCtx } from './constants';
18+
import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
19+
import { isExpectedVendureStripeEventMetadata } from './stripe-utils';
1920
import { stripePaymentMethodHandler } from './stripe.handler';
2021
import { StripeService } from './stripe.service';
21-
import { RequestWithRawBody } from './types';
22+
import { RequestWithRawBody, StripePluginOptions } from './types';
2223

2324
const missingHeaderErrorMessage = 'Missing stripe-signature header';
2425
const signatureErrorMessage = 'Error verifying Stripe webhook signature';
2526
const noPaymentIntentErrorMessage = 'No payment intent in the event payload';
27+
const ignorePaymentIntentEvent = 'Event has no Vendure metadata, skipped.';
2628

2729
@Controller('payments')
2830
export class StripeController {
2931
constructor(
32+
@Inject(STRIPE_PLUGIN_OPTIONS) private options: StripePluginOptions,
3033
private paymentMethodService: PaymentMethodService,
3134
private orderService: OrderService,
3235
private stripeService: StripeService,
@@ -56,7 +59,20 @@ export class StripeController {
5659
return;
5760
}
5861

59-
const { metadata: { channelToken, orderCode, orderId } = {} } = paymentIntent;
62+
const { metadata } = paymentIntent;
63+
64+
if (!isExpectedVendureStripeEventMetadata(metadata)) {
65+
if (this.options.skipPaymentIntentsWithoutExpectedMetadata) {
66+
response.status(HttpStatus.OK).send(ignorePaymentIntentEvent);
67+
return;
68+
}
69+
throw new Error(
70+
`Missing expected payment intent metadata, unable to settle payment ${paymentIntent.id}!`,
71+
);
72+
}
73+
74+
const { channelToken, orderCode, orderId } = metadata;
75+
6076
const outerCtx = await this.createContext(channelToken, request);
6177

6278
await this.connection.withTransaction(outerCtx, async (ctx: RequestContext) => {

packages/payments-plugin/src/stripe/types.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import '@vendure/core/dist/entity/custom-entity-fields';
21
import type { Injector, Order, RequestContext } from '@vendure/core';
2+
import '@vendure/core/dist/entity/custom-entity-fields';
33
import type { Request } from 'express';
44
import type Stripe from 'stripe';
55

@@ -188,6 +188,12 @@ export interface StripePluginOptions {
188188
ctx: RequestContext,
189189
order: Order,
190190
) => AdditionalCustomerCreateParams | Promise<AdditionalCustomerCreateParams>;
191+
/**
192+
* @description
193+
* If your Stripe account also generates payment intents which are independent of Vendure orders, you can set this
194+
* to `true` to skip processing those payment intents.
195+
*/
196+
skipPaymentIntentsWithoutExpectedMetadata?: boolean;
191197
}
192198

193199
export interface RequestWithRawBody extends Request {

0 commit comments

Comments
 (0)