Browse Source

Merge branch 'master' into minor

Michael Bromley 3 years ago
parent
commit
5767f38cf6

+ 89 - 0
packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.spec.ts

@@ -0,0 +1,89 @@
+import { getDefaultConfigArgValue } from '@vendure/admin-ui/core';
+
+describe('getDefaultConfigArgValue()', () => {
+    it('returns a default string value', () => {
+        const value = getDefaultConfigArgValue({
+            name: 'test',
+            type: 'string',
+            defaultValue: 'foo',
+            list: false,
+            required: false,
+        });
+
+        expect(value).toBe('foo');
+    });
+
+    it('returns a default empty string value', () => {
+        const value = getDefaultConfigArgValue({
+            name: 'test',
+            type: 'string',
+            defaultValue: '',
+            list: false,
+            required: false,
+        });
+
+        expect(value).toBe('');
+    });
+
+    it('returns a default number value', () => {
+        const value = getDefaultConfigArgValue({
+            name: 'test',
+            type: 'float',
+            defaultValue: 2.5,
+            list: false,
+            required: false,
+        });
+
+        expect(value).toBe(2.5);
+    });
+
+    it('returns a default zero number value', () => {
+        const value = getDefaultConfigArgValue({
+            name: 'test',
+            type: 'float',
+            defaultValue: 0,
+            list: false,
+            required: false,
+        });
+
+        expect(value).toBe(0);
+    });
+
+    it('returns a default list value', () => {
+        const value = getDefaultConfigArgValue({
+            name: 'test',
+            type: 'float',
+            list: true,
+            required: false,
+        });
+
+        expect(value).toEqual([]);
+    });
+
+    it('returns a null if no default set', () => {
+        function getValueForType(type: string) {
+            return getDefaultConfigArgValue({
+                name: 'test',
+                type,
+                list: false,
+                required: false,
+            });
+        }
+        expect(getValueForType('string')).toBeNull();
+        expect(getValueForType('datetime')).toBeNull();
+        expect(getValueForType('float')).toBeNull();
+        expect(getValueForType('ID')).toBeNull();
+        expect(getValueForType('int')).toBeNull();
+    });
+
+    it('returns false for boolean without default', () => {
+        const value = getDefaultConfigArgValue({
+            name: 'test',
+            type: 'boolean',
+            list: false,
+            required: false,
+        });
+
+        expect(value).toBe(false);
+    });
+});

+ 2 - 2
packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts

@@ -15,7 +15,7 @@ import {
  */
 export function getConfigArgValue(value: any) {
     try {
-        return value ? JSON.parse(value) : undefined;
+        return value != null ? JSON.parse(value) : undefined;
     } catch (e) {
         return value;
     }
@@ -103,7 +103,7 @@ export function getDefaultConfigArgValue(arg: ConfigArgDefinition): any {
     if (arg.list) {
         return [];
     }
-    if (arg.defaultValue) {
+    if (arg.defaultValue != null) {
         return arg.defaultValue;
     }
     const type = arg.type as ConfigArgType;

+ 31 - 23
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts

@@ -526,32 +526,45 @@ export class OrderDetailComponent
                         return of(undefined);
                     }
 
-                    const operations: Array<Observable<RefundOrder.RefundOrder | CancelOrder.CancelOrder>> =
-                        [];
-                    if (input.refund.lines.length) {
-                        operations.push(
-                            this.dataService.order
-                                .refundOrder(input.refund)
-                                .pipe(map(res => res.refundOrder)),
-                        );
-                    }
                     if (input.cancel.lines?.length) {
-                        operations.push(
-                            this.dataService.order
-                                .cancelOrder(input.cancel)
-                                .pipe(map(res => res.cancelOrder)),
+                        return this.dataService.order.cancelOrder(input.cancel).pipe(
+                            map(res => {
+                                const result = res.cancelOrder;
+                                switch (result.__typename) {
+                                    case 'Order':
+                                        this.refetchOrder(result).subscribe();
+                                        this.notificationService.success(_('order.cancelled-order-success'));
+                                        return input;
+                                    case 'CancelActiveOrderError':
+                                    case 'QuantityTooGreatError':
+                                    case 'MultipleOrderError':
+                                    case 'OrderStateTransitionError':
+                                    case 'EmptyOrderLineSelectionError':
+                                        this.notificationService.error(result.message);
+                                        return undefined;
+                                }
+                            }),
                         );
+                    } else {
+                        return [input];
+                    }
+                }),
+                switchMap(input => {
+                    if (!input) {
+                        return of(undefined);
+                    }
+                    if (input.refund.lines.length) {
+                        return this.dataService.order
+                            .refundOrder(input.refund)
+                            .pipe(map(res => res.refundOrder));
+                    } else {
+                        return [undefined];
                     }
-                    return merge(...operations);
                 }),
             )
             .subscribe(result => {
                 if (result) {
                     switch (result.__typename) {
-                        case 'Order':
-                            this.refetchOrder(result).subscribe();
-                            this.notificationService.success(_('order.cancelled-order-success'));
-                            break;
                         case 'Refund':
                             this.refetchOrder(result).subscribe();
                             if (result.state === 'Failed') {
@@ -560,11 +573,6 @@ export class OrderDetailComponent
                                 this.notificationService.success(_('order.refund-order-success'));
                             }
                             break;
-                        case 'QuantityTooGreatError':
-                        case 'MultipleOrderError':
-                        case 'OrderStateTransitionError':
-                        case 'CancelActiveOrderError':
-                        case 'EmptyOrderLineSelectionError':
                         case 'AlreadyRefundedError':
                         case 'NothingToRefundError':
                         case 'PaymentOrderMismatchError':

+ 55 - 0
packages/core/e2e/order.e2e-spec.ts

@@ -2342,6 +2342,61 @@ describe('Orders resolver', () => {
                     .price,
             ).toBe(108720);
         });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/1558
+        it('cancelling OrderItem avoids items that have been fulfilled', async () => {
+            await shopClient.asUserWithCredentials(customers[0].emailAddress, password);
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: 'T_1',
+                quantity: 2,
+            });
+
+            await proceedToArrangingPayment(shopClient);
+            const order = await addPaymentToOrder(shopClient, singleStageRefundablePaymentMethod);
+            orderGuard.assertSuccess(order);
+
+            await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
+                CREATE_FULFILLMENT,
+                {
+                    input: {
+                        lines: [
+                            {
+                                orderLineId: order.lines[0].id,
+                                quantity: 1,
+                            },
+                        ],
+                        handler: {
+                            code: manualFulfillmentHandler.code,
+                            arguments: [{ name: 'method', value: 'Test' }],
+                        },
+                    },
+                },
+            );
+
+            const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(
+                CANCEL_ORDER,
+                {
+                    input: {
+                        orderId: order.id,
+                        lines: [{ orderLineId: order.lines[0].id, quantity: 1 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(cancelOrder);
+
+            const { order: order2 } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: order.id,
+            });
+
+            const items = order2!.lines[0].items;
+            const itemWhichIsCancelledAndFulfilled = items.find(
+                i => i.cancelled === true && i.fulfillment != null,
+            );
+            expect(itemWhichIsCancelledAndFulfilled).toBeUndefined();
+        });
     });
 });
 

