فهرست منبع

Merge branch 'minor' into major

Michael Bromley 3 سال پیش
والد
کامیت
7d6204b2d4
32فایلهای تغییر یافته به همراه1045 افزوده شده و 220 حذف شده
  1. 20 0
      CHANGELOG.md
  2. 89 0
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.spec.ts
  3. 2 2
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts
  4. 32 24
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts
  5. 1 1
      packages/admin-ui/src/lib/order/src/components/order-table/order-table.component.html
  6. 8 0
      packages/admin-ui/src/lib/order/src/components/order-table/order-table.component.ts
  7. 2 2
      packages/asset-server-plugin/package.json
  8. 17 1
      packages/asset-server-plugin/src/common.ts
  9. 39 14
      packages/asset-server-plugin/src/plugin.ts
  10. 125 13
      packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts
  11. 20 1
      packages/asset-server-plugin/src/transform-image.ts
  12. 18 2
      packages/asset-server-plugin/src/types.ts
  13. 55 1
      packages/core/e2e/custom-field-relations.e2e-spec.ts
  14. 55 0
      packages/core/e2e/order.e2e-spec.ts
  15. 87 0
      packages/core/e2e/shop-auth.e2e-spec.ts
  16. 88 24
      packages/core/e2e/shop-order.e2e-spec.ts
  17. 2 1
      packages/core/src/config/asset-naming-strategy/default-asset-naming-strategy.spec.ts
  18. 1 1
      packages/core/src/config/asset-naming-strategy/default-asset-naming-strategy.ts
  19. 62 0
      packages/core/src/connection/transactional-connection.ts
  20. 6 2
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  21. 8 2
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  22. 11 3
      packages/core/src/service/services/order.service.ts
  23. 8 0
      packages/core/src/service/services/user.service.ts
  24. 87 0
      packages/dev-server/test-plugins/issue-1664.ts
  25. 46 14
      packages/job-queue-plugin/src/bullmq/bullmq-job-queue-strategy.ts
  26. 3 14
      packages/payments-plugin/src/braintree/braintree.handler.ts
  27. 0 42
      packages/payments-plugin/src/braintree/braintree.plugin.ts
  28. 41 0
      packages/payments-plugin/src/stripe/stripe-utils.ts
  29. 1 0
      packages/payments-plugin/src/stripe/stripe.controller.ts
  30. 4 2
      packages/payments-plugin/src/stripe/stripe.handler.ts
  31. 89 11
      packages/payments-plugin/src/stripe/stripe.plugin.ts
  32. 18 43
      packages/payments-plugin/src/stripe/stripe.service.ts

+ 20 - 0
CHANGELOG.md

