Procházet zdrojové kódy

feat(payments-plugin): Make Mollie plugin `redirecturl` dynamic (#2094)

Closes #2093
Pieter Doms před 2 roky
rodič
revize
b452419736

+ 1 - 1
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -162,7 +162,6 @@ export type AlreadyRefundedError = ErrorResult & {
 export type ApplyCouponCodeResult = Order | CouponCodeExpiredError | CouponCodeInvalidError | CouponCodeLimitError;
 
 export type Asset = Node & {
-  tags: Array<Tag>;
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
@@ -175,6 +174,7 @@ export type Asset = Node & {
   source: Scalars['String'];
   preview: Scalars['String'];
   focalPoint?: Maybe<Coordinate>;
+  tags: Array<Tag>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 

+ 1 - 0
packages/payments-plugin/e2e/graphql/generated-shop-types.ts

@@ -74,6 +74,7 @@ export type Asset = Node & {
   source: Scalars['String'];
   preview: Scalars['String'];
   focalPoint?: Maybe<Coordinate>;
+  tags: Array<Tag>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 

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

@@ -29,7 +29,8 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
  * This should only be used to locally test the Mollie payment plugin
  */
 /* tslint:disable:no-floating-promises */
-(async () => {
+async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
+    console.log('Starting Mollie dev server with dynamic redirectUrl: ', useDynamicRedirectUrl);
     require('dotenv').config();
 
     registerInitializer('sqljs', new SqljsInitializer(path.join(__dirname, '__data__')));
@@ -42,7 +43,7 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
                 route: 'admin',
                 port: 5001,
             }),
-            MolliePlugin.init({ vendureHost: tunnel.url }),
+            MolliePlugin.init({ vendureHost: tunnel.url, useDynamicRedirectUrl }),
         ],
         logger: new DefaultLogger({ level: LogLevel.Debug }),
         apiOptions: {
@@ -66,24 +67,26 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
         }
     `);
     // Create method
-    await adminClient.query<CreatePaymentMethod.Mutation,
-        CreatePaymentMethod.Variables>(CREATE_PAYMENT_METHOD, {
-        input: {
-            code: 'mollie',
-            name: 'Mollie payment test',
-            description: 'This is a Mollie test payment method',
-            enabled: true,
-            handler: {
-                code: molliePaymentHandler.code,
-                arguments: [
-                    { name: 'redirectUrl', value: `${tunnel.url}/admin/orders?filter=open&page=1` },
-                    // tslint:disable-next-line:no-non-null-assertion
-                    { name: 'apiKey', value: process.env.MOLLIE_APIKEY! },
-                    { name: 'autoCapture', value: 'false' },
-                ],
+    await adminClient.query<CreatePaymentMethod.Mutation, CreatePaymentMethod.Variables>(
+        CREATE_PAYMENT_METHOD,
+        {
+            input: {
+                code: 'mollie',
+                name: 'Mollie payment test',
+                description: 'This is a Mollie test payment method',
+                enabled: true,
+                handler: {
+                    code: molliePaymentHandler.code,
+                    arguments: [
+                        { name: 'redirectUrl', value: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=false` },
+                        // tslint:disable-next-line:no-non-null-assertion
+                        { name: 'apiKey', value: process.env.MOLLIE_APIKEY! },
+                        { name: 'autoCapture', value: 'false' },
+                    ],
+                },
             },
         },
-    });
+    );
     // Prepare order for payment
     await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
     await shopClient.query<AddItemToOrder.Order, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
