Procházet zdrojové kódy

fix(payments-plugin): Handle multiple payments & verify stock for Mollie

Closes #2026, closes #2030
Martijn před 2 roky
rodič
revize
1aad00e34e

+ 1 - 1
packages/core/src/service/services/order.service.ts

@@ -1070,7 +1070,7 @@ export class OrderService {
         if (payment.state === 'Declined') {
             return new PaymentDeclinedError(payment.errorMessage || '');
         }
-
+        
         return this.transitionOrderIfTotalIsCovered(ctx, order);
     }
 

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

@@ -2,10 +2,12 @@ import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
 import {
     ChannelService,
     DefaultLogger,
+    DefaultSearchPlugin,
     Logger,
     LogLevel,
     mergeConfig,
     OrderService,
+    PaymentService,
     RequestContext,
 } from '@vendure/core';
 import { createTestEnvironment, registerInitializer, SqljsInitializer, testConfig } from '@vendure/testing';
@@ -35,6 +37,7 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
     const config = mergeConfig(testConfig, {
         plugins: [
             ...testConfig.plugins,
+            DefaultSearchPlugin,
             AdminUiPlugin.init({
                 route: 'admin',
                 port: 5001,
@@ -42,6 +45,10 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
             MolliePlugin.init({ vendureHost: tunnel.url }),
         ],
         logger: new DefaultLogger({ level: LogLevel.Debug }),
+        apiOptions: {
+            adminApiPlayground: true,
+            shopApiPlayground: true,
+        }
     });
     const { server, shopClient, adminClient } = createTestEnvironment(config as any);
     await server.init({
@@ -94,6 +101,17 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
         listPrice: -20000,
     });
     await setShipping(shopClient);
+    // Add pre payment to order
+    const order = await server.app.get(OrderService).findOne(ctx, 1);
+    // tslint:disable-next-line:no-non-null-assertion
+    await server.app.get(PaymentService).createManualPayment(ctx, order!, 10000 ,{
+        method: 'Manual',
+        // tslint:disable-next-line:no-non-null-assertion
+        orderId: order!.id,
+        metadata: {
+            bogus: 'test'
+        }
+    });
     const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
         input: {
             paymentMethodCode: 'mollie',

+ 88 - 44
packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts

@@ -1,8 +1,8 @@
 import { OrderStatus } from '@mollie/api-client';
-import { ChannelService, mergeConfig, OrderService, RequestContext } from '@vendure/core';
+import { ChannelService, DefaultLogger, LogLevel, mergeConfig, OrderService, RequestContext } from '@vendure/core';
 import {
     SettlePaymentMutation,
-    SettlePaymentMutationVariables,
+    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';
@@ -12,6 +12,7 @@ import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { UPDATE_PRODUCT_VARIANTS } from '../../core/e2e/graphql/shared-definitions';
 import { MolliePlugin } from '../src/mollie';
 import { molliePaymentHandler } from '../src/mollie/mollie.handler';
 
@@ -19,7 +20,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 { CREATE_MOLLIE_PAYMENT_INTENT, GET_MOLLIE_PAYMENT_METHODS, refundOne, setShipping } from './payment-helpers';
+import { addManualPayment, CREATE_MOLLIE_PAYMENT_INTENT, GET_MOLLIE_PAYMENT_METHODS, refundOrderLine, setShipping } from './payment-helpers';
 
 describe('Mollie payments', () => {
     const mockData = {
@@ -103,7 +104,7 @@ describe('Mollie payments', () => {
         const devConfig = mergeConfig(testConfig(), {
             plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
             // Uncomment next line to debug e2e
-            // logger: new DefaultLogger({level: LogLevel.Verbose})
+            logger: new DefaultLogger({level: LogLevel.Verbose})
         });
         const env = createTestEnvironment(devConfig);
         serverPort = devConfig.apiOptions.port;
@@ -159,21 +160,21 @@ describe('Mollie payments', () => {
     it('Should add a Mollie paymentMethod', async () => {
         const { createPaymentMethod } = await adminClient.query<CreatePaymentMethod.Mutation,
             CreatePaymentMethod.Variables>(CREATE_PAYMENT_METHOD, {
-            input: {
-                code: mockData.methodCode,
-                name: 'Mollie payment test',
-                description: 'This is a Mollie test payment method',
-                enabled: true,
-                handler: {
-                    code: molliePaymentHandler.code,
-                    arguments: [
-                        { name: 'redirectUrl', value: mockData.redirectUrl },
-                        { name: 'apiKey', value: mockData.apiKey },
-                        { name: 'autoCapture', value: 'false' },
-                    ],
+                input: {
+                    code: mockData.methodCode,
+                    name: 'Mollie payment test',
+                    description: 'This is a Mollie test payment method',
+                    enabled: true,
+                    handler: {
+                        code: molliePaymentHandler.code,
+                        arguments: [
+                            { name: 'redirectUrl', value: mockData.redirectUrl },
+                            { name: 'apiKey', value: mockData.apiKey },
+                            { name: 'autoCapture', value: 'false' },
+                        ],
+                    },
                 },
-            },
-        });
+            });
         expect(createPaymentMethod.code).toBe(mockData.methodCode);
     });
 
@@ -199,16 +200,40 @@ describe('Mollie payments', () => {
         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('The following variants are out of stock');
+        // 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;
+        let mollieRequest: any | undefined;
         nock('https://api.mollie.com/')
-            .post(/.*/, body => {
+            .post('/v2/orders', body => {
                 mollieRequest = body;
                 return true;
             })
             .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,
@@ -232,12 +257,8 @@ describe('Mollie payments', () => {
     });
 
     it('Should get payment url with Mollie method', async () => {
-        let mollieRequest;
         nock('https://api.mollie.com/')
-            .post(/.*/, body => {
-                mollieRequest = body;
-                return true;
-            })
+            .post('/v2/orders')
             .reply(200, mockData.mollieOrderResponse);
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         await setShipping(shopClient);
@@ -250,9 +271,32 @@ describe('Mollie payments', () => {
         expect(createMolliePaymentIntent).toEqual({ url: 'https://www.mollie.com/payscreen/select-method/mock-payment' });
     });
 
+    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 immediately settle payment for standard payment methods', async () => {
         nock('https://api.mollie.com/')
-            .get(/.*/)
+            .get('/v2/orders/ord_mockId')
             .reply(200, {
                 ...mockData.mollieOrderResponse,
                 orderNumber: order.code,
@@ -275,7 +319,8 @@ describe('Mollie payments', () => {
     });
 
     it('Should have Mollie metadata on payment', async () => {
-        const { order: { payments: [{ metadata }] } } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
+        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);
@@ -284,38 +329,37 @@ describe('Mollie payments', () => {
     });
 
     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;
-                return true;
-            })
+            .post('/v2/payments/tr_mockPayment/refunds')
             .reply(200, { status: 'failed', resource: 'payment' });
-        const refund = await refundOne(adminClient, order.lines[0].id, order.payments[0].id);
+        // tslint:disable-next-line:no-non-null-assertion
+        const refund = await refundOrderLine(adminClient, order.lines[0].id, 1, order!.payments[1].id);
         expect(refund.state).toBe('Failed');
     });
 
-    it('Should successfully refund', async () => {
+    it('Should successfully refund the Mollie payment', async () => {
         let mollieRequest;
         nock('https://api.mollie.com/')
-            .post(/.*/, body => {
+            .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 refundOne(adminClient, order.lines[0].id, order.payments[0].id);
-        expect(mollieRequest?.amount.value).toBe('119.99');
-        expect(refund.total).toBe(11999);
+        const refund = await refundOrderLine(adminClient, order.lines[0].id, 10, order.payments[1].id);
+        expect(mollieRequest?.amount.value).toBe('909.90'); // Only refund mollie amount, not the gift card
+        expect(refund.total).toBe(90990);
         expect(refund.state).toBe('Settled');
     });
 
     it('Should get available paymentMethods', async () => {
         nock('https://api.mollie.com/')
-            .get(/.*/)
+            .get('/v2/methods')
             .reply(200, mockData.molliePaymentMethodsResponse);
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         const { molliePaymentMethods } = await shopClient.query(GET_MOLLIE_PAYMENT_METHODS, {
@@ -343,7 +387,7 @@ describe('Mollie payments', () => {
 
     it('Should authorize payment for pay-later payment methods', async () => {
         nock('https://api.mollie.com/')
-            .get(/.*/)
+            .get('/v2/orders/ord_mockId')
             .reply(200, {
                 ...mockData.mollieOrderResponse,
                 orderNumber: order.code,
@@ -368,7 +412,7 @@ describe('Mollie payments', () => {
     it('Should settle payment via settlePayment mutation', async () => {
         // Mock the getOrder Mollie call
         nock('https://api.mollie.com/')
-            .get(/.*/)
+            .get('/v2/orders/ord_mockId')
             .reply(200, {
                 ...mockData.mollieOrderResponse,
                 orderNumber: order.code,

+ 26 - 3
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 { ChannelService, OrderService, PaymentService, RequestContext } from '@vendure/core';
+import { SimpleGraphQLClient, TestServer } from '@vendure/testing';
 import gql from 'graphql-tag';
 
 import { REFUND_ORDER } from './graphql/admin-queries';
@@ -46,16 +47,17 @@ export async function proceedToArrangingPayment(shopClient: SimpleGraphQLClient)
     return (transitionOrderToState as TestOrderFragmentFragment)!.id;
 }
 
-export async function refundOne(
+export async function refundOrderLine(
     adminClient: SimpleGraphQLClient,
     orderLineId: string,
+    quantity: number,
     paymentId: string,
 ): Promise<RefundFragment> {
     const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
         REFUND_ORDER,
         {
             input: {
-                lines: [{ orderLineId, quantity: 1 }],
+                lines: [{ orderLineId, quantity }],
                 shipping: 0,
                 adjustment: 0,
                 paymentId,
@@ -64,6 +66,27 @@ export async function refundOne(
     );
     return refundOrder as RefundFragment;
 }
+/**
+ * Add a partial payment to an order. This happens, for example, when using Gift cards
+ */
+export async function addManualPayment(server: TestServer, orderId: ID, amount: number): Promise<void> {
+    const ctx = new RequestContext({
+        apiType: 'admin',
+        isAuthorized: true,
+        authorizedAsOwnerOnly: false,
+        channel: await server.app.get(ChannelService).getDefaultChannel(),
+    });
+    const order = await server.app.get(OrderService).findOne(ctx, orderId);
+    // tslint:disable-next-line:no-non-null-assertion
+    await server.app.get(PaymentService).createManualPayment(ctx, order!, amount, {
+        method: 'Gift card',
+        // tslint:disable-next-line:no-non-null-assertion
+        orderId: order!.id,
+        metadata: {
+            bogus: 'test'
+        }
+    });
+}
 
 export const CREATE_MOLLIE_PAYMENT_INTENT = gql`
     mutation createMolliePaymentIntent($input: MolliePaymentIntentInput!) {

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

@@ -29,10 +29,12 @@
     "devDependencies": {
         "@mollie/api-client": "^3.6.0",
         "@types/braintree": "^2.22.15",
+        "@types/localtunnel": "2.0.1",
         "@vendure/common": "^1.9.3",
         "@vendure/core": "^1.9.3",
         "@vendure/testing": "^1.9.3",
         "braintree": "^3.0.0",
+        "localtunnel": "2.0.1",
         "nock": "^13.1.4",
         "rimraf": "^3.0.2",
         "stripe": "^8.197.0",

+ 11 - 2
packages/payments-plugin/src/mollie/mollie.helpers.ts

@@ -26,7 +26,7 @@ export function toMollieAddress(address: OrderAddress, customer: Customer): Moll
 /**
  * Map all order and shipping lines to a single array of Mollie order lines
  */
-export function toMollieOrderLines(order: Order): CreateParameters['lines'] {
+export function toMollieOrderLines(order: Order, alreadyPaid: number): CreateParameters['lines'] {
     // Add order lines
     const lines: CreateParameters['lines'] = order.lines.map(line => ({
         name: line.productVariant.name,
@@ -57,6 +57,15 @@ export function toMollieOrderLines(order: Order): CreateParameters['lines'] {
         vatRate: String(surcharge.taxRate),
         vatAmount: toAmount(surcharge.priceWithTax - surcharge.price, order.currencyCode),
     })));
+    // Deduct amount already paid
+    lines.push({
+        name: 'Already paid',
+        quantity: 1,
+        unitPrice: toAmount(-alreadyPaid, order.currencyCode),
+        totalAmount: toAmount(-alreadyPaid, order.currencyCode),
+        vatRate: String(0),
+        vatAmount: toAmount(0, order.currencyCode),
+    });
     return lines;
 }
 
@@ -77,7 +86,7 @@ export function toAmount(value: number, orderCurrency: string): Amount {
  */
 export function calculateLineTaxAmount(taxRate: number, orderLinePriceWithTax: number): number {
     const taxMultiplier = taxRate / 100;
-    return orderLinePriceWithTax * (taxMultiplier / (1+taxMultiplier)); // I.E. €99,99 * (0,2 ÷ 1,2) with a 20% taxrate
+    return orderLinePriceWithTax * (taxMultiplier / (1 + taxMultiplier)); // I.E. €99,99 * (0,2 ÷ 1,2) with a 20% taxrate
 
 }
 

+ 30 - 4
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -17,8 +17,11 @@ import {
     OrderStateTransitionError,
     PaymentMethod,
     PaymentMethodService,
+    ProductVariant,
+    ProductVariantService,
     RequestContext,
 } from '@vendure/core';
+import { totalCoveredByPayments } from '@vendure/core/dist/service/helpers/utils/order-utils';
 
 import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
 import {
@@ -61,6 +64,7 @@ export class MollieService {
         private orderService: OrderService,
         private channelService: ChannelService,
         private entityHydrator: EntityHydrator,
+        private variantService: ProductVariantService,
     ) {
     }
 
@@ -83,7 +87,7 @@ export class MollieService {
             return new PaymentIntentError('No active order found for session');
         }
         await this.entityHydrator.hydrate(ctx, order,
-            { relations: ['customer', 'surcharges', 'lines.productVariant', 'shippingLines.shippingMethod'] }
+            { relations: ['customer', 'surcharges', 'lines.productVariant', 'shippingLines.shippingMethod', 'payments'] }
         );
         if (!order.lines?.length) {
             return new PaymentIntentError('Cannot create payment intent for empty order');
@@ -97,6 +101,12 @@ export class MollieService {
         if (!paymentMethod) {
             return new PaymentIntentError(`No paymentMethod found with code ${paymentMethodCode}`);
         }
+        const variantsWithInsufficientSaleableStock = await this.getVariantsWithInsufficientStock(ctx, order);
+        if (variantsWithInsufficientSaleableStock.length) {
+            return new PaymentIntentError(
+                `The following variants are out of stock: ${variantsWithInsufficientSaleableStock.map(v => v.name).join(', ')}`
+            );
+        }
         const apiKey = paymentMethod.handler.args.find(arg => arg.name === 'apiKey')?.value;
         let redirectUrl = paymentMethod.handler.args.find(arg => arg.name === 'redirectUrl')?.value;
         if (!apiKey || !redirectUrl) {
@@ -112,14 +122,16 @@ export class MollieService {
         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 alreadyPaid = totalCoveredByPayments(order);
+        const amountToPay = order.totalWithTax - alreadyPaid;
         const orderInput: CreateParameters = {
             orderNumber: order.code,
-            amount: toAmount(order.totalWithTax, order.currencyCode),
+            amount: toAmount(amountToPay, 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),
+            lines: toMollieOrderLines(order, alreadyPaid),
         };
         if (molliePaymentMethodCode) {
             orderInput.method = molliePaymentMethodCode as MollieClientMethod;
@@ -158,7 +170,7 @@ export class MollieService {
         if (!order) {
             throw Error(`Unable to find order ${mollieOrder.orderNumber}, unable to process Mollie order ${mollieOrder.id}`);
         }
-        if (mollieOrder.status === OrderStatus.paid ) {
+        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;
@@ -254,6 +266,20 @@ export class MollieService {
         }));
     }
 
+    async getVariantsWithInsufficientStock(ctx: RequestContext, order: Order): Promise<ProductVariant[]> {
+        const variantsWithInsufficientSaleableStock: ProductVariant[] = [];
+        for (const line of order.lines) {
+            const availableStock = await this.variantService.getSaleableStockLevel(
+                ctx,
+                line.productVariant,
+            );
+            if (line.quantity > availableStock) {
+                variantsWithInsufficientSaleableStock.push(line.productVariant);
+            }
+        }
+        return variantsWithInsufficientSaleableStock;
+    }
+
     private async getPaymentMethod(ctx: RequestContext, paymentMethodCode: string): Promise<PaymentMethod | undefined> {
         const paymentMethods = await this.paymentMethodService.findAll(ctx);
         return paymentMethods.items.find(pm => pm.code === paymentMethodCode);

+ 35 - 13
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"
@@ -12418,6 +12425,16 @@ loader-utils@^1.4.0:
     emojis-list "^3.0.0"
     json5 "^1.0.1"
 
+localtunnel@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/localtunnel/-/localtunnel-2.0.1.tgz#8f7c593f3005647f7675e6e69af9bf746571a631"
+  integrity sha512-LiaI5wZdz0xFkIQpXbNI62ZnNn8IMsVhwxHmhA+h4vj8R9JG/07bQHWwQlyy7b95/5fVOCHJfIHv+a5XnkvaJA==
+  dependencies:
+    axios "0.21.1"
+    debug "4.3.1"
+    openurl "1.1.1"
+    yargs "16.2.0"
+
 locate-path@^2.0.0:
   version "2.0.0"
   resolved "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@@ -14351,6 +14368,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"
@@ -19759,6 +19781,19 @@ yargs-parser@^5.0.1:
     camelcase "^3.0.0"
     object.assign "^4.1.0"
 
+yargs@16.2.0, yargs@^16.0.0, yargs@^16.0.3, yargs@^16.1.0, yargs@^16.1.1, yargs@^16.2.0:
+  version "16.2.0"
+  resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
+  integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==
+  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"
@@ -19792,19 +19827,6 @@ yargs@^15.3.1:
     y18n "^4.0.0"
     yargs-parser "^18.1.2"
 
-yargs@^16.0.0, yargs@^16.0.3, yargs@^16.1.0, yargs@^16.1.1, yargs@^16.2.0:
-  version "16.2.0"
-  resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
-  integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==
-  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.0:
   version "17.1.1"
   resolved "https://registry.npmjs.org/yargs/-/yargs-17.1.1.tgz#c2a8091564bdb196f7c0a67c1d12e5b85b8067ba"