@@ -1,3 +1,23 @@
+## <small>1.6.4 (2022-07-21)</small>
+
+
+#### Fixes
+
+* **admin-ui** Correctly handle falsy configArg default values ([1d8c30e](https://github.com/vendure-ecommerce/vendure/commit/1d8c30e)), closes [#1663](https://github.com/vendure-ecommerce/vendure/issues/1663)
+* **admin-ui** Display multiple shipping methods in order detail ([b45464e](https://github.com/vendure-ecommerce/vendure/commit/b45464e)), closes [#1665](https://github.com/vendure-ecommerce/vendure/issues/1665)
+* **admin-ui** Fix facet-value-form-input when used with custom fields ([0ae36a9](https://github.com/vendure-ecommerce/vendure/commit/0ae36a9))
+* **admin-ui** Improved handling of failed cancellations ([2c79cf0](https://github.com/vendure-ecommerce/vendure/commit/2c79cf0))
+* **core** Add missing `languageCode` field on ShippingMethod type ([4fab7cf](https://github.com/vendure-ecommerce/vendure/commit/4fab7cf))
+* **core** Correctly resolve translatable custom field relations ([354932c](https://github.com/vendure-ecommerce/vendure/commit/354932c))
+* **core** Fix issue with cancellation of fulfilled OrderItems ([13b0cf9](https://github.com/vendure-ecommerce/vendure/commit/13b0cf9)), closes [#1558](https://github.com/vendure-ecommerce/vendure/issues/1558)
+* **core** Fix order line custom field comparison logic ([dc3ea9c](https://github.com/vendure-ecommerce/vendure/commit/dc3ea9c)), closes [#1670](https://github.com/vendure-ecommerce/vendure/issues/1670)
+* **core** Fix regression when querying custom field relations ([b279d25](https://github.com/vendure-ecommerce/vendure/commit/b279d25)), closes [#1664](https://github.com/vendure-ecommerce/vendure/issues/1664) [#1636](https://github.com/vendure-ecommerce/vendure/issues/1636) [#1636](https://github.com/vendure-ecommerce/vendure/issues/1636)
+* **core** Handle user verification edge case ([1640ea7](https://github.com/vendure-ecommerce/vendure/commit/1640ea7)), closes [#1659](https://github.com/vendure-ecommerce/vendure/issues/1659)
+* **job-queue-plugin** Partially fix BullMQ shutdown error ([3835f8b](https://github.com/vendure-ecommerce/vendure/commit/3835f8b))
+* **payments-plugin** Fix error on Braintree refund failure ([0b79eb5](https://github.com/vendure-ecommerce/vendure/commit/0b79eb5))
+* **payments-plugin** Use idempotency key for Stripe API calls ([9b77d5c](https://github.com/vendure-ecommerce/vendure/commit/9b77d5c))
+* **payments-plugin** Verify Stripe payment intent amount ([b72ae18](https://github.com/vendure-ecommerce/vendure/commit/b72ae18))
+
 ## <small>1.6.3 (2022-07-05)</small>
 
 

+ 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: any) {
         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;

+ 32 - 24
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts

@@ -21,6 +21,7 @@ import {
     ServerConfigService,
     SortOrder,
 } from '@vendure/admin-ui/core';
+import { pick } from '@vendure/common/lib/pick';
 import { assertNever, summate } from '@vendure/common/lib/shared-utils';
 import { EMPTY, merge, Observable, of, Subject } from 'rxjs';
 import { map, mapTo, startWith, switchMap, take } from 'rxjs/operators';
@@ -525,33 +526,45 @@ export class OrderDetailComponent
                         return of(undefined);
                     }
 
-                    const operations: Array<
-                        Observable<RefundOrderMutation['refundOrder'] | CancelOrderMutation['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':

+ 1 - 1
packages/admin-ui/src/lib/order/src/components/order-table/order-table.component.html

@@ -112,7 +112,7 @@
         </tr>
         <tr class="shipping">
             <td class="left clr-align-middle">{{ 'order.shipping' | translate }}</td>
-            <td class="clr-align-middle">{{ order.shippingLines[0]?.shippingMethod?.name }}</td>
+            <td class="clr-align-middle">{{ getShippingNames(order) }}</td>
             <td colspan="3"></td>
             <td class="clr-align-middle">
                 {{ order.shippingWithTax | localeCurrency: order.currencyCode }}

+ 8 - 0
packages/admin-ui/src/lib/order/src/components/order-table/order-table.component.ts

@@ -72,4 +72,12 @@ export class OrderTableComponent implements OnInit {
             return promotion.couponCode || undefined;
         }
     }
+
+    getShippingNames(order: OrderDetail.Fragment) {
+        if (order.shippingLines.length) {
+            return order.shippingLines.map(shippingLine => shippingLine.shippingMethod.name).join(', ');
+        } else {
+            return '';
+        }
+    }
 }

+ 2 - 2
packages/asset-server-plugin/package.json

@@ -23,7 +23,7 @@
     "@types/express": "^4.17.8",
     "@types/fs-extra": "^9.0.8",
     "@types/node-fetch": "^2.5.8",
-    "@types/sharp": "^0.29.5",
+    "@types/sharp": "^0.30.4",
     "@vendure/common": "^2.0.0-next.10",
     "@vendure/core": "^2.0.0-next.10",
     "aws-sdk": "^2.856.0",
@@ -35,6 +35,6 @@
   "dependencies": {
     "file-type": "^16.5.3",
     "fs-extra": "^10.0.0",
-    "sharp": "~0.30.1"
+    "sharp": "~0.30.7"
   }
 }

+ 17 - 1
packages/asset-server-plugin/src/common.ts

@@ -1,7 +1,7 @@
 import { REQUEST_CONTEXT_KEY } from '@vendure/core/dist/common/constants';
 import { Request } from 'express';
 
-import { AssetServerOptions } from './types';
+import { AssetServerOptions, ImageTransformFormat } from './types';
 
 export function getAssetUrlPrefixFn(options: AssetServerOptions) {
     const { assetUrlPrefix, route } = options;
@@ -22,3 +22,19 @@ export function getAssetUrlPrefixFn(options: AssetServerOptions) {
     }
     throw new Error(`The assetUrlPrefix option was of an unexpected type: ${JSON.stringify(assetUrlPrefix)}`);
 }
+
+export function getValidFormat(format?: unknown): ImageTransformFormat | undefined {
+    if (typeof format !== 'string') {
+        return undefined;
+    }
+    switch (format) {
+        case 'jpg':
+        case 'jpeg':
+        case 'png':
+        case 'webp':
+        case 'avif':
+            return format;
+        default:
+            return undefined;
+    }
+}

+ 39 - 14
packages/asset-server-plugin/src/plugin.ts

@@ -15,6 +15,7 @@ import { fromBuffer } from 'file-type';
 import fs from 'fs-extra';
 import path from 'path';
 
+import { getValidFormat } from './common';
 import { loggerCtx } from './constants';
 import { defaultAssetStorageStrategyFactory } from './default-asset-storage-strategy-factory';
 import { HashedAssetNamingStrategy } from './hashed-asset-naming-strategy';
@@ -79,6 +80,22 @@ import { AssetServerOptions, ImageTransformPreset } from './types';
  *
  * `http://localhost:3000/assets/landscape.jpg?w=150&h=150&mode=crop&fpx=0.2&fpy=0.7`
  *
+ * ### Format
+ *
+ * Since v1.7.0, the image format can be specified by adding the `format` query parameter:
+ *
+ * `http://localhost:3000/assets/some-asset.jpg?format=webp`
+ *
+ * This means that, no matter the format of your original asset files, you can use more modern formats in your storefront if the browser
+ * supports them. Supported values for `format` are:
+ *
+ * * `jpeg` or `jpg`
+ * * `png`
+ * * `webp`
+ * * `avif`
+ *
+ * The `format` parameter can also be combined with presets (see below).
+ *
  * ### Transform presets
  *
  * Presets can be defined which allow a single preset name to be used instead of specifying the width, height and mode. Presets are
@@ -149,10 +166,12 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
         const storageStrategyFactory =
             this.options.storageStrategyFactory || defaultAssetStorageStrategyFactory;
         this.assetStorage = await storageStrategyFactory(this.options);
-        config.assetOptions.assetPreviewStrategy = new SharpAssetPreviewStrategy({
-            maxWidth: this.options.previewMaxWidth || 1600,
-            maxHeight: this.options.previewMaxHeight || 1600,
-        });
+        config.assetOptions.assetPreviewStrategy =
+            this.options.previewStrategy ??
+            new SharpAssetPreviewStrategy({
+                maxWidth: this.options.previewMaxWidth,
+                maxHeight: this.options.previewMaxHeight,
+            });
         config.assetOptions.assetStorageStrategy = this.assetStorage;
         config.assetOptions.assetNamingStrategy =
             this.options.namingStrategy || new HashedAssetNamingStrategy();
@@ -243,15 +262,19 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
                     const image = await transformImage(file, req.query as any, this.presets || []);
                     try {
                         const imageBuffer = await image.toBuffer();
+                        const cachedFileName = this.getFileNameFromRequest(req);
                         if (!req.query.cache || req.query.cache === 'true') {
-                            const cachedFileName = this.getFileNameFromRequest(req);
                             await AssetServerPlugin.assetStorage.writeFileFromBuffer(
                                 cachedFileName,
                                 imageBuffer,
                             );
                             Logger.debug(`Saved cached asset: ${cachedFileName}`, loggerCtx);
                         }
-                        res.set('Content-Type', `image/${(await image.metadata()).format}`);
+                        let mimeType = this.getMimeType(cachedFileName);
+                        if (!mimeType) {
+                            mimeType = (await fromBuffer(imageBuffer))?.mime || 'image/jpeg';
+                        }
+                        res.set('Content-Type', mimeType);
                         res.setHeader('content-security-policy', `default-src 'self'`);
                         res.send(imageBuffer);
                         return;
@@ -267,22 +290,23 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
     }
 
     private getFileNameFromRequest(req: Request): string {
-        const { w, h, mode, preset, fpx, fpy } = req.query;
+        const { w, h, mode, preset, fpx, fpy, format } = req.query;
         const focalPoint = fpx && fpy ? `_fpx${fpx}_fpy${fpy}` : '';
+        const imageFormat = getValidFormat(format);
         let imageParamHash: string | null = null;
         if (w || h) {
             const width = w || '';
             const height = h || '';
-            imageParamHash = this.md5(`_transform_w${width}_h${height}_m${mode}${focalPoint}`);
+            imageParamHash = this.md5(`_transform_w${width}_h${height}_m${mode}${focalPoint}${imageFormat}`);
         } else if (preset) {
             if (this.presets && !!this.presets.find(p => p.name === preset)) {
-                imageParamHash = this.md5(`_transform_pre_${preset}${focalPoint}`);
+                imageParamHash = this.md5(`_transform_pre_${preset}${focalPoint}${imageFormat}`);
             }
         }
 
         const decodedReqPath = decodeURIComponent(req.path);
         if (imageParamHash) {
-            return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash));
+            return path.join(this.cacheDir, this.addSuffix(decodedReqPath, imageParamHash, imageFormat));
         } else {
             return decodedReqPath;
         }
@@ -292,11 +316,12 @@ export class AssetServerPlugin implements NestModule, OnApplicationBootstrap {
         return createHash('md5').update(input).digest('hex');
     }
 
-    private addSuffix(fileName: string, suffix: string): string {
-        const ext = path.extname(fileName);
-        const baseName = path.basename(fileName, ext);
+    private addSuffix(fileName: string, suffix: string, ext?: string): string {
+        const originalExt = path.extname(fileName);
+        const effectiveExt = ext ? `.${ext}` : originalExt;
+        const baseName = path.basename(fileName, originalExt);
         const dirName = path.dirname(fileName);
-        return path.join(dirName, `${baseName}${suffix}${ext}`);
+        return path.join(dirName, `${baseName}${suffix}${effectiveExt}`);
     }
 
     /**

+ 125 - 13
packages/asset-server-plugin/src/sharp-asset-preview-strategy.ts

@@ -5,32 +5,144 @@ import sharp from 'sharp';
 
 import { loggerCtx } from './constants';
 
+/**
+ * @description
+ * This {@link AssetPreviewStrategy} uses the [Sharp library](https://sharp.pixelplumbing.com/) to generate
+ * preview images of uploaded binary files. For non-image binaries, a generic "file" icon with the mime type
+ * overlay will be generated.
+ *
+ * @docsCategory AssetServerPlugin
+ * @docsPage SharpAssetPreviewStrategy
+ */
+interface SharpAssetPreviewConfig {
+    /**
+     * @description
+     * The max height in pixels of a generated preview image.
+     *
+     * @default 1600
+     */
+    maxHeight?: number;
+    /**
+     * @description
+     * The max width in pixels of a generated preview image.
+     *
+     * @default 1600
+     */
+    maxWidth?: number;
+    /**
+     * @description
+     * Set Sharp's options for encoding jpeg files: https://sharp.pixelplumbing.com/api-output#jpeg
+     *
+     * @since 1.7.0
+     */
+    jpegOptions?: sharp.JpegOptions;
+    /**
+     * @description
+     * Set Sharp's options for encoding png files: https://sharp.pixelplumbing.com/api-output#png
+     *
+     * @since 1.7.0
+     */
+    pngOptions?: sharp.PngOptions;
+    /**
+     * @description
+     * Set Sharp's options for encoding webp files: https://sharp.pixelplumbing.com/api-output#webp
+     *
+     * @since 1.7.0
+     */
+    webpOptions?: sharp.WebpOptions;
+    /**
+     * @description
+     * Set Sharp's options for encoding gif files: https://sharp.pixelplumbing.com/api-output#gif
+     *
+     * @since 1.7.0
+     */
+    gifOptions?: sharp.GifOptions;
+    /**
+     * @description
+     * Set Sharp's options for encoding avif files: https://sharp.pixelplumbing.com/api-output#avif
+     *
+     * @since 1.7.0
+     */
+    avifOptions?: sharp.AvifOptions;
+}
+
+/**
+ * @description
+ * This {@link AssetPreviewStrategy} uses the [Sharp library](https://sharp.pixelplumbing.com/) to generate
+ * preview images of uploaded binary files. For non-image binaries, a generic "file" icon with the mime type
+ * overlay will be generated.
+ *
+ * By default, this strategy will produce previews up to maximum dimensions of 1600 x 1600 pixels. The created
+ * preview images will match the input format - so a source file in jpeg format will output a jpeg preview,
+ * a webp source file will output a webp preview, and so on.
+ *
+ * The settings for the outputs will default to Sharp's defaults (https://sharp.pixelplumbing.com/api-output).
+ * However, it is possible to pass your own configurations to control the output of each format:
+ *
+ * ```TypeScript
+ * AssetServerPlugin.init({
+ *   previewStrategy: new SharpAssetPreviewStrategy({
+ *     jpegOptions: { quality: 95 },
+ *     webpOptions: { quality: 95 },
+ *   }),
+ * }),
+ * ```
+ *
+ * @docsCategory AssetServerPlugin
+ * @docsPage SharpAssetPreviewStrategy
+ * @docsWeight 0
+ */
 export class SharpAssetPreviewStrategy implements AssetPreviewStrategy {
-    constructor(
-        private config: {
-            maxHeight: number;
-            maxWidth: number;
-        },
-    ) {}
+    private readonly defaultConfig: Required<SharpAssetPreviewConfig> = {
+        maxHeight: 1600,
+        maxWidth: 1600,
+        jpegOptions: {},
+        pngOptions: {},
+        webpOptions: {},
+        gifOptions: {},
+        avifOptions: {},
+    };
+    private readonly config: Required<SharpAssetPreviewConfig>;
+
+    constructor(config?: SharpAssetPreviewConfig) {
+        this.config = {
+            ...this.defaultConfig,
+            ...(config ?? {}),
+        };
+    }
 
     async generatePreviewImage(ctx: RequestContext, mimeType: string, data: Buffer): Promise<Buffer> {
         const assetType = getAssetType(mimeType);
+
         const { maxWidth, maxHeight } = this.config;
 
         if (assetType === AssetType.IMAGE) {
             try {
-                const image = sharp(data);
+                const image = sharp(data).rotate();
                 const metadata = await image.metadata();
                 const width = metadata.width || 0;
                 const height = metadata.height || 0;
                 if (maxWidth < width || maxHeight < height) {
-                    return image.rotate().resize(maxWidth, maxHeight, { fit: 'inside' }).toBuffer();
+                    image.resize(maxWidth, maxHeight, { fit: 'inside' });
+                }
+                if (mimeType === 'image/svg+xml') {
+                    // Convert the SVG to a raster for the preview
+                    return image.toBuffer();
                 } else {
-                    if (mimeType === 'image/svg+xml') {
-                        // Convert the SVG to a raster for the preview
-                        return image.toBuffer();
-                    } else {
-                        return image.rotate().toBuffer();
+                    switch (metadata.format) {
+                        case 'jpeg':
+                        case 'jpg':
+                            return image.jpeg(this.config.jpegOptions).toBuffer();
+                        case 'png':
+                            return image.png(this.config.pngOptions).toBuffer();
+                        case 'webp':
+                            return image.webp(this.config.webpOptions).toBuffer();
+                        case 'gif':
+                            return image.gif(this.config.jpegOptions).toBuffer();
+                        case 'avif':
+                            return image.avif(this.config.avifOptions).toBuffer();
+                        default:
+                            return image.toBuffer();
                     }
                 }
             } catch (err: any) {

+ 20 - 1
packages/asset-server-plugin/src/transform-image.ts

@@ -1,6 +1,7 @@
 import sharp, { Region, ResizeOptions } from 'sharp';
 
-import { ImageTransformPreset } from './types';
+import { getValidFormat } from './common';
+import { ImageTransformFormat, ImageTransformPreset } from './types';
 
 export type Dimensions = { w: number; h: number };
 export type Point = { x: number; y: number };
@@ -18,6 +19,7 @@ export async function transformImage(
     let mode = queryParams.mode || 'crop';
     const fpx = +queryParams.fpx || undefined;
     const fpy = +queryParams.fpy || undefined;
+    const imageFormat = getValidFormat(queryParams.format);
     if (queryParams.preset) {
         const matchingPreset = presets.find(p => p.name === queryParams.preset);
         if (matchingPreset) {
@@ -34,6 +36,7 @@ export async function transformImage(
     }
 
     const image = sharp(originalImage);
+    applyFormat(image, imageFormat);
     if (fpx && fpy && targetWidth && targetHeight && mode === 'crop') {
         const metadata = await image.metadata();
         if (metadata.width && metadata.height) {
@@ -51,6 +54,22 @@ export async function transformImage(
     return image.resize(targetWidth, targetHeight, options);
 }
 
+function applyFormat(image: sharp.Sharp, format: ImageTransformFormat | undefined) {
+    switch (format) {
+        case 'jpg':
+        case 'jpeg':
+            return image.jpeg();
+        case 'png':
+            return image.png();
+        case 'webp':
+            return image.webp();
+        case 'avif':
+            return image.avif();
+        default:
+            return image;
+    }
+}
+
 /**
  * Resize an image but keep it centered on the focal point.
  * Based on the method outlined in https://github.com/lovell/sharp/issues/1198#issuecomment-384591756

+ 18 - 2
packages/asset-server-plugin/src/types.ts

@@ -1,4 +1,11 @@
-import { AssetNamingStrategy, AssetStorageStrategy, RequestContext } from '@vendure/core';
+import {
+    AssetNamingStrategy,
+    AssetPreviewStrategy,
+    AssetStorageStrategy,
+    RequestContext,
+} from '@vendure/core';
+
+export type ImageTransformFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif';
 
 /**
  * @description
@@ -11,7 +18,6 @@ import { AssetNamingStrategy, AssetStorageStrategy, RequestContext } from '@vend
  *
  * @docsCategory AssetServerPlugin
  */
-
 export type ImageTransformMode = 'crop' | 'resize';
 
 /**
@@ -70,6 +76,7 @@ export interface AssetServerOptions {
      * The max width in pixels of a generated preview image.
      *
      * @default 1600
+     * @deprecated Use `previewStrategy: new SharpAssetPreviewStrategy({ maxWidth })` instead
      */
     previewMaxWidth?: number;
     /**
@@ -77,6 +84,7 @@ export interface AssetServerOptions {
      * The max height in pixels of a generated preview image.
      *
      * @default 1600
+     * @deprecated Use `previewStrategy: new SharpAssetPreviewStrategy({ maxHeight })` instead
      */
     previewMaxHeight?: number;
     /**
@@ -91,6 +99,14 @@ export interface AssetServerOptions {
      * @default HashedAssetNamingStrategy
      */
     namingStrategy?: AssetNamingStrategy;
+    /**
+     * @description
+     * Defines how previews are generated for a given Asset binary. By default, this uses
+     * the {@link SharpAssetPreviewStrategy}
+     *
+     * @since 1.7.0
+     */
+    previewStrategy?: AssetPreviewStrategy;
     /**
      * @description
      * A function which can be used to configure an {@link AssetStorageStrategy}. This is useful e.g. if you wish to store your assets

+ 55 - 1
packages/core/e2e/custom-field-relations.e2e-spec.ts

@@ -20,14 +20,16 @@ import {
     RequestContext,
     ShippingMethod,
     TransactionalConnection,
+    VendureEntity,
     VendurePlugin,
 } from '@vendure/core';
 import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
+import { Entity, JoinColumn, OneToOne } from 'typeorm';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import { AddItemToOrderMutation } from './graphql/generated-e2e-shop-types';
@@ -72,6 +74,16 @@ const entitiesWithCustomFields = enumerate<keyof CustomFields>()(
     'Zone',
 );
 
+@Entity()
+class Vendor extends VendureEntity {
+    constructor() {
+        super();
+    }
+    @OneToOne(type => Product, { eager: true })
+    @JoinColumn()
+    featuredProduct: Product;
+}
+
 const customFieldConfig: CustomFields = {};
 for (const entity of entitiesWithCustomFields) {
     customFieldConfig[entity] = [
@@ -90,6 +102,15 @@ customFieldConfig.Product?.push(
     { name: 'cfProduct', type: 'relation', entity: Product, list: false },
     { name: 'cfShippingMethod', type: 'relation', entity: ShippingMethod, list: false },
     { name: 'cfInternalAsset', type: 'relation', entity: Asset, list: false, internal: true },
+    {
+        name: 'cfVendor',
+        type: 'relation',
+        entity: Vendor,
+        graphQLType: 'Vendor',
+        list: false,
+        internal: false,
+        public: true,
+    },
 );
 
 const testResolverSpy = jest.fn();
@@ -110,14 +131,26 @@ class TestResolver1636 {
 
 @VendurePlugin({
     imports: [PluginCommonModule],
+    entities: [Vendor],
     shopApiExtensions: {
         schema: gql`
             extend type Query {
                 getAssetTest(id: ID!): Boolean!
             }
+            type Vendor {
+                featuredProduct: Product
+            }
         `,
         resolvers: [TestResolver1636],
     },
+    adminApiExtensions: {
+        schema: gql`
+            type Vendor {
+                featuredProduct: Product
+            }
+        `,
+        resolvers: [],
+    },
 })
 class TestPlugin1636 {}
 
@@ -125,6 +158,7 @@ const customConfig = mergeConfig(testConfig(), {
     paymentOptions: {
         paymentMethodHandlers: [testSuccessfulPaymentMethod],
     },
+    // logger: new DefaultLogger({ level: LogLevel.Debug }),
     dbConnectionOptions: {
         timezone: 'Z',
     },
@@ -764,6 +798,26 @@ describe('Custom field relations', () => {
                 `);
                 assertCustomFieldIds(updateProductVariants[0].customFields, 'T_2', ['T_3', 'T_4']);
             });
+
+            // https://github.com/vendure-ecommerce/vendure/issues/1664
+            it('successfully gets product with eager-loading custom field relation', async () => {
+                const { product } = await shopClient.query(gql`
+                    query {
+                        product(id: "T_1") {
+                            id
+                            customFields {
+                                cfVendor {
+                                    featuredProduct {
+                                        id
+                                    }
+                                }
+                            }
+                        }
+                    }
+                `);
+
+                expect(product).toBeDefined();
+            });
         });
 
         describe('ProductOptionGroup, ProductOption entity', () => {

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

@@ -2450,6 +2450,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();
+        });
     });
 });
 

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

@@ -56,6 +56,7 @@ let sendEmailFn: jest.Mock;
 })
 class TestEmailPlugin implements OnModuleInit {
     constructor(private eventBus: EventBus) {}
+
     onModuleInit() {
         this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
             sendEmailFn(event);
@@ -606,6 +607,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<Codegen.RegisterMutation, Codegen.RegisterMutationVariables>(
+                REGISTER_ACCOUNT,
+                { input },
+            );
+            successErrorGuard.assertSuccess(registerCustomerAccount);
+            verificationToken = await verificationTokenPromise;
+
+            const { customers } = await adminClient.query<
+                Codegen.GetCustomerListQuery,
+                Codegen.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: Codegen.GetCustomerQuery['customer'];

+ 88 - 24
packages/core/e2e/shop-order.e2e-spec.ts

@@ -354,6 +354,74 @@ describe('Shop orders', () => {
                 });
             });
 
+            // https://github.com/vendure-ecommerce/vendure/issues/1670
+            it('adding a second item after adjusting custom field adds new OrderLine', async () => {
+                const { addItemToOrder: add1 } = await shopClient.query<AddItemToOrder.Mutation>(
+                    ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                    {
+                        productVariantId: 'T_3',
+                        quantity: 1,
+                    },
+                );
+                orderResultGuard.assertSuccess(add1);
+                expect(add1!.lines.length).toBe(2);
+                expect(add1!.lines[1].quantity).toBe(1);
+
+                const { adjustOrderLine } = await shopClient.query(ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS, {
+                    orderLineId: add1.lines[1].id,
+                    quantity: 1,
+                    customFields: {
+                        notes: 'updated notes',
+                    },
+                });
+                expect(adjustOrderLine.lines[1].customFields).toEqual({
+                    lineImage: null,
+                    notes: 'updated notes',
+                });
+                const { activeOrder: ao1 } = await shopClient.query(GET_ORDER_WITH_ORDER_LINE_CUSTOM_FIELDS);
+                expect(ao1.lines[1].customFields).toEqual({
+                    lineImage: null,
+                    notes: 'updated notes',
+                });
+                const updatedNotesLineId = ao1.lines[1].id;
+
+                const { addItemToOrder: add2 } = await shopClient.query<AddItemToOrder.Mutation>(
+                    ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+                    {
+                        productVariantId: 'T_3',
+                        quantity: 1,
+                    },
+                );
+                orderResultGuard.assertSuccess(add2);
+                expect(add2!.lines.length).toBe(3);
+                expect(add2!.lines[1].quantity).toBe(1);
+                expect(add2!.lines[2].quantity).toBe(1);
+
+                const { activeOrder } = await shopClient.query(GET_ORDER_WITH_ORDER_LINE_CUSTOM_FIELDS);
+                expect(activeOrder.lines.find((l: any) => l.id === updatedNotesLineId)?.customFields).toEqual(
+                    {
+                        lineImage: null,
+                        notes: 'updated notes',
+                    },
+                );
+
+                // clean up
+                await shopClient.query<RemoveItemFromOrder.Mutation, RemoveItemFromOrder.Variables>(
+                    REMOVE_ITEM_FROM_ORDER,
+                    {
+                        orderLineId: add2!.lines[1].id,
+                    },
+                );
+                const { removeOrderLine } = await shopClient.query<
+                    RemoveItemFromOrder.Mutation,
+                    RemoveItemFromOrder.Variables
+                >(REMOVE_ITEM_FROM_ORDER, {
+                    orderLineId: add2!.lines[2].id,
+                });
+                orderResultGuard.assertSuccess(removeOrderLine);
+                expect(removeOrderLine.lines.length).toBe(1);
+            });
+
             it('addItemToOrder with relation customField', async () => {
                 const { addItemToOrder } = await shopClient.query<CodegenShop.AddItemToOrderMutation>(
                     ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
@@ -419,28 +487,6 @@ describe('Shop orders', () => {
 
             it('adjustOrderLine updates relation reference', async () => {
                 const { activeOrder } = await shopClient.query(GET_ORDER_WITH_ORDER_LINE_CUSTOM_FIELDS);
-
-                const ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS = gql`
-                    mutation ($orderLineId: ID!, $quantity: Int!, $customFields: OrderLineCustomFieldsInput) {
-                        adjustOrderLine(
-                            orderLineId: $orderLineId
-                            quantity: $quantity
-                            customFields: $customFields
-                        ) {
-                            ... on Order {
-                                lines {
-                                    id
-                                    customFields {
-                                        notes
-                                        lineImage {
-                                            id
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    }
-                `;
                 const { adjustOrderLine } = await shopClient.query(ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS, {
                     orderLineId: activeOrder.lines[2].id,
                     quantity: 1,
@@ -538,7 +584,7 @@ describe('Shop orders', () => {
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutationVariables
             >(ADJUST_ITEM_QUANTITY, {
-                orderLineId: 'T_8',
+                orderLineId: 'T_10',
                 quantity: 101,
             });
             orderResultGuard.assertErrorResult(adjustOrderLine);
@@ -554,7 +600,7 @@ describe('Shop orders', () => {
                 CodegenShop.AdjustItemQuantityMutation,
                 CodegenShop.AdjustItemQuantityMutationVariables
             >(ADJUST_ITEM_QUANTITY, {
-                orderLineId: 'T_8',
+                orderLineId: 'T_10',
                 quantity: 0,
             });
             orderResultGuard.assertSuccess(adjustLine2);
@@ -2158,3 +2204,21 @@ export const ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS = gql`
     }
     ${UPDATED_ORDER_FRAGMENT}
 `;
+
+const ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS = gql`
+    mutation ($orderLineId: ID!, $quantity: Int!, $customFields: OrderLineCustomFieldsInput) {
+        adjustOrderLine(orderLineId: $orderLineId, quantity: $quantity, customFields: $customFields) {
+            ... on Order {
+                lines {
+                    id
+                    customFields {
+                        notes
+                        lineImage {
+                            id
+                        }
+                    }
+                }
+            }
+        }
+    }
+`;

+ 2 - 1
packages/core/src/config/asset-naming-strategy/default-asset-naming-strategy.spec.ts

@@ -54,13 +54,14 @@ describe('DefaultAssetNamingStrategy', () => {
             expect(strategy.generatePreviewFileName(ctx, 'foo.png')).toBe('foo__preview.png');
             expect(strategy.generatePreviewFileName(ctx, 'foo.webp')).toBe('foo__preview.webp');
             expect(strategy.generatePreviewFileName(ctx, 'foo.tiff')).toBe('foo__preview.tiff');
+            expect(strategy.generatePreviewFileName(ctx, 'foo.gif')).toBe('foo__preview.gif');
+            expect(strategy.generatePreviewFileName(ctx, 'foo.avif')).toBe('foo__preview.avif');
         });
 
         it('adds a png extension for unsupported images and other files', () => {
             const strategy = new DefaultAssetNamingStrategy();
 
             expect(strategy.generatePreviewFileName(ctx, 'foo.svg')).toBe('foo__preview.svg.png');
-            expect(strategy.generatePreviewFileName(ctx, 'foo.gif')).toBe('foo__preview.gif.png');
             expect(strategy.generatePreviewFileName(ctx, 'foo.pdf')).toBe('foo__preview.pdf.png');
         });
     });

+ 1 - 1
packages/core/src/config/asset-naming-strategy/default-asset-naming-strategy.ts

@@ -44,7 +44,7 @@ export class DefaultAssetNamingStrategy implements AssetNamingStrategy {
      * See http://sharp.pixelplumbing.com/en/stable/api-output/#tobuffer
      */
     private isSupportedImageFormat(fileName: string): boolean {
-        const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.tiff'];
+        const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.tiff', '.avif', '.gif'];
         const ext = path.extname(fileName);
         return imageExtensions.includes(ext);
     }

+ 62 - 0
packages/core/src/connection/transactional-connection.ts

@@ -9,12 +9,14 @@ import {
     FindOptionsUtils,
     ObjectType,
     Repository,
+    SelectQueryBuilder,
 } from 'typeorm';
 
 import { RequestContext } from '../api/common/request-context';
 import { TRANSACTION_MANAGER_KEY } from '../common/constants';
 import { EntityNotFoundError } from '../common/error/errors';
 import { ChannelAware, SoftDeletable } from '../common/types/common-types';
+import { Logger } from '../config/index';
 import { VendureEntity } from '../entity/base/base.entity';
 
 import { TransactionWrapper } from './transaction-wrapper';
@@ -262,6 +264,7 @@ export class TransactionalConnection {
         options: FindOneOptions = {},
     ) {
         const qb = this.getRepository(ctx, entity).createQueryBuilder('entity');
+        options.relations = this.removeCustomFieldsWithEagerRelations(qb, options.relations);
         FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, options);
         if (options.loadEagerRelations !== false) {
             // tslint:disable-next-line:no-non-null-assertion
@@ -274,6 +277,65 @@ export class TransactionalConnection {
             .getOne();
     }
 
+    /**
+     * This is a work-around for this issue: https://github.com/vendure-ecommerce/vendure/issues/1664
+     *
+     * Explanation:
+     * When calling `FindOptionsUtils.joinEagerRelations()`, there appears to be a bug in TypeORM whereby
+     * it will throw the following error *if* the `options.relations` array contains any customField relations
+     * where the related entity itself has eagerly-loaded relations.
+     *
+     * For example, let's say we define a custom field on the Product entity like this:
+     * ```
+     * Product: [{
+     *   name: 'featuredFacet',
+     *   type: 'relation',
+     *   entity: Facet,
+     * }],
+     * ```
+     * and then we pass into `TransactionalConnection.findOneInChannel()` an options array of:
+     *
+     * ```
+     * { relations: ['customFields.featuredFacet'] }
+     * ```
+     * it will throw an error because the `Facet` entity itself has eager relations (namely the `translations` property).
+     * This will cause TypeORM to throw the error:
+     * ```
+     * TypeORMError: "entity__customFields" alias was not found. Maybe you forgot to join it?
+     * ```
+     *
+     * So this method introspects the QueryBuilder metadata and checks for any custom field relations which
+     * themselves have eager relations. If found, it removes those items from the `options.relations` array.
+     *
+     * TODO: Ideally create a minimal reproduction case and report in the TypeORM repo for an upstream fix.
+     */
+    private removeCustomFieldsWithEagerRelations(
+        qb: SelectQueryBuilder<any>,
+        relations: string[] = [],
+    ): string[] {
+        let resultingRelations = relations;
+        const mainAlias = qb.expressionMap.mainAlias;
+        const customFieldsMetadata = mainAlias?.metadata.embeddeds.find(
+            metadata => metadata.propertyName === 'customFields',
+        );
+        if (customFieldsMetadata) {
+            const customFieldRelationsWithEagerRelations = customFieldsMetadata.relations.filter(
+                relation => !!relation.inverseEntityMetadata.ownRelations.find(or => or.isEager === true),
+            );
+            for (const relation of customFieldRelationsWithEagerRelations) {
+                const propertyName = relation.propertyName;
+                const relationsToRemove = relations.filter(r => r.startsWith(`customFields.${propertyName}`));
+                if (relationsToRemove.length) {
+                    Logger.debug(
+                        `TransactionalConnection.findOneInChannel cannot automatically join relation [${mainAlias?.metadata.name}.customFields.${propertyName}]`,
+                    );
+                    resultingRelations = relations.filter(r => !r.startsWith(`customFields.${propertyName}`));
+                }
+            }
+        }
+        return resultingRelations;
+    }
+
     /**
      * @description
      * Like the TypeOrm `Repository.findByIds()` method, but limits the results to

+ 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',
      *   },
      * };

+ 8 - 2
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -582,8 +582,14 @@ export class OrderModifier {
             for (const def of customFieldDefs) {
                 const key = def.name;
                 const existingValue = existingCustomFields?.[key];
-                if (existingValue !== null && def.defaultValue && existingValue !== def.defaultValue) {
-                    return false;
+                if (existingValue != null) {
+                    if (def.defaultValue != null) {
+                        if (existingValue !== def.defaultValue) {
+                            return false;
+                        }
+                    } else {
+                        return false;
+                    }
                 }
             }
             return true;

+ 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();

+ 87 - 0
packages/dev-server/test-plugins/issue-1664.ts

@@ -0,0 +1,87 @@
+import {
+    LanguageCode,
+    LocaleString,
+    PluginCommonModule,
+    Product,
+    Translation,
+    VendureEntity,
+    VendurePlugin,
+} from '@vendure/core';
+import gql from 'graphql-tag';
+import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
+
+@Entity()
+class Vendor extends VendureEntity {
+    constructor(input: Partial<Vendor>) {
+        super(input);
+    }
+
+    description: LocaleString;
+
+    @Column()
+    name: string;
+
+    @OneToMany(() => Product, product => (product.customFields as any).vendor)
+    products: Product[];
+
+    @OneToMany(() => VendorTranslation, translation => translation.base, { eager: true })
+    translations: Array<Translation<Vendor>>;
+}
+
+@Entity()
+export class VendorTranslation extends VendureEntity implements Translation<Vendor> {
+    constructor(input?: Partial<Translation<Vendor>>) {
+        super(input);
+    }
+
+    @Column('varchar')
+    languageCode: LanguageCode;
+
+    @Column('text')
+    description: string;
+
+    @ManyToOne(() => Vendor, vendor => vendor.translations, { onDelete: 'CASCADE' })
+    base: Vendor;
+}
+
+const schema = gql`
+    type Vendor implements Node {
+        id: ID!
+        createdAt: DateTime!
+        updatedAt: DateTime!
+        name: String!
+        description: String!
+    }
+`;
+
+/**
+ * Test plugin for https://github.com/vendure-ecommerce/vendure/issues/1664
+ */
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    entities: [Vendor, VendorTranslation],
+    shopApiExtensions: { schema, resolvers: [] },
+    adminApiExtensions: { schema, resolvers: [] },
+    configuration: config => {
+        config.customFields.Product.push({
+            name: 'vendor',
+            label: [{ languageCode: LanguageCode.en_AU, value: 'Vendor' }],
+            type: 'relation',
+            entity: Vendor,
+            eager: true,
+            nullable: false,
+            defaultValue: null,
+            ui: {
+                component: 'cp-product-vendor-selector',
+            },
+        });
+
+        config.customFields.Product.push({
+            name: 'shopifyId',
+            type: 'float',
+            public: false,
+        });
+        return config;
+    },
+})
+export class Test1664Plugin {}

+ 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, { JobType, Processor, Queue, QueueScheduler, Worker, WorkerOptions } from 'bullmq';
+import Bull, { JobType, 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('failed', (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, error: Error) => {
+                .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 - 14
packages/payments-plugin/src/braintree/braintree.handler.ts

@@ -38,24 +38,14 @@ export const braintreePaymentMethodHandler = new PaymentMethodHandler({
     async createPayment(ctx, order, amount, args, metadata) {
         const gateway = getGateway(args, options);
         let customerId: string | undefined;
-        const { nonce, storeCardInVault } = metadata;
-        if (!nonce) {
-            return {
-                amount,
-                state: 'Error' as const,
-                transactionId: '',
-                errorMessage: `No "nonce" value was specified in the metadata`,
-                metadata,
-            };
-        }
         try {
             await entityHydrator.hydrate(ctx, order, { relations: ['customer'] });
             const customer = order.customer;
             if (options.storeCustomersInBraintree && ctx.activeUserId && customer) {
                 customerId = await getBraintreeCustomerId(ctx, gateway, customer);
             }
-            return processPayment(ctx, gateway, order, amount, nonce, customerId, options, storeCardInVault);
-        } catch (e: any) {
+            return processPayment(ctx, gateway, order, amount, metadata.nonce, customerId, options);
+        } catch (e) {
             Logger.error(e, loggerCtx);
             return {
                 amount,
@@ -99,7 +89,6 @@ async function processPayment(
     paymentMethodNonce: any,
     customerId: string | undefined,
     pluginOptions: BraintreePluginOptions,
-    storeCardInVault = true,
 ) {
     const response = await gateway.transaction.sale({
         customerId,
@@ -108,7 +97,7 @@ async function processPayment(
         paymentMethodNonce,
         options: {
             submitForSettlement: true,
-            storeInVaultOnSuccess: !!customerId && storeCardInVault,
+            storeInVaultOnSuccess: !!customerId,
         },
     });
     const extractMetadataFn = pluginOptions.extractMetadata ?? defaultExtractMetadataFn;

+ 0 - 42
packages/payments-plugin/src/braintree/braintree.plugin.ts

@@ -186,49 +186,7 @@ import { BraintreePluginOptions } from './types';
  *         dropin.clearSelectedPaymentMethod();
  *   }
  * }
- *
- * ## Storing cards in the vault
- *
- * Braintree has a "vault" mechanism which allows it to store the credit card information for a Customer, so that on the next purchase
- * the stored details do not need to be re-entered.
- *
- * To enable this feature, you need to ensure that the {@link BraintreePluginOptions} `storeCustomersInBraintree` option is set to
- * `true`. This will allow Braintree to associate a unique ID to each of your Customers.
- *
- * From v1.7.0, you can then specify on a per-payment basis whether a card should be stored in the vault. By default, all cards will
- * be automatically stored in the vault. But you can opt out of this behavior by specifying the `storeCardInVault` property in the `metadata` object
- * supplied to the `addPaymentToOrder` mutation:
- *
- * @example
- * ```TypeScript {hl_lines=[21]}
- * const { addPaymentToOrder } = await graphQlClient.query(gql`
- *   mutation AddPayment($input: PaymentInput!) {
- *     addPaymentToOrder(input: $input) {
- *       ... on Order {
- *         id
- *         payments {
- *           id
- *           # ... etc
- *         }
- *       }
- *       ... on ErrorResult {
- *         errorCode
- *         message
- *       }
- *     }
- *   }`, {
- *     input: {
- *       method: 'braintree',
- *       metadata: {
- *         nonce: paymentResult.nonce,
- *         storeCardInVault: false,
- *       },
- *     },
- *   },
- * );
  * ```
- *
- *
  * @docsCategory payments-plugin
  * @docsPage BraintreePlugin
  */

+ 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;
-    }
 }