Explorar o código

feat(payments-plugin): Add Stripe integration (#1417)

Vinicius Rosa %!s(int64=3) %!d(string=hai) anos
pai
achega
238be6ba00

+ 3 - 1
packages/payments-plugin/package.json

@@ -22,7 +22,8 @@
     },
     "peerDependencies": {
         "@mollie/api-client": "3.x",
-        "braintree": "3.x"
+        "braintree": "3.x",
+        "stripe": "8.x"
     },
     "devDependencies": {
         "@mollie/api-client": "^3.5.1",
@@ -33,6 +34,7 @@
         "braintree": "^3.0.0",
         "nock": "^13.1.4",
         "rimraf": "^3.0.2",
+        "stripe": "^8.197.0",
         "typescript": "4.3.5"
     }
 }

+ 1 - 2
packages/payments-plugin/src/braintree/README.md

@@ -1,6 +1,5 @@
-# Braintree  payment plugin
+# Braintree payment plugin
 
 This plugin enables payments to be processed by [Braintree](https://www.braintreepayments.com/).
 
 For documentation, see https://www.vendure.io/docs/typescript-api/payments-plugin/braintree-plugin
-

+ 4 - 1
packages/payments-plugin/src/braintree/types.ts

@@ -1,11 +1,14 @@
 import { ConfigArgValues } from '@vendure/core/dist/common/configurable-operation';
+import '@vendure/core/dist/entity/custom-entity-fields';
 import { Environment } from 'braintree';
 
 import { braintreePaymentMethodHandler } from './braintree.handler';
 
 export type PaymentMethodArgsHash = ConfigArgValues<typeof braintreePaymentMethodHandler['args']>;
 
-declare module '@vendure/core' {
+// Note: deep import is necessary here because CustomCustomerFields is also extended in the Stripe
+// plugin. Reference: https://github.com/microsoft/TypeScript/issues/46617
+declare module '@vendure/core/dist/entity/custom-entity-fields' {
     interface CustomCustomerFields {
         braintreeCustomerId?: string;
     }

+ 1 - 1
packages/payments-plugin/src/mollie/mollie.controller.ts

@@ -33,7 +33,7 @@ export class MollieController {
         const paymentMethod = await this.paymentMethodService.findOne(ctx, paymentMethodId);
         if (!paymentMethod) {
             // Fail silently, as we don't want to expose if a paymentMethodId exists or not
-            return Logger.error(`No paymentMethod found with id ${paymentMethod}`, loggerCtx);
+            return Logger.error(`No paymentMethod found with id ${paymentMethodId}`, loggerCtx);
         }
         const apiKey = paymentMethod.handler.args.find(a => a.name === 'apiKey')?.value;
         if (!apiKey) {

+ 5 - 1
packages/payments-plugin/src/stripe/README.md

@@ -1 +1,5 @@
-# Vendure Stripe integration
+# Stripe payment plugin
+
+Plugin to enable payments through [Stripe](https://stripe.com/docs).
+
+For documentation, see https://www.vendure.io/docs/typescript-api/payments-plugin/stripe-plugin

+ 2 - 0
packages/payments-plugin/src/stripe/constants.ts

@@ -0,0 +1,2 @@
+export const loggerCtx = 'StripePlugin';
+export const STRIPE_PLUGIN_OPTIONS = Symbol('STRIPE_PLUGIN_OPTIONS');

+ 2 - 0
packages/payments-plugin/src/stripe/index.ts

@@ -0,0 +1,2 @@
+export * from './stripe.plugin';
+export * from './';

+ 18 - 0
packages/payments-plugin/src/stripe/raw-body.middleware.ts

@@ -0,0 +1,18 @@
+import { json } from 'body-parser';
+import { ServerResponse } from 'http';
+
+import { IncomingMessageWithRawBody } from './types';
+
+/**
+ * Middleware which adds the raw request body to the incoming message object. This is needed by
+ * Stripe to properly verify webhook events.
+ */
+export const rawBodyMiddleware = json({
+    verify(req: IncomingMessageWithRawBody, _: ServerResponse, buf: Buffer) {
+        if (Buffer.isBuffer(buf)) {
+            req.rawBody = Buffer.from(buf);
+        }
+
+        return true;
+    },
+});

+ 137 - 0
packages/payments-plugin/src/stripe/stripe.controller.ts

@@ -0,0 +1,137 @@
+import { Controller, Headers, HttpStatus, Post, Req, Res } from '@nestjs/common';
+import {
+    ChannelService,
+    InternalServerError,
+    LanguageCode,
+    Logger,
+    Order,
+    OrderService,
+    PaymentMethod,
+    RequestContext,
+    TransactionalConnection,
+} from '@vendure/core';
+import { OrderStateTransitionError } from '@vendure/core/dist/common/error/generated-graphql-shop-errors';
+import { Response } from 'express';
+import Stripe from 'stripe';
+
+import { loggerCtx } from './constants';
+import { stripePaymentMethodHandler } from './stripe.handler';
+import { StripeService } from './stripe.service';
+import { IncomingMessageWithRawBody } from './types';
+
+const missingHeaderErrorMessage = 'Missing stripe-signature header';
+const signatureErrorMessage = 'Error verifying Stripe webhook signature';
+const noPaymentIntentErrorMessage = 'No payment intent in the event payload';
+
+@Controller('payments')
+export class StripeController {
+    constructor(
+        private connection: TransactionalConnection,
+        private channelService: ChannelService,
+        private orderService: OrderService,
+        private stripeService: StripeService,
+    ) {}
+
+    @Post('stripe')
+    async webhook(
+        @Headers('stripe-signature') signature: string | undefined,
+        @Req() request: IncomingMessageWithRawBody,
+        @Res() response: Response,
+    ): Promise<void> {
+        if (!signature) {
+            Logger.error(missingHeaderErrorMessage, loggerCtx);
+            response.status(HttpStatus.BAD_REQUEST).send(missingHeaderErrorMessage);
+            return;
+        }
+
+        let event = null;
+        try {
+            event = this.stripeService.constructEventFromPayload(request.rawBody, signature);
+        } catch (e: any) {
+            Logger.error(`${signatureErrorMessage} ${signature}: ${e.message}`, loggerCtx);
+            response.status(HttpStatus.BAD_REQUEST).send(signatureErrorMessage);
+            return;
+        }
+
+        const paymentIntent = event.data.object as Stripe.PaymentIntent;
+        if (!paymentIntent) {
+            Logger.error(noPaymentIntentErrorMessage, loggerCtx);
+            response.status(HttpStatus.BAD_REQUEST).send(noPaymentIntentErrorMessage);
+            return;
+        }
+
+        const { metadata: { channelToken, orderCode, orderId } = {} } = paymentIntent;
+
+        if (event.type === 'payment_intent.payment_failed') {
+            const message = paymentIntent.last_payment_error?.message;
+            Logger.warn(`Payment for order ${orderCode} failed: ${message}`, loggerCtx);
+            return;
+        }
+
+        if (event.type !== 'payment_intent.succeeded') {
+            // This should never happen as the webhook is configured to receive
+            // payment_intent.succeeded and payment_intent.payment_failed events only
+            Logger.info(`Received ${event.type} status update for order ${orderCode}`, loggerCtx);
+            return;
+        }
+
+        const ctx = await this.createContext(channelToken);
+
+        const transitionToStateResult = await this.orderService.transitionToState(
+            ctx,
+            orderId,
+            'ArrangingPayment',
+        );
+
+        if (transitionToStateResult instanceof OrderStateTransitionError) {
+            Logger.error(
+                `Error transitioning order ${orderCode} to ArrangingPayment state: ${transitionToStateResult.message}`,
+                loggerCtx,
+            );
+            return;
+        }
+
+        const paymentMethod = await this.getPaymentMethod(ctx);
+
+        const addPaymentToOrderResult = await this.orderService.addPaymentToOrder(ctx, orderId, {
+            method: paymentMethod.code,
+            metadata: {
+                paymentIntentId: paymentIntent.id,
+            },
+        });
+
+        if (!(addPaymentToOrderResult instanceof Order)) {
+            Logger.error(
+                `Error adding payment to order ${orderCode}: ${addPaymentToOrderResult.message}`,
+                loggerCtx,
+            );
+            return;
+        }
+
+        Logger.info(`Stripe payment intent id ${paymentIntent.id} added to order ${orderCode}`, loggerCtx);
+    }
+
+    private async createContext(channelToken: string): Promise<RequestContext> {
+        const channel = await this.channelService.getChannelFromToken(channelToken);
+
+        return new RequestContext({
+            apiType: 'admin',
+            isAuthorized: true,
+            authorizedAsOwnerOnly: false,
+            channel,
+            languageCode: LanguageCode.en,
+        });
+    }
+
+    private async getPaymentMethod(ctx: RequestContext): Promise<PaymentMethod> {
+        const method = (await this.connection.getRepository(ctx, PaymentMethod).find()).find(
+            m => m.handler.code === stripePaymentMethodHandler.code,
+        );
+
+        if (!method) {
+            throw new InternalServerError(`[${loggerCtx}] Could not find Stripe PaymentMethod`);
+        }
+
+        return method;
+    }
+}

+ 83 - 0
packages/payments-plugin/src/stripe/stripe.handler.ts

@@ -0,0 +1,83 @@
+import {
+    CreatePaymentResult,
+    CreateRefundResult,
+    Injector,
+    LanguageCode,
+    PaymentMethodHandler,
+    SettlePaymentResult,
+} from '@vendure/core';
+import Stripe from 'stripe';
+
+import { StripeService } from './stripe.service';
+
+const { StripeError } = Stripe.errors;
+
+let stripeService: StripeService;
+
+/**
+ * The handler for Stripe payments.
+ */
+export const stripePaymentMethodHandler = new PaymentMethodHandler({
+    code: 'stripe',
+
+    description: [{ languageCode: LanguageCode.en, value: 'Stripe payments' }],
+
+    args: {},
+
+    init(injector: Injector) {
+        stripeService = injector.get(StripeService);
+    },
+
+    async createPayment(_, __, amount, ___, metadata): Promise<CreatePaymentResult> {
+        // Payment is already settled in Stripe by the time the webhook in stripe.controller.ts
+        // adds the payment to the order
+        return {
+            amount,
+            state: 'Settled' as const,
+            transactionId: metadata.paymentIntentId,
+        };
+    },
+
+    settlePayment(): SettlePaymentResult {
+        return {
+            success: true,
+        };
+    },
+
+    async createRefund(ctx, input, amount, order, payment, args): Promise<CreateRefundResult> {
+        const result = await stripeService.createRefund(payment.transactionId, amount);
+
+        if (result instanceof StripeError) {
+            return {
+                state: 'Failed' as const,
+                transactionId: payment.transactionId,
+                metadata: {
+                    type: result.type,
+                    message: result.message,
+                },
+            };
+        }
+
+        if (result.status === 'succeeded') {
+            return {
+                state: 'Settled' as const,
+                transactionId: payment.transactionId,
+            };
+        }
+
+        if (result.status === 'pending') {
+            return {
+                state: 'Pending' as const,
+                transactionId: payment.transactionId,
+            };
+        }
+
+        return {
+            state: 'Failed' as const,
+            transactionId: payment.transactionId,
+            metadata: {
+                message: result.failure_reason,
+            },
+        };
+    },
+});

+ 134 - 0
packages/payments-plugin/src/stripe/stripe.plugin.ts

@@ -0,0 +1,134 @@
+import { LanguageCode, PluginCommonModule, Type, VendurePlugin } from '@vendure/core';
+import { gql } from 'graphql-tag';
+
+import { STRIPE_PLUGIN_OPTIONS } from './constants';
+import { rawBodyMiddleware } from './raw-body.middleware';
+import { StripeController } from './stripe.controller';
+import { stripePaymentMethodHandler } from './stripe.handler';
+import { StripeResolver } from './stripe.resolver';
+import { StripeService } from './stripe.service';
+import { StripePluginOptions } from './types';
+
+/**
+ * @description
+ * Plugin to enable payments through [Stripe](https://stripe.com/docs) via the Payment Intents API.
+ *
+ * ## Requirements
+ *
+ * 1. You will need to create a Stripe account and get your secret key in the dashboard.
+ * 2. Create a webhook endpoint in the Stripe dashboard which listens to the `payment_intent.succeeded` and
+ * `payment_intent.payment_failed` events. The URL should be `https://my-shop.com/payments/stripe`, where
+ * `my-shop.com` is the host of your storefront application.
+ * 3. Get the signing secret for the newly created webhook.
+ * 4. Install the Payments plugin and the Stripe Node library:
+ *
+ *     `yarn add \@vendure/payments-plugin stripe`
+ *
+ *     or
+ *
+ *     `npm install \@vendure/payments-plugin stripe`
+ *
+ * ## Setup
+ *
+ * 1. Add the plugin to your VendureConfig `plugins` array:
+ *     ```TypeScript
+ *     import { StripePlugin } from '\@vendure/payments-plugin/package/stripe';
+ *
+ *     // ...
+ *
+ *     plugins: [
+ *       StripePlugin.init({
+ *         apiKey: process.env.YOUR_STRIPE_SECRET_KEY,
+ *         webhookSigningSecret: process.env.YOUR_STRIPE_WEBHOOK_SIGNING_SECRET,
+ *         // This prevents different customers from using the same PaymentIntent
+ *         storeCustomersInStripe: true,
+ *       }),
+ *     ]
+ *     ````
+ * 2. Create a new PaymentMethod in the Admin UI, and select "Stripe payments" as the handler.
+ *
+ * ## Storefront usage
+ *
+ * The plugin is designed to work with the [Custom payment flow](https://stripe.com/docs/payments/accept-a-payment?platform=web&ui=elements).
+ * In this flow, Stripe provides libraries which handle the payment UI and confirmation for you. You can install it in your storefront project
+ * with:
+ *
+ * ```shell
+ * yarn add \@stripe/stripe-js
+ * # or
+ * npm install \@stripe/stripe-js
+ * ```
+ *
+ * If you are using React, you should also consider installing `@stripe/react-stripe-js`, which is a wrapper around Stripe Elements.
+ *
+ * The high-level workflow is:
+ * 1. Create a "payment intent" on the server by executing the `createStripePaymentIntent` mutation which is exposed by this plugin.
+ * 2. Use the returned client secret to instantiate the Stripe Payment Element.
+ * 3. Once the form is submitted and Stripe processes the payment, the webhook takes care of updating the order without additional action
+ * in the storefront.
+ *
+ * ## Local development
+ *
+ * Use something like [localtunnel](https://github.com/localtunnel/localtunnel) to test on localhost.
+ *
+ * ```bash
+ * npx localtunnel --port 3000 --subdomain my-shop-local-dev
+ * > your url is: https://my-shop-local-dev.loca.lt
+ * ```
+ *
+ * @docsCategory payments-plugin
+ * @docsPage StripePlugin
+ */
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    controllers: [StripeController],
+    providers: [
+        {
+            provide: STRIPE_PLUGIN_OPTIONS,
+            useFactory: (): StripePluginOptions => StripePlugin.options,
+        },
+        StripeService,
+    ],
+    configuration: config => {
+        config.paymentOptions.paymentMethodHandlers.push(stripePaymentMethodHandler);
+
+        config.apiOptions.middleware.push({
+            route: '/payments/stripe',
+            handler: rawBodyMiddleware,
+            beforeListen: true,
+        });
+
+        if (StripePlugin.options.storeCustomersInStripe) {
+            config.customFields.Customer.push({
+                name: 'stripeCustomerId',
+                type: 'string',
+                label: [{ languageCode: LanguageCode.en, value: 'Stripe Customer ID' }],
+                nullable: true,
+                public: false,
+                readonly: true,
+            });
+        }
+
+        return config;
+    },
+    shopApiExtensions: {
+        schema: gql`
+            extend type Mutation {
+                createStripePaymentIntent: String
+            }
+        `,
+        resolvers: [StripeResolver],
+    },
+})
+export class StripePlugin {
+    static options: StripePluginOptions;
+
+    /**
+     * @description
+     * Initialize the Stripe payment plugin
+     */
+    static init(options: StripePluginOptions): Type<StripePlugin> {
+        this.options = options;
+        return StripePlugin;
+    }
+}

+ 20 - 0
packages/payments-plugin/src/stripe/stripe.resolver.ts

@@ -0,0 +1,20 @@
+import { Mutation, Resolver } from '@nestjs/graphql';
+import { ActiveOrderService, Allow, Ctx, Permission, RequestContext } from '@vendure/core';
+
+import { StripeService } from './stripe.service';
+
+@Resolver()
+export class StripeResolver {
+    constructor(private stripeService: StripeService, private activeOrderService: ActiveOrderService) {}
+
+    @Mutation()
+    @Allow(Permission.Owner)
+    async createStripePaymentIntent(@Ctx() ctx: RequestContext): Promise<string | undefined> {
+        if (ctx.authorizedAsOwnerOnly) {
+            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            if (sessionOrder) {
+                return this.stripeService.createPaymentIntent(ctx, sessionOrder);
+            }
+        }
+    }
+}

+ 115 - 0
packages/payments-plugin/src/stripe/stripe.service.ts

@@ -0,0 +1,115 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Customer, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
+import Stripe from 'stripe';
+
+import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
+import { StripePluginOptions } from './types';
+
+@Injectable()
+export class StripeService {
+    private stripe: Stripe;
+
+    constructor(
+        private connection: TransactionalConnection,
+        @Inject(STRIPE_PLUGIN_OPTIONS) private options: StripePluginOptions,
+    ) {
+        this.stripe = new Stripe(this.options.apiKey, {
+            apiVersion: '2020-08-27',
+        });
+    }
+
+    async createPaymentIntent(ctx: RequestContext, order: Order): Promise<string | undefined> {
+        let customerId: string | undefined;
+
+        if (this.options.storeCustomersInStripe && ctx.activeUserId) {
+            customerId = await this.getStripeCustomerId(ctx, order);
+        }
+
+        const { client_secret } = await this.stripe.paymentIntents.create({
+            amount: order.totalWithTax,
+            currency: order.currencyCode.toLowerCase(),
+            customer: customerId,
+            automatic_payment_methods: {
+                enabled: true,
+            },
+            metadata: {
+                channelToken: ctx.channel.token,
+                orderId: order.id,
+                orderCode: order.code,
+            },
+        });
+
+        if (!client_secret) {
+            // This should never happen
+            Logger.warn(
+                `Payment intent creation for order ${order.code} did not return client secret`,
+                loggerCtx,
+            );
+        }
+
+        return client_secret ?? undefined;
+    }
+
+    async createRefund(paymentIntentId: string, amount: number): Promise<Stripe.Refund | Stripe.StripeError> {
+        // TODO: Consider passing the "reason" property once this feature request is addressed:
+        // https://github.com/vendure-ecommerce/vendure/issues/893
+        try {
+            const refund = await this.stripe.refunds.create({
+                payment_intent: paymentIntentId,
+                amount,
+            });
+
+            return refund;
+        } catch (e: any) {
+            return e as Stripe.StripeError;
+        }
+    }
+
+    constructEventFromPayload(payload: Buffer, signature: string): Stripe.Event {
+        return this.stripe.webhooks.constructEvent(payload, signature, this.options.webhookSigningSecret);
+    }
+
+    /**
+     * Returns the stripeCustomerId if the Customer has one. If that's not the case, queries Stripe to check
+     * if the customer is already registered, in which case it saves the id as stripeCustomerId and returns it.
+     * Otherwise, creates a new Customer record in Stripe and returns the generated id.
+     */
+    private async getStripeCustomerId(ctx: RequestContext, activeOrder: Order): Promise<string | undefined> {
+        // Load relation with customer not available in the response from activeOrderService.getOrderFromContext()
+        const order = await this.connection.getRepository(Order).findOne(activeOrder.id, {
+            relations: ['customer'],
+        });
+
+        if (!order || !order.customer) {
+            // This should never happen
+            return undefined;
+        }
+
+        const { customer } = order;
+
+        if (customer.customFields.stripeCustomerId) {
+            return customer.customFields.stripeCustomerId;
+        }
+
+        let stripeCustomerId;
+
+        const stripeCustomers = await this.stripe.customers.list({ email: customer.emailAddress });
+        if (stripeCustomers.data.length > 0) {
+            stripeCustomerId = stripeCustomers.data[0].id;
+        } else {
+            const newStripeCustomer = await this.stripe.customers.create({
+                email: customer.emailAddress,
+                name: `${customer.firstName} ${customer.lastName}`,
+            });
+
+            stripeCustomerId = newStripeCustomer.id;
+
+            Logger.info(`Created Stripe Customer record for customerId ${customer.id}`, loggerCtx);
+        }
+
+        customer.customFields.stripeCustomerId = stripeCustomerId;
+        await this.connection.getRepository(ctx, Customer).save(customer, { reload: false });
+
+        return stripeCustomerId;
+    }
+}

+ 44 - 0
packages/payments-plugin/src/stripe/types.ts

@@ -0,0 +1,44 @@
+import '@vendure/core/dist/entity/custom-entity-fields';
+import { IncomingMessage } from 'http';
+
+// Note: deep import is necessary here because CustomCustomerFields is also extended in the Braintree
+// plugin. Reference: https://github.com/microsoft/TypeScript/issues/46617
+declare module '@vendure/core/dist/entity/custom-entity-fields' {
+    interface CustomCustomerFields {
+        stripeCustomerId?: string;
+    }
+}
+
+/**
+ * @description
+ * Configuration options for the Stripe payments plugin.
+ *
+ * @docsCategory payments-plugin
+ * @docsPage StripePlugin
+ */
+export interface StripePluginOptions {
+    /**
+     * @description
+     * Secret key of your Stripe account.
+     */
+    apiKey: string;
+    /**
+     * @description
+     * Signing secret of your configured Stripe webhook.
+     */
+    webhookSigningSecret: string;
+    /**
+     * @description
+     * If set to `true`, a [Customer](https://stripe.com/docs/api/customers) object will be created in Stripe - if
+     * it doesn't already exist - for authenticated users, which prevents payment methods attached to other Customers
+     * to be used with the same PaymentIntent. This is done by adding a custom field to the Customer entity to store
+     * the Stripe customer ID, so switching this on will require a database migration / synchronization.
+     *
+     * @default false
+     */
+    storeCustomersInStripe?: boolean;
+}
+
+export interface IncomingMessageWithRawBody extends IncomingMessage {
+    rawBody: Buffer;
+}

+ 20 - 0
yarn.lock

@@ -4220,6 +4220,11 @@
   resolved "https://registry.npmjs.org/@types/node/-/node-16.7.1.tgz#c6b9198178da504dfca1fd0be9b2e1002f1586f0"
   integrity sha512-ncRdc45SoYJ2H4eWU9ReDfp3vtFqDYhjOsKlFFUDEn8V1Bgr2RjYal8YT5byfadWIRluhPFU6JiDOl0H6Sl87A==
 
+"@types/node@>=8.1.0":
+  version "17.0.14"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.14.tgz#33b9b94f789a8fedd30a68efdbca4dbb06b61f20"
+  integrity sha512-SbjLmERksKOGzWzPNuW7fJM7fk3YXVTFiZWB/Hs99gwhk+/dnrQRPBQjPW9aO+fi1tAffi9PrwFvsmOKmDTyng==
+
 "@types/node@^10.1.0":
   version "10.17.60"
   resolved "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b"
@@ -15785,6 +15790,13 @@ qs@6.7.0:
   resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
   integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
 
+qs@^6.6.0:
+  version "6.10.3"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e"
+  integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==
+  dependencies:
+    side-channel "^1.0.4"
+
 qs@^6.9.4:
   version "6.10.1"
   resolved "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a"
@@ -17584,6 +17596,14 @@ strip-json-comments@~2.0.1:
   resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
   integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
 
+stripe@^8.197.0:
+  version "8.202.0"
+  resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.202.0.tgz#884760713a690983d5a3128ea3cbeb677ee2645f"
+  integrity sha512-3YGHVnUatEn/At5+aRy+REdB2IyVa96/zls2xvQrKFTgaJzRu1MsJcK0GKg0p2B0y0VqlZo9gmdDEqphSHHvtA==
+  dependencies:
+    "@types/node" ">=8.1.0"
+    qs "^6.6.0"
+
 strong-log-transformer@^2.1.0:
   version "2.1.0"
   resolved "https://registry.npmjs.org/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz#0f5ed78d325e0421ac6f90f7f10e691d6ae3ae10"