@@ -114,6 +117,7 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
     });
     const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
         input: {
+            redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=true`,
             paymentMethodCode: 'mollie',
 //            molliePaymentMethodCode: 'klarnapaylater'
         },
@@ -122,5 +126,9 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
         throw createMolliePaymentIntent;
     }
     Logger.info(`Mollie payment link: ${createMolliePaymentIntent.url}`, 'Mollie DevServer');
-})();
+};
 
+(async () => {
+    // Change the value of the parameter to true to test with the dynamic redirectUrl functionality
+    await runMollieDevServer(false);
+})()

+ 289 - 75
packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts

@@ -40,88 +40,90 @@ import {
     setShipping,
 } from './payment-helpers';
 
-describe('Mollie payments', () => {
-    const mockData = {
-        host: 'https://my-vendure.io',
-        redirectUrl: 'https://my-storefront/order',
-        apiKey: 'myApiKey',
-        methodCode: `mollie-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
-        mollieOrderResponse: {
-            id: 'ord_mockId',
-            _links: {
-                checkout: {
-                    href: 'https://www.mollie.com/payscreen/select-method/mock-payment',
-                },
-            },
-            lines: [],
-            _embedded: {
-                payments: [
-                    {
-                        id: 'tr_mockPayment',
-                        status: 'paid',
-                        resource: 'payment',
-                    },
-                ],
+const mockData = {
+    host: 'https://my-vendure.io',
+    redirectUrl: 'https://my-storefront/order',
+    apiKey: 'myApiKey',
+    methodCode: `mollie-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
+    methodCodeBroken: `mollie-payment-broken-${E2E_DEFAULT_CHANNEL_TOKEN}`,
+    mollieOrderResponse: {
+        id: 'ord_mockId',
+        _links: {
+            checkout: {
+                href: 'https://www.mollie.com/payscreen/select-method/mock-payment',
             },
-            resource: 'order',
-            mode: 'test',
-            method: 'test-method',
-            profileId: '123',
-            settlementAmount: 'test amount',
-            customerId: '456',
-            authorizedAt: new Date(),
-            paidAt: new Date(),
         },
-        molliePaymentMethodsResponse: {
-            count: 1,
-            _embedded: {
-                methods: [
-                    {
-                        resource: 'method',
-                        id: 'ideal',
-                        description: 'iDEAL',
-                        minimumAmount: {
-                            value: '0.01',
-                            currency: 'EUR',
-                        },
-                        maximumAmount: {
-                            value: '50000.00',
-                            currency: 'EUR',
-                        },
-                        image: {
-                            size1x: 'https://www.mollie.com/external/icons/payment-methods/ideal.png',
-                            size2x: 'https://www.mollie.com/external/icons/payment-methods/ideal%402x.png',
-                            svg: 'https://www.mollie.com/external/icons/payment-methods/ideal.svg',
-                        },
-                        _links: {
-                            self: {
-                                href: 'https://api.mollie.com/v2/methods/ideal',
-                                type: 'application/hal+json',
-                            },
+        lines: [],
+        _embedded: {
+            payments: [
+                {
+                    id: 'tr_mockPayment',
+                    status: 'paid',
+                    resource: 'payment',
+                },
+            ],
+        },
+        resource: 'order',
+        mode: 'test',
+        method: 'test-method',
+        profileId: '123',
+        settlementAmount: 'test amount',
+        customerId: '456',
+        authorizedAt: new Date(),
+        paidAt: new Date(),
+    },
+    molliePaymentMethodsResponse: {
+        count: 1,
+        _embedded: {
+            methods: [
+                {
+                    resource: 'method',
+                    id: 'ideal',
+                    description: 'iDEAL',
+                    minimumAmount: {
+                        value: '0.01',
+                        currency: 'EUR',
+                    },
+                    maximumAmount: {
+                        value: '50000.00',
+                        currency: 'EUR',
+                    },
+                    image: {
+                        size1x: 'https://www.mollie.com/external/icons/payment-methods/ideal.png',
+                        size2x: 'https://www.mollie.com/external/icons/payment-methods/ideal%402x.png',
+                        svg: 'https://www.mollie.com/external/icons/payment-methods/ideal.svg',
+                    },
+                    _links: {
+                        self: {
+                            href: 'https://api.mollie.com/v2/methods/ideal',
+                            type: 'application/hal+json',
                         },
                     },
-                ],
-            },
-            _links: {
-                self: {
-                    href: 'https://api.mollie.com/v2/methods',
-                    type: 'application/hal+json',
-                },
-                documentation: {
-                    href: 'https://docs.mollie.com/reference/v2/methods-api/list-methods',
-                    type: 'text/html',
                 },
+            ],
+        },
+        _links: {
+            self: {
+                href: 'https://api.mollie.com/v2/methods',
+                type: 'application/hal+json',
+            },
+            documentation: {
+                href: 'https://docs.mollie.com/reference/v2/methods-api/list-methods',
+                type: 'text/html',
             },
         },
-    };
-    let shopClient: SimpleGraphQLClient;
-    let adminClient: SimpleGraphQLClient;
-    let server: TestServer;
-    let started = false;
-    let customers: GetCustomerListQuery['customers']['items'];
-    let order: TestOrderFragmentFragment;
-    let serverPort: number;
-    const SURCHARGE_AMOUNT = -20000;
+    },
+};
+let shopClient: SimpleGraphQLClient;
+let adminClient: SimpleGraphQLClient;
+let server: TestServer;
+let started = false;
+let customers: GetCustomerListQuery['customers']['items'];
+let order: TestOrderFragmentFragment;
+let serverPort: number;
+const SURCHARGE_AMOUNT = -20000;
+
+describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
     beforeAll(async () => {
         const devConfig = mergeConfig(testConfig(), {
             plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
@@ -151,6 +153,10 @@ describe('Mollie payments', () => {
         await server.destroy();
     });
 
+    afterEach(async () => {
+        nock.cleanAll();
+    });
+
     it('Should start successfully', async () => {
         expect(started).toEqual(true);
         expect(customers).toHaveLength(2);
@@ -527,4 +533,212 @@ describe('Mollie payments', () => {
             expect(order.state).toBe('PaymentSettled');
         });
     });
+
+    it('Should add an unusable Mollie paymentMethod (missing redirectUrl)', async () => {
+        const { createPaymentMethod } = await adminClient.query<
+            CreatePaymentMethod.Mutation,
+            CreatePaymentMethod.Variables
+        >(CREATE_PAYMENT_METHOD, {
+            input: {
+                code: mockData.methodCodeBroken,
+                name: 'Mollie payment test',
+                description: 'This is a Mollie test payment method',
+                enabled: true,
+                handler: {
+                    code: molliePaymentHandler.code,
+                    arguments: [
+                        { name: 'apiKey', value: mockData.apiKey },
+                        { name: 'autoCapture', value: 'false' },
+                    ],
+                },
+            },
+        });
+        expect(createPaymentMethod.code).toBe(mockData.methodCodeBroken);
+    });
+
+    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_5',
+                quantity: 10,
+            },
+        );
+        order = addItemToOrder as TestOrderFragmentFragment;
+        // Add surcharge
+        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: SURCHARGE_AMOUNT,
+        });
+        expect(order.code).toBeDefined();
+    });
+
+    it('Should fail to get payment url with Mollie method without redirectUrl configured', async () => {
+        nock('https://api.mollie.com/').post('/v2/orders').reply(200, mockData.mollieOrderResponse);
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        await setShipping(shopClient);
+        const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCodeBroken,
+                molliePaymentMethodCode: 'ideal',
+            },
+        });
+        expect(createMolliePaymentIntent.message).toContain('Cannot create payment intent without redirectUrl specified in paymentMethod');
+    });
+});
+
+describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
+    beforeAll(async () => {
+        const devConfig = mergeConfig(testConfig(), {
+            plugins: [MolliePlugin.init({ vendureHost: mockData.host, useDynamicRedirectUrl: true })],
+        });
+        const env = createTestEnvironment(devConfig);
+        serverPort = devConfig.apiOptions.port;
+        shopClient = env.shopClient;
+        adminClient = env.adminClient;
+        server = env.server;
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 2,
+        });
+        started = true;
+        await adminClient.asSuperAdmin();
+        ({
+            customers: { items: customers },
+        } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(GET_CUSTOMER_LIST, {
+            options: {
+                take: 2,
+            },
+        }));
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    afterEach(async () => {
+        nock.cleanAll();
+    });
+
+    it('Should start successfully', async () => {
+        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_5',
+                quantity: 10,
+            },
+        );
+        order = addItemToOrder as TestOrderFragmentFragment;
+        // Add surcharge
+        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: SURCHARGE_AMOUNT,
+        });
+        expect(order.code).toBeDefined();
+    });
+
+    it('Should add a working Mollie paymentMethod without specifying redirectUrl', async () => {
+        const { createPaymentMethod } = await adminClient.query<
+            CreatePaymentMethod.Mutation,
+            CreatePaymentMethod.Variables
+        >(CREATE_PAYMENT_METHOD, {
+            input: {
+                code: mockData.methodCode,
+                name: 'Mollie payment test',
+                description: 'This is a Mollie test payment method',
+                enabled: true,
+                handler: {
+                    code: molliePaymentHandler.code,
+                    arguments: [
+                        { name: 'apiKey', value: mockData.apiKey },
+                        { name: 'autoCapture', value: 'false' },
+                    ],
+                },
+            },
+        });
+        expect(createPaymentMethod.code).toBe(mockData.methodCode);
+    });
+
+    it('Should get payment url without Mollie method', async () => {
+        await setShipping(shopClient);
+        let mollieRequest: any | undefined;
+        nock('https://api.mollie.com/')
+            .post('/v2/orders', body => {
+                mollieRequest = body;
+                return true;
+            })
+            .reply(200, mockData.mollieOrderResponse);
+        const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+                redirectUrl: mockData.redirectUrl,
+            },
+        });
+        expect(createMolliePaymentIntent).toEqual({
+            url: 'https://www.mollie.com/payscreen/select-method/mock-payment',
+        });
+        expect(mollieRequest?.orderNumber).toEqual(order.code);
+        expect(mollieRequest?.redirectUrl).toEqual(mockData.redirectUrl);
+        expect(mollieRequest?.webhookUrl).toEqual(
+            `${mockData.host}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`,
+        );
+        expect(mollieRequest?.amount?.value).toBe('1009.90');
+        expect(mollieRequest?.amount?.currency).toBe('USD');
+        expect(mollieRequest.lines[0].vatAmount.value).toEqual('199.98');
+        let totalLineAmount = 0;
+        for (const line of mollieRequest.lines) {
+            totalLineAmount += Number(line.totalAmount.value);
+        }
+        // Sum of lines should equal order total
+        expect(mollieRequest.amount.value).toEqual(totalLineAmount.toFixed(2));
+    });
+
+    it('Should get payment url with Mollie method', async () => {
+        nock('https://api.mollie.com/').post('/v2/orders').reply(200, mockData.mollieOrderResponse);
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        await setShipping(shopClient);
+        const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+                molliePaymentMethodCode: 'ideal',
+                redirectUrl: mockData.redirectUrl,
+            },
+        });
+        expect(createMolliePaymentIntent).toEqual({
+            url: 'https://www.mollie.com/payscreen/select-method/mock-payment',
+        });
+    });
+
+    it('Should fail to get payment url without specifying redirectUrl in the createMolliePaymentIntent mutation', async () => {
+        nock('https://api.mollie.com/').post('/v2/orders').reply(200, mockData.mollieOrderResponse);
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        await setShipping(shopClient);
+        const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+                molliePaymentMethodCode: 'ideal',
+            },
+        });
+        expect(createMolliePaymentIntent.message).toContain('Cannot create payment intent without redirectUrl specified');
+    });
 });

+ 2 - 0
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts

@@ -78,6 +78,7 @@ export type Asset = Node & {
   source: Scalars['String'];
   preview: Scalars['String'];
   focalPoint?: Maybe<Coordinate>;
+  tags: Array<Tag>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 
@@ -1572,6 +1573,7 @@ export type MolliePaymentIntentError = ErrorResult & {
 };
 
 export type MolliePaymentIntentInput = {
+  redirectUrl?: Maybe<Scalars['String']>;
   paymentMethodCode: Scalars['String'];
   molliePaymentMethodCode?: Maybe<Scalars['String']>;
 };

+ 1 - 0
packages/payments-plugin/src/mollie/mollie-shop-schema.ts

@@ -27,6 +27,7 @@ export const shopSchema = gql`
     }
     union MolliePaymentIntentResult = MolliePaymentIntent | MolliePaymentIntentError
     input MolliePaymentIntentInput {
+        redirectUrl: String
         paymentMethodCode: String!
         molliePaymentMethodCode: String
     }

+ 4 - 2
packages/payments-plugin/src/mollie/mollie.controller.ts

@@ -1,5 +1,5 @@
 import { Body, Controller, Param, Post } from '@nestjs/common';
-import { Logger } from '@vendure/core';
+import { Ctx, Logger, RequestContext, Transaction } from '@vendure/core';
 
 import { loggerCtx } from './constants';
 import { MollieService } from './mollie.service';
@@ -9,7 +9,9 @@ export class MollieController {
     constructor(private mollieService: MollieService) {}
 
     @Post('mollie/:channelToken/:paymentMethodId')
+    @Transaction()
     async webhook(
+        @Ctx() ctx: RequestContext,
         @Param('channelToken') channelToken: string,
         @Param('paymentMethodId') paymentMethodId: string,
         @Body() body: any,
@@ -18,7 +20,7 @@ export class MollieController {
             return Logger.warn(` Ignoring incoming webhook, because it has no body.id.`, loggerCtx);
         }
         try {
-            await this.mollieService.handleMollieStatusUpdate({ channelToken, paymentMethodId, orderId: body.id });
+            await this.mollieService.handleMollieStatusUpdate(ctx, { channelToken, paymentMethodId, orderId: body.id });
         } catch (error) {
             Logger.error(`Failed to process incoming webhook: ${error?.message}`, loggerCtx, error);
             throw error;

+ 10 - 8
packages/payments-plugin/src/mollie/mollie.handler.ts

@@ -27,13 +27,6 @@ export const molliePaymentHandler = new PaymentMethodHandler({
             type: 'string',
             label: [{ languageCode: LanguageCode.en, value: 'API Key' }],
         },
-        redirectUrl: {
-            type: 'string',
-            label: [{ languageCode: LanguageCode.en, value: 'Redirect URL' }],
-            description: [
-                { languageCode: LanguageCode.en, value: 'Redirect the client to this URL after payment' },
-            ],
-        },
         autoCapture: {
             type: 'boolean',
             label: [{ languageCode: LanguageCode.en, value: 'Auto capture payments' }],
@@ -42,6 +35,15 @@ export const molliePaymentHandler = new PaymentMethodHandler({
                 { languageCode: LanguageCode.en, value: 'This option only affects pay-later methods. Automatically capture payments immediately after authorization. Without autoCapture orders will remain in the PaymentAuthorized state, and you need to manually settle payments to get paid.' },
             ],
         },
+        redirectUrl: {
+            type: 'string',
+            required: false,
+            defaultValue: '',
+            label: [{ languageCode: LanguageCode.en, value: 'Redirect URL' }],
+            description: [
+                { languageCode: LanguageCode.en, value: 'Redirect the client to this URL after payment' },
+            ],
+        }
     },
     init(injector) {
         mollieService = injector.get(MollieService);
@@ -54,7 +56,7 @@ export const molliePaymentHandler = new PaymentMethodHandler({
         metadata,
     ): Promise<CreatePaymentResult | CreatePaymentErrorResult> => {
         // Only Admins and internal calls should be allowed to settle and authorize payments
-        if (ctx.apiType !== 'admin') {
+        if (ctx.apiType !== 'admin' && ctx.apiType !== 'custom') {
             throw Error(`CreatePayment is not allowed for apiType '${ctx.apiType}'`);
         }
         if (metadata.status !== 'Authorized' && metadata.status !== 'Settled') {

+ 31 - 5
packages/payments-plugin/src/mollie/mollie.plugin.ts

@@ -21,6 +21,18 @@ export interface MolliePluginOptions {
      * This is used by Mollie to send webhook events to the Vendure server
      */
     vendureHost: string;
+
+    /**
+     * @description
+     * For backwards compatibility, by default set to false.
+     * This option will be deprecated in a future version.
+     * When enabled, the `redirectUrl` can be passed via the `createPaymentIntent` mutation 
+     * instead of being configured in the Payment Method.
+     * 
+     * @default false
+     * @since 2.0.0
+     */
+    useDynamicRedirectUrl?: boolean;
 }
 
 /**
@@ -48,21 +60,34 @@ export interface MolliePluginOptions {
  *     // ...
  *
  *     plugins: [
- *       MolliePlugin.init({ vendureHost: 'https://yourhost.io/' }),
+ *       MolliePlugin.init({ vendureHost: 'https://yourhost.io/', useDynamicRedirectUrl: true }),
  *     ]
  *     ```
  * 2. Create a new PaymentMethod in the Admin UI, and select "Mollie payments" as the handler.
- * 3. Set the Redirect URL. This is the url that is used to redirect the end-user, e.g. `https://storefront/order`
- * 4. Set your Mollie apiKey in the `API Key` field.
- *
+ * 3. Set your Mollie apiKey in the `API Key` field.
+ *
+ * ## Specifying the redirectUrl
+ * 
+ * Currently, there are two ways to specify the `redirectUrl` to which the customer is redirected after completing the payment:
+ * 1. Configure the `redirectUrl` in the PaymentMethod. 
+ * 2. Pass the `redirectUrl` as an argument to the `createPaymentIntent` mutation.
+ * 
+ * Which method is used depends on the value of the `useDynamicRedirectUrl` option while initializing the plugin.
+ * By default, this option is set to `false` for backwards compatibility. In a future version, this option will be deprecated.
+ * Upon deprecation, the `redirectUrl` will always be passed as an argument to the `createPaymentIntent` mutation.
+ * 
+ * TODO toevoegen van /code weggehaald..!
  * ## Storefront usage
  *
  * In your storefront you add a payment to an order using the `createMolliePaymentIntent` mutation. In this example, our Mollie
- * PaymentMethod was given the code "mollie-payment-method".
+ * PaymentMethod was given the code "mollie-payment-method". The `redirectUrl``is the url that is used to redirect the end-user
+ * back to your storefront after completing the payment. When using the first method specified in `Specifying the redirectUrl`,
+ * the order code is appened to the `redirectUrl`. For the second method, the order code is not appended to the specified `redirectUrl`.
  *
  * ```GraphQL
  * mutation CreateMolliePaymentIntent {
  *   createMolliePaymentIntent(input: {
+ *     redirectUrl: "https://storefront/order"
  *     paymentMethodCode: "mollie-payment-method"
  *     molliePaymentMethodCode: "ideal"
  *   }) {
@@ -143,6 +168,7 @@ export class MolliePlugin {
      * @description
      * Initialize the mollie payment plugin
      * @param vendureHost is needed to pass to mollie for callback
+     * @param useDynamicRedirectUrl to indicate if the redirectUrl can be passed via the `createPaymentIntent` mutation, versus being configured in the Payment Method.
      */
     static init(options: MolliePluginOptions): typeof MolliePlugin {
         this.options = options;

+ 35 - 24
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -7,10 +7,8 @@ import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/orde
 import { Inject, Injectable } from '@nestjs/common';
 import {
     ActiveOrderService,
-    ChannelService,
     EntityHydrator,
     ErrorResult,
-    LanguageCode,
     Logger,
     Order,
     OrderService,
@@ -62,7 +60,6 @@ export class MollieService {
         @Inject(PLUGIN_INIT_OPTIONS) private options: MolliePluginOptions,
         private activeOrderService: ActiveOrderService,
         private orderService: OrderService,
-        private channelService: ChannelService,
         private entityHydrator: EntityHydrator,
         private variantService: ProductVariantService,
     ) {
@@ -73,8 +70,10 @@ export class MollieService {
      */
     async createPaymentIntent(
         ctx: RequestContext,
-        { paymentMethodCode, molliePaymentMethodCode }: MolliePaymentIntentInput,
+        input: MolliePaymentIntentInput,
     ): Promise<MolliePaymentIntentResult> {
+        const { paymentMethodCode, molliePaymentMethodCode } = input;
+        let redirectUrl: string;
         const allowedMethods = Object.values(MollieClientMethod) as string[];
         if (molliePaymentMethodCode && !allowedMethods.includes(molliePaymentMethodCode)) {
             return new InvalidInputError(`molliePaymentMethodCode has to be one of "${allowedMethods.join(',')}"`);
@@ -95,12 +94,34 @@ export class MollieService {
         if (!order.customer) {
             return new PaymentIntentError('Cannot create payment intent for order without customer');
         }
+        if (!order.customer.firstName.length) {
+            return new PaymentIntentError('Cannot create payment intent for order with customer that has no firstName set');
+        }
+        if (!order.customer.lastName.length) {
+            return new PaymentIntentError('Cannot create payment intent for order with customer that has no lastName set');
+        }
+        if (!order.customer.emailAddress.length) {
+            return new PaymentIntentError('Cannot create payment intent for order with customer that has no emailAddress set');
+        }
         if (!order.shippingLines?.length) {
             return new PaymentIntentError('Cannot create payment intent for order without shippingMethod');
         }
         if (!paymentMethod) {
             return new PaymentIntentError(`No paymentMethod found with code ${paymentMethodCode}`);
         }
+        if (this.options.useDynamicRedirectUrl == true) {
+            if (!input.redirectUrl) {
+                return new InvalidInputError(`Cannot create payment intent without redirectUrl specified`);
+            }
+            redirectUrl = input.redirectUrl;
+        } else {
+            let paymentMethodRedirectUrl =  paymentMethod.handler.args.find(arg => arg.name === 'redirectUrl')?.value;
+            if (!paymentMethodRedirectUrl) {
+                return new PaymentIntentError(`Cannot create payment intent without redirectUrl specified in paymentMethod`);
+            }
+            redirectUrl = paymentMethodRedirectUrl;
+
+        }
         const variantsWithInsufficientSaleableStock = await this.getVariantsWithInsufficientStock(ctx, order);
         if (variantsWithInsufficientSaleableStock.length) {
             return new PaymentIntentError(
@@ -108,26 +129,28 @@ export class MollieService {
             );
         }
         const apiKey = paymentMethod.handler.args.find(arg => arg.name === 'apiKey')?.value;
-        let redirectUrl = paymentMethod.handler.args.find(arg => arg.name === 'redirectUrl')?.value;
-        if (!apiKey || !redirectUrl) {
-            Logger.warn(`CreatePaymentIntent failed, because no apiKey or redirect is configured for ${paymentMethod.code}`, loggerCtx);
-            return new PaymentIntentError(`Paymentmethod ${paymentMethod.code} has no apiKey or redirectUrl configured`);
+        if (!apiKey) {
+            Logger.warn(
+                `CreatePaymentIntent failed, because no apiKey is configured for ${paymentMethod.code}`,
+                loggerCtx,
+            );
+            return new PaymentIntentError(`Paymentmethod ${paymentMethod.code} has no apiKey configured`);
         }
         const mollieClient = createMollieClient({ apiKey });
-        redirectUrl = redirectUrl.endsWith('/') ? redirectUrl.slice(0, -1) : redirectUrl; // remove appending slash
+        redirectUrl = redirectUrl.endsWith('/') && this.options.useDynamicRedirectUrl != true ? redirectUrl.slice(0, -1) : redirectUrl; // remove appending slash
         const vendureHost = this.options.vendureHost.endsWith('/')
             ? this.options.vendureHost.slice(0, -1)
             : this.options.vendureHost; // remove appending slash
         const billingAddress = toMollieAddress(order.billingAddress, order.customer) || toMollieAddress(order.shippingAddress, order.customer);
         if (!billingAddress) {
-            return new InvalidInputError(`Order doesn't have a complete shipping address or billing address. At least city, streetline1 and country are needed to create a payment intent.`);
+            return new InvalidInputError(`Order doesn't have a complete shipping address or billing address. At least city, postalCode, streetline1 and country are needed to create a payment intent.`);
         }
         const alreadyPaid = totalCoveredByPayments(order);
         const amountToPay = order.totalWithTax - alreadyPaid;
         const orderInput: CreateParameters = {
             orderNumber: order.code,
             amount: toAmount(amountToPay, order.currencyCode),
-            redirectUrl: `${redirectUrl}/${order.code}`,
+            redirectUrl: this.options.useDynamicRedirectUrl == true ? redirectUrl :`${redirectUrl}/${order.code}`,
             webhookUrl: `${vendureHost}/payments/mollie/${ctx.channel.token}/${paymentMethod.id}`,
             billingAddress,
             locale: getLocale(billingAddress.country, ctx.languageCode),
@@ -150,8 +173,7 @@ export class MollieService {
     /**
      * Update Vendure payments and order status based on the incoming Mollie order
      */
-    async handleMollieStatusUpdate({ channelToken, paymentMethodId, orderId }: OrderStatusInput): Promise<void> {
-        const ctx = await this.createContext(channelToken);
+    async handleMollieStatusUpdate(ctx: RequestContext, { channelToken, paymentMethodId, orderId }: OrderStatusInput): Promise<void> {
         Logger.info(`Received status update for channel ${channelToken} for Mollie order ${orderId}`, loggerCtx);
         const paymentMethod = await this.paymentMethodService.findOne(ctx, paymentMethodId);
         if (!paymentMethod) {
@@ -285,15 +307,4 @@ export class MollieService {
         const paymentMethods = await this.paymentMethodService.findAll(ctx);
         return paymentMethods.items.find(pm => pm.code === paymentMethodCode);
     }
-
-    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,
-        });
-    }
 }