+ 89 - 0
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -29,6 +29,8 @@ import {
     GetCustomer,
     GetCustomerHistory,
     GetCustomerList,
+    GetCustomerListQuery,
+    GetCustomerListQueryVariables,
     HistoryEntryType,
     Permission,
 } from './graphql/generated-e2e-admin-types';
@@ -73,6 +75,7 @@ let sendEmailFn: jest.Mock;
 })
 class TestEmailPlugin implements OnModuleInit {
     constructor(private eventBus: EventBus) {}
+
     onModuleInit() {
         this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
             sendEmailFn(event);
@@ -616,6 +619,92 @@ describe('Shop auth & accounts', () => {
         });
     });
 
+    // https://github.com/vendure-ecommerce/vendure/issues/1659
+    describe('password reset before verification', () => {
+        const password = 'password';
+        const emailAddress = 'test3@test.com';
+        let verificationToken: string;
+        let passwordResetToken: string;
+        let newCustomerId: string;
+
+        beforeEach(() => {
+            sendEmailFn = jest.fn();
+        });
+
+        it('register a new account without password', async () => {
+            const verificationTokenPromise = getVerificationTokenPromise();
+            const input: RegisterCustomerInput = {
+                firstName: 'Bobby',
+                lastName: 'Tester',
+                phoneNumber: '123456',
+                emailAddress,
+            };
+            const { registerCustomerAccount } = await shopClient.query<Register.Mutation, Register.Variables>(
+                REGISTER_ACCOUNT,
+                { input },
+            );
+            successErrorGuard.assertSuccess(registerCustomerAccount);
+            verificationToken = await verificationTokenPromise;
+
+            const { customers } = await adminClient.query<
+                GetCustomerListQuery,
+                GetCustomerListQueryVariables
+            >(GET_CUSTOMER_LIST, {
+                options: {
+                    filter: {
+                        emailAddress: { eq: emailAddress },
+                    },
+                },
+            });
+
+            expect(customers.items[0].user?.verified).toBe(false);
+            newCustomerId = customers.items[0].id;
+        });
+
+        it('requestPasswordReset', async () => {
+            const passwordResetTokenPromise = getPasswordResetTokenPromise();
+            const { requestPasswordReset } = await shopClient.query<
+                RequestPasswordReset.Mutation,
+                RequestPasswordReset.Variables
+            >(REQUEST_PASSWORD_RESET, {
+                identifier: emailAddress,
+            });
+            successErrorGuard.assertSuccess(requestPasswordReset);
+
+            await waitForSendEmailFn();
+            passwordResetToken = await passwordResetTokenPromise;
+            expect(requestPasswordReset.success).toBe(true);
+            expect(sendEmailFn).toHaveBeenCalled();
+            expect(passwordResetToken).toBeDefined();
+        });
+
+        it('resetPassword also performs verification', async () => {
+            const { resetPassword } = await shopClient.query<ResetPassword.Mutation, ResetPassword.Variables>(
+                RESET_PASSWORD,
+                {
+                    token: passwordResetToken,
+                    password: 'newPassword',
+                },
+            );
+            currentUserErrorGuard.assertSuccess(resetPassword);
+
+            expect(resetPassword.identifier).toBe(emailAddress);
+            const { customer } = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(
+                GET_CUSTOMER,
+                {
+                    id: newCustomerId,
+                },
+            );
+
+            expect(customer?.user?.verified).toBe(true);
+        });
+
+        it('can log in with new password', async () => {
+            const loginResult = await shopClient.asUserWithCredentials(emailAddress, 'newPassword');
+            expect(loginResult.identifier).toBe(emailAddress);
+        });
+    });
+
     describe('updating emailAddress', () => {
         let emailUpdateToken: string;
         let customer: GetCustomer.Customer;

+ 6 - 2
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -66,7 +66,7 @@ export type ExtendedListQueryOptions<T extends VendureEntity> = {
      *
      * Example: we want to allow sort/filter by and Order's `customerLastName`. The actual lastName property is
      * not a column in the Order table, it exists on the Customer entity, and Order has a relation to Customer via
-     * `Order.customer`. Therefore we can define a customPropertyMap like this:
+     * `Order.customer`. Therefore, we can define a customPropertyMap like this:
      *
      * @example
      * ```GraphQL
@@ -90,7 +90,11 @@ export type ExtendedListQueryOptions<T extends VendureEntity> = {
      *   customPropertyMap: {
      *     // Tell TypeORM how to map that custom
      *     // sort/filter field to the property on a
-     *     // related entity
+     *     // related entity. Note that the `customer`
+     *     // part needs to match the *table name* of the
+     *     // related entity. So, e.g. if you are mapping to
+     *     // a `FacetValue` relation's `id` property, the value
+     *     // would be `facet_value.id`.
      *     customerLastName: 'customer.lastName',
      *   },
      * };

+ 11 - 3
packages/core/src/service/services/order.service.ts

@@ -1797,9 +1797,17 @@ export class OrderService {
             if (matchingItems.length < inputLine.quantity) {
                 return false;
             }
-            matchingItems.slice(0, inputLine.quantity).forEach(item => {
-                items.set(item.id, item);
-            });
+            matchingItems
+                .slice(0)
+                .sort((a, b) =>
+                    // sort the OrderItems so that those without Fulfillments come first, as
+                    // it makes sense to cancel these prior to cancelling fulfilled items.
+                    !a.fulfillment && b.fulfillment ? -1 : a.fulfillment && !b.fulfillment ? 1 : 0,
+                )
+                .slice(0, inputLine.quantity)
+                .forEach(item => {
+                    items.set(item.id, item);
+                });
         }
         return {
             orders: Array.from(orders.values()),

+ 8 - 0
packages/core/src/service/services/user.service.ts

@@ -260,6 +260,14 @@ export class UserService {
             nativeAuthMethod.passwordHash = await this.passwordCipher.hash(password);
             nativeAuthMethod.passwordResetToken = null;
             await this.connection.getRepository(ctx, NativeAuthenticationMethod).save(nativeAuthMethod);
+            if (user.verified === false && this.configService.authOptions.requireVerification) {
+                // This code path represents an edge-case in which the Customer creates an account,
+                // but prior to verifying their email address, they start the password reset flow.
+                // Since the password reset flow makes the exact same guarantee as the email verification
+                // flow (i.e. the person controls the specified email account), we can also consider it
+                // a verification.
+                user.verified = true;
+            }
             return this.connection.getRepository(ctx, User).save(user);
         } else {
             return new PasswordResetTokenExpiredError();

+ 2 - 3
packages/job-queue-plugin/package.json

@@ -23,11 +23,10 @@
   },
   "devDependencies": {
     "@google-cloud/pubsub": "^2.8.0",
-    "@types/redis": "^2.8.28",
+    "@types/ioredis": "^4.28.10",
     "@vendure/common": "^1.6.3",
     "@vendure/core": "^1.6.3",
-    "bullmq": "^1.40.1",
-    "redis": "^3.0.2",
+    "bullmq": "^1.86.7",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"
   }

+ 46 - 14
packages/job-queue-plugin/src/bullmq/bullmq-job-queue-strategy.ts

@@ -10,7 +10,9 @@ import {
     Logger,
     PaginatedList,
 } from '@vendure/core';
-import Bull, { Processor, Queue, QueueScheduler, Worker, WorkerOptions } from 'bullmq';
+import Bull, { ConnectionOptions, Processor, Queue, QueueScheduler, Worker, WorkerOptions } from 'bullmq';
+import { EventEmitter } from 'events';
+import Redis, { RedisOptions } from 'ioredis';
 
 import { ALL_JOB_TYPES, BULLMQ_PLUGIN_OPTIONS, loggerCtx } from './constants';
 import { RedisHealthIndicator } from './redis-health-indicator';
@@ -27,6 +29,8 @@ const DEFAULT_CONCURRENCY = 3;
  * @docsCategory job-queue-plugin
  */
 export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
+    private redisConnection: Redis.Redis | Redis.Cluster;
+    private connectionOptions: ConnectionOptions;
     private queue: Queue;
     private worker: Worker;
     private scheduler: QueueScheduler;
@@ -37,6 +41,18 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
     async init(injector: Injector): Promise<void> {
         const options = injector.get<BullMQPluginOptions>(BULLMQ_PLUGIN_OPTIONS);
         this.options = options;
+        this.connectionOptions =
+            options.connection ??
+            ({
+                host: 'localhost',
+                port: 6379,
+                maxRetriesPerRequest: null,
+            } as RedisOptions);
+
+        this.redisConnection =
+            this.connectionOptions instanceof EventEmitter
+                ? this.connectionOptions
+                : new Redis(this.connectionOptions);
 
         const redisHealthIndicator = injector.get(RedisHealthIndicator);
         Logger.info(`Checking Redis connection...`, loggerCtx);
@@ -49,8 +65,11 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
 
         this.queue = new Queue(QUEUE_NAME, {
             ...options.queueOptions,
-            connection: options.connection,
-        }).on('error', (e: any) => Logger.error(`BullMQ Queue error: ${e.message}`, loggerCtx, e.stack));
+            connection: this.redisConnection,
+        })
+            .on('error', (e: any) => Logger.error(`BullMQ Queue error: ${e.message}`, loggerCtx, e.stack))
+            .on('resumed', () => Logger.verbose(`BullMQ Queue resumed`, loggerCtx))
+            .on('paused', () => Logger.verbose(`BullMQ Queue paused`, loggerCtx));
 
         if (await this.queue.isPaused()) {
             await this.queue.resume();
@@ -80,12 +99,15 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
 
         this.scheduler = new QueueScheduler(QUEUE_NAME, {
             ...options.schedulerOptions,
-            connection: options.connection,
-        }).on('error', (e: any) => Logger.error(`BullMQ Scheduler error: ${e.message}`, loggerCtx, e.stack));
+            connection: this.redisConnection,
+        })
+            .on('error', (e: any) => Logger.error(`BullMQ Scheduler error: ${e.message}`, loggerCtx, e.stack))
+            .on('stalled', jobId => Logger.warn(`BullMQ Scheduler stalled on job ${jobId}`, loggerCtx))
+            .on('failed', jobId => Logger.warn(`BullMQ Scheduler failed on job ${jobId}`, loggerCtx));
     }
 
     async destroy() {
-        await Promise.all([this.queue.close(), this.worker.close(), this.scheduler.close()]);
+        await Promise.all([this.queue.close(), this.worker?.close(), this.scheduler.close()]);
     }
 
     async add<Data extends JobData<Data> = {}>(job: Job<Data>): Promise<Job<Data>> {
@@ -211,13 +233,13 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
             const options: WorkerOptions = {
                 concurrency: DEFAULT_CONCURRENCY,
                 ...this.options.workerOptions,
-                connection: this.options.connection,
+                connection: this.redisConnection,
             };
             this.worker = new Worker(QUEUE_NAME, this.workerProcessor, options)
-                .on('error', (e: any) =>
-                    Logger.error(`BullMQ Worker error: ${e.message}`, loggerCtx, e.stack),
-                )
-                .on('failed', (job: Bull.Job, failedReason: string) => {
+                .on('error', e => Logger.error(`BullMQ Worker error: ${e.message}`, loggerCtx, e.stack))
+                .on('closing', e => Logger.verbose(`BullMQ Worker closing: ${e}`, loggerCtx))
+                .on('closed', () => Logger.verbose(`BullMQ Worker closed`))
+                .on('failed', (job: Bull.Job, failedReason) => {
                     Logger.warn(
                         `Job ${job.id} [${job.name}] failed (attempt ${job.attemptsMade} of ${
                             job.opts.attempts ?? 1
@@ -230,13 +252,23 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
         }
     }
 
+    private stopped = false;
     async stop<Data extends JobData<Data> = {}>(
         queueName: string,
         process: (job: Job<Data>) => Promise<any>,
     ): Promise<void> {
-        await this.scheduler.disconnect();
-        await this.queue.disconnect();
-        await this.worker.disconnect();
+        if (!this.stopped) {
+            this.stopped = true;
+            try {
+                await Promise.all([
+                    this.scheduler.disconnect(),
+                    this.queue.disconnect(),
+                    this.worker.disconnect(),
+                ]);
+            } catch (e: any) {
+                Logger.error(e, loggerCtx, e.stack);
+            }
+        }
     }
 
     private async createVendureJob(bullJob: Bull.Job): Promise<Job> {

+ 3 - 1
packages/job-queue-plugin/src/bullmq/constants.ts

@@ -1,7 +1,9 @@
+import { JobType } from 'bullmq';
+
 export const loggerCtx = 'BullMQJobQueuePlugin';
 export const BULLMQ_PLUGIN_OPTIONS = Symbol('BULLMQ_PLUGIN_OPTIONS');
 
-export const ALL_JOB_TYPES = [
+export const ALL_JOB_TYPES: JobType[] = [
     'completed',
     'failed',
     'delayed',

+ 41 - 0
packages/payments-plugin/src/stripe/stripe-utils.ts

@@ -0,0 +1,41 @@
+import { CurrencyCode, Order } from '@vendure/core';
+
+/**
+ * @description
+ * From the [Stripe docs](https://stripe.com/docs/currencies#zero-decimal):
+ * > All API requests expect amounts to be provided in a currency’s smallest unit.
+ * > For example, to charge 10 USD, provide an amount value of 1000 (that is, 1000 cents).
+ * > For zero-decimal currencies, still provide amounts as an integer but without multiplying by 100.
+ * > For example, to charge ¥500, provide an amount value of 500.
+ *
+ * Therefore, for a fractionless currency like JPY, we need to divide the amount by 100 (since Vendure always
+ * stores money amounts multiplied by 100). See https://github.com/vendure-ecommerce/vendure/issues/1630
+ */
+export function getAmountInStripeMinorUnits(order: Order): number {
+    const amountInStripeMinorUnits = currencyHasFractionPart(order.currencyCode)
+        ? order.totalWithTax
+        : Math.round(order.totalWithTax / 100);
+    return amountInStripeMinorUnits;
+}
+
+/**
+ * @description
+ * Performs the reverse of `getAmountInStripeMinorUnits` - converting the Stripe minor units into the format
+ * used by Vendure.
+ */
+export function getAmountFromStripeMinorUnits(order: Order, stripeAmount: number): number {
+    const amountInVendureMinorUnits = currencyHasFractionPart(order.currencyCode)
+        ? stripeAmount
+        : stripeAmount * 100;
+    return amountInVendureMinorUnits;
+}
+
+function currencyHasFractionPart(currencyCode: CurrencyCode): boolean {
+    const parts = new Intl.NumberFormat(undefined, {
+        style: 'currency',
+        currency: currencyCode,
+        currencyDisplay: 'symbol',
+    }).formatToParts(123.45);
+    const hasFractionPart = !!parts.find(p => p.type === 'fraction');
+    return hasFractionPart;
+}

+ 1 - 0
packages/payments-plugin/src/stripe/stripe.controller.ts

@@ -103,6 +103,7 @@ export class StripeController {
         const addPaymentToOrderResult = await this.orderService.addPaymentToOrder(ctx, orderId, {
             method: paymentMethod.code,
             metadata: {
+                paymentIntentAmountReceived: paymentIntent.amount_received,
                 paymentIntentId: paymentIntent.id,
             },
         });

+ 4 - 2
packages/payments-plugin/src/stripe/stripe.handler.ts

@@ -8,6 +8,7 @@ import {
 } from '@vendure/core';
 import Stripe from 'stripe';
 
+import { getAmountFromStripeMinorUnits, getAmountInStripeMinorUnits } from './stripe-utils';
 import { StripeService } from './stripe.service';
 
 const { StripeError } = Stripe.errors;
@@ -28,14 +29,15 @@ export const stripePaymentMethodHandler = new PaymentMethodHandler({
         stripeService = injector.get(StripeService);
     },
 
-    async createPayment(ctx, _, amount, ___, metadata): Promise<CreatePaymentResult> {
+    async createPayment(ctx, order, amount, ___, metadata): Promise<CreatePaymentResult> {
         // Payment is already settled in Stripe by the time the webhook in stripe.controller.ts
         // adds the payment to the order
         if (ctx.apiType !== 'admin') {
             throw Error(`CreatePayment is not allowed for apiType '${ctx.apiType}'`);
         }
+        const amountInMinorUnits = getAmountFromStripeMinorUnits(order, metadata.paymentIntentAmountReceived);
         return {
-            amount,
+            amount: amountInMinorUnits,
             state: 'Settled' as const,
             transactionId: metadata.paymentIntentId,
         };

+ 89 - 11
packages/payments-plugin/src/stripe/stripe.plugin.ts

@@ -16,9 +16,10 @@ import { StripePluginOptions } from './types';
  * ## Requirements
  *
  * 1. You will need to create a Stripe account and get your secret key in the dashboard.
- * 2. Create a webhook endpoint in the Stripe dashboard which listens to the `payment_intent.succeeded` and
- * `payment_intent.payment_failed` events. The URL should be `https://my-shop.com/payments/stripe`, where
- * `my-shop.com` is the host of your storefront application.
+ * 2. Create a webhook endpoint in the Stripe dashboard (Developers -> Webhooks, "Add an endpoint") which listens to the `payment_intent.succeeded`
+ * and `payment_intent.payment_failed` events. The URL should be `https://my-shop.com/payments/stripe`, where
+ * `my-shop.com` is the host of your storefront application. *Note:* for local development, you'll need to use
+ * the Stripe CLI to test your webhook locally. See the _local development_ section below.
  * 3. Get the signing secret for the newly created webhook.
  * 4. Install the Payments plugin and the Stripe Node library:
  *
@@ -63,18 +64,95 @@ import { StripePluginOptions } from './types';
  *
  * The high-level workflow is:
  * 1. Create a "payment intent" on the server by executing the `createStripePaymentIntent` mutation which is exposed by this plugin.
- * 2. Use the returned client secret to instantiate the Stripe Payment Element.
+ * 2. Use the returned client secret to instantiate the Stripe Payment Element:
+ *    ```TypeScript
+ *    import { Elements } from '\@stripe/react-stripe-js';
+ *    import { loadStripe, Stripe } from '\@stripe/stripe-js';
+ *    import { CheckoutForm } from './CheckoutForm';
+ *
+ *    const stripePromise = getStripe('pk_test_....wr83u');
+ *
+ *    type StripePaymentsProps = {
+ *      clientSecret: string;
+ *      orderCode: string;
+ *    }
+ *
+ *    export function StripePayments({ clientSecret, orderCode }: StripePaymentsProps) {
+ *      const options = {
+ *        // passing the client secret obtained from the server
+ *        clientSecret,
+ *      }
+ *      return (
+ *        <Elements stripe={stripePromise} options={options}>
+ *          <CheckoutForm orderCode={orderCode} />
+ *        </Elements>
+ *      );
+ *    }
+ *    ```
+ *    ```TypeScript
+ *    // CheckoutForm.tsx
+ *    import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';
+ *    import { FormEvent } from 'react';
+ *
+ *    export const CheckoutForm = ({ orderCode }: { orderCode: string }) => {
+ *      const stripe = useStripe();
+ *      const elements = useElements();
+ *
+ *      const handleSubmit = async (event: FormEvent) => {
+ *        // We don't want to let default form submission happen here,
+ *        // which would refresh the page.
+ *        event.preventDefault();
+ *
+ *        if (!stripe || !elements) {
+ *          // Stripe.js has not yet loaded.
+ *          // Make sure to disable form submission until Stripe.js has loaded.
+ *          return;
+ *        }
+ *
+ *        const result = await stripe.confirmPayment({
+ *          //`Elements` instance that was used to create the Payment Element
+ *          elements,
+ *          confirmParams: {
+ *            return_url: location.origin + `/checkout/confirmation/${orderCode}`,
+ *          },
+ *        });
+ *
+ *        if (result.error) {
+ *          // Show error to your customer (for example, payment details incomplete)
+ *          console.log(result.error.message);
+ *        } else {
+ *          // Your customer will be redirected to your `return_url`. For some payment
+ *          // methods like iDEAL, your customer will be redirected to an intermediate
+ *          // site first to authorize the payment, then redirected to the `return_url`.
+ *        }
+ *      };
+ *
+ *      return (
+ *        <form onSubmit={handleSubmit}>
+ *          <PaymentElement />
+ *          <button disabled={!stripe}>Submit</button>
+ *        </form>
+ *      );
+ *    };
+ *    ```
  * 3. Once the form is submitted and Stripe processes the payment, the webhook takes care of updating the order without additional action
- * in the storefront.
+ * in the storefront. As in the code above, the customer will be redirected to `/checkout/confirmation/${orderCode}`.
  *
- * ## Local development
+ * {{% alert "primary" %}}
+ * A full working storefront example of the Stripe integration can be found in the
+ * [Remix Starter repo](https://github.com/vendure-ecommerce/storefront-remix-starter/tree/master/app/components/checkout/stripe)
+ * {{% /alert %}}
  *
- * Use something like [localtunnel](https://github.com/localtunnel/localtunnel) to test on localhost.
+ * ## Local development
  *
- * ```bash
- * npx localtunnel --port 3000 --subdomain my-shop-local-dev
- * > your url is: https://my-shop-local-dev.loca.lt
- * ```
+ * 1. Download & install the Stripe CLI: https://stripe.com/docs/stripe-cli
+ * 2. From your Stripe dashboard, go to Developers -> Webhooks and click "Add an endpoint" and follow the instructions
+ * under "Test in a local environment".
+ * 3. The Stripe CLI command will look like
+ *    ```shell
+ *    stripe listen --forward-to localhost:3000/payments/stripe
+ *    ```
+ * 4. The Stripe CLI will create a webhook signing secret you can then use in your config of the StripePlugin.
  *
  * @docsCategory payments-plugin
  * @docsPage StripePlugin

+ 18 - 43
packages/payments-plugin/src/stripe/stripe.service.ts

@@ -1,15 +1,9 @@
 import { Inject, Injectable } from '@nestjs/common';
-import {
-    CurrencyCode,
-    Customer,
-    Logger,
-    Order,
-    RequestContext,
-    TransactionalConnection,
-} from '@vendure/core';
+import { Customer, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
 import Stripe from 'stripe';
 
 import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
+import { getAmountInStripeMinorUnits } from './stripe-utils';
 import { StripePluginOptions } from './types';
 
 @Injectable()
@@ -31,32 +25,23 @@ export class StripeService {
         if (this.options.storeCustomersInStripe && ctx.activeUserId) {
             customerId = await this.getStripeCustomerId(ctx, order);
         }
-
-        // From the [Stripe docs](https://stripe.com/docs/currencies#zero-decimal):
-        // > All API requests expect amounts to be provided in a currency’s smallest unit.
-        // > For example, to charge 10 USD, provide an amount value of 1000 (that is, 1000 cents).
-        // > For zero-decimal currencies, still provide amounts as an integer but without multiplying by 100.
-        // > For example, to charge ¥500, provide an amount value of 500.
-        //
-        // Therefore, for a fractionless currency like JPY, we need to divide the amount by 100 (since Vendure always
-        // stores money amounts multiplied by 100). See https://github.com/vendure-ecommerce/vendure/issues/1630
-        const amountInMinorUnits = this.currencyHasFractionPart(order.currencyCode)
-            ? order.totalWithTax
-            : Math.round(order.totalWithTax / 100);
-
-        const { client_secret } = await this.stripe.paymentIntents.create({
-            amount: amountInMinorUnits,
-            currency: order.currencyCode.toLowerCase(),
-            customer: customerId,
-            automatic_payment_methods: {
-                enabled: true,
-            },
-            metadata: {
-                channelToken: ctx.channel.token,
-                orderId: order.id,
-                orderCode: order.code,
+        const amountInMinorUnits = getAmountInStripeMinorUnits(order);
+        const { client_secret } = await this.stripe.paymentIntents.create(
+            {
+                amount: amountInMinorUnits,
+                currency: order.currencyCode.toLowerCase(),
+                customer: customerId,
+                automatic_payment_methods: {
+                    enabled: true,
+                },
+                metadata: {
+                    channelToken: ctx.channel.token,
+                    orderId: order.id,
+                    orderCode: order.code,
+                },
             },
-        });
+            { idempotencyKey: `${order.code}_${amountInMinorUnits}` },
+        );
 
         if (!client_secret) {
             // This should never happen
@@ -131,14 +116,4 @@ export class StripeService {
 
         return stripeCustomerId;
     }
-
-    private currencyHasFractionPart(currencyCode: CurrencyCode): boolean {
-        const parts = new Intl.NumberFormat(undefined, {
-            style: 'currency',
-            currency: currencyCode,
-            currencyDisplay: 'symbol',
-        }).formatToParts(123.45);
-        const hasFractionPart = !!parts.find(p => p.type === 'fraction');
-        return hasFractionPart;
-    }
 }

+ 116 - 66
yarn.lock

@@ -3181,6 +3181,36 @@
   dependencies:
     axios "^0.25.0"
 
+"@msgpackr-extract/msgpackr-extract-darwin-arm64@2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-2.0.2.tgz#01e3669b8b2dc01f6353f2c87e1ec94faf52c587"
+  integrity sha512-FMX5i7a+ojIguHpWbzh5MCsCouJkwf4z4ejdUY/fsgB9Vkdak4ZnoIEskOyOUMMB4lctiZFGszFQJXUeFL8tRg==
+
+"@msgpackr-extract/msgpackr-extract-darwin-x64@2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-2.0.2.tgz#5ca32f16e6f1b7854001a1a2345b61d4e26a0931"
+  integrity sha512-DznYtF3lHuZDSRaIOYeif4JgO0NtO2Xf8DsngAugMx/bUdTFbg86jDTmkVJBNmV+cxszz6OjGvinnS8AbJ342g==
+
+"@msgpackr-extract/msgpackr-extract-linux-arm64@2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-2.0.2.tgz#ff629f94379981bf476dffb1439a7c1d3dba2d72"
+  integrity sha512-b0jMEo566YdM2K+BurSed7bswjo3a6bcdw5ETqoIfSuxKuRLPfAiOjVbZyZBgx3J/TAM/QrvEQ/VN89A0ZAxSg==
+
+"@msgpackr-extract/msgpackr-extract-linux-arm@2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-2.0.2.tgz#5f6fd30d266c4a90cf989049c7f2e50e5d4fcd4c"
+  integrity sha512-Gy9+c3Wj+rUlD3YvCZTi92gs+cRX7ZQogtwq0IhRenloTTlsbpezNgk6OCkt59V4ATEWSic9rbU92H/l7XsRvA==
+
+"@msgpackr-extract/msgpackr-extract-linux-x64@2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-2.0.2.tgz#167faa553b9dbffac8b03bf27de9b6f846f0e1bc"
+  integrity sha512-zrBHaePwcv4cQXxzYgNj0+A8I1uVN97E7/3LmkRocYZ+rMwUsnPpp4RuTAHSRoKlTQV3nSdCQW4Qdt4MXw/iHw==
+
+"@msgpackr-extract/msgpackr-extract-win32-x64@2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-2.0.2.tgz#baea7764b1adf201ce4a792fe971fd7211dad2e4"
+  integrity sha512-fpnI00dt+yO1cKx9qBXelKhPBdEgvc8ZPav1+0r09j0woYQU2N79w/jcGawSY5UGlgQ3vjaJsFHnGbGvvqdLzg==
+
 "@n1ru4l/graphql-live-query@0.7.1":
   version "0.7.1"
   resolved "https://registry.npmjs.org/@n1ru4l/graphql-live-query/-/graphql-live-query-0.7.1.tgz#c020d017c3ed6bcfdde49a7106ba035e4d0774f5"
@@ -4046,10 +4076,10 @@
   dependencies:
     "@types/node" "*"
 
-"@types/ioredis@^4.26.4":
-  version "4.26.7"
-  resolved "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.26.7.tgz#8c8174b9db38f71f0e372174c66a031a2ca7d9cf"
-  integrity sha512-TOGRR+e1to00CihjgPNygD7+G7ruVnMi62YdIvGUBRfj11k/aWq+Fv5Ea8St0Oy56NngTBfA8GvLn1uvHvhX6Q==
+"@types/ioredis@^4.28.10":
+  version "4.28.10"
+  resolved "https://registry.yarnpkg.com/@types/ioredis/-/ioredis-4.28.10.tgz#40ceb157a4141088d1394bb87c98ed09a75a06ff"
+  integrity sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==
   dependencies:
     "@types/node" "*"
 
@@ -4377,13 +4407,6 @@
   resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
   integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
-"@types/redis@^2.8.28":
-  version "2.8.31"
-  resolved "https://registry.npmjs.org/@types/redis/-/redis-2.8.31.tgz#c11c1b269fec132ac2ec9eb891edf72fc549149e"
-  integrity sha512-daWrrTDYaa5iSDFbgzZ9gOOzyp2AJmYK59OlG/2KGBgYWF3lfs8GDKm1c//tik5Uc93hDD36O+qLPvzDolChbA==
-  dependencies:
-    "@types/node" "*"
-
 "@types/resize-observer-browser@^0.1.3", "@types/resize-observer-browser@^0.1.5":
   version "0.1.6"
   resolved "https://registry.npmjs.org/@types/resize-observer-browser/-/resize-observer-browser-0.1.6.tgz#d8e6c2f830e2650dc06fe74464472ff64b54a302"
@@ -6051,19 +6074,20 @@ builtins@^1.0.3:
   resolved "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88"
   integrity sha1-y5T662HIaWRR2zZTThQi+U8K7og=
 
-bullmq@^1.40.1:
-  version "1.42.1"
-  resolved "https://registry.npmjs.org/bullmq/-/bullmq-1.42.1.tgz#ba810e419e59a7f644828d8884aade23d27789e3"
-  integrity sha512-x5vJ2e1G9aPwX5vyaq7zq8yTOWTwC4kmceqJIkfqDO5DvSHNN+iCjpUApQhK07ShP5+HYYj3vkSN8Vcd09D/Rg==
+bullmq@^1.86.7:
+  version "1.86.7"
+  resolved "https://registry.yarnpkg.com/bullmq/-/bullmq-1.86.7.tgz#29b0d4c4ab47360e87917b316931820e032dc4f8"
+  integrity sha512-4fsIcoMmdRmPvll/dV205wfKJlx7jfn1zX1AzO2t56juxiVrbXv5MtYLYzFgx8FW4CnyWzhxXMqJSdTmdxxJ+g==
   dependencies:
-    "@types/ioredis" "^4.26.4"
-    cron-parser "^2.7.3"
-    get-port "^5.0.0"
-    ioredis "^4.27.5"
+    cron-parser "^4.2.1"
+    get-port "^5.1.1"
+    glob "^7.2.0"
+    ioredis "^4.28.5"
     lodash "^4.17.21"
-    semver "^6.3.0"
-    tslib "^1.10.0"
-    uuid "^8.2.0"
+    msgpackr "^1.4.6"
+    semver "^7.3.7"
+    tslib "^1.14.1"
+    uuid "^8.3.2"
 
 busboy@^0.2.11:
   version "0.2.14"
@@ -7182,13 +7206,12 @@ critters@0.0.12:
     postcss "^8.3.7"
     pretty-bytes "^5.3.0"
 
-cron-parser@^2.7.3:
-  version "2.18.0"
-  resolved "https://registry.npmjs.org/cron-parser/-/cron-parser-2.18.0.tgz#de1bb0ad528c815548371993f81a54e5a089edcf"
-  integrity sha512-s4odpheTyydAbTBQepsqd2rNWGa2iV3cyo8g7zbI2QQYGLVsfbhmwukayS1XHppe02Oy1fg7mg6xoaraVJeEcg==
+cron-parser@^4.2.1:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.5.0.tgz#2c6240a0301eff1424689835ce9c8de4cde9cfbd"
+  integrity sha512-QHdeh3zLWz6YvYTFKpRb860rJlip16pEinbxXT1i2NZB/nOxBjd2RbSv54sn5UrAj9WykiSLYWWDgo8azQK0HA==
   dependencies:
-    is-nan "^1.3.0"
-    moment-timezone "^0.5.31"
+    luxon "^2.4.0"
 
 cross-fetch@3.0.6:
   version "3.0.6"
@@ -7781,7 +7804,7 @@ delegates@^1.0.0:
   resolved "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
   integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
 
-denque@^1.1.0, denque@^1.5.0:
+denque@^1.1.0:
   version "1.5.1"
   resolved "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz#07f670e29c9a78f8faecb2566a1e2c11929c5cbf"
   integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==
@@ -9364,7 +9387,7 @@ get-pkg-repo@^4.0.0:
     meow "^7.0.0"
     through2 "^2.0.0"
 
-get-port@^5.0.0, get-port@^5.1.1:
+get-port@^5.1.1:
   version "5.1.1"
   resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
   integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
@@ -9532,6 +9555,18 @@ glob@7.1.7, glob@^7.0.3, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glo
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+glob@^7.2.0:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+  integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.1.1"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 global-dirs@^0.1.1:
   version "0.1.1"
   resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
@@ -10503,10 +10538,10 @@ invert-kv@^1.0.0:
   resolved "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
   integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY=
 
-ioredis@^4.27.5:
-  version "4.27.8"
-  resolved "https://registry.npmjs.org/ioredis/-/ioredis-4.27.8.tgz#822c2d1ac44067a8f7b92fb673070fc9d661c56e"
-  integrity sha512-AcMEevap2wKxNcYEybZ/Qp+MR2HbNNUwGjG4sVCC3cAJ/zR9HXKAkolXOuR6YcOGPf7DHx9mWb/JKtAGujyPow==
+ioredis@^4.28.5:
+  version "4.28.5"
+  resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.28.5.tgz#5c149e6a8d76a7f8fa8a504ffc85b7d5b6797f9f"
+  integrity sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==
   dependencies:
     cluster-key-slot "^1.1.0"
     debug "^4.3.1"
@@ -10769,14 +10804,6 @@ is-module@^1.0.0:
   resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
   integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=
 
-is-nan@^1.3.0:
-  version "1.3.2"
-  resolved "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d"
-  integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==
-  dependencies:
-    call-bind "^1.0.0"
-    define-properties "^1.1.3"
-
 is-negated-glob@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2"
@@ -12689,6 +12716,11 @@ lru-cache@^6.0.0:
   dependencies:
     yallist "^4.0.0"
 
+luxon@^2.4.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/luxon/-/luxon-2.5.0.tgz#098090f67d690b247e83c090267a60b1aa8ea96c"
+  integrity sha512-IDkEPB80Rb6gCAU+FEib0t4FeJ4uVOuX1CQ9GsvU3O+JAGIgu0J7sf1OarXKaKDygTZIoJyU6YdZzTFRu+YR0A==
+
 magic-string@0.25.7, magic-string@^0.25.0, magic-string@^0.25.7:
   version "0.25.7"
   resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051"
@@ -13062,6 +13094,13 @@ minimatch@3.0.4, minimatch@^3.0.4, minimatch@~3.0.4:
   dependencies:
     brace-expansion "^1.1.7"
 
+minimatch@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+  dependencies:
+    brace-expansion "^1.1.7"
+
 minimist-options@4.1.0:
   version "4.1.0"
   resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
@@ -13529,18 +13568,6 @@ modify-values@^1.0.0:
   resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022"
   integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==
 
-moment-timezone@^0.5.31:
-  version "0.5.33"
-  resolved "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz#b252fd6bb57f341c9b59a5ab61a8e51a73bbd22c"
-  integrity sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==
-  dependencies:
-    moment ">= 2.9.0"
-
-"moment@>= 2.9.0":
-  version "2.29.1"
-  resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
-  integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
-
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -13561,6 +13588,27 @@ ms@^2.0.0, ms@^2.1.1, ms@^2.1.2, ms@^2.1.3:
   resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
 
+msgpackr-extract@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-2.0.2.tgz#201a8d7ade47e99b3ba277c45736b00e195d4670"
+  integrity sha512-coskCeJG2KDny23zWeu+6tNy7BLnAiOGgiwzlgdm4oeSsTpqEJJPguHIuKZcCdB7tzhZbXNYSg6jZAXkZErkJA==
+  dependencies:
+    node-gyp-build-optional-packages "5.0.2"
+  optionalDependencies:
+    "@msgpackr-extract/msgpackr-extract-darwin-arm64" "2.0.2"
+    "@msgpackr-extract/msgpackr-extract-darwin-x64" "2.0.2"
+    "@msgpackr-extract/msgpackr-extract-linux-arm" "2.0.2"
+    "@msgpackr-extract/msgpackr-extract-linux-arm64" "2.0.2"
+    "@msgpackr-extract/msgpackr-extract-linux-x64" "2.0.2"
+    "@msgpackr-extract/msgpackr-extract-win32-x64" "2.0.2"
+
+msgpackr@^1.4.6:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.6.1.tgz#4f3c94d6a5b819b838ffc736eddaf60eba436d20"
+  integrity sha512-Je+xBEfdjtvA4bKaOv8iRhjC8qX2oJwpYH4f7JrG4uMVJVmnmkAT4pjKdbztKprGj3iwjcxPzb5umVZ02Qq3tA==
+  optionalDependencies:
+    msgpackr-extract "^2.0.2"
+
 multer@1.4.2:
   version "1.4.2"
   resolved "https://registry.npmjs.org/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a"
@@ -13805,6 +13853,11 @@ node-forge@^0.10.0:
   resolved "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
   integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==
 
+node-gyp-build-optional-packages@5.0.2:
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.2.tgz#3de7d30bd1f9057b5dfbaeab4a4442b7fe9c5901"
+  integrity sha512-PiN4NWmlQPqvbEFcH/omQsswWQbe5Z9YK/zdB23irp5j2XibaA2IrGvpSWmVVG4qMZdmPdwPctSy4a86rOMn6g==
+
 node-gyp-build@^4.2.2:
   version "4.2.3"
   resolved "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz#ce6277f853835f718829efb47db20f3e4d9c4739"
@@ -16353,7 +16406,7 @@ redent@^3.0.0:
     indent-string "^4.0.0"
     strip-indent "^3.0.0"
 
-redis-commands@1.7.0, redis-commands@^1.7.0:
+redis-commands@1.7.0:
   version "1.7.0"
   resolved "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89"
   integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==
@@ -16370,16 +16423,6 @@ redis-parser@^3.0.0:
   dependencies:
     redis-errors "^1.0.0"
 
-redis@^3.0.2:
-  version "3.1.2"
-  resolved "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz#766851117e80653d23e0ed536254677ab647638c"
-  integrity sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==
-  dependencies:
-    denque "^1.5.0"
-    redis-commands "^1.7.0"
-    redis-errors "^1.2.0"
-    redis-parser "^3.0.0"
-
 reflect-metadata@^0.1.13, reflect-metadata@^0.1.2:
   version "0.1.13"
   resolved "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"
@@ -17007,6 +17050,13 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
   resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
+semver@^7.3.7:
+  version "7.3.7"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
+  integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
+  dependencies:
+    lru-cache "^6.0.0"
+
 semver@~5.3.0:
   version "5.3.0"
   resolved "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"
@@ -18476,7 +18526,7 @@ tslib@2.3.0:
   resolved "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
   integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
 
-tslib@^1.10.0, tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
+tslib@^1.10.0, tslib@^1.13.0, tslib@^1.14.1, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
   version "1.14.1"
   resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==