Răsfoiți Sursa

feat(payments-plugin): Make Stripe plugin channel-aware (#2058)

BREAKING CHANGE: The Stripe plugin has been made channel aware. This means your api key and webhook secret are now stored in the database, per channel, instead of environment variables.

To migrate to v2 of the Stripe plugin from @vendure/payments you need to:

Remove the apiKey and webhookSigningSecret from the plugin initialization in vendure-config.ts:
```diff
-StripePlugin.init({
-    apiKey: process.env.YOUR_STRIPE_SECRET_KEY,
-    webhookSigningSecret: process.env.YOUR_STRIPE_WEBHOOK_SIGNING_SECRET,
-    storeCustomersInStripe: true,
-}),
+StripePlugin.init({
+    storeCustomersInStripe: true,
+ }),
```
Start the server and login as administrator.

For each channel that you'd like to use Stripe payments, you need to create a payment method with payment handler Stripe payment and the apiKey and webhookSigningSecret belonging to that channel's Stripe account.
Martijn 2 ani în urmă
părinte
comite
3b88702ea6

+ 1 - 1
package.json

@@ -87,7 +87,7 @@
     "hooks": {
       "commit-msg": "commitlint -e $HUSKY_GIT_PARAMS",
       "post-commit": "git update-index --again",
-      "pre-commit": "lint-staged",
+      "pre-commit": "NODE_OPTIONS=\"--max-old-space-size=8096\" lint-staged",
       "pre-push": "yarn check-imports && yarn check-angular-versions && yarn build && yarn test && yarn e2e"
     }
   },

+ 10 - 17
packages/job-queue-plugin/src/bullmq/bullmq-job-queue-strategy.ts

@@ -10,14 +10,7 @@ import {
     Logger,
     PaginatedList,
 } from '@vendure/core';
-import Bull, {
-    ConnectionOptions,
-    JobType,
-    Processor,
-    Queue,
-    Worker,
-    WorkerOptions,
-} from 'bullmq';
+import Bull, { ConnectionOptions, JobType, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
 import { EventEmitter } from 'events';
 import { Cluster, Redis, RedisOptions } from 'ioredis';
 
@@ -94,6 +87,7 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
             if (processFn) {
                 const job = await this.createVendureJob(bullJob);
                 try {
+                    // eslint-disable-next-line
                     job.on('progress', _job => bullJob.updateProgress(_job.progress));
                     const result = await processFn(job);
                     await bullJob.updateProgress(100);
@@ -212,6 +206,7 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
         }
     }
 
+    // TODO V2: actually make it use the olderThan parameter
     async removeSettledJobs(queueNames?: string[], olderThan?: Date): Promise<number> {
         try {
             const jobCounts = await this.queue.getJobCounts('completed', 'failed');
@@ -224,6 +219,7 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
         }
     }
 
+    // eslint-disable-next-line @typescript-eslint/require-await
     async start<Data extends JobData<Data> = object>(
         queueName: string,
         process: (job: Job<Data>) => Promise<any>,
@@ -238,19 +234,19 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
             this.worker = new Worker(QUEUE_NAME, this.workerProcessor, options)
                 .on('error', e => Logger.error(`BullMQ Worker error: ${e.message}`, loggerCtx, e.stack))
                 .on('closing', e => Logger.verbose(`BullMQ Worker closing: ${e}`, loggerCtx))
-                .on('closed', () => Logger.verbose(`BullMQ Worker closed`))
+                .on('closed', () => Logger.verbose('BullMQ Worker closed'))
                 .on('failed', (job: Bull.Job | undefined, error) => {
                     Logger.warn(
-                        `Job ${job?.id} [${job?.name}] failed (attempt ${job?.attemptsMade} of ${
-                            job?.opts.attempts ?? 1
-                        })`,
+                        `Job ${job?.id ?? '(unknown id)'} [${job?.name ?? 'unknown name'}] failed (attempt ${
+                            job?.attemptsMade ?? 'unknown'
+                        } of ${job?.opts.attempts ?? 1})`,
                     );
                 })
                 .on('stalled', (jobId: string) => {
                     Logger.warn(`BullMQ Worker: job ${jobId} stalled`, loggerCtx);
                 })
                 .on('completed', (job: Bull.Job) => {
-                    Logger.debug(`Job ${job.id} [${job.name}] completed`);
+                    Logger.debug(`Job ${job?.id ?? 'unknown id'} [${job.name}] completed`);
                 });
         }
     }
