Просмотр исходного кода

feat(payments-plugin): Backend admin api implemented

Martijn 1 год назад
Родитель
Сommit
5390a4c8b4

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

@@ -34,9 +34,7 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
  * Make sure you have `MOLLIE_APIKEY=test_xxxx` in your .env file
  */
 /* eslint-disable @typescript-eslint/no-floating-promises */
-async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
-    // eslint-disable-next-line no-console
-    console.log('Starting Mollie dev server with dynamic redirectUrl: ', useDynamicRedirectUrl);
+async function runMollieDevServer() {
     // eslint-disable-next-line @typescript-eslint/no-var-requires
     require('dotenv').config();
 
@@ -50,7 +48,7 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
                 route: 'admin',
                 port: 5001,
             }),
-            MolliePlugin.init({ vendureHost: tunnel.url, useDynamicRedirectUrl }),
+            MolliePlugin.init({ vendureHost: tunnel.url }),
         ],
         logger: new DefaultLogger({ level: LogLevel.Debug }),
         apiOptions: {
@@ -92,7 +90,7 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
                     arguments: [
                         {
                             name: 'redirectUrl',
-                            value: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=false`,
+                            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! },
@@ -118,7 +116,7 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
     // Create payment intent
     const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
         input: {
-            redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=true`,
+            redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1`,
             paymentMethodCode: 'mollie',
             //            molliePaymentMethodCode: 'klarnapaylater'
         },
@@ -133,7 +131,7 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
     await new Promise(resolve => setTimeout(resolve, 10000));
     const { createMolliePaymentIntent: secondIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
         input: {
-            redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=true`,
+            redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1`,
             paymentMethodCode: 'mollie',
         },
     });
@@ -142,6 +140,5 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
 }
 
 (async () => {
-    // Change the value of the parameter to true to test with the dynamic redirectUrl functionality
-    await runMollieDevServer(false);
+    await runMollieDevServer();
 })();

+ 361 - 567
packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts

@@ -55,7 +55,7 @@ import {
 
 const mockData = {
     host: 'https://my-vendure.io',
-    redirectUrl: 'https://my-storefront/order',
+    redirectUrl: 'https://fallback-redirect/order',
     apiKey: 'myApiKey',
     methodCode: `mollie-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
     methodCodeBroken: `mollie-payment-broken-${E2E_DEFAULT_CHANNEL_TOKEN}`,
@@ -139,568 +139,45 @@ let order: TestOrderFragmentFragment;
 let serverPort: number;
 const SURCHARGE_AMOUNT = -20000;
 
-describe('Mollie payments with useDynamicRedirectUrl=false', () => {
-    beforeAll(async () => {
-        const devConfig = mergeConfig(testConfig(), {
-            plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
-        });
-        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<GetCustomerListQuery, GetCustomerListQueryVariables>(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);
-    });
-
-    describe('Payment intent creation', () => {
-        it('Should prepare an order', async () => {
-            await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-            const { addItemToOrder } = await shopClient.query<
-                AddItemToOrderMutation,
-                AddItemToOrderMutationVariables
-            >(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 Mollie paymentMethod', async () => {
-            const { createPaymentMethod } = await adminClient.query<
-                CreatePaymentMethodMutation,
-                CreatePaymentMethodMutationVariables
-            >(CREATE_PAYMENT_METHOD, {
-                input: {
-                    code: mockData.methodCode,
-                    enabled: true,
-                    handler: {
-                        code: molliePaymentHandler.code,
-                        arguments: [
-                            { name: 'redirectUrl', value: mockData.redirectUrl },
-                            { name: 'apiKey', value: mockData.apiKey },
-                            { name: 'autoCapture', value: 'false' },
-                        ],
-                    },
-                    translations: [
-                        {
-                            languageCode: LanguageCode.en,
-                            name: 'Mollie payment test',
-                            description: 'This is a Mollie test payment method',
-                        },
-                    ],
-                },
-            });
-            expect(createPaymentMethod.code).toBe(mockData.methodCode);
-        });
-
-        it('Should fail to create payment intent without shippingmethod', async () => {
-            await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-            const { createMolliePaymentIntent: result } = await shopClient.query(
-                CREATE_MOLLIE_PAYMENT_INTENT,
-                {
-                    input: {
-                        paymentMethodCode: mockData.methodCode,
-                    },
-                },
-            );
-            expect(result.errorCode).toBe('ORDER_PAYMENT_STATE_ERROR');
-        });
-
-        it('Should fail to create payment intent with invalid Mollie method', async () => {
-            await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-            await setShipping(shopClient);
-            const { createMolliePaymentIntent: result } = await shopClient.query(
-                CREATE_MOLLIE_PAYMENT_INTENT,
-                {
-                    input: {
-                        paymentMethodCode: mockData.methodCode,
-                        molliePaymentMethodCode: 'invalid',
-                    },
-                },
-            );
-            expect(result.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
-        });
-
-        it('Should fail to get payment url when items are out of stock', async () => {
-            let { updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
-                input: {
-                    id: 'T_5',
-                    trackInventory: 'TRUE',
-                    outOfStockThreshold: 0,
-                    stockOnHand: 1,
-                },
-            });
-            expect(updateProductVariants[0].stockOnHand).toBe(1);
-            const { createMolliePaymentIntent: result } = await shopClient.query(
-                CREATE_MOLLIE_PAYMENT_INTENT,
-                {
-                    input: {
-                        paymentMethodCode: mockData.methodCode,
-                    },
-                },
-            );
-            expect(result.message).toContain('insufficient stock of Pinelab stickers');
-            // Set stock back to not tracking
-            ({ updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
-                input: {
-                    id: 'T_5',
-                    trackInventory: 'FALSE',
-                },
-            }));
-            expect(updateProductVariants[0].trackInventory).toBe('FALSE');
-        });
-
-        it('Should get payment url without Mollie method', async () => {
-            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,
-                },
-            });
-            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}/${order.code}`);
-            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',
-                },
-            });
-            expect(createMolliePaymentIntent).toEqual({
-                url: 'https://www.mollie.com/payscreen/select-method/mock-payment',
-            });
-        });
-
-        it('Should reuse payment url when amount is the same', async () => {
-            // Should only fetch the order from Mollie, not create a new one
-            nock('https://api.mollie.com/')
-                .get('/v2/orders/ord_mockId')
-                .reply(200, {
-                    ...mockData.mollieOrderResponse,
-                    amount: {
-                        value: '1009.90',
-                        currency: 'USD',
-                    },
-                    _links: {
-                        // Mock a new checkout url, to test that this one is actually reused
-                        checkout: {
-                            href: 'https://this-means-reuse-succeeded',
-                        },
-                    },
-                });
-            const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
-                input: {
-                    paymentMethodCode: mockData.methodCode,
-                },
-            });
-            expect(createMolliePaymentIntent).toEqual({ url: 'https://this-means-reuse-succeeded' });
-        });
-
-        it('Should get payment url with deducted amount if a payment is already made', async () => {
-            let mollieRequest: any | undefined;
-            nock('https://api.mollie.com/')
-                .post('/v2/orders', body => {
-                    mollieRequest = body;
-                    return true;
-                })
-                .reply(200, mockData.mollieOrderResponse);
-            await addManualPayment(server, 1, 10000);
-            await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
-                input: {
-                    paymentMethodCode: mockData.methodCode,
-                },
-            });
-            expect(mollieRequest.amount?.value).toBe('909.90'); // minus 100,00 from manual payment
-            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 available paymentMethods', async () => {
-            nock('https://api.mollie.com/')
-                .get('/v2/methods?resource=orders')
-                .reply(200, mockData.molliePaymentMethodsResponse);
-            await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-            const { molliePaymentMethods } = await shopClient.query(GET_MOLLIE_PAYMENT_METHODS, {
-                input: {
-                    paymentMethodCode: mockData.methodCode,
-                },
-            });
-            const method = molliePaymentMethods[0];
-            expect(method.code).toEqual('ideal');
-            expect(method.minimumAmount).toBeDefined();
-            expect(method.maximumAmount).toBeDefined();
-            expect(method.image).toBeDefined();
-        });
-    });
-
-    describe('Handle standard payment methods', () => {
-        it('Should transition to ArrangingPayment when partially paid', async () => {
-            nock('https://api.mollie.com/')
-                .get('/v2/orders/ord_mockId')
-                .reply(200, {
-                    ...mockData.mollieOrderResponse,
-                    // Add a payment of 20.00
-                    amount: { value: '20.00', currency: 'EUR' },
-                    orderNumber: order.code,
-                    status: OrderStatus.paid,
-                });
-            await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
-                method: 'post',
-                body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
-                headers: { 'Content-Type': 'application/json' },
-            });
-            const { order: adminOrder } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order?.id });
-            expect(adminOrder.state).toBe('ArrangingPayment');
-        });
-
-        let orderPlacedEvent: OrderPlacedEvent | undefined;
-
-        it('Should place order after paying outstanding amount', async () => {
-            server.app
-                .get(EventBus)
-                .ofType(OrderPlacedEvent)
-                .subscribe(event => {
-                    orderPlacedEvent = event;
-                });
-            nock('https://api.mollie.com/')
-                .get('/v2/orders/ord_mockId')
-                .reply(200, {
-                    ...mockData.mollieOrderResponse,
-                    // Add a payment of 1089.90
-                    amount: { value: '1089.90', currency: 'EUR' }, // 1109.90 minus the previously paid 20.00
-                    orderNumber: order.code,
-                    status: OrderStatus.paid,
-                });
-            await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
-                method: 'post',
-                body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
-                headers: { 'Content-Type': 'application/json' },
-            });
-            const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
-                GET_ORDER_BY_CODE,
-                {
-                    code: order.code,
-                },
-            );
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            order = orderByCode!;
-            expect(order.state).toBe('PaymentSettled');
-        });
-
-        it('Should have preserved original languageCode ', async () => {
-            // We've set the languageCode to 'nl' in the mock response's metadata
-            expect(orderPlacedEvent?.ctx.languageCode).toBe('nl');
-        });
-
-        it('Should have Mollie metadata on payment', async () => {
-            const {
-                order: { payments },
-            } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
-            const metadata = payments[1].metadata;
-            expect(metadata.mode).toBe(mockData.mollieOrderResponse.mode);
-            expect(metadata.method).toBe(mockData.mollieOrderResponse.method);
-            expect(metadata.profileId).toBe(mockData.mollieOrderResponse.profileId);
-            expect(metadata.authorizedAt).toEqual(mockData.mollieOrderResponse.authorizedAt.toISOString());
-            expect(metadata.paidAt).toEqual(mockData.mollieOrderResponse.paidAt.toISOString());
-        });
-
-        it('Should fail to refund', async () => {
-            nock('https://api.mollie.com/')
-                .get('/v2/orders/ord_mockId?embed=payments')
-                .reply(200, mockData.mollieOrderResponse);
-            nock('https://api.mollie.com/')
-                .post('/v2/payments/tr_mockPayment/refunds')
-                .reply(200, { status: 'failed', resource: 'payment' });
-            const refund = await refundOrderLine(
-                adminClient,
-                order.lines[0].id,
-                1,
-                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                order!.payments![1].id,
-                SURCHARGE_AMOUNT,
-            );
-            expect(refund.state).toBe('Failed');
-        });
-
-        it('Should successfully refund the Mollie payment', async () => {
-            let mollieRequest: any;
-            nock('https://api.mollie.com/')
-                .get('/v2/orders/ord_mockId?embed=payments')
-                .reply(200, mockData.mollieOrderResponse);
-            nock('https://api.mollie.com/')
-                .post('/v2/payments/tr_mockPayment/refunds', body => {
-                    mollieRequest = body;
-                    return true;
-                })
-                .reply(200, { status: 'pending', resource: 'payment' });
-            const refund = await refundOrderLine(
-                adminClient,
-                order.lines[0].id,
-                10,
-                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                order.payments!.find(p => p.amount === 108990)!.id,
-                SURCHARGE_AMOUNT,
-            );
-            expect(mollieRequest?.amount.value).toBe('999.90'); // Only refund mollie amount, not the gift card
-            expect(refund.total).toBe(99990);
-            expect(refund.state).toBe('Settled');
-        });
+beforeAll(async () => {
+    const devConfig = mergeConfig(testConfig(), {
+        plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
     });
-
-    describe('Handle pay-later methods', () => {
-        // TODO: Add testcases that mock incoming webhook to: 1. Authorize payment and 2. AutoCapture payments
-
-        it('Should prepare a new order', async () => {
-            await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-            const { addItemToOrder } = await shopClient.query<
-                AddItemToOrderMutation,
-                AddItemToOrderMutationVariables
-            >(ADD_ITEM_TO_ORDER, {
-                productVariantId: 'T_1',
-                quantity: 2,
-            });
-            order = addItemToOrder as TestOrderFragmentFragment;
-            await setShipping(shopClient);
-            expect(order.code).toBeDefined();
-        });
-
-        it('Should authorize payment for pay-later payment methods', async () => {
-            nock('https://api.mollie.com/')
-                .get('/v2/orders/ord_mockId')
-                .reply(200, {
-                    ...mockData.mollieOrderResponse,
-                    amount: { value: '3127.60', currency: 'EUR' },
-                    orderNumber: order.code,
-                    status: OrderStatus.authorized,
-                });
-            await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
-                method: 'post',
-                body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
-                headers: { 'Content-Type': 'application/json' },
-            });
-            const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
-                GET_ORDER_BY_CODE,
-                {
-                    code: order.code,
-                },
-            );
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            order = orderByCode!;
-            expect(order.state).toBe('PaymentAuthorized');
-        });
-
-        it('Should settle payment via settlePayment mutation', async () => {
-            // Mock the getOrder Mollie call
-            nock('https://api.mollie.com/')
-                .get('/v2/orders/ord_mockId')
-                .reply(200, {
-                    ...mockData.mollieOrderResponse,
-                    orderNumber: order.code,
-                    status: OrderStatus.authorized,
-                });
-            // Mock the createShipment call
-            let createShipmentBody;
-            nock('https://api.mollie.com/')
-                .post('/v2/orders/ord_mockId/shipments', body => {
-                    createShipmentBody = body;
-                    return true;
-                })
-                .reply(200, { resource: 'shipment', lines: [] });
-            const { settlePayment } = await adminClient.query<
-                SettlePaymentMutation,
-                SettlePaymentMutationVariables
-            >(SETTLE_PAYMENT, {
-                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                id: order.payments![0].id,
-            });
-            const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
-                GET_ORDER_BY_CODE,
-                {
-                    code: order.code,
-                },
-            );
-            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            order = orderByCode!;
-            expect(createShipmentBody).toBeDefined();
-            expect(order.state).toBe('PaymentSettled');
-        });
-    });
-
-    it('Should add an unusable Mollie paymentMethod (missing redirectUrl)', async () => {
-        const { createPaymentMethod } = await adminClient.query<
-            CreatePaymentMethodMutation,
-            CreatePaymentMethodMutationVariables
-        >(CREATE_PAYMENT_METHOD, {
-            input: {
-                code: mockData.methodCodeBroken,
-
-                enabled: true,
-                handler: {
-                    code: molliePaymentHandler.code,
-                    arguments: [
-                        { name: 'apiKey', value: mockData.apiKey },
-                        { name: 'autoCapture', value: 'false' },
-                    ],
-                },
-                translations: [
-                    {
-                        languageCode: LanguageCode.en,
-                        name: 'Mollie payment test',
-                        description: 'This is a Mollie test payment method',
-                    },
-                ],
-            },
-        });
-        expect(createPaymentMethod.code).toBe(mockData.methodCodeBroken);
-    });
-
-    it('Should prepare an order', async () => {
-        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-        const { addItemToOrder } = await shopClient.query<
-            AddItemToOrderMutation,
-            AddItemToOrderMutationVariables
-        >(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();
+    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<GetCustomerListQuery, GetCustomerListQueryVariables>(GET_CUSTOMER_LIST, {
+        options: {
+            take: 2,
+        },
+    }));
+}, TEST_SETUP_TIMEOUT_MS);
 
-    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',
-        );
-    });
+afterAll(async () => {
+    await server.destroy();
 });
 
-describe('Mollie payments with useDynamicRedirectUrl=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<GetCustomerListQuery, GetCustomerListQueryVariables>(GET_CUSTOMER_LIST, {
-            options: {
-                take: 2,
-            },
-        }));
-    }, TEST_SETUP_TIMEOUT_MS);
-
-    afterAll(async () => {
-        await server.destroy();
-    });
-
-    afterEach(async () => {
-        nock.cleanAll();
-    });
+afterEach(() => {
+    nock.cleanAll();
+});
 
-    it('Should start successfully', async () => {
-        expect(started).toEqual(true);
-        expect(customers).toHaveLength(2);
-    });
+it('Should start successfully', () => {
+    expect(started).toEqual(true);
+    expect(customers).toHaveLength(2);
+});
 
+describe('Payment intent creation', () => {
     it('Should prepare an order', async () => {
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         const { addItemToOrder } = await shopClient.query<
@@ -725,7 +202,7 @@ describe('Mollie payments with useDynamicRedirectUrl=true', () => {
         expect(order.code).toBeDefined();
     });
 
-    it('Should add a working Mollie paymentMethod without specifying redirectUrl', async () => {
+    it('Should add a Mollie paymentMethod', async () => {
         const { createPaymentMethod } = await adminClient.query<
             CreatePaymentMethodMutation,
             CreatePaymentMethodMutationVariables
@@ -736,6 +213,7 @@ describe('Mollie payments with useDynamicRedirectUrl=true', () => {
                 handler: {
                     code: molliePaymentHandler.code,
                     arguments: [
+                        { name: 'redirectUrl', value: mockData.redirectUrl },
                         { name: 'apiKey', value: mockData.apiKey },
                         { name: 'autoCapture', value: 'false' },
                     ],
@@ -752,8 +230,55 @@ describe('Mollie payments with useDynamicRedirectUrl=true', () => {
         expect(createPaymentMethod.code).toBe(mockData.methodCode);
     });
 
-    it('Should get payment url without Mollie method', async () => {
+    it('Should fail to create payment intent without shippingmethod', async () => {
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        const { createMolliePaymentIntent: result } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+            },
+        });
+        expect(result.errorCode).toBe('ORDER_PAYMENT_STATE_ERROR');
+    });
+
+    it('Should fail to create payment intent with invalid Mollie method', async () => {
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         await setShipping(shopClient);
+        const { createMolliePaymentIntent: result } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+                molliePaymentMethodCode: 'invalid',
+            },
+        });
+        expect(result.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
+    });
+
+    it('Should fail to get payment url when items are out of stock', async () => {
+        let { updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
+            input: {
+                id: 'T_5',
+                trackInventory: 'TRUE',
+                outOfStockThreshold: 0,
+                stockOnHand: 1,
+            },
+        });
+        expect(updateProductVariants[0].stockOnHand).toBe(1);
+        const { createMolliePaymentIntent: result } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+            },
+        });
+        expect(result.message).toContain('insufficient stock of Pinelab stickers');
+        // Set stock back to not tracking
+        ({ updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
+            input: {
+                id: 'T_5',
+                trackInventory: 'FALSE',
+            },
+        }));
+        expect(updateProductVariants[0].trackInventory).toBe('FALSE');
+    });
+
+    it('Should get payment url without Mollie method', async () => {
         let mollieRequest: any | undefined;
         nock('https://api.mollie.com/')
             .post('/v2/orders', body => {
@@ -764,14 +289,14 @@ describe('Mollie payments with useDynamicRedirectUrl=true', () => {
         const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
             input: {
                 paymentMethodCode: mockData.methodCode,
-                redirectUrl: mockData.redirectUrl,
+                redirectUrl: 'given-storefront-redirect-url',
             },
         });
         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?.redirectUrl).toEqual('given-storefront-redirect-url');
         expect(mollieRequest?.webhookUrl).toEqual(
             `${mockData.host}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`,
         );
@@ -786,6 +311,22 @@ describe('Mollie payments with useDynamicRedirectUrl=true', () => {
         expect(mollieRequest.amount.value).toEqual(totalLineAmount.toFixed(2));
     });
 
+    it('Should use fallback redirect when no redirect is given', async () => {
+        let mollieRequest: any | undefined;
+        nock('https://api.mollie.com/')
+            .post('/v2/orders', body => {
+                mollieRequest = body;
+                return true;
+            })
+            .reply(200, mockData.mollieOrderResponse);
+        await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+            },
+        });
+        expect(mollieRequest?.redirectUrl).toEqual(`${mockData.redirectUrl}/${order.code}`);
+    });
+
     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');
@@ -794,7 +335,6 @@ describe('Mollie payments with useDynamicRedirectUrl=true', () => {
             input: {
                 paymentMethodCode: mockData.methodCode,
                 molliePaymentMethodCode: 'ideal',
-                redirectUrl: mockData.redirectUrl,
             },
         });
         expect(createMolliePaymentIntent).toEqual({
@@ -802,18 +342,272 @@ describe('Mollie payments with useDynamicRedirectUrl=true', () => {
         });
     });
 
-    it('Should fail to get payment url without specifying redirectUrl in the createMolliePaymentIntent mutation', async () => {
+    it('Should reuse payment url when amount is the same', async () => {
+        // Should only fetch the order from Mollie, not create a new one
+        nock('https://api.mollie.com/')
+            .get('/v2/orders/ord_mockId')
+            .reply(200, {
+                ...mockData.mollieOrderResponse,
+                amount: {
+                    value: '1009.90',
+                    currency: 'USD',
+                },
+                _links: {
+                    // Mock a new checkout url, to test that this one is actually reused
+                    checkout: {
+                        href: 'https://this-means-reuse-succeeded',
+                    },
+                },
+            });
+        const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+            },
+        });
+        expect(createMolliePaymentIntent).toEqual({ url: 'https://this-means-reuse-succeeded' });
+    });
+
+    it('Should get payment url with deducted amount if a payment is already made', async () => {
+        let mollieRequest: any | undefined;
+        nock('https://api.mollie.com/')
+            .post('/v2/orders', body => {
+                mollieRequest = body;
+                return true;
+            })
+            .reply(200, mockData.mollieOrderResponse);
+        await addManualPayment(server, 1, 10000);
+        await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+            },
+        });
+        expect(mollieRequest.amount?.value).toBe('909.90'); // minus 100,00 from manual payment
+        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 create intent as admin', async () => {
         nock('https://api.mollie.com/').post('/v2/orders').reply(200, mockData.mollieOrderResponse);
+        // Admin API passes order ID, and no payment method code
+        const { createMolliePaymentIntent: intent } = await adminClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                orderId: '1',
+            },
+        });
+        expect(intent.url).toBe(mockData.mollieOrderResponse._links.checkout.href);
+    });
+
+    it('Should get available paymentMethods', async () => {
+        nock('https://api.mollie.com/')
+            .get('/v2/methods?resource=orders')
+            .reply(200, mockData.molliePaymentMethodsResponse);
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-        await setShipping(shopClient);
-        const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+        const { molliePaymentMethods } = await shopClient.query(GET_MOLLIE_PAYMENT_METHODS, {
             input: {
                 paymentMethodCode: mockData.methodCode,
-                molliePaymentMethodCode: 'ideal',
             },
         });
-        expect(createMolliePaymentIntent.message).toContain(
-            'Cannot create payment intent without redirectUrl specified',
+        const method = molliePaymentMethods[0];
+        expect(method.code).toEqual('ideal');
+        expect(method.minimumAmount).toBeDefined();
+        expect(method.maximumAmount).toBeDefined();
+        expect(method.image).toBeDefined();
+    });
+});
+
+describe('Handle standard payment methods', () => {
+    it('Should transition to ArrangingPayment when partially paid', async () => {
+        nock('https://api.mollie.com/')
+            .get('/v2/orders/ord_mockId')
+            .reply(200, {
+                ...mockData.mollieOrderResponse,
+                // Add a payment of 20.00
+                amount: { value: '20.00', currency: 'EUR' },
+                orderNumber: order.code,
+                status: OrderStatus.paid,
+            });
+        await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
+            method: 'post',
+            body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
+            headers: { 'Content-Type': 'application/json' },
+        });
+        const { order: adminOrder } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order?.id });
+        expect(adminOrder.state).toBe('ArrangingPayment');
+    });
+
+    let orderPlacedEvent: OrderPlacedEvent | undefined;
+
+    it('Should place order after paying outstanding amount', async () => {
+        server.app
+            .get(EventBus)
+            .ofType(OrderPlacedEvent)
+            .subscribe(event => {
+                orderPlacedEvent = event;
+            });
+        nock('https://api.mollie.com/')
+            .get('/v2/orders/ord_mockId')
+            .reply(200, {
+                ...mockData.mollieOrderResponse,
+                // Add a payment of 1089.90
+                amount: { value: '1089.90', currency: 'EUR' }, // 1109.90 minus the previously paid 20.00
+                orderNumber: order.code,
+                status: OrderStatus.paid,
+            });
+        await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
+            method: 'post',
+            body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
+            headers: { 'Content-Type': 'application/json' },
+        });
+        const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
+            GET_ORDER_BY_CODE,
+            {
+                code: order.code,
+            },
+        );
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        order = orderByCode!;
+        expect(order.state).toBe('PaymentSettled');
+    });
+
+    it('Should have preserved original languageCode ', () => {
+        // We've set the languageCode to 'nl' in the mock response's metadata
+        expect(orderPlacedEvent?.ctx.languageCode).toBe('nl');
+    });
+
+    it('Should have Mollie metadata on payment', async () => {
+        const {
+            order: { payments },
+        } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
+        const metadata = payments[1].metadata;
+        expect(metadata.mode).toBe(mockData.mollieOrderResponse.mode);
+        expect(metadata.method).toBe(mockData.mollieOrderResponse.method);
+        expect(metadata.profileId).toBe(mockData.mollieOrderResponse.profileId);
+        expect(metadata.authorizedAt).toEqual(mockData.mollieOrderResponse.authorizedAt.toISOString());
+        expect(metadata.paidAt).toEqual(mockData.mollieOrderResponse.paidAt.toISOString());
+    });
+
+    it('Should fail to refund', async () => {
+        nock('https://api.mollie.com/')
+            .get('/v2/orders/ord_mockId?embed=payments')
+            .reply(200, mockData.mollieOrderResponse);
+        nock('https://api.mollie.com/')
+            .post('/v2/payments/tr_mockPayment/refunds')
+            .reply(200, { status: 'failed', resource: 'payment' });
+        const refund = await refundOrderLine(
+            adminClient,
+            order.lines[0].id,
+            1,
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            order!.payments![1].id,
+            SURCHARGE_AMOUNT,
+        );
+        expect(refund.state).toBe('Failed');
+    });
+
+    it('Should successfully refund the Mollie payment', async () => {
+        let mollieRequest: any;
+        nock('https://api.mollie.com/')
+            .get('/v2/orders/ord_mockId?embed=payments')
+            .reply(200, mockData.mollieOrderResponse);
+        nock('https://api.mollie.com/')
+            .post('/v2/payments/tr_mockPayment/refunds', body => {
+                mollieRequest = body;
+                return true;
+            })
+            .reply(200, { status: 'pending', resource: 'payment' });
+        const refund = await refundOrderLine(
+            adminClient,
+            order.lines[0].id,
+            10,
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            order.payments!.find(p => p.amount === 108990)!.id,
+            SURCHARGE_AMOUNT,
+        );
+        expect(mollieRequest?.amount.value).toBe('999.90'); // Only refund mollie amount, not the gift card
+        expect(refund.total).toBe(99990);
+        expect(refund.state).toBe('Settled');
+    });
+});
+
+describe('Handle pay-later methods', () => {
+    // TODO: Add testcases that mock incoming webhook to: 1. Authorize payment and 2. AutoCapture payments
+
+    it('Should prepare a new order', async () => {
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        const { addItemToOrder } = await shopClient.query<
+            AddItemToOrderMutation,
+            AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 2,
+        });
+        order = addItemToOrder as TestOrderFragmentFragment;
+        await setShipping(shopClient);
+        expect(order.code).toBeDefined();
+    });
+
+    it('Should authorize payment for pay-later payment methods', async () => {
+        nock('https://api.mollie.com/')
+            .get('/v2/orders/ord_mockId')
+            .reply(200, {
+                ...mockData.mollieOrderResponse,
+                amount: { value: '3127.60', currency: 'EUR' },
+                orderNumber: order.code,
+                status: OrderStatus.authorized,
+            });
+        await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
+            method: 'post',
+            body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
+            headers: { 'Content-Type': 'application/json' },
+        });
+        const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
+            GET_ORDER_BY_CODE,
+            {
+                code: order.code,
+            },
+        );
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        order = orderByCode!;
+        expect(order.state).toBe('PaymentAuthorized');
+    });
+
+    it('Should settle payment via settlePayment mutation', async () => {
+        // Mock the getOrder Mollie call
+        nock('https://api.mollie.com/')
+            .get('/v2/orders/ord_mockId')
+            .reply(200, {
+                ...mockData.mollieOrderResponse,
+                orderNumber: order.code,
+                status: OrderStatus.authorized,
+            });
+        // Mock the createShipment call
+        let createShipmentBody;
+        nock('https://api.mollie.com/')
+            .post('/v2/orders/ord_mockId/shipments', body => {
+                createShipmentBody = body;
+                return true;
+            })
+            .reply(200, { resource: 'shipment', lines: [] });
+        const { settlePayment } = await adminClient.query<
+            SettlePaymentMutation,
+            SettlePaymentMutationVariables
+        >(SETTLE_PAYMENT, {
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            id: order.payments![0].id,
+        });
+        const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
+            GET_ORDER_BY_CODE,
+            {
+                code: order.code,
+            },
         );
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        order = orderByCode!;
+        expect(createShipmentBody).toBeDefined();
+        expect(order.state).toBe('PaymentSettled');
     });
 });

Разница между файлами не показана из-за своего большого размера
+ 670 - 650
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts


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

@@ -1,44 +0,0 @@
-import { gql } from 'graphql-tag';
-
-export const shopSchema = gql`
-    type MolliePaymentIntentError implements ErrorResult {
-        errorCode: ErrorCode!
-        message: String!
-    }
-    type MollieAmount {
-        value: String
-        currency: String
-    }
-    type MolliePaymentMethodImages {
-        size1x: String
-        size2x: String
-        svg: String
-    }
-    type MolliePaymentMethod {
-        id: ID!
-        code: String!
-        description: String
-        minimumAmount: MollieAmount
-        maximumAmount: MollieAmount
-        image: MolliePaymentMethodImages
-        status: String
-    }
-    type MolliePaymentIntent {
-        url: String!
-    }
-    union MolliePaymentIntentResult = MolliePaymentIntent | MolliePaymentIntentError
-    input MolliePaymentIntentInput {
-        redirectUrl: String
-        paymentMethodCode: String!
-        molliePaymentMethodCode: String
-    }
-    input MolliePaymentMethodsInput {
-        paymentMethodCode: String!
-    }
-    extend type Mutation {
-        createMolliePaymentIntent(input: MolliePaymentIntentInput!): MolliePaymentIntentResult!
-    }
-    extend type Query {
-        molliePaymentMethods(input: MolliePaymentMethodsInput!): [MolliePaymentMethod!]!
-    }
-`;

+ 13 - 5
packages/payments-plugin/src/mollie/mollie.handler.ts

@@ -43,13 +43,16 @@ export const molliePaymentHandler = new PaymentMethodHandler({
         },
         redirectUrl: {
             type: 'string',
-            required: false,
+            required: true,
             defaultValue: '',
-            label: [{ languageCode: LanguageCode.en, value: 'Redirect URL' }],
+            label: [{ languageCode: LanguageCode.en, value: 'Fallback redirect URL' }],
             description: [
-                { languageCode: LanguageCode.en, value: 'Redirect the client to this URL after payment' },
+                {
+                    languageCode: LanguageCode.en,
+                    value: 'Redirect URl to use when no url is given by the storefront. Order code is appended to this URL',
+                },
             ],
-        }
+        },
     },
     init(injector) {
         mollieService = injector.get(MollieService);
@@ -72,7 +75,12 @@ export const molliePaymentHandler = new PaymentMethodHandler({
                 }. Only Authorized or Settled are allowed.`,
             );
         }
-        Logger.info(`Payment for order ${order.code} with amount ${metadata.amount} created with state '${metadata.status}'`, loggerCtx);
+        Logger.info(
+            `Payment for order ${order.code} with amount ${metadata.amount as string} created with state '${
+                metadata.status as string
+            }'`,
+            loggerCtx,
+        );
         return {
             amount: metadata.amount,
             state: metadata.status,

+ 15 - 34
packages/payments-plugin/src/mollie/mollie.plugin.ts

@@ -8,13 +8,14 @@ import {
     VendurePlugin,
 } from '@vendure/core';
 
+import { shopApiExtensions, adminApiExtensions } from './api-extensions';
 import { PLUGIN_INIT_OPTIONS } from './constants';
 import { orderCustomFields } from './custom-fields';
-import { shopSchema } from './mollie-shop-schema';
+import { MollieCommonResolver } from './mollie.common-resolver';
 import { MollieController } from './mollie.controller';
 import { molliePaymentHandler } from './mollie.handler';
-import { MollieResolver } from './mollie.resolver';
 import { MollieService } from './mollie.service';
+import { MollieShopResolver } from './mollie.shop-resolver';
 
 export type AdditionalEnabledPaymentMethodsParams = Partial<Omit<ListParameters, 'resource'>>;
 
@@ -33,18 +34,6 @@ export interface MolliePluginOptions {
      */
     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;
-
     /**
      * @description
      * Provide additional parameters to the Mollie enabled payment methods API call. By default,
@@ -113,34 +102,23 @@ export interface MolliePluginOptions {
  *     // ...
  *
  *     plugins: [
- *       MolliePlugin.init({ vendureHost: 'https://yourhost.io/', useDynamicRedirectUrl: true }),
+ *       MolliePlugin.init({ vendureHost: 'https://yourhost.io/' }),
  *     ]
  *     ```
  * 2. Run a database migration to add the `mollieOrderId` custom field to the order entity.
  * 3. Create a new PaymentMethod in the Admin UI, and select "Mollie payments" as the handler.
  * 4. 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.
- *
  * ## 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". 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`.
+ * back to your storefront after completing the payment.
  *
  * ```GraphQL
  * mutation CreateMolliePaymentIntent {
  *   createMolliePaymentIntent(input: {
- *     redirectUrl: "https://storefront/order"
+ *     redirectUrl: "https://storefront/order/1234XYZ"
  *     paymentMethodCode: "mollie-payment-method"
  *     molliePaymentMethodCode: "ideal"
  *   }) {
@@ -184,10 +162,10 @@ export interface MolliePluginOptions {
  *  }
  * }
  * ```
- * You can pass `MolliePaymentMethod.code` to the `createMolliePaymentIntent` mutation to skip the method selection.
+ * You can pass `creditcard` for example, to the `createMolliePaymentIntent` mutation to skip the method selection.
  *
  * After completing payment on the Mollie platform,
- * the user is redirected to the configured redirect url + orderCode: `https://storefront/order/CH234X5`
+ * the user is redirected to the given redirect url, e.g. `https://storefront/order/CH234X5`
  *
  * ## Pay later methods
  * Mollie supports pay-later methods like 'Klarna Pay Later'. For pay-later methods, the status of an order is
@@ -219,10 +197,14 @@ export interface MolliePluginOptions {
         return config;
     },
     shopApiExtensions: {
-        schema: shopSchema,
-        resolvers: [MollieResolver],
+        schema: shopApiExtensions,
+        resolvers: [MollieCommonResolver, MollieShopResolver],
+    },
+    adminApiExtensions: {
+        schema: adminApiExtensions,
+        resolvers: [MollieCommonResolver],
     },
-    compatibility: '^2.0.0',
+    compatibility: '^2.2.0',
 })
 export class MolliePlugin {
     static options: MolliePluginOptions;
@@ -231,7 +213,6 @@ 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;

+ 0 - 45
packages/payments-plugin/src/mollie/mollie.resolver.ts

@@ -1,45 +0,0 @@
-import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
-import { Allow, Ctx, Permission, RequestContext } from '@vendure/core';
-
-import {
-    MolliePaymentIntent,
-    MolliePaymentIntentError,
-    MolliePaymentIntentInput,
-    MolliePaymentIntentResult,
-    MolliePaymentMethod,
-    MolliePaymentMethodsInput,
-} from './graphql/generated-shop-types';
-import { MollieService } from './mollie.service';
-
-@Resolver()
-export class MollieResolver {
-    constructor(private mollieService: MollieService) {}
-
-    @Mutation()
-    @Allow(Permission.Owner)
-    async createMolliePaymentIntent(
-        @Ctx() ctx: RequestContext,
-        @Args('input') input: MolliePaymentIntentInput,
-    ): Promise<MolliePaymentIntentResult> {
-        return this.mollieService.createPaymentIntent(ctx, input);
-    }
-
-    @ResolveField()
-    @Resolver('MolliePaymentIntentResult')
-    __resolveType(value: MolliePaymentIntentError | MolliePaymentIntent): string {
-        if ((value as MolliePaymentIntentError).errorCode) {
-            return 'MolliePaymentIntentError';
-        } else {
-            return 'MolliePaymentIntent';
-        }
-    }
-
-    @Query()
-    @Allow(Permission.Public)
-    async molliePaymentMethods(
-        @Ctx() ctx: RequestContext,
-        @Args('input') { paymentMethodCode }: MolliePaymentMethodsInput,
-    ): Promise<MolliePaymentMethod[]> {
-        return this.mollieService.getEnabledPaymentMethods(ctx, paymentMethodCode);
-    }
-}

+ 49 - 28
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -38,6 +38,7 @@ import {
     MolliePaymentIntentResult,
     MolliePaymentMethod,
 } from './graphql/generated-shop-types';
+import { molliePaymentHandler } from './mollie.handler';
 import {
     amountToCents,
     getLocale,
@@ -89,7 +90,6 @@ export class MollieService {
         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(
@@ -97,11 +97,11 @@ export class MollieService {
             );
         }
         const [order, paymentMethod] = await Promise.all([
-            this.activeOrderService.getActiveOrder(ctx, undefined),
+            this.getOrder(ctx, input.orderId),
             this.getPaymentMethod(ctx, paymentMethodCode),
         ]);
-        if (!order) {
-            return new PaymentIntentError('No active order found for session');
+        if (order instanceof PaymentIntentError) {
+            return order;
         }
         await this.entityHydrator.hydrate(ctx, order, {
             relations: [
@@ -134,23 +134,23 @@ export class MollieService {
             );
         }
         if (!paymentMethod) {
-            return new PaymentIntentError(`No paymentMethod found with code ${paymentMethodCode}`);
+            return new PaymentIntentError(`No paymentMethod found with code ${String(paymentMethodCode)}`);
         }
-        if (this.options.useDynamicRedirectUrl === true) {
-            if (!input.redirectUrl) {
-                return new InvalidInputError('Cannot create payment intent without redirectUrl specified');
-            }
-            redirectUrl = input.redirectUrl;
-        } else {
-            const paymentMethodRedirectUrl = paymentMethod.handler.args.find(
-                arg => arg.name === 'redirectUrl',
-            )?.value;
-            if (!paymentMethodRedirectUrl) {
+        let redirectUrl = input.redirectUrl;
+        if (!redirectUrl) {
+            // Use fallback redirect if no redirectUrl is given
+            let fallbackRedirect = paymentMethod.handler.args.find(arg => arg.name === 'redirectUrl')?.value;
+            if (!fallbackRedirect) {
                 return new PaymentIntentError(
-                    'Cannot create payment intent without redirectUrl specified in paymentMethod',
+                    'No redirect URl was given and no fallback redirect is configured',
                 );
             }
-            redirectUrl = paymentMethodRedirectUrl;
+            redirectUrl = fallbackRedirect;
+            // remove appending slash if present
+            fallbackRedirect = fallbackRedirect.endsWith('/')
+                ? fallbackRedirect.slice(0, -1)
+                : fallbackRedirect;
+            redirectUrl = `${fallbackRedirect}/${order.code}`;
         }
         const apiKey = paymentMethod.handler.args.find(arg => arg.name === 'apiKey')?.value;
         if (!apiKey) {
@@ -161,10 +161,6 @@ export class MollieService {
             return new PaymentIntentError(`Paymentmethod ${paymentMethod.code} has no apiKey configured`);
         }
         const mollieClient = createMollieClient({ apiKey });
-        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
@@ -182,8 +178,7 @@ export class MollieService {
         const orderInput: CreateParameters = {
             orderNumber: order.code,
             amount: toAmount(amountToPay, order.currencyCode),
-            redirectUrl:
-                this.options.useDynamicRedirectUrl === true ? redirectUrl : `${redirectUrl}/${order.code}`,
+            redirectUrl,
             webhookUrl: `${vendureHost}/payments/mollie/${ctx.channel.token}/${paymentMethod.id}`,
             billingAddress,
             locale: getLocale(billingAddress.country, ctx.languageCode),
@@ -463,16 +458,19 @@ export class MollieService {
         vendureOrder: Order,
         amountToPay: number,
         mollieOrderId: string,
+        redirectUrl?: string,
     ): Promise<string | undefined> {
         const existingMollieOrder = await mollieClient.orders.get(mollieOrderId);
         const checkoutUrl = existingMollieOrder.getCheckoutUrl();
+        // When redirect url changed, we need to create a new order
+        const redirectIsTheSame = existingMollieOrder.redirectUrl === redirectUrl;
         const amountsMatch = isAmountEqual(
             vendureOrder.currencyCode,
             amountToPay,
             existingMollieOrder.amount,
         );
-        if (amountsMatch) {
-            return checkoutUrl ?? undefined;
+        if (amountsMatch && redirectIsTheSame && checkoutUrl) {
+            return checkoutUrl;
         }
     }
 
@@ -489,9 +487,32 @@ export class MollieService {
 
     private async getPaymentMethod(
         ctx: RequestContext,
-        paymentMethodCode: string,
+        paymentMethodCode?: string | null,
     ): Promise<PaymentMethod | undefined> {
-        const paymentMethods = await this.paymentMethodService.findAll(ctx);
-        return paymentMethods.items.find(pm => pm.code === paymentMethodCode);
+        if (paymentMethodCode) {
+            const { items } = await this.paymentMethodService.findAll(ctx, {
+                filter: {
+                    code: { eq: paymentMethodCode },
+                },
+            });
+            return items.find(pm => pm.code === paymentMethodCode);
+        } else {
+            const { items } = await this.paymentMethodService.findAll(ctx);
+            return items.find(pm => pm.handler.code === molliePaymentHandler.code);
+        }
+    }
+
+    /**
+     * Get order by id, or active order if no orderId is given
+     */
+    private async getOrder(ctx: RequestContext, orderId?: ID | null): Promise<Order | PaymentIntentError> {
+        if (orderId) {
+            return await assertFound(this.orderService.findOne(ctx, orderId));
+        }
+        const order = await this.activeOrderService.getActiveOrder(ctx, undefined);
+        if (!order) {
+            return new PaymentIntentError('No active order found for session');
+        }
+        return order;
     }
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов