Bläddra i källkod

feat(payments-plugin): Use Mollie's Order API (#1884)

Martijn 3 år sedan
förälder
incheckning
56b86468f5

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

@@ -1,3 +1,19 @@
 # Payments plugin
 
 For documentation, see https://www.vendure.io/docs/typescript-api/payments-plugin
+
+## Development
+
+### Mollie local development
+
+For testing out changes to the Mollie plugin locally, with a real Mollie account, follow the steps below. These steps
+will create an order, set Mollie as payment method, and create a payment intent link to the Mollie platform.
+
+1. Get a test api key from your Mollie
+   dashboard: https://help.mollie.com/hc/en-us/articles/115000328205-Where-can-I-find-the-API-key-
+2. Create the file `packages/payments-plugin/.env` with content `MOLLIE_APIKEY=your-test-apikey`
+3. `cd packages/payments-plugin`
+5. `yarn dev-server:mollie`
+6. Watch the logs for `Mollie payment link` and click the link to finalize the test payment.
+
+You can change the order flow, payment methods and more in the file `e2e/mollie-dev-server`, and restart the devserver.

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

@@ -0,0 +1,109 @@
+import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
+import {
+    ChannelService,
+    DefaultLogger,
+    Logger,
+    LogLevel,
+    mergeConfig,
+    Order,
+    OrderService,
+    RequestContext,
+} from '@vendure/core';
+import { createTestEnvironment, registerInitializer, SqljsInitializer, testConfig } from '@vendure/testing';
+import gql from 'graphql-tag';
+import localtunnel from 'localtunnel';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { MolliePlugin } from '../package/mollie';
+import { molliePaymentHandler } from '../package/mollie/mollie.handler';
+
+import { CREATE_PAYMENT_METHOD } from './graphql/admin-queries';
+import { CreatePaymentMethod } from './graphql/generated-admin-types';
+import { AddItemToOrder } from './graphql/generated-shop-types';
+import { ADD_ITEM_TO_ORDER } from './graphql/shop-queries';
+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 () => {
+    require('dotenv').config();
+
+    registerInitializer('sqljs', new SqljsInitializer(path.join(__dirname, '__data__')));
+    const tunnel = await localtunnel({ port: 3050 });
+    const config = mergeConfig(testConfig, {
+        plugins: [
+            ...testConfig.plugins,
+            AdminUiPlugin.init({
+                route: 'admin',
+                port: 5001,
+            }),
+            MolliePlugin.init({ vendureHost: tunnel.url }),
+        ],
+        logger: new DefaultLogger({ level: LogLevel.Debug }),
+    });
+    const { server, shopClient, adminClient } = createTestEnvironment(config as any);
+    await server.init({
+        initialData,
+        productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+        customerCount: 1,
+    });
+    // Set EUR as currency for Mollie
+    await adminClient.asSuperAdmin();
+    await adminClient.query(gql`
+        mutation {
+            updateChannel(input: {id: "T_1", currencyCode: EUR}) {
+                __typename
+            }
+        }
+    `);
+    // 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' },
+                ],
+            },
+        },
+    });
+    // Prepare order for payment
+    await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+    await shopClient.query<AddItemToOrder.Order, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+        productVariantId: '1',
+        quantity: 2,
+    });
+    const ctx = new RequestContext({
+        apiType: 'admin',
+        isAuthorized: true,
+        authorizedAsOwnerOnly: false,
+        channel: await server.app.get(ChannelService).getDefaultChannel()
+    });
+    await server.app.get(OrderService).addSurchargeToOrder(ctx, 1, {
+        description: 'Negative test surcharge',
+        listPrice: -20000,
+    });
+    await setShipping(shopClient);
+    const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+        input: {
+            paymentMethodCode: 'mollie',
+//            molliePaymentMethodCode: 'klarnapaylater'
+        },
+    });
+    if (createMolliePaymentIntent.errorCode) {
+        throw createMolliePaymentIntent;
+    }
+    Logger.info(`Mollie payment link: ${createMolliePaymentIntent.url}`, 'Mollie DevServer');
+})();
+

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

@@ -1,7 +1,11 @@
-import { PaymentStatus } from '@mollie/api-client';
-import { mergeConfig } from '@vendure/core';
+import { OrderStatus } from '@mollie/api-client';
+import { ChannelService, mergeConfig, OrderService, RequestContext } from '@vendure/core';
+import {
+    SettlePaymentMutation,
+    SettlePaymentMutationVariables,
+} from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
+import { SETTLE_PAYMENT } from '@vendure/core/e2e/graphql/shared-definitions';
 import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN, SimpleGraphQLClient, TestServer } from '@vendure/testing';
-import gql from 'graphql-tag';
 import nock from 'nock';
 import fetch from 'node-fetch';
 import path from 'path';
@@ -15,42 +19,7 @@ import { CREATE_PAYMENT_METHOD, GET_CUSTOMER_LIST, GET_ORDER_PAYMENTS } from './
 import { CreatePaymentMethod, GetCustomerList, GetCustomerListQuery } from './graphql/generated-admin-types';
 import { AddItemToOrder, GetOrderByCode, TestOrderFragmentFragment } from './graphql/generated-shop-types';
 import { ADD_ITEM_TO_ORDER, GET_ORDER_BY_CODE } from './graphql/shop-queries';
-import { refundOne, setShipping } from './payment-helpers';
-
-export const CREATE_MOLLIE_PAYMENT_INTENT = gql`
-    mutation createMolliePaymentIntent($input: MolliePaymentIntentInput!) {
-        createMolliePaymentIntent(input: $input) {
-            ... on MolliePaymentIntent {
-                url
-            }
-            ... on MolliePaymentIntentError {
-                errorCode
-                message
-            }
-        }
-    }`;
-
-export const GET_MOLLIE_PAYMENT_METHODS = gql`
-    query molliePaymentMethods($input: MolliePaymentMethodsInput!) {
-        molliePaymentMethods(input: $input) {
-            id
-            code
-            description
-            minimumAmount {
-                value
-                currency
-            }
-            maximumAmount {
-                value
-                currency
-            }
-            image {
-                size1x
-                size2x
-                svg
-            }
-        }
-    }`;
+import { CREATE_MOLLIE_PAYMENT_INTENT, GET_MOLLIE_PAYMENT_METHODS, refundOne, setShipping } from './payment-helpers';
 
 describe('Mollie payments', () => {
     const mockData = {
@@ -58,14 +27,22 @@ describe('Mollie payments', () => {
         redirectUrl: 'https://my-storefront/order',
         apiKey: 'myApiKey',
         methodCode: `mollie-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
-        molliePaymentResponse: {
-            id: 'tr_mockId',
+        mollieOrderResponse: {
+            id: 'ord_mockId',
             _links: {
                 checkout: {
                     href: 'https://www.mollie.com/payscreen/select-method/mock-payment',
                 },
             },
-            resource: 'payment',
+            lines: [],
+            _embedded: {
+                payments: [{
+                    id: 'tr_mockPayment',
+                    status: 'paid',
+                    resource: 'payment',
+                }],
+            },
+            resource: 'order',
             mode: 'test',
             method: 'test-method',
             profileId: '123',
@@ -74,7 +51,7 @@ describe('Mollie payments', () => {
             authorizedAt: new Date(),
             paidAt: new Date(),
         },
-        molliePaymentMethodsResponse:{
+        molliePaymentMethodsResponse: {
             count: 1,
             _embedded: {
                 methods: [
@@ -84,36 +61,36 @@ describe('Mollie payments', () => {
                         description: 'iDEAL',
                         minimumAmount: {
                             value: '0.01',
-                            currency: 'EUR'
+                            currency: 'EUR',
                         },
                         maximumAmount: {
                             value: '50000.00',
-                            currency: 'EUR'
+                            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'
+                            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'
-                            }
-                        }
-                    }]
+                                type: 'application/hal+json',
+                            },
+                        },
+                    }],
             },
             _links: {
                 self: {
                     href: 'https://api.mollie.com/v2/methods',
-                    type: 'application/hal+json'
+                    type: 'application/hal+json',
                 },
                 documentation: {
                     href: 'https://docs.mollie.com/reference/v2/methods-api/list-methods',
-                    type: 'text/html'
-                }
-            }
-        }
+                    type: 'text/html',
+                },
+            },
+        },
     };
     let shopClient: SimpleGraphQLClient;
     let adminClient: SimpleGraphQLClient;
@@ -125,6 +102,8 @@ describe('Mollie payments', () => {
     beforeAll(async () => {
         const devConfig = mergeConfig(testConfig(), {
             plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
+            // Uncomment next line to debug e2e
+            // logger: new DefaultLogger({level: LogLevel.Verbose})
         });
         const env = createTestEnvironment(devConfig);
         serverPort = devConfig.apiOptions.port;
@@ -163,6 +142,17 @@ describe('Mollie payments', () => {
             quantity: 2,
         });
         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: -20000,
+        });
         expect(order.code).toBeDefined();
     });
 
@@ -179,6 +169,7 @@ describe('Mollie payments', () => {
                     arguments: [
                         { name: 'redirectUrl', value: mockData.redirectUrl },
                         { name: 'apiKey', value: mockData.apiKey },
+                        { name: 'autoCapture', value: 'false' },
                     ],
                 },
             },
@@ -202,7 +193,7 @@ describe('Mollie payments', () => {
         const { createMolliePaymentIntent: result } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
             input: {
                 paymentMethodCode: mockData.methodCode,
-                molliePaymentMethodCode: 'invalid'
+                molliePaymentMethodCode: 'invalid',
             },
         });
         expect(result.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
@@ -215,7 +206,7 @@ describe('Mollie payments', () => {
                 mollieRequest = body;
                 return true;
             })
-            .reply(200, mockData.molliePaymentResponse);
+            .reply(200, mockData.mollieOrderResponse);
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         await setShipping(shopClient);
         const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
@@ -224,13 +215,19 @@ describe('Mollie payments', () => {
             },
         });
         expect(createMolliePaymentIntent).toEqual({ url: 'https://www.mollie.com/payscreen/select-method/mock-payment' });
-        expect(mollieRequest?.metadata.orderCode).toEqual(order.code);
+        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('3127.60');
+        expect(mollieRequest?.amount?.value).toBe('2927.60');
         expect(mollieRequest?.amount?.currency).toBeDefined();
+        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 () => {
@@ -240,7 +237,7 @@ describe('Mollie payments', () => {
                 mollieRequest = body;
                 return true;
             })
-            .reply(200, mockData.molliePaymentResponse);
+            .reply(200, mockData.mollieOrderResponse);
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         await setShipping(shopClient);
         const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
@@ -252,17 +249,17 @@ describe('Mollie payments', () => {
         expect(createMolliePaymentIntent).toEqual({ url: 'https://www.mollie.com/payscreen/select-method/mock-payment' });
     });
 
-    it('Should settle payment for order', async () => {
+    it('Should immediately settle payment for standard payment methods', async () => {
         nock('https://api.mollie.com/')
             .get(/.*/)
             .reply(200, {
-                ...mockData.molliePaymentResponse,
-                status: PaymentStatus.paid,
-                metadata: { orderCode: order.code },
+                ...mockData.mollieOrderResponse,
+                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.molliePaymentResponse.id }),
+            body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
             headers: { 'Content-Type': 'application/json' },
         });
         const { orderByCode } = await shopClient.query<GetOrderByCode.Query, GetOrderByCode.Variables>(
@@ -278,17 +275,19 @@ describe('Mollie payments', () => {
 
     it('Should have Mollie metadata on payment', async () => {
         const { order: { payments: [{ metadata }] } } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
-        expect(metadata.mode).toBe(mockData.molliePaymentResponse.mode);
-        expect(metadata.method).toBe(mockData.molliePaymentResponse.method);
-        expect(metadata.profileId).toBe(mockData.molliePaymentResponse.profileId);
-        expect(metadata.settlementAmount).toBe(mockData.molliePaymentResponse.settlementAmount);
-        expect(metadata.customerId).toBe(mockData.molliePaymentResponse.customerId);
-        expect(metadata.authorizedAt).toEqual(mockData.molliePaymentResponse.authorizedAt.toISOString());
-        expect(metadata.paidAt).toEqual(mockData.molliePaymentResponse.paidAt.toISOString());
+        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 () => {
         let mollieRequest;
+        nock('https://api.mollie.com/')
+            .get('/v2/orders/ord_mockId?embed=payments')
+            .twice()
+            .reply(200, mockData.mollieOrderResponse);
         nock('https://api.mollie.com/')
             .post(/.*/, body => {
                 mollieRequest = body;
@@ -325,9 +324,81 @@ describe('Mollie payments', () => {
         });
         const method = molliePaymentMethods[0];
         expect(method.code).toEqual('ideal');
-        expect(method.minimumAmount).toBeDefined()
-        expect(method.maximumAmount).toBeDefined()
-        expect(method.image).toBeDefined()
+        expect(method.minimumAmount).toBeDefined();
+        expect(method.maximumAmount).toBeDefined();
+        expect(method.image).toBeDefined();
     });
 
+    it('Should prepare a new order', async () => {
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 2,
+        });
+        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(/.*/)
+            .reply(200, {
+                ...mockData.mollieOrderResponse,
+                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<GetOrderByCode.Query, GetOrderByCode.Variables>(
+            GET_ORDER_BY_CODE,
+            {
+                code: order.code,
+            },
+        );
+        // tslint:disable-next-line: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(/.*/)
+            .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,
+            {
+                // tslint:disable-next-line:no-non-null-assertion
+                id: order.payments![0].id,
+            },
+        );
+        const { orderByCode } = await shopClient.query<GetOrderByCode.Query, GetOrderByCode.Variables>(
+            GET_ORDER_BY_CODE,
+            {
+                code: order.code,
+            },
+        );
+        // tslint:disable-next-line:no-non-null-assertion
+        order = orderByCode!;
+        expect(createShipmentBody).toBeDefined();
+        expect(order.state).toBe('PaymentSettled');
+    });
+
+
 });

+ 38 - 2
packages/payments-plugin/e2e/payment-helpers.ts

@@ -1,5 +1,6 @@
 import { ID } from '@vendure/common/lib/shared-types';
 import { SimpleGraphQLClient } from '@vendure/testing';
+import gql from 'graphql-tag';
 
 import { REFUND_ORDER } from './graphql/admin-queries';
 import { RefundFragment, RefundOrder } from './graphql/generated-admin-types';
@@ -22,9 +23,9 @@ export async function setShipping(shopClient: SimpleGraphQLClient): Promise<void
         input: {
             fullName: 'name',
             streetLine1: '12 the street',
-            city: 'foo',
+            city: 'Leeuwarden',
             postalCode: '123456',
-            countryCode: 'US',
+            countryCode: 'AT',
         },
     });
     const { eligibleShippingMethods } = await shopClient.query<GetShippingMethods.Query>(
@@ -63,3 +64,38 @@ export async function refundOne(
     );
     return refundOrder as RefundFragment;
 }
+
+export const CREATE_MOLLIE_PAYMENT_INTENT = gql`
+    mutation createMolliePaymentIntent($input: MolliePaymentIntentInput!) {
+        createMolliePaymentIntent(input: $input) {
+            ... on MolliePaymentIntent {
+                url
+            }
+            ... on MolliePaymentIntentError {
+                errorCode
+                message
+            }
+        }
+    }`;
+
+export const GET_MOLLIE_PAYMENT_METHODS = gql`
+    query molliePaymentMethods($input: MolliePaymentMethodsInput!) {
+        molliePaymentMethods(input: $input) {
+            id
+            code
+            description
+            minimumAmount {
+                value
+                currency
+            }
+            maximumAmount {
+                value
+                currency
+            }
+            image {
+                size1x
+                size2x
+                svg
+            }
+        }
+    }`;

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

@@ -13,7 +13,8 @@
         "build": "rimraf package && tsc -p ./tsconfig.build.json",
         "e2e": "jest --config ../../e2e-common/jest-config.js --runInBand --package=payments-plugin",
         "lint": "tslint --fix --project ./",
-        "ci": "yarn build"
+        "ci": "yarn build",
+        "dev-server:mollie": "yarn build && DB=sqlite node -r ts-node/register e2e/mollie-dev-server.ts"
     },
     "homepage": "https://www.vendure.io/",
     "funding": "https://github.com/sponsors/michaelbromley",

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

@@ -18,7 +18,7 @@ export class MollieController {
             return Logger.warn(` Ignoring incoming webhook, because it has no body.id.`, loggerCtx);
         }
         try {
-            await this.mollieService.settlePayment({ channelToken, paymentMethodId, paymentId: body.id });
+            await this.mollieService.handleMollieStatusUpdate({ channelToken, paymentMethodId, orderId: body.id });
         } catch (error) {
             Logger.error(`Failed to process incoming webhook: ${error?.message}`, loggerCtx, error);
             throw error;

+ 37 - 15
packages/payments-plugin/src/mollie/mollie.handler.ts

@@ -1,4 +1,4 @@
-import createMollieClient, { RefundStatus } from '@mollie/api-client';
+import createMollieClient, { OrderEmbed, PaymentStatus, RefundStatus } from '@mollie/api-client';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import {
     CreatePaymentErrorResult,
@@ -6,12 +6,11 @@ import {
     CreateRefundResult,
     Logger,
     PaymentMethodHandler,
-    PaymentMethodService,
     SettlePaymentResult,
 } from '@vendure/core';
-import { Permission } from '@vendure/core';
 
-import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
+import { loggerCtx } from './constants';
+import { toAmount } from './mollie.helpers';
 import { MollieService } from './mollie.service';
 
 let mollieService: MollieService;
@@ -35,6 +34,14 @@ export const molliePaymentHandler = new PaymentMethodHandler({
                 { languageCode: LanguageCode.en, value: 'Redirect the client to this URL after payment' },
             ],
         },
+        autoCapture: {
+            type: 'boolean',
+            label: [{ languageCode: LanguageCode.en, value: 'Auto capture payments' }],
+            defaultValue: false,
+            description: [
+                { 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.' },
+            ],
+        },
     },
     init(injector) {
         mollieService = injector.get(MollieService);
@@ -46,36 +53,51 @@ export const molliePaymentHandler = new PaymentMethodHandler({
         args,
         metadata,
     ): Promise<CreatePaymentResult | CreatePaymentErrorResult> => {
-        // Creating a payment immediately settles the payment in Mollie flow, so only Admins and internal calls should be allowed to do this
+        // Only Admins and internal calls should be allowed to settle and authorize payments
         if (ctx.apiType !== 'admin') {
             throw Error(`CreatePayment is not allowed for apiType '${ctx.apiType}'`);
         }
+        if (metadata.status !== 'Authorized' && metadata.status !== 'Settled') {
+            throw Error(`Cannot create payment for status ${metadata.status} for order ${order.code}. Only Authorized or Settled are allowed.`);
+        }
+        Logger.info(`Payment for order ${order.code} created with state '${metadata.status}'`, loggerCtx);
         return {
             amount,
-            state: 'Settled' as const,
-            transactionId: metadata.paymentId,
-            metadata // Store all given metadata on a payment
+            state: metadata.status,
+            transactionId: metadata.orderId, // The plugin now only supports 1 payment per order, so a mollie order equals a payment
+            metadata, // Store all given metadata on a payment
         };
     },
     settlePayment: async (ctx, order, payment, args): Promise<SettlePaymentResult> => {
-        // this should never be called
+        // Called for Authorized payments
+        const { apiKey } = args;
+        const mollieClient = createMollieClient({ apiKey });
+        const mollieOrder = await mollieClient.orders.get(payment.transactionId);
+        // Order could have been completed via Mollie dashboard, then we can just settle
+        if (!mollieOrder.isCompleted()) {
+            await mollieClient.orders_shipments.create({orderId: payment.transactionId}); // Creating a shipment captures the payment
+        }
+        Logger.info(`Settled payment for ${order.code}`, loggerCtx);
         return { success: true };
     },
     createRefund: async (ctx, input, amount, order, payment, args): Promise<CreateRefundResult> => {
         const { apiKey } = args;
         const mollieClient = createMollieClient({ apiKey });
+        const mollieOrder = await mollieClient.orders.get(payment.transactionId, {embed: [OrderEmbed.payments]});
+        const molliePayments = await mollieOrder.getPayments();
+        const molliePayment = molliePayments.find(p => p.status === PaymentStatus.paid); // Only one paid payment should be there
+        if (!molliePayment) {
+            throw Error(`No payment with status 'paid' was found in Mollie for order ${order.code} (Mollie order ${mollieOrder.id})`);
+        }
         const refund = await mollieClient.payments_refunds.create({
-            paymentId: payment.transactionId,
+            paymentId: molliePayment.id,
             description: input.reason,
-            amount: {
-                value: (amount / 100).toFixed(2),
-                currency: order.currencyCode,
-            },
+            amount: toAmount(amount, order.currencyCode)
         });
         if (refund.status === RefundStatus.failed) {
             Logger.error(
                 `Failed to create refund of ${amount.toFixed()} for order ${order.code} for transaction ${
-                    payment.transactionId
+                    molliePayment.id
                 }`,
                 loggerCtx,
             );

+ 88 - 0
packages/payments-plugin/src/mollie/mollie.helpers.ts

@@ -0,0 +1,88 @@
+import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/orders/parameters';
+import { Amount } from '@mollie/api-client/dist/types/src/data/global';
+import { OrderAddress as MollieOrderAddress } from '@mollie/api-client/dist/types/src/data/orders/data';
+import { Customer, Order } from '@vendure/core';
+
+import { OrderAddress } from './graphql/generated-shop-types';
+
+/**
+ * Check if given address has mandatory fields for Mollie Order and map to a MollieOrderAddress.
+ * Returns undefined if address doesn't have all mandatory fields
+ */
+export function toMollieAddress(address: OrderAddress, customer: Customer): MollieOrderAddress | undefined {
+    if (address.city && address.countryCode && address.streetLine1 && address.postalCode) {
+        return {
+            streetAndNumber: `${address.streetLine1} ${address.streetLine2 || ''}`,
+            postalCode: address.postalCode,
+            city: address.city,
+            country: address.countryCode.toUpperCase(),
+            givenName: customer.firstName,
+            familyName: customer.lastName,
+            email: customer.emailAddress,
+        };
+    }
+}
+
+/**
+ * Map all order and shipping lines to a single array of Mollie order lines
+ */
+export function toMollieOrderLines(order: Order): CreateParameters['lines'] {
+    // Add order lines
+    const lines: CreateParameters['lines'] = order.lines.map(line => ({
+        name: line.productVariant.name,
+        quantity: line.quantity,
+        unitPrice: toAmount(line.proratedUnitPriceWithTax, order.currencyCode), // totalAmount has to match unitPrice * quantity
+        totalAmount: toAmount(line.proratedLinePriceWithTax, order.currencyCode),
+        vatRate: String(line.taxRate),
+        vatAmount: toAmount(line.lineTax, order.currencyCode),
+    }));
+    // Add shippingLines
+    lines.push(...order.shippingLines.map(line => ({
+        name: line.shippingMethod?.name || 'Shipping',
+        quantity: 1,
+        unitPrice: toAmount(line.priceWithTax, order.currencyCode),
+        totalAmount: toAmount(line.discountedPriceWithTax, order.currencyCode),
+        vatRate: String(line.taxRate),
+        vatAmount: toAmount(line.discountedPriceWithTax - line.discountedPrice, order.currencyCode),
+    })));
+    // Add surcharges
+    lines.push(...order.surcharges.map(surcharge => ({
+        name: surcharge.description,
+        quantity: 1,
+        unitPrice: toAmount(surcharge.price, order.currencyCode),
+        totalAmount: toAmount(surcharge.priceWithTax, order.currencyCode),
+        vatRate: String(surcharge.taxRate),
+        vatAmount: toAmount(surcharge.priceWithTax - surcharge.price, order.currencyCode),
+    })));
+    return lines;
+}
+
+/**
+ * Stringify and fixed decimals
+ */
+export function toAmount(value: number, orderCurrency: string): Amount {
+    return {
+        value: (value / 100).toFixed(2),
+        currency: orderCurrency,
+    };
+}
+
+/**
+ * Lookup one of Mollies allowed locales based on an orders countrycode or channel default.
+ * If both lookups fail, resolve to en_US to prevent payment failure
+ */
+export function getLocale(countryCode: string, channelLanguage: string): string {
+    const allowedLocales = ['en_US', 'en_GB', 'nl_NL', 'nl_BE', 'fr_FR', 'fr_BE', 'de_DE', 'de_AT', 'de_CH', 'es_ES', 'ca_ES', 'pt_PT', 'it_IT', 'nb_NO', 'sv_SE', 'fi_FI', 'da_DK', 'is_IS', 'hu_HU', 'pl_PL', 'lv_LV', 'lt_LT'];
+    // Prefer order locale if possible
+    const orderLocale = allowedLocales.find(locale => (locale.toLowerCase()).indexOf(countryCode.toLowerCase()) > -1);
+    if (orderLocale) {
+        return orderLocale;
+    }
+    // If no order locale, try channel default
+    const channelLocale = allowedLocales.find(locale => (locale.toLowerCase()).indexOf(channelLanguage.toLowerCase()) > -1);
+    if (channelLocale) {
+        return channelLocale;
+    }
+    // If no order locale and no channel locale, return a default, otherwise order creation will fail
+    return allowedLocales[0];
+}

+ 6 - 8
packages/payments-plugin/src/mollie/mollie.plugin.ts

@@ -1,5 +1,4 @@
 import { PluginCommonModule, RuntimeVendureConfig, VendurePlugin } from '@vendure/core';
-import { gql } from 'graphql-tag';
 
 import { PLUGIN_INIT_OPTIONS } from './constants';
 import { shopSchema } from './mollie-shop-schema';
@@ -111,14 +110,13 @@ export interface MolliePluginOptions {
  * After completing payment on the Mollie platform,
  * the user is redirected to the configured redirect url + orderCode: `https://storefront/order/CH234X5`
  *
- * ## Local development
+ * ## Pay later methods
+ * Mollie supports pay-later methods like 'Klarna Pay Later'. For pay-later methods, the status of an order is
+ * 'PaymentAuthorized' after the Mollie hosted checkout. You need to manually settle the payment via the admin ui to capture the payment!
+ * Make sure you capture a payment within 28 days, because this is the Klarna expiry time
  *
- * Use something like [localtunnel](https://github.com/localtunnel/localtunnel) to test on localhost.
- *
- * ```bash
- * npx localtunnel --port 3000 --subdomain my-shop-local-dev
- * > your url is: https://my-shop-local-dev.loca.lt     <- use this as the vendureHost for local dev.
- * ```
+ * If you don't want this behaviour (Authorized first), you can set 'autoCapture=true' on the payment method. This option will immediately
+ * capture the payment after a customer authorizes the payment.
  *
  * @docsCategory payments-plugin
  * @docsPage MolliePlugin

+ 100 - 53
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -1,18 +1,24 @@
-import createMollieClient, { PaymentStatus } from '@mollie/api-client';
+import createMollieClient, {
+    Order as MollieOrder,
+    OrderStatus,
+    PaymentMethod as MollieClientMethod,
+} from '@mollie/api-client';
+import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/orders/parameters';
 import { Inject, Injectable } from '@nestjs/common';
 import {
     ActiveOrderService,
     ChannelService,
     EntityHydrator,
+    ErrorResult,
     LanguageCode,
     Logger,
     Order,
     OrderService,
+    OrderStateTransitionError,
     PaymentMethod,
     PaymentMethodService,
     RequestContext,
 } from '@vendure/core';
-import { OrderStateTransitionError } from '@vendure/core/dist/common/error/generated-graphql-shop-errors';
 
 import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
 import {
@@ -22,14 +28,13 @@ import {
     MolliePaymentIntentResult,
     MolliePaymentMethod,
 } from './graphql/generated-shop-types';
+import { getLocale, toAmount, toMollieAddress, toMollieOrderLines } from './mollie.helpers';
 import { MolliePluginOptions } from './mollie.plugin';
-import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/payments/parameters';
-import { PaymentMethod as MollieClientMethod } from '@mollie/api-client';
 
-interface SettlePaymentInput {
+interface OrderStatusInput {
     channelToken: string;
     paymentMethodId: string;
-    paymentId: string;
+    orderId: string;
 }
 
 class PaymentIntentError implements MolliePaymentIntentError {
@@ -39,7 +44,7 @@ class PaymentIntentError implements MolliePaymentIntentError {
     }
 }
 
-class InvalidInput implements MolliePaymentIntentError {
+class InvalidInputError implements MolliePaymentIntentError {
     errorCode = ErrorCode.INELIGIBLE_PAYMENT_METHOD_ERROR;
 
     constructor(public message: string) {
@@ -68,16 +73,18 @@ export class MollieService {
     ): Promise<MolliePaymentIntentResult> {
         const allowedMethods = Object.values(MollieClientMethod) as string[];
         if (molliePaymentMethodCode && !allowedMethods.includes(molliePaymentMethodCode)) {
-            return new InvalidInput(`molliePaymentMethodCode has to be one of "${allowedMethods.join(',')}"`);
+            return new InvalidInputError(`molliePaymentMethodCode has to be one of "${allowedMethods.join(',')}"`);
         }
         const [order, paymentMethod] = await Promise.all([
-            this.activeOrderService.getOrderFromContext(ctx),
+            this.activeOrderService.getActiveOrder(ctx, undefined),
             this.getPaymentMethod(ctx, paymentMethodCode),
         ]);
         if (!order) {
             return new PaymentIntentError('No active order found for session');
         }
-        await this.entityHydrator.hydrate(ctx, order, { relations: ['lines', 'customer', 'shippingLines'] });
+        await this.entityHydrator.hydrate(ctx, order,
+            { relations: ['customer', 'surcharges', 'lines.productVariant', 'shippingLines.shippingMethod'] }
+        );
         if (!order.lines?.length) {
             return new PaymentIntentError('Cannot create payment intent for empty order');
         }
@@ -101,25 +108,27 @@ export class MollieService {
         const vendureHost = this.options.vendureHost.endsWith('/')
             ? this.options.vendureHost.slice(0, -1)
             : this.options.vendureHost; // remove appending slash
-        const paymentInput: CreateParameters = {
-            amount: {
-                value: `${(order.totalWithTax / 100).toFixed(2)}`,
-                currency: order.currencyCode,
-            },
-            metadata: {
-                orderCode: order.code,
-            },
-            description: `Order ${order.code}`,
+        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.`);
+        }
+        const orderInput: CreateParameters = {
+            orderNumber: order.code,
+            amount: toAmount(order.totalWithTax, order.currencyCode),
             redirectUrl: `${redirectUrl}/${order.code}`,
             webhookUrl: `${vendureHost}/payments/mollie/${ctx.channel.token}/${paymentMethod.id}`,
+            billingAddress,
+            locale: getLocale(billingAddress.country, ctx.languageCode),
+            lines: toMollieOrderLines(order),
         };
         if (molliePaymentMethodCode) {
-            paymentInput.method = molliePaymentMethodCode as MollieClientMethod;
+            orderInput.method = molliePaymentMethodCode as MollieClientMethod;
         }
-        const payment = await mollieClient.payments.create(paymentInput);
-        const url = payment.getCheckoutUrl();
+        const mollieOrder = await mollieClient.orders.create(orderInput);
+        Logger.info(`Created Mollie order ${mollieOrder.id} for order ${order.code}`);
+        const url = mollieOrder.getCheckoutUrl();
         if (!url) {
-            throw Error(`Unable to getCheckoutUrl() from Mollie payment`);
+            throw Error(`Unable to getCheckoutUrl() from Mollie order`);
         }
         return {
             url,
@@ -127,40 +136,63 @@ export class MollieService {
     }
 
     /**
-     * Makes a request to Mollie to verify the given payment by id
+     * Update Vendure payments and order status based on the incoming Mollie order
      */
-    async settlePayment({ channelToken, paymentMethodId, paymentId }: SettlePaymentInput): Promise<void> {
+    async handleMollieStatusUpdate({ channelToken, paymentMethodId, orderId }: OrderStatusInput): Promise<void> {
         const ctx = await this.createContext(channelToken);
-        Logger.info(`Received payment for ${channelToken}`, loggerCtx);
+        Logger.info(`Received status update for channel ${channelToken} for Mollie order ${orderId}`, loggerCtx);
         const paymentMethod = await this.paymentMethodService.findOne(ctx, paymentMethodId);
         if (!paymentMethod) {
             // Fail silently, as we don't want to expose if a paymentMethodId exists or not
             return Logger.warn(`No paymentMethod found with id ${paymentMethodId}`, loggerCtx);
         }
         const apiKey = paymentMethod.handler.args.find(a => a.name === 'apiKey')?.value;
+        const autoCapture = paymentMethod.handler.args.find(a => a.name === 'autoCapture')?.value === 'true';
         if (!apiKey) {
             throw Error(`No apiKey found for payment ${paymentMethod.id} for channel ${channelToken}`);
         }
         const client = createMollieClient({ apiKey });
-        const molliePayment = await client.payments.get(paymentId);
-        const orderCode = molliePayment.metadata.orderCode;
-        if (molliePayment.status !== PaymentStatus.paid) {
-            return Logger.warn(
-                `Received payment for ${channelToken} for order ${orderCode} with status ${molliePayment.status}`,
-                loggerCtx,
-            );
+        const mollieOrder = await client.orders.get(orderId);
+        Logger.info(`Processing status '${mollieOrder.status}' for order ${mollieOrder.orderNumber} for channel ${channelToken} for Mollie order ${orderId}`, loggerCtx);
+        let order = await this.orderService.findOneByCode(ctx, mollieOrder.orderNumber, ['payments']);
+        if (!order) {
+            throw Error(`Unable to find order ${mollieOrder.orderNumber}, unable to process Mollie order ${mollieOrder.id}`);
         }
-        if (!orderCode) {
-            throw Error(`Molliepayment does not have metadata.orderCode, unable to settle payment ${molliePayment.id}!`);
+        if (mollieOrder.status === OrderStatus.paid ) {
+            // Paid is only used by 1-step payments without Authorized state. This will settle immediately
+            await this.addPayment(ctx, order, mollieOrder, paymentMethod.code, 'Settled');
+            return;
         }
-        Logger.info(
-            `Received payment ${molliePayment.id} for order ${orderCode} with status ${molliePayment.status}`,
-            loggerCtx,
-        );
-        const order = await this.orderService.findOneByCode(ctx, orderCode);
-        if (!order) {
-            throw Error(`Unable to find order ${orderCode}, unable to settle payment ${molliePayment.id}!`);
+        if (order.state === 'AddingItems' && mollieOrder.status === OrderStatus.authorized) {
+            order = await this.addPayment(ctx, order, mollieOrder, paymentMethod.code, 'Authorized');
+            if (autoCapture && mollieOrder.status === OrderStatus.authorized) {
+                // Immediately capture payment if autoCapture is set
+                Logger.info(`Auto capturing payment for order ${order.code}`, loggerCtx);
+                await this.settleExistingPayment(ctx, order, mollieOrder.id);
+            }
+            return;
+        }
+        if (order.state === 'PaymentAuthorized' && mollieOrder.status === OrderStatus.completed) {
+            return this.settleExistingPayment(ctx, order, mollieOrder.id);
+        }
+        if (order.state === 'PaymentAuthorized' || order.state === 'PaymentSettled') {
+            Logger.info(`Order ${order.code} is '${order.state}', no need for handling Mollie status '${mollieOrder.status}'`, loggerCtx);
+            return;
         }
+        // Any other combination of Mollie status and Vendure status indicates something is wrong.
+        throw Error(`Unhandled incoming Mollie status '${mollieOrder.status}' for order ${order.code} with status '${order.state}'`);
+    }
+
+    /**
+     * Add payment to order. Can be settled or authorized depending on the payment method.
+     */
+    async addPayment(
+        ctx: RequestContext,
+        order: Order,
+        mollieOrder: MollieOrder,
+        paymentMethodCode: string,
+        status: 'Authorized' | 'Settled'
+    ): Promise<Order> {
         if (order.state !== 'ArrangingPayment') {
             const transitionToStateResult = await this.orderService.transitionToState(
                 ctx,
@@ -173,24 +205,39 @@ export class MollieService {
             }
         }
         const addPaymentToOrderResult = await this.orderService.addPaymentToOrder(ctx, order.id, {
-            method: paymentMethod.code,
+            method: paymentMethodCode,
             metadata: {
-                paymentId: molliePayment.id,
-                mode: molliePayment.mode,
-                method: molliePayment.method,
-                profileId: molliePayment.profileId,
-                settlementAmount: molliePayment.settlementAmount,
-                customerId: molliePayment.customerId,
-                authorizedAt: molliePayment.authorizedAt,
-                paidAt: molliePayment.paidAt,
+                status,
+                orderId: mollieOrder.id,
+                mode: mollieOrder.mode,
+                method: mollieOrder.method,
+                profileId: mollieOrder.profileId,
+                settlementAmount: mollieOrder.amount,
+                authorizedAt: mollieOrder.authorizedAt,
+                paidAt: mollieOrder.paidAt,
             },
         });
         if (!(addPaymentToOrderResult instanceof Order)) {
             throw Error(
-                `Error adding payment to order ${orderCode}: ${addPaymentToOrderResult.message}`,
+                `Error adding payment to order ${order.code}: ${addPaymentToOrderResult.message}`,
             );
         }
-        Logger.info(`Payment for order ${molliePayment.metadata.orderCode} settled`, loggerCtx);
+        return addPaymentToOrderResult;
+    }
+
+    /**
+     * Settle an existing payment based on the given mollieOrder
+     */
+    async settleExistingPayment(ctx: RequestContext, order: Order, mollieOrderId: string): Promise<void> {
+        const payment = order.payments.find(p => p.transactionId === mollieOrderId);
+        if (!payment) {
+            throw Error(`Cannot find payment ${mollieOrderId} for ${order.code}. Unable to settle this payment`);
+        }
+        const result = await this.orderService.settlePayment(ctx, payment.id);
+        if ((result as ErrorResult).message) {
+            throw Error(
+                `Error settling payment ${payment.id} for order ${order.code}: ${(result as ErrorResult).errorCode} - ${(result as ErrorResult).message}`);
+        }
     }
 
     async getEnabledPaymentMethods(ctx: RequestContext, paymentMethodCode: string): Promise<MolliePaymentMethod[]> {

+ 48 - 14
yarn.lock

@@ -4190,6 +4190,13 @@
     "@types/node" "*"
     rxjs "^6.5.1"
 
+"@types/localtunnel@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@types/localtunnel/-/localtunnel-2.0.1.tgz#c28a067de1da81b0b4730a9fc2f98e067f973996"
+  integrity sha512-0h/ggh+tp9uKHc2eEOLdMgWW0cNwsQfn6iEE1Y44FszNB4BQyL5N6xvd5BnChZksB0YgVqa5MKxJt0dFoOKRxw==
+  dependencies:
+    "@types/node" "*"
+
 "@types/long@^4.0.0", "@types/long@^4.0.1":
   version "4.0.1"
   resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
@@ -5528,6 +5535,13 @@ axios@0.21.1:
   dependencies:
     follow-redirects "^1.10.0"
 
+axios@0.21.4:
+  version "0.21.4"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
+  integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
+  dependencies:
+    follow-redirects "^1.14.0"
+
 axios@^0.25.0:
   version "0.25.0"
   resolved "https://registry.yarnpkg.com/axios/-/axios-0.25.0.tgz#349cfbb31331a9b4453190791760a8d35b093e0a"
@@ -9057,6 +9071,11 @@ follow-redirects@^1.0.0, follow-redirects@^1.10.0:
   resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.2.tgz#cecb825047c00f5e66b142f90fed4f515dec789b"
   integrity sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==
 
+follow-redirects@^1.14.0:
+  version "1.15.2"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
+  integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
+
 follow-redirects@^1.14.7:
   version "1.14.9"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7"
@@ -12411,6 +12430,16 @@ loader-utils@^1.4.0:
     emojis-list "^3.0.0"
     json5 "^1.0.1"
 
+localtunnel@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/localtunnel/-/localtunnel-2.0.2.tgz#528d50087151c4790f89c2db374fe7b0a48501f0"
+  integrity sha512-n418Cn5ynvJd7m/N1d9WVJISLJF/ellZnfsLnx8WBWGzxv/ntNcFkJ1o6se5quUhCplfLGBNL5tYHiq5WF3Nug==
+  dependencies:
+    axios "0.21.4"
+    debug "4.3.2"
+    openurl "1.1.1"
+    yargs "17.1.1"
+
 locate-path@^2.0.0:
   version "2.0.0"
   resolved "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@@ -14068,7 +14097,7 @@ npm-package-arg@8.1.5, npm-package-arg@^8.0.0, npm-package-arg@^8.0.1, npm-packa
 
 npm-packlist@1.1.12, npm-packlist@^1.1.6, npm-packlist@^2.1.4, npm-packlist@^3.0.0:
   version "1.1.12"
-  resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.1.12.tgz#22bde2ebc12e72ca482abd67afc51eb49377243a"
+  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.12.tgz#22bde2ebc12e72ca482abd67afc51eb49377243a"
   integrity sha512-WJKFOVMeAlsU/pjXuqVdzU0WfgtIBCupkEVwn+1Y0ERAbUfWw8R4GjgVbaKnUjRoD2FoQbHOCbOyT5Mbs9Lw4g==
   dependencies:
     ignore-walk "^3.0.1"
@@ -14344,6 +14373,11 @@ opencollective-postinstall@^2.0.2:
   resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259"
   integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==
 
+openurl@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/openurl/-/openurl-1.1.1.tgz#3875b4b0ef7a52c156f0db41d4609dbb0f94b387"
+  integrity sha512-d/gTkTb1i1GKz5k3XE3XFV/PxQ1k45zDqGP2OA7YhgsaLoqm6qRvARAZOFer1fcXritWlGBRCu/UgeS4HAnXAA==
+
 opn@^5.5.0:
   version "5.5.0"
   resolved "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc"
@@ -19747,6 +19781,19 @@ yargs-parser@^5.0.1:
     camelcase "^3.0.0"
     object.assign "^4.1.0"
 
+yargs@17.1.1, yargs@^17.0.0:
+  version "17.1.1"
+  resolved "https://registry.npmjs.org/yargs/-/yargs-17.1.1.tgz#c2a8091564bdb196f7c0a67c1d12e5b85b8067ba"
+  integrity sha512-c2k48R0PwKIqKhPMWjeiF6y2xY/gPMUlro0sgxqXpbOIohWiLNXWslsootttv7E1e73QPAMQSg5FeySbVcpsPQ==
+  dependencies:
+    cliui "^7.0.2"
+    escalade "^3.1.1"
+    get-caller-file "^2.0.5"
+    require-directory "^2.1.1"
+    string-width "^4.2.0"
+    y18n "^5.0.5"
+    yargs-parser "^20.2.2"
+
 yargs@^13.3.0, yargs@^13.3.2:
   version "13.3.2"
   resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
@@ -19793,19 +19840,6 @@ yargs@^16.0.0, yargs@^16.0.3, yargs@^16.1.0, yargs@^16.1.1, yargs@^16.2.0:
     y18n "^5.0.5"
     yargs-parser "^20.2.2"
 
-yargs@^17.0.0:
-  version "17.1.1"
-  resolved "https://registry.npmjs.org/yargs/-/yargs-17.1.1.tgz#c2a8091564bdb196f7c0a67c1d12e5b85b8067ba"
-  integrity sha512-c2k48R0PwKIqKhPMWjeiF6y2xY/gPMUlro0sgxqXpbOIohWiLNXWslsootttv7E1e73QPAMQSg5FeySbVcpsPQ==
-  dependencies:
-    cliui "^7.0.2"
-    escalade "^3.1.1"
-    get-caller-file "^2.0.5"
-    require-directory "^2.1.1"
-    string-width "^4.2.0"
-    y18n "^5.0.5"
-    yargs-parser "^20.2.2"
-
 yargs@^17.0.1:
   version "17.2.1"
   resolved "https://registry.npmjs.org/yargs/-/yargs-17.2.1.tgz#e2c95b9796a0e1f7f3bf4427863b42e0418191ea"