@@ -263,10 +259,7 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
         if (!this.stopped) {
             this.stopped = true;
             try {
-                await Promise.all([
-                    this.queue.disconnect(),
-                    this.worker.disconnect(),
-                ]);
+                await Promise.all([this.queue.disconnect(), this.worker.disconnect()]);
             } catch (e: any) {
                 Logger.error(e, loggerCtx, e.stack);
             }

+ 20 - 0
packages/payments-plugin/README.md

@@ -17,3 +17,23 @@ will create an order, set Mollie as payment method, and create a payment intent
 6. Watch the logs for `Mollie payment link` and click the link to finalize the test payment.
 
 You can change the order flow, payment methods and more in the file `e2e/mollie-dev-server`, and restart the devserver.
+
+### Stripe local development
+
+For testing out changes to the Stripe plugin locally, with a real Stripe account, follow the steps below. These steps
+will create an order, set Stripe as payment method, and create a payment secret.
+
+1. Get a test api key from your Stripe
+   dashboard: https://dashboard.stripe.com/test/apikeys
+2. Use Ngrok or Localtunnel to make your localhost publicly available and create a webhook as described here: https://www.vendure.io/docs/typescript-api/payments-plugin/stripe-plugin/
+3. Create the file `packages/payments-plugin/.env` with these contents:
+```sh
+STRIPE_APIKEY=sk_test_xxxx
+STRIPE_WEBHOOK_SECRET=webhook-secret
+STRIPE_PUBLISHABLE_KEY=pk_test_xxxx
+```
+1. `cd packages/payments-plugin`
+2. `yarn dev-server:stripe`
+3. Watch the logs for the link or go to `http://localhost:3050/checkout` to test the checkout.
+
+After checkout completion you can see your payment in https://dashboard.stripe.com/test/payments/

+ 7 - 7
packages/payments-plugin/e2e/mollie-dev-server.ts

@@ -49,7 +49,7 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
         apiOptions: {
             adminApiPlayground: true,
             shopApiPlayground: true,
-        }
+        },
     });
     const { server, shopClient, adminClient } = createTestEnvironment(config as any);
     await server.init({
@@ -80,7 +80,7 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
                     arguments: [
                         {
                             name: 'redirectUrl',
-                            value: `${tunnel.url as string}/admin/orders?filter=open&page=1`,
+                            value: `${tunnel.url}/admin/orders?filter=open&page=1`,
                         },
                         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                         { name: 'apiKey', value: process.env.MOLLIE_APIKEY! },
@@ -109,14 +109,14 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
     await setShipping(shopClient);
     // Add pre payment to order
     const order = await server.app.get(OrderService).findOne(ctx, 1);
-    // tslint:disable-next-line:no-non-null-assertion
-    await server.app.get(PaymentService).createManualPayment(ctx, order!, 10000 ,{
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    await server.app.get(PaymentService).createManualPayment(ctx, order!, 10000, {
         method: 'Manual',
-        // tslint:disable-next-line:no-non-null-assertion
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         orderId: order!.id,
         metadata: {
-            bogus: 'test'
-        }
+            bogus: 'test',
+        },
     });
     const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
         input: {

+ 16 - 9
packages/payments-plugin/e2e/payment-helpers.ts

@@ -4,12 +4,14 @@ import { SimpleGraphQLClient, TestServer } from '@vendure/testing';
 import gql from 'graphql-tag';
 
 import { REFUND_ORDER } from './graphql/admin-queries';
-import { RefundFragment, RefundOrder } from './graphql/generated-admin-types';
+import { RefundFragment, RefundOrderMutation, RefundOrderMutationVariables } from './graphql/generated-admin-types';
 import {
-    GetShippingMethods,
-    SetShippingMethod,
+    GetShippingMethodsQuery,
+    SetShippingMethodMutation,
+    SetShippingMethodMutationVariables,
     TestOrderFragmentFragment,
-    TransitionToState,
+    TransitionToStateMutation,
+    TransitionToStateMutationVariables,
 } from './graphql/generated-shop-types';
 import {
     GET_ELIGIBLE_SHIPPING_METHODS,
@@ -28,10 +30,10 @@ export async function setShipping(shopClient: SimpleGraphQLClient): Promise<void
             countryCode: 'AT',
         },
     });
-    const { eligibleShippingMethods } = await shopClient.query<GetShippingMethods.Query>(
+    const { eligibleShippingMethods } = await shopClient.query<GetShippingMethodsQuery>(
         GET_ELIGIBLE_SHIPPING_METHODS,
     );
-    await shopClient.query<SetShippingMethod.Mutation, SetShippingMethod.Variables>(SET_SHIPPING_METHOD, {
+    await shopClient.query<SetShippingMethodMutation, SetShippingMethodMutationVariables>(SET_SHIPPING_METHOD, {
         id: eligibleShippingMethods[1].id,
     });
 }
@@ -39,8 +41,8 @@ export async function setShipping(shopClient: SimpleGraphQLClient): Promise<void
 export async function proceedToArrangingPayment(shopClient: SimpleGraphQLClient): Promise<ID> {
     await setShipping(shopClient);
     const { transitionOrderToState } = await shopClient.query<
-        TransitionToState.Mutation,
-        TransitionToState.Variables
+        TransitionToStateMutation,
+        TransitionToStateMutationVariables
     >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     return (transitionOrderToState as TestOrderFragmentFragment)!.id;
@@ -53,7 +55,7 @@ export async function refundOrderLine(
     paymentId: string,
     adjustment: number,
 ): Promise<RefundFragment> {
-    const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
+    const { refundOrder } = await adminClient.query<RefundOrderMutation, RefundOrderMutationVariables>(
         REFUND_ORDER,
         {
             input: {
@@ -102,6 +104,11 @@ export const CREATE_MOLLIE_PAYMENT_INTENT = gql`
     }
 `;
 
+export const CREATE_STRIPE_PAYMENT_INTENT = gql`
+    mutation createStripePaymentIntent{
+        createStripePaymentIntent
+    }`;
+
 export const GET_MOLLIE_PAYMENT_METHODS = gql`
     query molliePaymentMethods($input: MolliePaymentMethodsInput!) {
         molliePaymentMethods(input: $input) {

+ 88 - 0
packages/payments-plugin/e2e/stripe-checkout-test.plugin.ts

@@ -0,0 +1,88 @@
+/* eslint-disable */
+import { Controller, Res, Get } from '@nestjs/common';
+import { PluginCommonModule, VendurePlugin } from '@vendure/core';
+import { Response } from 'express';
+
+import { clientSecret } from './stripe-dev-server';
+
+/**
+ * This test controller returns the Stripe intent checkout page
+ * with the client secret generated by the dev-server
+ */
+@Controller()
+export class StripeTestCheckoutController {
+    @Get('checkout')
+    async webhook(@Res() res: Response): Promise<void> {
+        res.send(`
+<head>
+  <title>Checkout</title>
+  <script src="https://js.stripe.com/v3/"></script>
+</head>
+<html>
+
+<form id="payment-form">
+  <div id="payment-element">
+    <!-- Elements will create form elements here -->
+  </div>
+  <button id="submit">Submit</button>
+  <div id="error-message">
+    <!-- Display error message to your customers here -->
+  </div>
+</form>
+
+<script>
+// Set your publishable key: remember to change this to your live publishable key in production
+// See your keys here: https://dashboard.stripe.com/apikeys
+const stripe = Stripe('${process.env.STRIPE_PUBLISHABLE_KEY}');
+const options = {
+  clientSecret: '${clientSecret}',
+  // Fully customizable with appearance API.
+  appearance: {/*...*/},
+};
+
+// Set up Stripe.js and Elements to use in checkout form, passing the client secret obtained in step 3
+const elements = stripe.elements(options);
+
+// Create and mount the Payment Element
+const paymentElement = elements.create('payment');
+paymentElement.mount('#payment-element');
+const form = document.getElementById('payment-form');
+
+form.addEventListener('submit', async (event) => {
+  event.preventDefault();
+
+  // const {error} = await stripe.confirmSetup({
+  const {error} = await stripe.confirmPayment({
+    //\`Elements\` instance that was used to create the Payment Element
+    elements,
+    confirmParams: {
+      return_url: 'http://localhost:3050/checkout?success=true',
+    }
+  });
+
+  if (error) {
+    // This point will only be reached if there is an immediate error when
+    // confirming the payment. Show error to your customer (for example, payment
+    // details incomplete)
+    const messageContainer = document.querySelector('#error-message');
+    messageContainer.textContent = error.message;
+  } else {
+    // Your customer will be redirected to your \`return_url\`. For some payment
+    // methods like iDEAL, your customer will be redirected to an intermediate
+    // site first to authorize the payment, then redirected to the \`return_url\`.
+  }
+});
+</script>
+</html>
+    `);
+    }
+}
+
+/**
+ * Test plugin for serving the Stripe intent checkout page
+ */
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    controllers: [StripeTestCheckoutController],
+})
+export class StripeCheckoutTestPlugin {}

+ 103 - 0
packages/payments-plugin/e2e/stripe-dev-server.ts

@@ -0,0 +1,103 @@
+import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
+import {
+    ChannelService,
+    DefaultLogger,
+    LanguageCode,
+    Logger,
+    LogLevel,
+    mergeConfig,
+    OrderService,
+    RequestContext,
+} from '@vendure/core';
+import { createTestEnvironment, registerInitializer, SqljsInitializer, testConfig } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { StripePlugin } from '../src/stripe';
+import { stripePaymentMethodHandler } from '../src/stripe/stripe.handler';
+
+/* eslint-disable */
+import { CREATE_PAYMENT_METHOD } from './graphql/admin-queries';
+import {
+    CreatePaymentMethodMutation,
+    CreatePaymentMethodMutationVariables,
+} from './graphql/generated-admin-types';
+import { AddItemToOrderMutation, AddItemToOrderMutationVariables } from './graphql/generated-shop-types';
+import { ADD_ITEM_TO_ORDER } from './graphql/shop-queries';
+import { CREATE_STRIPE_PAYMENT_INTENT, setShipping } from './payment-helpers';
+import { StripeCheckoutTestPlugin } from './stripe-checkout-test.plugin';
+
+export let clientSecret: string;
+
+/**
+ * The actual starting of the dev server
+ */
+(async () => {
+    require('dotenv').config();
+
+    registerInitializer('sqljs', new SqljsInitializer(path.join(__dirname, '__data__')));
+    const config = mergeConfig(testConfig, {
+        plugins: [
+            ...testConfig.plugins,
+            AdminUiPlugin.init({
+                route: 'admin',
+                port: 5001,
+            }),
+            StripePlugin.init({}),
+            StripeCheckoutTestPlugin,
+        ],
+        logger: new DefaultLogger({ level: LogLevel.Debug }),
+    });
+    const { server, shopClient, adminClient } = createTestEnvironment(config as any);
+    await server.init({
+        initialData,
+        productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+        customerCount: 1,
+    });
+    // Create method
+    await adminClient.asSuperAdmin();
+    await adminClient.query<CreatePaymentMethodMutation, CreatePaymentMethodMutationVariables>(
+        CREATE_PAYMENT_METHOD,
+        {
+            input: {
+                code: 'stripe-payment-method',
+                enabled: true,
+                translations: [
+                    {
+                        name: 'Stripe',
+                        description: 'This is a Stripe test payment method',
+                        languageCode: LanguageCode.en,
+                    },
+                ],
+                handler: {
+                    code: stripePaymentMethodHandler.code,
+                    arguments: [
+                        { name: 'apiKey', value: process.env.STRIPE_APIKEY! },
+                        { name: 'webhookSecret', value: process.env.STRIPE_WEBHOOK_SECRET! },
+                    ],
+                },
+            },
+        },
+    );
+    // Prepare order for payment
+    await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+    await shopClient.query<AddItemToOrderMutation, AddItemToOrderMutationVariables>(ADD_ITEM_TO_ORDER, {
+        productVariantId: 'T_1',
+        quantity: 1,
+    });
+    const ctx = new RequestContext({
+        apiType: 'admin',
+        isAuthorized: true,
+        authorizedAsOwnerOnly: false,
+        channel: await server.app.get(ChannelService).getDefaultChannel(),
+    });
+    await server.app.get(OrderService).addSurchargeToOrder(ctx, 1, {
+        description: 'Negative test surcharge',
+        listPrice: -20000,
+    });
+    await setShipping(shopClient);
+    const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
+    clientSecret = createStripePaymentIntent;
+    Logger.info('http://localhost:3050/checkout', 'Stripe DevServer');
+})();

+ 63 - 29
packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts

@@ -1,6 +1,11 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 import { mergeConfig } from '@vendure/core';
-import { CreateProduct, CreateProductVariants } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
+import {
+    CreateProductMutation,
+    CreateProductMutationVariables,
+    CreateProductVariantsMutation,
+    CreateProductVariantsMutationVariables,
+} from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
 import { CREATE_PRODUCT, CREATE_PRODUCT_VARIANTS } from '@vendure/core/e2e/graphql/shared-definitions';
 import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
 import gql from 'graphql-tag';
@@ -17,14 +22,16 @@ import { CREATE_CHANNEL, CREATE_PAYMENT_METHOD, GET_CUSTOMER_LIST } from './grap
 import {
     CreateChannelMutation,
     CreateChannelMutationVariables,
-    CreatePaymentMethod,
+    CreatePaymentMethodMutation,
+    CreatePaymentMethodMutationVariables,
     CurrencyCode,
-    GetCustomerList,
     GetCustomerListQuery,
+    GetCustomerListQueryVariables,
     LanguageCode,
 } from './graphql/generated-admin-types';
 import {
-    AddItemToOrder,
+    AddItemToOrderMutation,
+    AddItemToOrderMutationVariables,
     GetActiveOrderQuery,
     TestOrderFragmentFragment,
 } from './graphql/generated-shop-types';
@@ -41,8 +48,6 @@ describe('Stripe payments', () => {
     const devConfig = mergeConfig(testConfig(), {
         plugins: [
             StripePlugin.init({
-                apiKey: 'test-api-key',
-                webhookSigningSecret: 'test-signing-secret',
                 storeCustomersInStripe: true,
             }),
         ],
@@ -63,7 +68,7 @@ describe('Stripe payments', () => {
         await adminClient.asSuperAdmin();
         ({
             customers: { items: customers },
-        } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(GET_CUSTOMER_LIST, {
+        } = await adminClient.query<GetCustomerListQuery, GetCustomerListQueryVariables>(GET_CUSTOMER_LIST, {
             options: {
                 take: 2,
             },
@@ -74,43 +79,46 @@ describe('Stripe payments', () => {
         await server.destroy();
     });
 
-    it('Should start successfully', async () => {
+    it('Should start successfully', () => {
         expect(started).toEqual(true);
         expect(customers).toHaveLength(2);
     });
 
     it('Should prepare an order', async () => {
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
-            ADD_ITEM_TO_ORDER,
-            {
-                productVariantId: 'T_1',
-                quantity: 2,
-            },
-        );
+        const { addItemToOrder } = await shopClient.query<
+            AddItemToOrderMutation,
+            AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 2,
+        });
         order = addItemToOrder as TestOrderFragmentFragment;
         expect(order.code).toBeDefined();
     });
 
     it('Should add a Stripe paymentMethod', async () => {
         const { createPaymentMethod } = await adminClient.query<
-            CreatePaymentMethod.Mutation,
-            CreatePaymentMethod.Variables
+            CreatePaymentMethodMutation,
+            CreatePaymentMethodMutationVariables
         >(CREATE_PAYMENT_METHOD, {
             input: {
                 code: `stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
-                enabled: true,
-                handler: {
-                    code: stripePaymentMethodHandler.code,
-                    arguments: [],
-                },
                 translations: [
                     {
-                        languageCode: LanguageCode.en,
                         name: 'Stripe payment test',
                         description: 'This is a Stripe test payment method',
+                        languageCode: LanguageCode.en,
                     },
                 ],
+                enabled: true,
+                handler: {
+                    code: stripePaymentMethodHandler.code,
+                    arguments: [
+                        { name: 'apiKey', value: 'test-api-key' },
+                        { name: 'webhookSecret', value: 'test-signing-secret' },
+                    ],
+                },
             },
         });
         expect(createPaymentMethod.code).toBe(`stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`);
@@ -192,8 +200,8 @@ describe('Stripe payments', () => {
             shopClient.setChannelToken(JAPAN_CHANNEL_TOKEN);
 
             const { createProduct } = await adminClient.query<
-                CreateProduct.Mutation,
-                CreateProduct.Variables
+                CreateProductMutation,
+                CreateProductMutationVariables
             >(CREATE_PRODUCT, {
                 input: {
                     translations: [
@@ -207,8 +215,8 @@ describe('Stripe payments', () => {
                 },
             });
             const { createProductVariants } = await adminClient.query<
-                CreateProductVariants.Mutation,
-                CreateProductVariants.Variables
+                CreateProductVariantsMutation,
+                CreateProductVariantsMutationVariables
             >(CREATE_PRODUCT_VARIANTS, {
                 input: [
                     {
@@ -222,13 +230,37 @@ describe('Stripe payments', () => {
                 ],
             });
             japanProductId = createProductVariants[0]!.id;
+            // Create a payment method for the Japan channel
+            await adminClient.query<CreatePaymentMethodMutation, CreatePaymentMethodMutationVariables>(
+                CREATE_PAYMENT_METHOD,
+                {
+                    input: {
+                        code: `stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
+                        translations: [
+                            {
+                                name: 'Stripe payment test',
+                                description: 'This is a Stripe test payment method',
+                                languageCode: LanguageCode.en,
+                            },
+                        ],
+                        enabled: true,
+                        handler: {
+                            code: stripePaymentMethodHandler.code,
+                            arguments: [
+                                { name: 'apiKey', value: 'test-api-key' },
+                                { name: 'webhookSecret', value: 'test-signing-secret' },
+                            ],
+                        },
+                    },
+                },
+            );
         });
 
         it('prepares order', async () => {
             await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
             const { addItemToOrder } = await shopClient.query<
-                AddItemToOrder.Mutation,
-                AddItemToOrder.Variables
+                AddItemToOrderMutation,
+                AddItemToOrderMutationVariables
             >(ADD_ITEM_TO_ORDER, {
                 productVariantId: japanProductId,
                 quantity: 1,
@@ -252,4 +284,6 @@ describe('Stripe payments', () => {
             expect(createPaymentIntentPayload.currency).toBe('jpy');
         });
     });
+
+    // TODO: Contribution welcome: test webhook handling and order settlement
 });

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

@@ -15,7 +15,8 @@
         "e2e:watch": "cross-env PACKAGE=payments-plugin vitest --config ../../e2e-common/vitest.config.ts",
         "lint": "eslint --fix .",
         "ci": "yarn build",
-        "dev-server:mollie": "yarn build && DB=sqlite node -r ts-node/register e2e/mollie-dev-server.ts"
+        "dev-server:mollie": "yarn build && DB=sqlite node -r ts-node/register e2e/mollie-dev-server.ts",
+        "dev-server:stripe": "yarn build && DB=sqlite node -r ts-node/register e2e/stripe-dev-server.ts"
     },
     "homepage": "https://www.vendure.io/",
     "funding": "https://github.com/sponsors/michaelbromley",

+ 12 - 0
packages/payments-plugin/src/stripe/stripe-client.ts

@@ -0,0 +1,12 @@
+import Stripe from 'stripe';
+
+/**
+ * Wrapper around the Stripe client that exposes ApiKey and WebhookSecret
+ */
+export class VendureStripeClient extends Stripe {
+    constructor(private apiKey: string, public webhookSecret: string) {
+        super(apiKey, {
+            apiVersion: null as any, // Use accounts default version
+        });
+    }
+}

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

@@ -43,46 +43,39 @@ export class StripeController {
             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}: ${JSON.stringify(e.message)}`, loggerCtx);
-            response.status(HttpStatus.BAD_REQUEST).send(signatureErrorMessage);
-            return;
-        }
-
+        const event = request.body as Stripe.Event;
         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;
-
+        const ctx = await this.createContext(channelToken, request);
+        const order = await this.orderService.findOneByCode(ctx, orderCode);
+        if (!order) {
+            throw Error(`Unable to find order ${orderCode}, unable to settle payment ${paymentIntent.id}!`);
+        }
+        try {
+            // Throws an error if the signature is invalid
+            await this.stripeService.constructEventFromPayload(ctx, order, request.rawBody, signature);
+        } catch (e: any) {
+            Logger.error(`${signatureErrorMessage} ${signature}: ${(e as Error)?.message}`, loggerCtx);
+            response.status(HttpStatus.BAD_REQUEST).send(signatureErrorMessage);
+            return;
+        }
         if (event.type === 'payment_intent.payment_failed') {
             const message = paymentIntent.last_payment_error?.message ?? 'unknown error';
             Logger.warn(`Payment for order ${orderCode} failed: ${message}`, loggerCtx);
             response.status(HttpStatus.OK).send('Ok');
             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, request);
-
-        const order = await this.orderService.findOneByCode(ctx, orderCode);
-        if (!order) {
-            throw Error(`Unable to find order ${orderCode}, unable to settle payment ${paymentIntent.id}!`);
-        }
-
         if (order.state !== 'ArrangingPayment') {
             const transitionToStateResult = await this.orderService.transitionToState(
                 ctx,

+ 54 - 28
packages/payments-plugin/src/stripe/stripe.handler.ts

@@ -23,13 +23,35 @@ export const stripePaymentMethodHandler = new PaymentMethodHandler({
 
     description: [{ languageCode: LanguageCode.en, value: 'Stripe payments' }],
 
-    args: {},
+    args: {
+        apiKey: {
+            type: 'string',
+            label: [{ languageCode: LanguageCode.en, value: 'API Key' }],
+            ui: { component: 'password-form-input' },
+        },
+        webhookSecret: {
+            type: 'string',
+            label: [
+                {
+                    languageCode: LanguageCode.en,
+                    value: 'Webhook secret',
+                },
+            ],
+            description: [
+                {
+                    languageCode: LanguageCode.en,
+                    value: 'Secret to validate incoming webhooks. Get this from your Stripe dashboard',
+                },
+            ],
+            ui: { component: 'password-form-input' },
+        },
+    },
 
     init(injector: Injector) {
         stripeService = injector.get(StripeService);
     },
 
-    async createPayment(ctx, order, amount, ___, metadata): Promise<CreatePaymentResult> {
+    createPayment(ctx, order, amount, ___, metadata): CreatePaymentResult {
         // Payment is already settled in Stripe by the time the webhook in stripe.controller.ts
         // adds the payment to the order
         if (ctx.apiType !== 'admin') {
@@ -50,39 +72,43 @@ export const stripePaymentMethodHandler = new PaymentMethodHandler({
     },
 
     async createRefund(ctx, input, amount, order, payment, args): Promise<CreateRefundResult> {
-        const result = await stripeService.createRefund(payment.transactionId, amount);
+        // TODO: Consider passing the "reason" property once this feature request is addressed:
+        // https://github.com/vendure-ecommerce/vendure/issues/893
+        try {
+            const refund = await stripeService.createRefund(ctx, order, payment, amount);
+            if (refund.status === 'succeeded') {
+                return {
+                    state: 'Settled' as const,
+                    transactionId: payment.transactionId,
+                };
+            }
+
+            if (refund.status === 'pending') {
+                return {
+                    state: 'Pending' as const,
+                    transactionId: payment.transactionId,
+                };
+            }
 
-        if (result instanceof Stripe.StripeError) {
             return {
                 state: 'Failed' as const,
                 transactionId: payment.transactionId,
                 metadata: {
-                    type: result.type,
-                    message: result.message,
+                    message: refund.failure_reason,
                 },
             };
+        } catch (e: any) {
+            if (e instanceof StripeError) {
+                return {
+                    state: 'Failed' as const,
+                    transactionId: payment.transactionId,
+                    metadata: {
+                        type: e.type,
+                        message: e.message,
+                    },
+                };
+            }
+            throw e;
         }
-
-        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,
-            },
-        };
     },
 });

+ 2 - 3
packages/payments-plugin/src/stripe/stripe.plugin.ts

@@ -39,14 +39,13 @@ import { StripePluginOptions } from './types';
  *
  *     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.
+ * 3. Set the webhook secret and API key in the PaymentMethod form.
  *
  * ## Storefront usage
  *
@@ -192,7 +191,7 @@ import { StripePluginOptions } from './types';
     shopApiExtensions: {
         schema: gql`
             extend type Mutation {
-                createStripePaymentIntent: String
+                createStripePaymentIntent: String!
             }
         `,
         resolvers: [StripeResolver],

+ 17 - 7
packages/payments-plugin/src/stripe/stripe.resolver.ts

@@ -1,5 +1,13 @@
 import { Mutation, Resolver } from '@nestjs/graphql';
-import { ActiveOrderService, Allow, Ctx, Permission, RequestContext } from '@vendure/core';
+import {
+    ActiveOrderService,
+    Allow,
+    Ctx,
+    Permission,
+    RequestContext,
+    UnauthorizedError,
+    UserInputError,
+} from '@vendure/core';
 
 import { StripeService } from './stripe.service';
 
@@ -9,12 +17,14 @@ export class StripeResolver {
 
     @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);
-            }
+    async createStripePaymentIntent(@Ctx() ctx: RequestContext): Promise<string> {
+        if (!ctx.authorizedAsOwnerOnly) {
+            throw new UnauthorizedError();
         }
+        const sessionOrder = await this.activeOrderService.getActiveOrder(ctx, undefined);
+        if (!sessionOrder) {
+            throw new UserInputError('No active order found for session');
+        }
+        return this.stripeService.createPaymentIntent(ctx, sessionOrder);
     }
 }

+ 82 - 30
packages/payments-plugin/src/stripe/stripe.service.ts

@@ -1,32 +1,41 @@
 import { Inject, Injectable } from '@nestjs/common';
-import { Customer, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
+import { ConfigArg } from '@vendure/common/lib/generated-types';
+import {
+    Ctx,
+    Customer,
+    Logger,
+    Order,
+    Payment,
+    PaymentMethodService,
+    RequestContext,
+    TransactionalConnection,
+    UserInputError,
+} from '@vendure/core';
 import Stripe from 'stripe';
 
 import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
+import { VendureStripeClient } from './stripe-client';
 import { getAmountInStripeMinorUnits } from './stripe-utils';
+import { stripePaymentMethodHandler } from './stripe.handler';
 import { StripePluginOptions } from './types';
 
 @Injectable()
 export class StripeService {
-    protected stripe: Stripe;
-
     constructor(
         private connection: TransactionalConnection,
+        private paymentMethodService: PaymentMethodService,
         @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> {
+    async createPaymentIntent(ctx: RequestContext, order: Order): Promise<string> {
         let customerId: string | undefined;
+        const stripe = await this.getStripeClient(ctx, order);
 
         if (this.options.storeCustomersInStripe && ctx.activeUserId) {
             customerId = await this.getStripeCustomerId(ctx, order);
         }
         const amountInMinorUnits = getAmountInStripeMinorUnits(order);
-        const { client_secret } = await this.stripe.paymentIntents.create(
+        const { client_secret } = await stripe.paymentIntents.create(
             {
                 amount: amountInMinorUnits,
                 currency: order.currencyCode.toLowerCase(),
@@ -49,28 +58,68 @@ export class StripeService {
                 `Payment intent creation for order ${order.code} did not return client secret`,
                 loggerCtx,
             );
+            throw Error('Failed to create payment intent');
         }
 
         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,
-            });
+    async constructEventFromPayload(
+        ctx: RequestContext,
+        order: Order,
+        payload: Buffer,
+        signature: string,
+    ): Promise<Stripe.Event> {
+        const stripe = await this.getStripeClient(ctx, order);
+        return stripe.webhooks.constructEvent(payload, signature, stripe.webhookSecret);
+    }
+
+    async createRefund(
+        ctx: RequestContext,
+        order: Order,
+        payment: Payment,
+        amount: number,
+    ): Promise<Stripe.Response<Stripe.Refund>> {
+        const stripe = await this.getStripeClient(ctx, order);
+        return stripe.refunds.create({
+            payment_intent: payment.transactionId,
+            amount,
+        });
+    }
 
-            return refund;
-        } catch (e: any) {
-            return e as Stripe.StripeError;
+    /**
+     * Get Stripe client based on eligible payment methods for order
+     */
+    async getStripeClient(ctx: RequestContext, order: Order): Promise<VendureStripeClient> {
+        const [eligiblePaymentMethods, paymentMethods] = await Promise.all([
+            this.paymentMethodService.getEligiblePaymentMethods(ctx, order),
+            this.paymentMethodService.findAll(ctx, {
+                filter: {
+                    enabled: { eq: true },
+                },
+            }),
+        ]);
+        const stripePaymentMethod = paymentMethods.items.find(
+            pm => pm.handler.code === stripePaymentMethodHandler.code,
+        );
+        if (!stripePaymentMethod) {
+            throw new UserInputError('No enabled Stripe payment method found');
         }
+        const isEligible = eligiblePaymentMethods.some(pm => pm.code === stripePaymentMethod.code);
+        if (!isEligible) {
+            throw new UserInputError(`Stripe payment method is not eligible for order ${order.code}`);
+        }
+        const apiKey = this.findOrThrowArgValue(stripePaymentMethod.handler.args, 'apiKey');
+        const webhookSecret = this.findOrThrowArgValue(stripePaymentMethod.handler.args, 'webhookSecret');
+        return new VendureStripeClient(apiKey, webhookSecret);
     }
 
-    constructEventFromPayload(payload: Buffer, signature: string): Stripe.Event {
-        return this.stripe.webhooks.constructEvent(payload, signature, this.options.webhookSigningSecret);
+    private findOrThrowArgValue(args: ConfigArg[], name: string): string {
+        const value = args.find(arg => arg.name === name)?.value;
+        if (!value) {
+            throw Error(`No argument named '${name}' found!`);
+        }
+        return value;
     }
 
     /**
@@ -79,11 +128,14 @@ export class StripeService {
      * 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({
-            where: { id: activeOrder.id },
-            relations: ['customer'],
-        });
+        const [stripe, order] = await Promise.all([
+            this.getStripeClient(ctx, activeOrder),
+            // Load relation with customer not available in the response from activeOrderService.getOrderFromContext()
+            this.connection.getRepository(ctx, Order).findOne({
+                where: { id: activeOrder.id },
+                relations: ['customer'],
+            }),
+        ]);
 
         if (!order || !order.customer) {
             // This should never happen
@@ -98,11 +150,11 @@ export class StripeService {
 
         let stripeCustomerId;
 
-        const stripeCustomers = await this.stripe.customers.list({ email: customer.emailAddress });
+        const stripeCustomers = await stripe.customers.list({ email: customer.emailAddress });
         if (stripeCustomers.data.length > 0) {
             stripeCustomerId = stripeCustomers.data[0].id;
         } else {
-            const newStripeCustomer = await this.stripe.customers.create({
+            const newStripeCustomer = await stripe.customers.create({
                 email: customer.emailAddress,
                 name: `${customer.firstName} ${customer.lastName}`,
             });

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

@@ -17,16 +17,6 @@ declare module '@vendure/core/dist/entity/custom-entity-fields' {
  * @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