Browse Source

Merge branch 'master' into minor

Michael Bromley 3 years ago
parent
commit
74551468c4
74 changed files with 2695 additions and 1734 deletions
  1. 22 0
      CHANGELOG.md
  2. 1 1
      docs/content/developer-guide/deployment.md
  3. 1 0
      e2e-common/tsconfig.e2e.json
  4. 1 1
      lerna.json
  5. 3 3
      packages/admin-ui-plugin/package.json
  6. 2 2
      packages/admin-ui/package.json
  7. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  8. 3 3
      packages/asset-server-plugin/package.json
  9. 4 2
      packages/asset-server-plugin/src/common.ts
  10. 1 1
      packages/common/package.json
  11. 475 305
      packages/core/e2e/custom-field-relations.e2e-spec.ts
  12. 10 0
      packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts
  13. 16 0
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  14. 62 0
      packages/core/e2e/list-query-builder.e2e-spec.ts
  15. 15 1
      packages/core/e2e/order-promotion.e2e-spec.ts
  16. 47 0
      packages/core/e2e/shop-order.e2e-spec.ts
  17. 116 1
      packages/core/e2e/stock-control.e2e-spec.ts
  18. 2 2
      packages/core/package.json
  19. 9 4
      packages/core/src/api/config/generate-resolvers.ts
  20. 2 2
      packages/core/src/api/resolvers/admin/channel.resolver.ts
  21. 2 2
      packages/core/src/common/self-refreshing-cache.spec.ts
  22. 7 2
      packages/core/src/common/self-refreshing-cache.ts
  23. 1 1
      packages/core/src/config/promotion/actions/facet-values-percentage-discount-action.ts
  24. 1 1
      packages/core/src/config/promotion/conditions/has-facet-values-condition.ts
  25. 3 2
      packages/core/src/config/promotion/utils/facet-value-checker.ts
  26. 1 6
      packages/core/src/connection/transactional-connection.ts
  27. 5 5
      packages/core/src/data-import/providers/importer/fast-importer.service.ts
  28. 10 13
      packages/core/src/data-import/providers/importer/importer.ts
  29. 1 1
      packages/core/src/data-import/providers/populator/populator.ts
  30. 5 2
      packages/core/src/plugin/default-job-queue-plugin/sql-job-buffer-storage-strategy.ts
  31. 28 24
      packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts
  32. 5 5
      packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts
  33. 4 4
      packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts
  34. 4 4
      packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts
  35. 1 1
      packages/core/src/plugin/default-search-plugin/types.ts
  36. 1 1
      packages/core/src/service/helpers/external-authentication/external-authentication.service.ts
  37. 2 3
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  38. 4 4
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  39. 21 5
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  40. 1 1
      packages/core/src/service/initializer.service.ts
  41. 8 7
      packages/core/src/service/services/administrator.service.ts
  42. 35 16
      packages/core/src/service/services/channel.service.ts
  43. 8 4
      packages/core/src/service/services/collection.service.ts
  44. 1 1
      packages/core/src/service/services/customer.service.ts
  45. 12 4
      packages/core/src/service/services/facet-value.service.ts
  46. 22 10
      packages/core/src/service/services/facet.service.ts
  47. 3 3
      packages/core/src/service/services/global-settings.service.ts
  48. 25 4
      packages/core/src/service/services/order.service.ts
  49. 2 2
      packages/core/src/service/services/product-variant.service.ts
  50. 3 3
      packages/core/src/service/services/promotion.service.ts
  51. 13 9
      packages/core/src/service/services/role.service.ts
  52. 7 7
      packages/core/src/service/services/session.service.ts
  53. 2 2
      packages/core/src/service/services/shipping-method.service.ts
  54. 1 1
      packages/core/src/service/services/user.service.ts
  55. 3 3
      packages/create/package.json
  56. 9 9
      packages/dev-server/package.json
  57. 63 0
      packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
  58. 3 3
      packages/elasticsearch-plugin/package.json
  59. 16 4
      packages/elasticsearch-plugin/src/indexing/indexer.controller.ts
  60. 1 1
      packages/elasticsearch-plugin/src/options.ts
  61. 3 3
      packages/email-plugin/package.json
  62. 3 3
      packages/job-queue-plugin/package.json
  63. 18 0
      packages/payments-plugin/e2e/graphql/admin-queries.ts
  64. 503 489
      packages/payments-plugin/e2e/graphql/generated-admin-types.ts
  65. 714 699
      packages/payments-plugin/e2e/graphql/generated-shop-types.ts
  66. 10 0
      packages/payments-plugin/e2e/graphql/shop-queries.ts
  67. 249 0
      packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts
  68. 4 4
      packages/payments-plugin/package.json
  69. 2 2
      packages/payments-plugin/src/stripe/raw-body.middleware.ts
  70. 11 14
      packages/payments-plugin/src/stripe/stripe.controller.ts
  71. 32 3
      packages/payments-plugin/src/stripe/stripe.service.ts
  72. 2 1
      packages/payments-plugin/src/stripe/types.ts
  73. 3 3
      packages/testing/package.json
  74. 4 4
      packages/ui-devkit/package.json

+ 22 - 0
CHANGELOG.md

@@ -1,3 +1,25 @@
+## <small>1.6.3 (2022-07-05)</small>
+
+
+#### Fixes
+
+* **asset-server-plugin** Detect protocol for assetUrlPrefix when behind a proxy (#1641) ([a39c592](https://github.com/vendure-ecommerce/vendure/commit/a39c592)), closes [#1641](https://github.com/vendure-ecommerce/vendure/issues/1641) [#1640](https://github.com/vendure-ecommerce/vendure/issues/1640)
+* **core** Consistently apply coupon code checks on MySQL DBs ([bfaee82](https://github.com/vendure-ecommerce/vendure/commit/bfaee82)), closes [#1604](https://github.com/vendure-ecommerce/vendure/issues/1604)
+* **core** Correctly join custom field relations in findOneInChannel ([9834225](https://github.com/vendure-ecommerce/vendure/commit/9834225)), closes [#1636](https://github.com/vendure-ecommerce/vendure/issues/1636)
+* **core** Do no de-allocate OrderItems that were not allocated ([11b69c7](https://github.com/vendure-ecommerce/vendure/commit/11b69c7)), closes [#1557](https://github.com/vendure-ecommerce/vendure/issues/1557)
+* **core** Fix edge case for custom field comparison on MySQL ([f08f62c](https://github.com/vendure-ecommerce/vendure/commit/f08f62c)), closes [#1612](https://github.com/vendure-ecommerce/vendure/issues/1612)
+* **core** Fix error when calling assignToChannels on an Order ([5dbca2d](https://github.com/vendure-ecommerce/vendure/commit/5dbca2d)), closes [#1391](https://github.com/vendure-ecommerce/vendure/issues/1391)
+* **core** Fix OrderLine deduplication with customField default values ([9522f34](https://github.com/vendure-ecommerce/vendure/commit/9522f34)), closes [#1612](https://github.com/vendure-ecommerce/vendure/issues/1612)
+* **core** Introduced errorOnFail flag for job.updates() method (#1627) ([464924c](https://github.com/vendure-ecommerce/vendure/commit/464924c)), closes [#1627](https://github.com/vendure-ecommerce/vendure/issues/1627) [#1551](https://github.com/vendure-ecommerce/vendure/issues/1551)
+* **core** Re-evaluate shipping when all OrderLines removed ([19a554d](https://github.com/vendure-ecommerce/vendure/commit/19a554d)), closes [#1441](https://github.com/vendure-ecommerce/vendure/issues/1441)
+* **core** Resolve customField relations on related types ([3e81821](https://github.com/vendure-ecommerce/vendure/commit/3e81821)), closes [#1610](https://github.com/vendure-ecommerce/vendure/issues/1610)
+* **core** Use correct ctx when importing FacetValues ([fcaff4e](https://github.com/vendure-ecommerce/vendure/commit/fcaff4e))
+* **core** Use RequestContext where available in all DB operations (#1639) ([a683ef5](https://github.com/vendure-ecommerce/vendure/commit/a683ef5)), closes [#1639](https://github.com/vendure-ecommerce/vendure/issues/1639)
+* **elasticsearch-plugin** Support hydration of custom field relations ([a75390e](https://github.com/vendure-ecommerce/vendure/commit/a75390e)), closes [#1638](https://github.com/vendure-ecommerce/vendure/issues/1638)
+* **email-plugin** Relax typings of `handlers` config option ([0dfa9d0](https://github.com/vendure-ecommerce/vendure/commit/0dfa9d0))
+* **payments-plugin** Attach incoming req to `ctx` in Stripe webhook ([cb13e99](https://github.com/vendure-ecommerce/vendure/commit/cb13e99)), closes [#1643](https://github.com/vendure-ecommerce/vendure/issues/1643)
+* **payments-plugin** Stripe - send correct amount for JPY ([cd0a48f](https://github.com/vendure-ecommerce/vendure/commit/cd0a48f)), closes [#1630](https://github.com/vendure-ecommerce/vendure/issues/1630)
+
 ## <small>1.6.2 (2022-06-02)</small>
 
 

+ 1 - 1
docs/content/developer-guide/deployment.md

@@ -12,7 +12,7 @@ The bare minimum requirements are:
 * A server with Node.js installed
 * A database server (if using MySQL/Postgres)
 
-A typical pattern is to run the Vendure app on the server, e.g. at `http://localhost:3000` an then use [nginx as a reverse proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) to direct requests from the Internet to the Vendure application.
+A typical pattern is to run the Vendure app on the server, e.g. at `http://localhost:3000` and then use [nginx as a reverse proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) to direct requests from the Internet to the Vendure application.
 
 Here is a good guide to setting up a production-ready server for an app such as Vendure: https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-ubuntu-18-04
 

+ 1 - 0
e2e-common/tsconfig.e2e.json

@@ -5,6 +5,7 @@
     "lib": ["es2015"],
     "skipLibCheck": true,
     "inlineSourceMap": false,
+    "sourceMap": true,
     "allowSyntheticDefaultImports": true,
     "esModuleInterop": true,
     "allowJs": false,

+ 1 - 1
lerna.json

@@ -2,7 +2,7 @@
   "packages": [
     "packages/*"
   ],
-  "version": "1.6.2",
+  "version": "1.6.3",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "command": {

+ 3 - 3
packages/admin-ui-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui-plugin",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -21,8 +21,8 @@
   "devDependencies": {
     "@types/express": "^4.17.8",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^1.6.2",
-    "@vendure/core": "^1.6.2",
+    "@vendure/common": "^1.6.3",
+    "@vendure/core": "^1.6.3",
     "express": "^4.17.1",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"

+ 2 - 2
packages/admin-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "license": "MIT",
   "scripts": {
     "ng": "ng",
@@ -39,7 +39,7 @@
     "@ng-select/ng-select": "^7.2.0",
     "@ngx-translate/core": "^13.0.0",
     "@ngx-translate/http-loader": "^6.0.0",
-    "@vendure/common": "^1.6.2",
+    "@vendure/common": "^1.6.3",
     "@webcomponents/custom-elements": "^1.4.3",
     "apollo-angular": "^2.6.0",
     "apollo-upload-client": "^16.0.0",

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/version.ts

@@ -1,2 +1,2 @@
 // Auto-generated by the set-version.js script.
-export const ADMIN_UI_VERSION = '1.6.2';
+export const ADMIN_UI_VERSION = '1.6.3';

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/asset-server-plugin",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -24,8 +24,8 @@
     "@types/fs-extra": "^9.0.8",
     "@types/node-fetch": "^2.5.8",
     "@types/sharp": "^0.27.1",
-    "@vendure/common": "^1.6.2",
-    "@vendure/core": "^1.6.2",
+    "@vendure/common": "^1.6.3",
+    "@vendure/core": "^1.6.3",
     "aws-sdk": "^2.856.0",
     "express": "^4.17.1",
     "node-fetch": "^2.6.1",

+ 4 - 2
packages/asset-server-plugin/src/common.ts

@@ -6,8 +6,10 @@ import { AssetServerOptions } from './types';
 export function getAssetUrlPrefixFn(options: AssetServerOptions) {
     const { assetUrlPrefix, route } = options;
     if (assetUrlPrefix == null) {
-        return (request: Request, identifier: string) =>
-            `${request.protocol}://${request.get('host')}/${route}/`;
+        return (request: Request, identifier: string) => {
+            const protocol = request.headers['x-forwarded-proto'] ?? request.protocol;
+            return `${protocol}://${request.get('host')}/${route}/`;
+        };
     }
     if (typeof assetUrlPrefix === 'string') {
         return (...args: any[]) => assetUrlPrefix;

+ 1 - 1
packages/common/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/common",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "main": "index.js",
   "license": "MIT",
   "scripts": {

+ 475 - 305
packages/core/e2e/custom-field-relations.e2e-spec.ts

@@ -1,7 +1,10 @@
+import { Args, Query, Resolver } from '@nestjs/graphql';
+import { ID } from '@vendure/common/lib/shared-types';
 import {
     Asset,
     Collection,
     Country,
+    Ctx,
     CustomFields,
     defaultShippingCalculator,
     defaultShippingEligibilityChecker,
@@ -9,11 +12,15 @@ import {
     FacetValue,
     manualFulfillmentHandler,
     mergeConfig,
+    PluginCommonModule,
     Product,
     ProductOption,
     ProductOptionGroup,
     ProductVariant,
+    RequestContext,
     ShippingMethod,
+    TransactionalConnection,
+    VendurePlugin,
 } from '@vendure/core';
 import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
@@ -22,6 +29,7 @@ import path from 'path';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import { AddItemToOrder } from './graphql/generated-e2e-shop-types';
 import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
 import { sortById } from './utils/test-order-utils';
@@ -42,19 +50,26 @@ const entitiesWithCustomFields = enumerate<keyof CustomFields>()(
     'Asset',
     'Channel',
     'Collection',
+    'Country',
     'Customer',
+    'CustomerGroup',
     'Facet',
     'FacetValue',
     'Fulfillment',
     'GlobalSettings',
     'Order',
     'OrderLine',
+    'PaymentMethod',
     'Product',
     'ProductOption',
     'ProductOptionGroup',
     'ProductVariant',
-    'User',
+    'Promotion',
     'ShippingMethod',
+    'TaxCategory',
+    'TaxRate',
+    'User',
+    'Zone',
 );
 
 const customFieldConfig: CustomFields = {};
@@ -77,11 +92,43 @@ customFieldConfig.Product?.push(
     { name: 'cfInternalAsset', type: 'relation', entity: Asset, list: false, internal: true },
 );
 
+const testResolverSpy = jest.fn();
+@Resolver()
+class TestResolver1636 {
+    constructor(private connection: TransactionalConnection) {}
+
+    @Query()
+    async getAssetTest(@Ctx() ctx: RequestContext, @Args() args: { id: ID }) {
+        const asset = await this.connection.findOneInChannel(ctx, Asset, args.id, ctx.channelId, {
+            relations: ['customFields.single', 'customFields.multi'],
+        });
+        testResolverSpy(asset);
+        return true;
+    }
+}
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    shopApiExtensions: {
+        schema: gql`
+            extend type Query {
+                getAssetTest(id: ID!): Boolean!
+            }
+        `,
+        resolvers: [TestResolver1636],
+    },
+})
+class TestPlugin1636 {}
+
 const customConfig = mergeConfig(testConfig(), {
+    paymentOptions: {
+        paymentMethodHandlers: [testSuccessfulPaymentMethod],
+    },
     dbConnectionOptions: {
         timezone: 'Z',
     },
     customFields: customFieldConfig,
+    plugins: [TestPlugin1636],
 });
 
 describe('Custom field relations', () => {
@@ -236,7 +283,6 @@ describe('Custom field relations', () => {
         });
 
         it('ProductVariant prices get resolved', async () => {
-            debugger;
             const { product } = await adminClient.query(gql`
                 query {
                     product(id: "${productId}") {
@@ -249,7 +295,7 @@ describe('Custom field relations', () => {
                             }
                         }
                     }
-            }`);
+                }`);
 
             expect(product.customFields.cfProductVariant).toEqual({
                 price: 129900,
@@ -264,6 +310,7 @@ describe('Custom field relations', () => {
             expect(customFields.single).toEqual({ id: single });
             expect(customFields.multi.sort(sortById)).toEqual(multi.map(id => ({ id })));
         }
+
         const customFieldsSelection = `
             customFields {
                 single {
@@ -277,20 +324,20 @@ describe('Custom field relations', () => {
         describe('Address entity', () => {
             it('admin createCustomerAddress', async () => {
                 const { createCustomerAddress } = await adminClient.query(gql`
-                        mutation {
-                            createCustomerAddress(
-                                customerId: "T_1"
-                                input: {
-                                    countryCode: "GB"
-                                    streetLine1: "Test Street"
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        createCustomerAddress(
+                            customerId: "T_1"
+                            input: {
+                                countryCode: "GB"
+                                streetLine1: "Test Street"
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createCustomerAddress.customFields, 'T_1', ['T_1', 'T_2']);
             });
@@ -298,49 +345,49 @@ describe('Custom field relations', () => {
             it('shop createCustomerAddress', async () => {
                 await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
                 const { createCustomerAddress } = await shopClient.query(gql`
-                        mutation {
-                            createCustomerAddress(
-                                input: {
-                                    countryCode: "GB"
-                                    streetLine1: "Test Street"
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        createCustomerAddress(
+                            input: {
+                                countryCode: "GB"
+                                streetLine1: "Test Street"
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createCustomerAddress.customFields, 'T_1', ['T_1', 'T_2']);
             });
 
             it('admin updateCustomerAddress', async () => {
                 const { updateCustomerAddress } = await adminClient.query(gql`
-                        mutation {
-                            updateCustomerAddress(
-                                input: { id: "T_1", customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] } }
-                            ) {
-                                id
-                                ${customFieldsSelection}
-                            }
+                    mutation {
+                        updateCustomerAddress(
+                            input: { id: "T_1", customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] } }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(updateCustomerAddress.customFields, 'T_2', ['T_3', 'T_4']);
             });
 
             it('shop updateCustomerAddress', async () => {
                 const { updateCustomerAddress } = await shopClient.query(gql`
-                        mutation {
-                            updateCustomerAddress(
-                                input: { id: "T_1", customFields: { singleId: "T_3", multiIds: ["T_4", "T_2"] } }
-                            ) {
-                                id
-                                ${customFieldsSelection}
-                            }
+                    mutation {
+                        updateCustomerAddress(
+                            input: { id: "T_1", customFields: { singleId: "T_3", multiIds: ["T_4", "T_2"] } }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(updateCustomerAddress.customFields, 'T_3', ['T_2', 'T_4']);
             });
@@ -350,39 +397,39 @@ describe('Custom field relations', () => {
             let collectionId: string;
             it('admin createCollection', async () => {
                 const { createCollection } = await adminClient.query(gql`
-                        mutation {
-                            createCollection(
-                                input: {
-                                    translations: [
-                                        { languageCode: en, name: "Test", description: "test", slug: "test" }
-                                    ]
-                                    filters: []
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        createCollection(
+                            input: {
+                                translations: [
+                                    { languageCode: en, name: "Test", description: "test", slug: "test" }
+                                ]
+                                filters: []
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(createCollection.customFields, 'T_1', ['T_1', 'T_2']);
                 collectionId = createCollection.id;
             });
 
             it('admin updateCollection', async () => {
                 const { updateCollection } = await adminClient.query(gql`
-                        mutation {
-                            updateCollection(
-                                input: {
-                                    id: "${collectionId}"
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        updateCollection(
+                            input: {
+                                id: "${collectionId}"
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(updateCollection.customFields, 'T_2', ['T_3', 'T_4']);
             });
@@ -392,22 +439,22 @@ describe('Custom field relations', () => {
             let customerId: string;
             it('admin createCustomer', async () => {
                 const { createCustomer } = await adminClient.query(gql`
-                        mutation {
-                            createCustomer(
-                                input: {
-                                    emailAddress: "test@test.com"
-                                    firstName: "Test"
-                                    lastName: "Person"
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                ... on Customer {
-                                    id
-                                    ${customFieldsSelection}
-                                }
+                    mutation {
+                        createCustomer(
+                            input: {
+                                emailAddress: "test@test.com"
+                                firstName: "Test"
+                                lastName: "Person"
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                            }
+                        ) {
+                            ... on Customer {
+                                id
+                                ${customFieldsSelection}
                             }
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createCustomer.customFields, 'T_1', ['T_1', 'T_2']);
                 customerId = createCustomer.id;
@@ -415,32 +462,32 @@ describe('Custom field relations', () => {
 
             it('admin updateCustomer', async () => {
                 const { updateCustomer } = await adminClient.query(gql`
-                        mutation {
-                            updateCustomer(
-                                input: {
-                                    id: "${customerId}"
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                ...on Customer {
-                                    id
-                                    ${customFieldsSelection}
-                                }
+                    mutation {
+                        updateCustomer(
+                            input: {
+                                id: "${customerId}"
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                            }
+                        ) {
+                            ...on Customer {
+                                id
+                                ${customFieldsSelection}
                             }
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateCustomer.customFields, 'T_2', ['T_3', 'T_4']);
             });
 
             it('shop updateCustomer', async () => {
                 const { updateCustomer } = await shopClient.query(gql`
-                        mutation {
-                            updateCustomer(input: { customFields: { singleId: "T_4", multiIds: ["T_2", "T_4"] } }) {
-                                id
-                                ${customFieldsSelection}
-                            }
+                    mutation {
+                        updateCustomer(input: { customFields: { singleId: "T_4", multiIds: ["T_2", "T_4"] } }) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateCustomer.customFields, 'T_4', ['T_2', 'T_4']);
             });
         });
@@ -449,20 +496,20 @@ describe('Custom field relations', () => {
             let facetId: string;
             it('admin createFacet', async () => {
                 const { createFacet } = await adminClient.query(gql`
-                        mutation {
-                            createFacet(
-                                input: {
-                                    code: "test"
-                                    isPrivate: false
-                                    translations: [{ languageCode: en, name: "test" }]
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        createFacet(
+                            input: {
+                                code: "test"
+                                isPrivate: false
+                                translations: [{ languageCode: en, name: "test" }]
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createFacet.customFields, 'T_1', ['T_1', 'T_2']);
                 facetId = createFacet.id;
@@ -470,18 +517,18 @@ describe('Custom field relations', () => {
 
             it('admin updateFacet', async () => {
                 const { updateFacet } = await adminClient.query(gql`
-                        mutation {
-                            updateFacet(
-                                input: {
-                                    id: "${facetId}"
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        updateFacet(
+                            input: {
+                                id: "${facetId}"
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateFacet.customFields, 'T_2', ['T_3', 'T_4']);
             });
         });
@@ -490,20 +537,20 @@ describe('Custom field relations', () => {
             let facetValueId: string;
             it('admin createFacetValues', async () => {
                 const { createFacetValues } = await adminClient.query(gql`
-                        mutation {
-                            createFacetValues(
-                                input: {
-                                    code: "test"
-                                    facetId: "T_1"
-                                    translations: [{ languageCode: en, name: "test" }]
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        createFacetValues(
+                            input: {
+                                code: "test"
+                                facetId: "T_1"
+                                translations: [{ languageCode: en, name: "test" }]
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createFacetValues[0].customFields, 'T_1', ['T_1', 'T_2']);
                 facetValueId = createFacetValues[0].id;
@@ -511,18 +558,18 @@ describe('Custom field relations', () => {
 
             it('admin updateFacetValues', async () => {
                 const { updateFacetValues } = await adminClient.query(gql`
-                        mutation {
-                            updateFacetValues(
-                                input: {
-                                    id: "${facetValueId}"
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        updateFacetValues(
+                            input: {
+                                id: "${facetValueId}"
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateFacetValues[0].customFields, 'T_2', ['T_3', 'T_4']);
             });
         });
@@ -534,19 +581,19 @@ describe('Custom field relations', () => {
         describe('GlobalSettings entity', () => {
             it('admin updateGlobalSettings', async () => {
                 const { updateGlobalSettings } = await adminClient.query(gql`
-                        mutation {
-                            updateGlobalSettings(
-                                input: {
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                ... on GlobalSettings {
-                                    id
-                                    ${customFieldsSelection}
-                                }
+                    mutation {
+                        updateGlobalSettings(
+                            input: {
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                            }
+                        ) {
+                            ... on GlobalSettings {
+                                id
+                                ${customFieldsSelection}
                             }
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateGlobalSettings.customFields, 'T_2', ['T_3', 'T_4']);
             });
         });
@@ -568,39 +615,39 @@ describe('Custom field relations', () => {
 
             it('shop setOrderCustomFields', async () => {
                 const { setOrderCustomFields } = await shopClient.query(gql`
-                        mutation {
-                            setOrderCustomFields(
-                                input: {
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                ... on Order {
-                                    id
-                                    ${customFieldsSelection}
-                                }
+                    mutation {
+                        setOrderCustomFields(
+                            input: {
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                            }
+                        ) {
+                            ... on Order {
+                                id
+                                ${customFieldsSelection}
                             }
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(setOrderCustomFields.customFields, 'T_2', ['T_3', 'T_4']);
             });
 
             it('admin setOrderCustomFields', async () => {
                 const { setOrderCustomFields } = await adminClient.query(gql`
-                        mutation {
-                            setOrderCustomFields(
-                                input: {
-                                    id: "${orderId}"
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                ... on Order {
-                                    id
-                                    ${customFieldsSelection}
-                                }
+                    mutation {
+                        setOrderCustomFields(
+                            input: {
+                                id: "${orderId}"
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                            }
+                        ) {
+                            ... on Order {
+                                id
+                                ${customFieldsSelection}
                             }
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(setOrderCustomFields.customFields, 'T_1', ['T_1', 'T_2']);
             });
@@ -610,13 +657,13 @@ describe('Custom field relations', () => {
             it('shop addItemToOrder', async () => {
                 const { addItemToOrder } = await shopClient.query(
                     gql`mutation {
-                            addItemToOrder(productVariantId: "T_1", quantity: 1, customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }) {
-                                ... on Order {
-                                    id
-                                    ${customFieldsSelection}
-                                }
+                        addItemToOrder(productVariantId: "T_1", quantity: 1, customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }) {
+                            ... on Order {
+                                id
+                                ${customFieldsSelection}
                             }
-                        }`,
+                        }
+                    }`,
                 );
 
                 assertCustomFieldIds(addItemToOrder.customFields, 'T_1', ['T_1', 'T_2']);
@@ -627,18 +674,18 @@ describe('Custom field relations', () => {
             let productId: string;
             it('admin createProduct', async () => {
                 const { createProduct } = await adminClient.query(gql`
-                        mutation {
-                            createProduct(
-                                input: {
-                                    translations: [{ languageCode: en, name: "test" slug: "test" description: "test" }]
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        createProduct(
+                            input: {
+                                translations: [{ languageCode: en, name: "test" slug: "test" description: "test" }]
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createProduct.customFields, 'T_1', ['T_1', 'T_2']);
                 productId = createProduct.id;
@@ -646,38 +693,38 @@ describe('Custom field relations', () => {
 
             it('admin updateProduct', async () => {
                 const { updateProduct } = await adminClient.query(gql`
-                        mutation {
-                            updateProduct(
-                                input: {
-                                    id: "${productId}"
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        updateProduct(
+                            input: {
+                                id: "${productId}"
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateProduct.customFields, 'T_2', ['T_3', 'T_4']);
             });
 
             let productVariantId: string;
             it('admin createProductVariant', async () => {
                 const { createProductVariants } = await adminClient.query(gql`
-                        mutation {
-                            createProductVariants(
-                                input: [{
-                                    sku: "TEST01"
-                                    productId: "${productId}"
-                                    translations: [{ languageCode: en, name: "test" }]
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }]
-                            ) {
-                                id
-                                ${customFieldsSelection}
-                            }
+                    mutation {
+                        createProductVariants(
+                            input: [{
+                                sku: "TEST01"
+                                productId: "${productId}"
+                                translations: [{ languageCode: en, name: "test" }]
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                            }]
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createProductVariants[0].customFields, 'T_1', ['T_1', 'T_2']);
                 productVariantId = createProductVariants[0].id;
@@ -685,18 +732,18 @@ describe('Custom field relations', () => {
 
             it('admin updateProductVariant', async () => {
                 const { updateProductVariants } = await adminClient.query(gql`
-                        mutation {
-                            updateProductVariants(
-                                input: [{
-                                    id: "${productVariantId}"
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }]
-                            ) {
-                                id
-                                ${customFieldsSelection}
-                            }
+                    mutation {
+                        updateProductVariants(
+                            input: [{
+                                id: "${productVariantId}"
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                            }]
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateProductVariants[0].customFields, 'T_2', ['T_3', 'T_4']);
             });
         });
@@ -705,20 +752,20 @@ describe('Custom field relations', () => {
             let productOptionGroupId: string;
             it('admin createProductOptionGroup', async () => {
                 const { createProductOptionGroup } = await adminClient.query(gql`
-                        mutation {
-                            createProductOptionGroup(
-                                input: {
-                                    code: "test"
-                                    options: []
-                                    translations: [{ languageCode: en, name: "test" }]
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        createProductOptionGroup(
+                            input: {
+                                code: "test"
+                                options: []
+                                translations: [{ languageCode: en, name: "test" }]
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createProductOptionGroup.customFields, 'T_1', ['T_1', 'T_2']);
                 productOptionGroupId = createProductOptionGroup.id;
@@ -726,38 +773,38 @@ describe('Custom field relations', () => {
 
             it('admin updateProductOptionGroup', async () => {
                 const { updateProductOptionGroup } = await adminClient.query(gql`
-                        mutation {
-                            updateProductOptionGroup(
-                                input: {
-                                    id: "${productOptionGroupId}"
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        updateProductOptionGroup(
+                            input: {
+                                id: "${productOptionGroupId}"
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateProductOptionGroup.customFields, 'T_2', ['T_3', 'T_4']);
             });
 
             let productOptionId: string;
             it('admin createProductOption', async () => {
                 const { createProductOption } = await adminClient.query(gql`
-                        mutation {
-                            createProductOption(
-                                input: {
-                                    productOptionGroupId: "${productOptionGroupId}"
-                                    code: "test-option"
-                                    translations: [{ languageCode: en, name: "test-option" }]
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        createProductOption(
+                            input: {
+                                productOptionGroupId: "${productOptionGroupId}"
+                                code: "test-option"
+                                translations: [{ languageCode: en, name: "test-option" }]
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createProductOption.customFields, 'T_1', ['T_1', 'T_2']);
                 productOptionId = createProductOption.id;
@@ -765,18 +812,18 @@ describe('Custom field relations', () => {
 
             it('admin updateProductOption', async () => {
                 const { updateProductOption } = await adminClient.query(gql`
-                        mutation {
-                            updateProductOption(
-                                input: {
-                                    id: "${productOptionId}"
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        updateProductOption(
+                            input: {
+                                id: "${productOptionId}"
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateProductOption.customFields, 'T_2', ['T_3', 'T_4']);
             });
         });
@@ -789,34 +836,34 @@ describe('Custom field relations', () => {
             let shippingMethodId: string;
             it('admin createShippingMethod', async () => {
                 const { createShippingMethod } = await adminClient.query(gql`
-                        mutation {
-                            createShippingMethod(
-                                input: {
-                                    code: "test"
-                                    calculator: {
-                                        code: "${defaultShippingCalculator.code}"
-                                        arguments: [
-                                            { name: "rate" value: "10"},
-                                            { name: "includesTax" value: "true"},
-                                            { name: "taxRate" value: "10"},
-                                        ]
-                                    }
-                                    checker: {
-                                        code: "${defaultShippingEligibilityChecker.code}"
-                                        arguments: [
-                                            { name: "orderMinimum" value: "0"},
-                                        ]
-                                    }
-                                    fulfillmentHandler: "${manualFulfillmentHandler.code}"
-                                    translations: [{ languageCode: en, name: "test" description: "test" }]
-                                    customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                    mutation {
+                        createShippingMethod(
+                            input: {
+                                code: "test"
+                                calculator: {
+                                    code: "${defaultShippingCalculator.code}"
+                                    arguments: [
+                                        { name: "rate" value: "10"},
+                                        { name: "includesTax" value: "true"},
+                                        { name: "taxRate" value: "10"},
+                                    ]
                                 }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                                checker: {
+                                    code: "${defaultShippingEligibilityChecker.code}"
+                                    arguments: [
+                                        { name: "orderMinimum" value: "0"},
+                                    ]
+                                }
+                                fulfillmentHandler: "${manualFulfillmentHandler.code}"
+                                translations: [{ languageCode: en, name: "test" description: "test" }]
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
 
                 assertCustomFieldIds(createShippingMethod.customFields, 'T_1', ['T_1', 'T_2']);
                 shippingMethodId = createShippingMethod.id;
@@ -824,21 +871,144 @@ describe('Custom field relations', () => {
 
             it('admin updateShippingMethod', async () => {
                 const { updateShippingMethod } = await adminClient.query(gql`
-                        mutation {
-                            updateShippingMethod(
-                                input: {
-                                    id: "${shippingMethodId}"
-                                    translations: []
-                                    customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
-                                }
-                            ) {
-                                id
-                                ${customFieldsSelection}
+                    mutation {
+                        updateShippingMethod(
+                            input: {
+                                id: "${shippingMethodId}"
+                                translations: []
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
                             }
+                        ) {
+                            id
+                            ${customFieldsSelection}
                         }
-                    `);
+                    }
+                `);
                 assertCustomFieldIds(updateShippingMethod.customFields, 'T_2', ['T_3', 'T_4']);
             });
+
+            it('shop eligibleShippingMethods (ShippingMethodQuote)', async () => {
+                const { eligibleShippingMethods } = await shopClient.query(gql`
+                    query {
+                        eligibleShippingMethods {
+                            id
+                            name
+                            code
+                            description
+                            ${customFieldsSelection}
+                        }
+                    }
+                `);
+                const testShippingMethodQuote = eligibleShippingMethods.find(
+                    (quote: any) => quote.code === 'test',
+                );
+                assertCustomFieldIds(testShippingMethodQuote.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+        });
+
+        describe('PaymentMethod entity', () => {
+            let paymentMethodId: string;
+            it('admin createShippingMethod', async () => {
+                const { createPaymentMethod } = await adminClient.query(gql`
+                    mutation {
+                        createPaymentMethod(
+                            input: {
+                                name: "test"
+                                code: "test"
+                                enabled: true
+                                handler: {
+                                    code: "${testSuccessfulPaymentMethod.code}"
+                                    arguments: []
+                                }
+                                customFields: { singleId: "T_1", multiIds: ["T_1", "T_2"] }
+                            }
+                        ) {
+                            id
+                            ${customFieldsSelection}
+                        }
+                    }
+                `);
+
+                assertCustomFieldIds(createPaymentMethod.customFields, 'T_1', ['T_1', 'T_2']);
+                paymentMethodId = createPaymentMethod.id;
+            });
+
+            it('admin updatePaymentMethod', async () => {
+                const { updatePaymentMethod } = await adminClient.query(gql`
+                    mutation {
+                        updatePaymentMethod(
+                            input: {
+                                id: "${paymentMethodId}"
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                            }
+                        ) {
+                            id
+                            ${customFieldsSelection}
+                        }
+                    }
+                `);
+                assertCustomFieldIds(updatePaymentMethod.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+
+            it('shop eligiblePaymentMethods (PaymentMethodQuote)', async () => {
+                const { eligiblePaymentMethods } = await shopClient.query(gql`
+                    query {
+                        eligiblePaymentMethods {
+                            id
+                            name
+                            description
+                            ${customFieldsSelection}
+                        }
+                    }
+                `);
+                assertCustomFieldIds(eligiblePaymentMethods[0].customFields, 'T_2', ['T_3', 'T_4']);
+            });
+        });
+
+        describe('Asset entity', () => {
+            it('set custom field relations on Asset', async () => {
+                const { updateAsset } = await adminClient.query(gql`
+                    mutation {
+                        updateAsset(
+                            input: {
+                                id: "T_1",
+                                customFields: { singleId: "T_2", multiIds: ["T_3", "T_4"] }
+                            }
+                        ) {
+                            id
+                            ${customFieldsSelection}
+                        }
+                    }
+                            `);
+
+                assertCustomFieldIds(updateAsset.customFields, 'T_2', ['T_3', 'T_4']);
+            });
+
+            it('findOne on Asset', async () => {
+                const { asset } = await adminClient.query(gql`
+                    query {
+                        asset(id: "T_1") {
+                            id
+                            ${customFieldsSelection}
+                        }
+                    }
+                `);
+                expect(asset.customFields.single.id).toBe('T_2');
+                expect(asset.customFields.multi.length).toEqual(2);
+            });
+
+            // https://github.com/vendure-ecommerce/vendure/issues/1636
+            it('calling TransactionalConnection.findOneInChannel() returns custom field relations', async () => {
+                testResolverSpy.mockReset();
+                await shopClient.query(gql`
+                    query {
+                        getAssetTest(id: "T_1")
+                    }
+                `);
+                const args = testResolverSpy.mock.calls[0];
+                expect(args[0].customFields.single.id).toEqual(2);
+                expect(args[0].customFields.multi.length).toEqual(2);
+            });
         });
     });
 

+ 10 - 0
packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts

@@ -26,6 +26,7 @@ export class TestEntity extends VendureEntity implements Translatable {
     constructor(input: Partial<TestEntity>) {
         super(input);
     }
+
     @Column()
     label: string;
 
@@ -155,6 +156,14 @@ export class ListQueryResolver {
 }
 
 const apiExtensions = gql`
+    type TestEntityTranslation implements Node {
+        id: ID!
+        createdAt: DateTime!
+        updatedAt: DateTime!
+        languageCode: LanguageCode!
+        name: String!
+    }
+
     type TestEntity implements Node {
         id: ID!
         createdAt: DateTime!
@@ -168,6 +177,7 @@ const apiExtensions = gql`
         descriptionLength: Int!
         price: Int!
         ownerId: ID!
+        translations: [TestEntityTranslation!]!
     }
 
     type TestEntityList implements PaginatedList {

+ 16 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -7117,6 +7117,12 @@ export type TransitionFulfillmentToStateMutation = {
         | Pick<FulfillmentStateTransitionError, 'errorCode' | 'message' | 'transitionError'>;
 };
 
+export type UpdateOrderCustomFieldsMutationVariables = Exact<{
+    input: UpdateOrderInput;
+}>;
+
+export type UpdateOrderCustomFieldsMutation = { setOrderCustomFields?: Maybe<Pick<Order, 'id'>> };
+
 export type GetTagListQueryVariables = Exact<{
     options?: Maybe<TagListOptions>;
 }>;
@@ -9581,6 +9587,16 @@ export namespace TransitionFulfillmentToState {
     >;
 }
 
+export namespace UpdateOrderCustomFields {
+    export type Variables = UpdateOrderCustomFieldsMutationVariables;
+    export type Mutation = UpdateOrderCustomFieldsMutation;
+    export type SetOrderCustomFields = NonNullable<UpdateOrderCustomFieldsMutation['setOrderCustomFields']>;
+    export type OrderInlineFragment = { __typename: 'Order' } & Pick<
+        NonNullable<UpdateOrderCustomFieldsMutation['setOrderCustomFields']>,
+        'id'
+    >;
+}
+
 export namespace GetTagList {
     export type Variables = GetTagListQueryVariables;
     export type Query = GetTagListQuery;

+ 62 - 0
packages/core/e2e/list-query-builder.e2e-spec.ts

@@ -962,6 +962,50 @@ describe('ListQueryBuilder', () => {
             11, 9, 22, 14, 13, 33,
         ]);
     });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/1611
+    xdescribe('translations handling', () => {
+        const allTranslations = [
+            [
+                { languageCode: LanguageCode.en, name: 'apple' },
+                { languageCode: LanguageCode.de, name: 'apfel' },
+            ],
+            [
+                { languageCode: LanguageCode.en, name: 'bike' },
+                { languageCode: LanguageCode.de, name: 'fahrrad' },
+            ],
+        ];
+        it('returns all translations with default languageCode', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TRANSLATIONS, {
+                options: {
+                    take: 2,
+                    sort: {
+                        order: SortOrder.ASC,
+                    },
+                },
+            });
+
+            expect(testEntities.items.map((e: any) => e.name)).toEqual(['apple', 'bike']);
+            expect(testEntities.items.map((e: any) => e.translations)).toEqual(allTranslations);
+        });
+        it('returns all translations with non-default languageCode', async () => {
+            const { testEntities } = await shopClient.query(
+                GET_LIST_WITH_TRANSLATIONS,
+                {
+                    options: {
+                        take: 2,
+                        sort: {
+                            order: SortOrder.ASC,
+                        },
+                    },
+                },
+                { languageCode: LanguageCode.de },
+            );
+
+            expect(testEntities.items.map((e: any) => e.name)).toEqual(['apfel', 'fahrrad']);
+            expect(testEntities.items.map((e: any) => e.translations)).toEqual(allTranslations);
+        });
+    });
 });
 
 const GET_LIST = gql`
@@ -978,6 +1022,24 @@ const GET_LIST = gql`
     }
 `;
 
+const GET_LIST_WITH_TRANSLATIONS = gql`
+    query GetTestEntitiesWithTranslations($options: TestEntityListOptions) {
+        testEntities(options: $options) {
+            totalItems
+            items {
+                id
+                label
+                name
+                date
+                translations {
+                    languageCode
+                    name
+                }
+            }
+        }
+    }
+`;
+
 const GET_ARRAY_LIST = gql`
     query GetTestEntitiesArray($options: TestEntityListOptions) {
         testEntitiesGetMany(options: $options) {

+ 15 - 1
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -95,7 +95,7 @@ import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order
 describe('Promotions applied to Orders', () => {
     const { server, adminClient, shopClient } = createTestEnvironment({
         ...testConfig(),
-        logger: new DefaultLogger({ level: LogLevel.Info }),
+        // logger: new DefaultLogger({ level: LogLevel.Info }),
         paymentOptions: {
             paymentMethodHandlers: [testSuccessfulPaymentMethod],
         },
@@ -203,6 +203,20 @@ describe('Promotions applied to Orders', () => {
             expect(applyCouponCode.errorCode).toBe(ErrorCode.COUPON_CODE_EXPIRED_ERROR);
         });
 
+        it('coupon code application is case-sensitive', async () => {
+            const { applyCouponCode } = await shopClient.query<
+                ApplyCouponCode.Mutation,
+                ApplyCouponCode.Variables
+            >(APPLY_COUPON_CODE, {
+                couponCode: TEST_COUPON_CODE.toLowerCase(),
+            });
+            orderResultGuard.assertErrorResult(applyCouponCode);
+            expect(applyCouponCode.message).toBe(
+                `Coupon code "${TEST_COUPON_CODE.toLowerCase()}" is not valid`,
+            );
+            expect(applyCouponCode.errorCode).toBe(ErrorCode.COUPON_CODE_INVALID_ERROR);
+        });
+
         it('applies a valid coupon code', async () => {
             const { applyCouponCode } = await shopClient.query<
                 ApplyCouponCode.Mutation,

+ 47 - 0
packages/core/e2e/shop-order.e2e-spec.ts

@@ -130,6 +130,7 @@ describe('Shop orders', () => {
                     { name: 'notes', type: 'string' },
                     { name: 'privateField', type: 'string', public: false },
                     { name: 'lineImage', type: 'relation', entity: Asset },
+                    { name: 'dropShip', type: 'boolean', defaultValue: false },
                 ],
             },
             orderOptions: {
@@ -2012,6 +2013,52 @@ describe('Shop orders', () => {
             const result = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
             expect(result.activeOrder?.shippingLines).toEqual([]);
         });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/1441
+        it('shipping methods are re-evaluated when all OrderLines are removed', async () => {
+            const { createShippingMethod } = await adminClient.query<
+                CreateShippingMethod.Mutation,
+                CreateShippingMethod.Variables
+            >(CREATE_SHIPPING_METHOD, {
+                input: {
+                    code: `min-price-shipping`,
+                    translations: [
+                        { languageCode: LanguageCode.en, name: `min price shipping`, description: '' },
+                    ],
+                    fulfillmentHandler: manualFulfillmentHandler.code,
+                    checker: {
+                        code: defaultShippingEligibilityChecker.code,
+                        arguments: [{ name: 'orderMinimum', value: '100' }],
+                    },
+                    calculator: {
+                        code: defaultShippingCalculator.code,
+                        arguments: [
+                            { name: 'rate', value: '1000' },
+                            { name: 'taxRate', value: '0' },
+                            { name: 'includesTax', value: 'auto' },
+                        ],
+                    },
+                },
+            });
+            const minPriceShippingMethodId = createShippingMethod.id;
+
+            await shopClient.query<SetShippingMethod.Mutation, SetShippingMethod.Variables>(
+                SET_SHIPPING_METHOD,
+                {
+                    id: minPriceShippingMethodId,
+                },
+            );
+            const result1 = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
+            expect(result1.activeOrder?.shippingLines[0].shippingMethod.id).toBe(minPriceShippingMethodId);
+
+            const { removeAllOrderLines } = await shopClient.query<
+                RemoveAllOrderLines.Mutation,
+                RemoveAllOrderLines.Variables
+            >(REMOVE_ALL_ORDER_LINES);
+            orderResultGuard.assertSuccess(removeAllOrderLines);
+            expect(removeAllOrderLines.shippingLines.length).toBe(0);
+            expect(removeAllOrderLines.shippingWithTax).toBe(0);
+        });
     });
 });
 

+ 116 - 1
packages/core/e2e/stock-control.e2e-spec.ts

@@ -1,5 +1,13 @@
 /* tslint:disable:no-non-null-assertion */
-import { manualFulfillmentHandler, mergeConfig, OrderState } from '@vendure/core';
+import {
+    DefaultOrderPlacedStrategy,
+    manualFulfillmentHandler,
+    mergeConfig,
+    Order,
+    OrderPlacedStrategy,
+    OrderState,
+    RequestContext,
+} from '@vendure/core';
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
@@ -67,13 +75,43 @@ import {
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
 
+class TestOrderPlacedStrategy extends DefaultOrderPlacedStrategy {
+    shouldSetAsPlaced(
+        ctx: RequestContext,
+        fromState: OrderState,
+        toState: OrderState,
+        order: Order,
+    ): boolean {
+        if ((order.customFields as any).test1557) {
+            // This branch is used in testing https://github.com/vendure-ecommerce/vendure/issues/1557
+            // i.e. it will cause the Order to be set to `active: false` but without creating any
+            // Allocations for the OrderLines.
+            if (fromState === 'AddingItems' && toState === 'ArrangingPayment') {
+                return true;
+            }
+            return false;
+        }
+        return super.shouldSetAsPlaced(ctx, fromState, toState, order);
+    }
+}
+
 describe('Stock control', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig(), {
             paymentOptions: {
                 paymentMethodHandlers: [testSuccessfulPaymentMethod, twoStagePaymentMethod],
             },
+            orderOptions: {
+                orderPlacedStrategy: new TestOrderPlacedStrategy(),
+            },
             customFields: {
+                Order: [
+                    {
+                        name: 'test1557',
+                        type: 'boolean',
+                        defaultValue: false,
+                    },
+                ],
                 OrderLine: [{ name: 'customization', type: 'string', nullable: true }],
             },
         }),
@@ -1171,6 +1209,73 @@ describe('Stock control', () => {
                 const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
                 expect(activeOrder!.lines.length).toBe(0);
             });
+
+            // https://github.com/vendure-ecommerce/vendure/issues/1557
+            it('cancelling an Order only creates Releases for OrderItems that have actually been allocated', async () => {
+                const product = await getProductWithStockMovement('T_2');
+                const variant6 = product!.variants.find(v => v.id === variant6Id)!;
+                expect(variant6.stockOnHand).toBe(3);
+                expect(variant6.stockAllocated).toBe(0);
+
+                await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
+                const { addItemToOrder: add1 } = await shopClient.query<
+                    AddItemToOrder.Mutation,
+                    AddItemToOrder.Variables
+                >(ADD_ITEM_TO_ORDER, {
+                    productVariantId: variant6.id,
+                    quantity: 1,
+                });
+                orderGuard.assertSuccess(add1);
+
+                // Set this flag so that our custom OrderPlacedStrategy uses the special logic
+                // designed to test this scenario.
+                const res = await shopClient.query(UPDATE_ORDER_CUSTOM_FIELDS, {
+                    input: { customFields: { test1557: true } },
+                });
+
+                await shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(
+                    SET_SHIPPING_ADDRESS,
+                    {
+                        input: {
+                            streetLine1: '1 Test Street',
+                            countryCode: 'GB',
+                        } as CreateAddressInput,
+                    },
+                );
+                await setFirstEligibleShippingMethod();
+                const { transitionOrderToState } = await shopClient.query<
+                    TransitionToState.Mutation,
+                    TransitionToState.Variables
+                >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
+                orderGuard.assertSuccess(transitionOrderToState);
+                expect(transitionOrderToState.state).toBe('ArrangingPayment');
+                expect(transitionOrderToState.active).toBe(false);
+
+                const product2 = await getProductWithStockMovement('T_2');
+                const variant6_2 = product2!.variants.find(v => v.id === variant6Id)!;
+                expect(variant6_2.stockOnHand).toBe(3);
+                expect(variant6_2.stockAllocated).toBe(0);
+
+                const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(
+                    CANCEL_ORDER,
+                    {
+                        input: {
+                            orderId: transitionOrderToState.id,
+                            lines: transitionOrderToState.lines.map(l => ({
+                                orderLineId: l.id,
+                                quantity: l.quantity,
+                            })),
+                            reason: 'Cancelled by test',
+                        },
+                    },
+                );
+                orderGuard.assertSuccess(cancelOrder);
+
+                const product3 = await getProductWithStockMovement('T_2');
+                const variant6_3 = product3!.variants.find(v => v.id === variant6Id)!;
+                expect(variant6_3.stockOnHand).toBe(3);
+                expect(variant6_3.stockAllocated).toBe(0);
+            });
         });
     });
 
@@ -1328,3 +1433,13 @@ export const TRANSITION_FULFILLMENT_TO_STATE = gql`
         }
     }
 `;
+
+export const UPDATE_ORDER_CUSTOM_FIELDS = gql`
+    mutation UpdateOrderCustomFields($input: UpdateOrderInput!) {
+        setOrderCustomFields(input: $input) {
+            ... on Order {
+                id
+            }
+        }
+    }
+`;

+ 2 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/core",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "description": "A modern, headless ecommerce framework",
   "repository": {
     "type": "git",
@@ -49,7 +49,7 @@
     "@nestjs/testing": "7.6.17",
     "@nestjs/typeorm": "7.1.5",
     "@types/fs-extra": "^9.0.1",
-    "@vendure/common": "^1.6.2",
+    "@vendure/common": "^1.6.3",
     "apollo-server-express": "2.24.1",
     "bcrypt": "^5.0.0",
     "body-parser": "^1.19.0",

+ 9 - 4
packages/core/src/api/config/generate-resolvers.ts

@@ -157,13 +157,18 @@ function generateCustomFieldRelationResolvers(
                 [ENTITY_ID_KEY]: source.id,
             };
         };
-        adminResolvers[entityName] = {
+        const resolverObject = {
             customFields: customFieldResolver,
         };
+        adminResolvers[entityName] = resolverObject;
         if (!excludeFromShopApi) {
-            shopResolvers[entityName] = {
-                customFields: customFieldResolver,
-            };
+            shopResolvers[entityName] = resolverObject;
+            if (entityName === 'ShippingMethod') {
+                shopResolvers.ShippingMethodQuote = resolverObject;
+            }
+            if (entityName === 'PaymentMethod') {
+                shopResolvers.PaymentMethodQuote = resolverObject;
+            }
         }
         for (const fieldDef of relationCustomFields) {
             if (fieldDef.internal === true) {

+ 2 - 2
packages/core/src/api/resolvers/admin/channel.resolver.ts

@@ -52,8 +52,8 @@ export class ChannelResolver {
         if (isGraphQlErrorResult(result)) {
             return result;
         }
-        const superAdminRole = await this.roleService.getSuperAdminRole();
-        const customerRole = await this.roleService.getCustomerRole();
+        const superAdminRole = await this.roleService.getSuperAdminRole(ctx);
+        const customerRole = await this.roleService.getCustomerRole(ctx);
         await this.roleService.assignRoleToChannel(ctx, superAdminRole.id, result.id);
         await this.roleService.assignRoleToChannel(ctx, customerRole.id, result.id);
         return result;

+ 2 - 2
packages/core/src/common/self-refreshing-cache.spec.ts

@@ -36,8 +36,8 @@ describe('SelfRefreshingCache', () => {
 
     it('automatically refresh after ttl expires', async () => {
         currentTime = 1001;
-        const result = await testCache.value();
-        expect(result).toBe(7);
+        const result = await testCache.value('custom');
+        expect(result).toBe(6);
         expect(fetchFn.mock.calls.length).toBe(2);
     });
 

+ 7 - 2
packages/core/src/common/self-refreshing-cache.ts

@@ -13,6 +13,7 @@ export interface SelfRefreshingCache<V, RefreshArgs extends any[] = []> {
      * the fresh value will be returned.
      */
     value(): Promise<V>;
+    value(...refreshArgs: RefreshArgs | [undefined]): Promise<V>;
 
     /**
      * @description
@@ -40,6 +41,9 @@ export interface SelfRefreshingCacheConfig<V, RefreshArgs extends any[]> {
     ttl: number;
     refresh: {
         fn: (...args: RefreshArgs) => Promise<V>;
+        /**
+         * Default arguments, passed to refresh function
+         */
         defaultArgs: RefreshArgs;
     };
     /**
@@ -61,10 +65,11 @@ export interface SelfRefreshingCacheConfig<V, RefreshArgs extends any[]> {
  */
 export async function createSelfRefreshingCache<V, RefreshArgs extends any[]>(
     config: SelfRefreshingCacheConfig<V, RefreshArgs>,
+    refreshArgs?: RefreshArgs
 ): Promise<SelfRefreshingCache<V, RefreshArgs>> {
     const { ttl, name, refresh, getTimeFn } = config;
     const getTimeNow = getTimeFn ?? (() => new Date().getTime());
-    const initialValue = await refresh.fn(...refresh.defaultArgs);
+    const initialValue = await refresh.fn(...(refreshArgs ?? refresh.defaultArgs));
     let value = initialValue;
     let expires = getTimeNow() + ttl;
     const memoCache = new Map<string, { expires: number; value: any }>();
@@ -114,7 +119,7 @@ export async function createSelfRefreshingCache<V, RefreshArgs extends any[]>(
         return result;
     };
     return {
-        value: getValue,
+        value: (...args) => getValue(!args.length || args.length === 1 && args[0] === undefined ? undefined : args as RefreshArgs),
         refresh: (...args) => refreshValue(true, args),
         memoize,
     };

+ 1 - 1
packages/core/src/config/promotion/actions/facet-values-percentage-discount-action.ts

@@ -26,7 +26,7 @@ export const discountOnItemWithFacets = new PromotionItemAction({
         facetValueChecker = new FacetValueChecker(injector.get(TransactionalConnection));
     },
     async execute(ctx, orderItem, orderLine, args) {
-        if (await facetValueChecker.hasFacetValues(orderLine, args.facets)) {
+        if (await facetValueChecker.hasFacetValues(orderLine, args.facets, ctx)) {
             const unitPrice = ctx.channel.pricesIncludeTax ? orderLine.unitPriceWithTax : orderLine.unitPrice;
             return -unitPrice * (args.discount / 100);
         }

+ 1 - 1
packages/core/src/config/promotion/conditions/has-facet-values-condition.ts

@@ -22,7 +22,7 @@ export const hasFacetValues = new PromotionCondition({
     async check(ctx, order, args) {
         let matches = 0;
         for (const line of order.lines) {
-            if (await facetValueChecker.hasFacetValues(line, args.facets)) {
+            if (await facetValueChecker.hasFacetValues(line, args.facets, ctx)) {
                 matches += line.quantity;
             }
         }

+ 3 - 2
packages/core/src/config/promotion/utils/facet-value-checker.ts

@@ -1,5 +1,6 @@
 import { ID } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
+import { RequestContext } from '../../../api';
 
 import { TtlCache } from '../../../common/ttl-cache';
 import { idsAreEqual } from '../../../common/utils';
@@ -55,11 +56,11 @@ export class FacetValueChecker {
      * `true` if the associated {@link ProductVariant} & {@link Product} together
      * have *all* the specified {@link FacetValue}s.
      */
-    async hasFacetValues(orderLine: OrderLine, facetValueIds: ID[]): Promise<boolean> {
+    async hasFacetValues(orderLine: OrderLine, facetValueIds: ID[], ctx?: RequestContext): Promise<boolean> {
         let variant = this.variantCache.get(orderLine.productVariant.id);
         if (!variant) {
             variant = await this.connection
-                .getRepository(ProductVariant)
+                .getRepository(ctx, ProductVariant)
                 .findOne(orderLine.productVariant.id, {
                     relations: ['product', 'product.facetValues', 'facetValues'],
                 });

+ 1 - 6
packages/core/src/connection/transactional-connection.ts

@@ -112,7 +112,7 @@ export class TransactionalConnection {
      * private async transferCredit(outerCtx: RequestContext, fromId: ID, toId: ID, amount: number) {
      *   await this.connection.withTransaction(outerCtx, ctx => {
      *     await this.giftCardService.updateCustomerCredit(fromId, -amount);
-     * 
+     *
      *     // Note you must not use outerCtx here, instead use ctx. Otherwise this query
      *     // will be executed outside of transaction
      *     await this.connection.getRepository(ctx, GiftCard).update(fromId, { transferred: true })
@@ -262,11 +262,6 @@ export class TransactionalConnection {
         options: FindOneOptions = {},
     ) {
         const qb = this.getRepository(ctx, entity).createQueryBuilder('entity');
-        if (options.relations) {
-            // Joining custom field relations here does not seem to work well,
-            // so we simply omit them and rely on the custom field relation resolvers.
-            options.relations = options.relations.filter(r => !r.startsWith('customFields.'));
-        }
         FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, options);
         if (options.loadEagerRelations !== false) {
             // tslint:disable-next-line:no-non-null-assertion

+ 5 - 5
packages/core/src/data-import/providers/importer/fast-importer.service.ts

@@ -66,7 +66,7 @@ export class FastImporterService {
                   channelOrToken: channel,
               })
             : RequestContext.empty();
-        this.defaultChannel = await this.channelService.getDefaultChannel();
+        this.defaultChannel = await this.channelService.getDefaultChannel(this.importCtx);
     }
 
     async createProduct(input: CreateProductInput): Promise<ID> {
@@ -95,7 +95,7 @@ export class FastImporterService {
                         position: i,
                     }),
             );
-            await this.connection.getRepository(ProductAsset).save(productAssets, { reload: false });
+            await this.connection.getRepository(this.importCtx, ProductAsset).save(productAssets, { reload: false });
         }
         return product.id;
     }
@@ -126,7 +126,7 @@ export class FastImporterService {
     async addOptionGroupToProduct(productId: ID, optionGroupId: ID) {
         this.ensureInitialized();
         await this.connection
-            .getRepository(Product)
+            .getRepository(this.importCtx, Product)
             .createQueryBuilder()
             .relation('optionGroups')
             .of(productId)
@@ -177,7 +177,7 @@ export class FastImporterService {
                         position: i,
                     }),
             );
-            await this.connection.getRepository(ProductVariantAsset).save(variantAssets, { reload: false });
+            await this.connection.getRepository(this.importCtx, ProductVariantAsset).save(variantAssets, { reload: false });
         }
         if (input.stockOnHand != null && input.stockOnHand !== 0) {
             await this.stockMovementService.adjustProductVariantStock(
@@ -194,7 +194,7 @@ export class FastImporterService {
                 channelId,
             });
             variantPrice.variant = createdVariant;
-            await this.connection.getRepository(ProductVariantPrice).save(variantPrice, { reload: false });
+            await this.connection.getRepository(this.importCtx, ProductVariantPrice).save(variantPrice, { reload: false });
         }
 
         return createdVariant.id;

+ 10 - 13
packages/core/src/data-import/providers/importer/importer.ts

@@ -314,6 +314,7 @@ export class Importer {
                 facetEntity = cachedFacet;
             } else {
                 const existing = await this.facetService.findByCode(
+                    ctx,
                     normalizeString(facetName, '-'),
                     languageCode,
                 );
@@ -344,19 +345,15 @@ export class Importer {
                 if (existing) {
                     facetValueEntity = existing;
                 } else {
-                    facetValueEntity = await this.facetValueService.create(
-                        RequestContext.empty(),
-                        facetEntity,
-                        {
-                            code: normalizeString(valueName, '-'),
-                            translations: item.translations.map(translation => {
-                                return {
-                                    languageCode: translation.languageCode,
-                                    name: translation.value,
-                                };
-                            }),
-                        },
-                    );
+                    facetValueEntity = await this.facetValueService.create(ctx, facetEntity, {
+                        code: normalizeString(valueName, '-'),
+                        translations: item.translations.map(translation => {
+                            return {
+                                languageCode: translation.languageCode,
+                                name: translation.value,
+                            };
+                        }),
+                    });
                 }
                 this.facetValueMap.set(facetValueMapKey, facetValueEntity);
             }

+ 1 - 1
packages/core/src/data-import/providers/populator/populator.ts

@@ -110,7 +110,7 @@ export class Populator {
     async populateCollections(data: InitialData, channel?: Channel) {
         const ctx = await this.createRequestContext(data, channel);
 
-        const allFacetValues = await this.facetValueService.findAll(ctx.languageCode);
+        const allFacetValues = await this.facetValueService.findAll(ctx, ctx.languageCode);
         const collectionMap = new Map<string, Collection>();
         for (const collectionDef of data.collections) {
             const parent = collectionDef.parentName && collectionMap.get(collectionDef.parentName);

+ 5 - 2
packages/core/src/plugin/default-job-queue-plugin/sql-job-buffer-storage-strategy.ts

@@ -18,7 +18,7 @@ export class SqlJobBufferStorageStrategy implements JobBufferStorageStrategy {
     }
 
     async add(bufferId: string, job: Job): Promise<Job> {
-        await this.connection.getRepository(JobRecordBuffer).save(
+        await this.connection.rawConnection.getRepository(JobRecordBuffer).save(
             new JobRecordBuffer({
                 bufferId,
                 job: this.toJobConfig(job),
@@ -30,6 +30,7 @@ export class SqlJobBufferStorageStrategy implements JobBufferStorageStrategy {
 
     async bufferSize(bufferIds?: string[]): Promise<{ [bufferId: string]: number }> {
         const qb = await this.connection
+            .rawConnection
             .getRepository(JobRecordBuffer)
             .createQueryBuilder('record')
             .select(`COUNT(*)`, 'count')
@@ -49,7 +50,9 @@ export class SqlJobBufferStorageStrategy implements JobBufferStorageStrategy {
     }
 
     async flush(bufferIds?: string[]): Promise<{ [bufferId: string]: Job[] }> {
-        const selectQb = this.connection.getRepository(JobRecordBuffer).createQueryBuilder('record');
+        const selectQb = this.connection.rawConnection
+            .getRepository(JobRecordBuffer)
+            .createQueryBuilder('record');
         if (bufferIds?.length) {
             selectQb.where(`record.bufferId IN (:...bufferIds)`, { bufferIds });
         }

+ 28 - 24
packages/core/src/plugin/default-search-plugin/indexer/indexer.controller.ts

@@ -65,13 +65,13 @@ export class IndexerController {
         const ctx = MutableRequestContext.deserialize(rawContext);
         return asyncObservable(async observer => {
             const timeStart = Date.now();
-            const qb = this.getSearchIndexQueryBuilder(ctx.channelId);
+            const qb = this.getSearchIndexQueryBuilder(ctx, ctx.channelId);
             const count = await qb.getCount();
             Logger.verbose(`Reindexing ${count} variants for channel ${ctx.channel.code}`, workerLoggerCtx);
             const batches = Math.ceil(count / BATCH_SIZE);
 
             await this.connection
-                .getRepository(SearchIndexItem)
+                .getRepository(ctx, SearchIndexItem)
                 .delete({ languageCode: ctx.languageCode, channelId: ctx.channelId });
             Logger.verbose('Deleted existing index items', workerLoggerCtx);
 
@@ -116,7 +116,7 @@ export class IndexerController {
                     const end = begin + BATCH_SIZE;
                     Logger.verbose(`Updating ids from index ${begin} to ${end}`);
                     const batchIds = ids.slice(begin, end);
-                    const batch = await this.connection.getRepository(ProductVariant).findByIds(batchIds, {
+                    const batch = await this.connection.getRepository(ctx, ProductVariant).findByIds(batchIds, {
                         relations: variantRelations,
                         where: { deletedAt: null },
                     });
@@ -154,7 +154,7 @@ export class IndexerController {
 
     async deleteVariant(data: UpdateVariantMessageData): Promise<boolean> {
         const ctx = MutableRequestContext.deserialize(data.ctx);
-        const variants = await this.connection.getRepository(ProductVariant).findByIds(data.variantIds);
+        const variants = await this.connection.getRepository(ctx, ProductVariant).findByIds(data.variantIds);
         if (variants.length) {
             const languageVariants = unique([
                 ...variants
@@ -162,6 +162,7 @@ export class IndexerController {
                     .map(t => t.languageCode),
             ]);
             await this.removeSearchIndexItems(
+                ctx,
                 ctx.channelId,
                 variants.map(v => v.id),
                 languageVariants,
@@ -187,14 +188,15 @@ export class IndexerController {
 
     async removeVariantFromChannel(data: VariantChannelMessageData): Promise<boolean> {
         const ctx = MutableRequestContext.deserialize(data.ctx);
-        const variant = await this.connection.getRepository(ProductVariant).findOne(data.productVariantId);
+        const variant = await this.connection.getRepository(ctx, ProductVariant).findOne(data.productVariantId);
         const languageVariants = variant?.translations.map(t => t.languageCode) ?? [];
-        await this.removeSearchIndexItems(data.channelId, [data.productVariantId], languageVariants);
+        await this.removeSearchIndexItems(ctx, data.channelId, [data.productVariantId], languageVariants);
         return true;
     }
 
     async updateAsset(data: UpdateAssetMessageData): Promise<boolean> {
         const id = data.asset.id;
+        const ctx = MutableRequestContext.deserialize(data.ctx);
 
         function getFocalPoint(point?: { x: number; y: number }) {
             return point && point.x && point.y ? point : null;
@@ -202,21 +204,23 @@ export class IndexerController {
 
         const focalPoint = getFocalPoint(data.asset.focalPoint);
         await this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .update({ productAssetId: id }, { productPreviewFocalPoint: focalPoint });
         await this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .update({ productVariantAssetId: id }, { productVariantPreviewFocalPoint: focalPoint });
         return true;
     }
 
     async deleteAsset(data: UpdateAssetMessageData): Promise<boolean> {
         const id = data.asset.id;
+        const ctx = MutableRequestContext.deserialize(data.ctx);
+
         await this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .update({ productAssetId: id }, { productAssetId: null });
         await this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .update({ productVariantAssetId: id }, { productVariantAssetId: null });
         return true;
     }
@@ -226,11 +230,11 @@ export class IndexerController {
         productId: ID,
         channelId: ID,
     ): Promise<boolean> {
-        const product = await this.connection.getRepository(Product).findOne(productId, {
+        const product = await this.connection.getRepository(ctx, Product).findOne(productId, {
             relations: ['variants'],
         });
         if (product) {
-            const updatedVariants = await this.connection.getRepository(ProductVariant).findByIds(
+            const updatedVariants = await this.connection.getRepository(ctx, ProductVariant).findByIds(
                 product.variants.map(v => v.id),
                 {
                     relations: variantRelations,
@@ -260,7 +264,7 @@ export class IndexerController {
         variantIds: ID[],
         channelId: ID,
     ): Promise<boolean> {
-        const variants = await this.connection.getRepository(ProductVariant).findByIds(variantIds, {
+        const variants = await this.connection.getRepository(ctx, ProductVariant).findByIds(variantIds, {
             relations: variantRelations,
             where: { deletedAt: null },
         });
@@ -276,7 +280,7 @@ export class IndexerController {
         productId: ID,
         channelId: ID,
     ): Promise<boolean> {
-        const product = await this.connection.getRepository(Product).findOne(productId, {
+        const product = await this.connection.getRepository(ctx, Product).findOne(productId, {
             relations: ['variants'],
         });
         if (product) {
@@ -289,14 +293,14 @@ export class IndexerController {
 
             const removedVariantIds = product.variants.map(v => v.id);
             if (removedVariantIds.length) {
-                await this.removeSearchIndexItems(channelId, removedVariantIds, languageVariants);
+                await this.removeSearchIndexItems(ctx, channelId, removedVariantIds, languageVariants);
             }
         }
         return true;
     }
 
-    private getSearchIndexQueryBuilder(channelId: ID) {
-        const qb = this.connection.getRepository(ProductVariant).createQueryBuilder('variants');
+    private getSearchIndexQueryBuilder(ctx: RequestContext, channelId: ID) {
+        const qb = this.connection.getRepository(ctx, ProductVariant).createQueryBuilder('variants');
         FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, {
             relations: variantRelations,
         });
@@ -316,7 +320,7 @@ export class IndexerController {
     private async saveVariants(ctx: MutableRequestContext, variants: ProductVariant[]) {
         const items: SearchIndexItem[] = [];
 
-        await this.removeSyntheticVariants(variants);
+        await this.removeSyntheticVariants(ctx, variants);
         const productMap = new Map<ID, Product>();
 
         for (const variant of variants) {
@@ -403,7 +407,7 @@ export class IndexerController {
         }
 
         await this.queue.push(() =>
-            this.connection.getRepository(SearchIndexItem).save(items, { chunk: 2500 }),
+            this.connection.getRepository(ctx, SearchIndexItem).save(items, { chunk: 2500 }),
         );
     }
 
@@ -438,17 +442,17 @@ export class IndexerController {
             collectionIds: [],
             collectionSlugs: [],
         });
-        await this.queue.push(() => this.connection.getRepository(SearchIndexItem).save(item));
+        await this.queue.push(() => this.connection.getRepository(ctx, SearchIndexItem).save(item));
     }
 
     /**
      * Removes any synthetic variants for the given product
      */
-    private async removeSyntheticVariants(variants: ProductVariant[]) {
+    private async removeSyntheticVariants(ctx: RequestContext, variants: ProductVariant[]) {
         const prodIds = unique(variants.map(v => v.productId));
         for (const productId of prodIds) {
             await this.queue.push(() =>
-                this.connection.getRepository(SearchIndexItem).delete({
+                this.connection.getRepository(ctx, SearchIndexItem).delete({
                     productId,
                     sku: '',
                     price: 0,
@@ -483,7 +487,7 @@ export class IndexerController {
     /**
      * Remove items from the search index
      */
-    private async removeSearchIndexItems(channelId: ID, variantIds: ID[], languageCodes: LanguageCode[]) {
+    private async removeSearchIndexItems(ctx: RequestContext, channelId: ID, variantIds: ID[], languageCodes: LanguageCode[]) {
         const keys: Array<Partial<SearchIndexItem>> = [];
         for (const productVariantId of variantIds) {
             for (const languageCode of languageCodes) {
@@ -494,7 +498,7 @@ export class IndexerController {
                 });
             }
         }
-        await this.queue.push(() => this.connection.getRepository(SearchIndexItem).delete(keys as any));
+        await this.queue.push(() => this.connection.getRepository(ctx, SearchIndexItem).delete(keys as any));
     }
 
     /**

+ 5 - 5
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -38,7 +38,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
         enabledOnly: boolean,
     ): Promise<Map<ID, number>> {
         const facetValuesQb = this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
             .select(['MIN(productId)', 'MIN(productVariantId)'])
             .addSelect('GROUP_CONCAT(facetValueIds)', 'facetValues');
@@ -60,7 +60,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
         enabledOnly: boolean,
     ): Promise<Map<ID, number>> {
         const collectionsQb = this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
             .select(['MIN(productId)', 'MIN(productVariantId)'])
             .addSelect('GROUP_CONCAT(collectionIds)', 'collections');
@@ -85,7 +85,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
         const skip = input.skip || 0;
         const sort = input.sort;
         const qb = this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
             .select(this.createMysqlSelect(!!input.groupByProduct));
         if (input.groupByProduct) {
@@ -122,7 +122,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
         const innerQb = this.applyTermAndFilters(
             ctx,
             this.connection
-                .getRepository(SearchIndexItem)
+                .getRepository(ctx, SearchIndexItem)
                 .createQueryBuilder('si')
                 .select(this.createMysqlSelect(!!input.groupByProduct)),
             input,
@@ -149,7 +149,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
 
         if (term && term.length > this.minTermLength) {
             const termScoreQuery = this.connection
-                .getRepository(SearchIndexItem)
+                .getRepository(ctx, SearchIndexItem)
                 .createQueryBuilder('si_inner')
                 .select('si_inner.productId', 'inner_productId')
                 .addSelect('si_inner.productVariantId', 'inner_productVariantId')

+ 4 - 4
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -38,7 +38,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
         enabledOnly: boolean,
     ): Promise<Map<ID, number>> {
         const facetValuesQb = this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
             .select(['"si"."productId"', 'MAX("si"."productVariantId")'])
             .addSelect(`string_agg("si"."facetValueIds",',')`, 'facetValues');
@@ -60,7 +60,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
         enabledOnly: boolean,
     ): Promise<Map<ID, number>> {
         const collectionsQb = this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
             .select(['"si"."productId"', 'MAX("si"."productVariantId")'])
             .addSelect(`string_agg("si"."collectionIds",',')`, 'collections');
@@ -85,7 +85,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
         const skip = input.skip || 0;
         const sort = input.sort;
         const qb = this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
             .select(this.createPostgresSelect(!!input.groupByProduct));
         if (input.groupByProduct) {
@@ -125,7 +125,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
         const innerQb = this.applyTermAndFilters(
             ctx,
             this.connection
-                .getRepository(SearchIndexItem)
+                .getRepository(ctx, SearchIndexItem)
                 .createQueryBuilder('si')
                 .select(this.createPostgresSelect(!!input.groupByProduct)),
             input,

+ 4 - 4
packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts

@@ -38,7 +38,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
         enabledOnly: boolean,
     ): Promise<Map<ID, number>> {
         const facetValuesQb = this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
             .select(['productId', 'productVariantId'])
             .addSelect('GROUP_CONCAT(si.facetValueIds)', 'facetValues');
@@ -60,7 +60,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
         enabledOnly: boolean,
     ): Promise<Map<ID, number>> {
         const collectionsQb = this.connection
-            .getRepository(SearchIndexItem)
+            .getRepository(ctx, SearchIndexItem)
             .createQueryBuilder('si')
             .select(['productId', 'productVariantId'])
             .addSelect('GROUP_CONCAT(si.collectionIds)', 'collections');
@@ -84,7 +84,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
         const take = input.take || 25;
         const skip = input.skip || 0;
         const sort = input.sort;
-        const qb = this.connection.getRepository(SearchIndexItem).createQueryBuilder('si');
+        const qb = this.connection.getRepository(ctx, SearchIndexItem).createQueryBuilder('si');
         if (input.groupByProduct) {
             qb.addSelect('MIN(price)', 'minPrice').addSelect('MAX(price)', 'maxPrice');
             qb.addSelect('MIN(priceWithTax)', 'minPriceWithTax').addSelect(
@@ -120,7 +120,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {
         const innerQb = this.applyTermAndFilters(
             ctx,
-            this.connection.getRepository(SearchIndexItem).createQueryBuilder('si'),
+            this.connection.getRepository(ctx, SearchIndexItem).createQueryBuilder('si'),
             input,
         );
 

+ 1 - 1
packages/core/src/plugin/default-search-plugin/types.ts

@@ -15,7 +15,7 @@ import { SearchStrategy } from './search-strategy/search-strategy';
 export interface DefaultSearchPluginInitOptions {
     /**
      * @description
-     * If set to `true`, the stock status of a ProductVariant (isStock: Boolean) will
+     * If set to `true`, the stock status of a ProductVariant (inStock: Boolean) will
      * be exposed in the `search` query results. Enabling this option on an existing
      * Vendure installation will require a DB migration/synchronization.
      *

+ 1 - 1
packages/core/src/service/helpers/external-authentication/external-authentication.service.ts

@@ -98,7 +98,7 @@ export class ExternalAuthenticationService {
         if (existingUser) {
             user = existingUser;
         } else {
-            const customerRole = await this.roleService.getCustomerRole();
+            const customerRole = await this.roleService.getCustomerRole(ctx);
             user = new User({
                 identifier: config.emailAddress,
                 roles: [customerRole],

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

@@ -195,7 +195,7 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
 
         const repo = extendedOptions.ctx
             ? this.connection.getRepository(extendedOptions.ctx, entity)
-            : this.connection.getRepository(entity);
+            : this.connection.rawConnection.getRepository(entity);
 
         const qb = repo.createQueryBuilder(extendedOptions.entityAlias || entity.name.toLowerCase());
         const minimumRequiredRelations = this.getMinimumRequiredRelations(repo, options, extendedOptions);
@@ -539,13 +539,13 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
                     qb1.where(`${translationsAlias}.languageCode = :languageCode`, { languageCode });
                     const defaultLanguageCode =
                         ctx?.channel.defaultLanguageCode ?? this.configService.defaultLanguageCode;
+                    const translationEntity = translationColumns[0].entityMetadata.target;
                     if (languageCode !== defaultLanguageCode) {
                         // If the current languageCode is not the default, then we create a more
                         // complex WHERE clause to allow us to use the non-default translations and
                         // fall back to the default language if no translation exists.
                         qb1.orWhere(
                             new Brackets(qb2 => {
-                                const translationEntity = translationColumns[0].entityMetadata.target;
                                 const subQb1 = this.connection.rawConnection
                                     .createQueryBuilder(translationEntity, 'translation')
                                     .where(`translation.base = ${alias}.id`)
@@ -563,7 +563,6 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
                     } else {
                         qb1.orWhere(
                             new Brackets(qb2 => {
-                                const translationEntity = translationColumns[0].entityMetadata.target;
                                 const subQb1 = this.connection.rawConnection
                                     .createQueryBuilder(translationEntity, 'translation')
                                     .where(`translation.base = ${alias}.id`)

+ 4 - 4
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -85,10 +85,10 @@ export class OrderCalculator {
                 // altered the unit prices, which in turn will alter the tax payable.
                 await this.applyTaxes(ctx, order, activeTaxZone);
             }
-            if (options?.recalculateShipping !== false) {
-                await this.applyShipping(ctx, order);
-                await this.applyShippingPromotions(ctx, order, promotions);
-            }
+        }
+        if (options?.recalculateShipping !== false) {
+            await this.applyShipping(ctx, order);
+            await this.applyShippingPromotions(ctx, order, promotions);
         }
         this.calculateOrderTotals(order);
         return taxZoneChanged ? order.getOrderItems() : Array.from(updatedOrderItems);

+ 21 - 5
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -574,12 +574,20 @@ export class OrderModifier {
         inputCustomFields: { [key: string]: any } | null | undefined,
         existingCustomFields?: { [key: string]: any },
     ): Promise<boolean> {
+        const customFieldDefs = this.configService.customFields.OrderLine;
         if (inputCustomFields == null && typeof existingCustomFields === 'object') {
             // A null value for an OrderLine customFields input is the equivalent
-            // of every property of an existing customFields object being null.
-            return Object.values(existingCustomFields).every(v => v === null);
+            // of every property of an existing customFields object being null
+            // or equal to the defaultValue
+            for (const def of customFieldDefs) {
+                const key = def.name;
+                const existingValue = existingCustomFields?.[key];
+                if (existingValue !== null && def.defaultValue && existingValue !== def.defaultValue) {
+                    return false;
+                }
+            }
+            return true;
         }
-        const customFieldDefs = this.configService.customFields.OrderLine;
 
         const customFieldRelations = customFieldDefs.filter(d => d.type === 'relation');
         let lineWithCustomFieldRelations: OrderLine | undefined;
@@ -595,12 +603,20 @@ export class OrderModifier {
 
         for (const def of customFieldDefs) {
             const key = def.name;
-            const existingValue = existingCustomFields?.[key];
+            // This ternary is there because with the MySQL driver, boolean customFields with a default
+            // of `false` were being rep-resented as `0`, thus causing the equality check to fail.
+            // So if it's a boolean, we'll explicitly coerce the value to a boolean.
+            const existingValue =
+                def.type === 'boolean' && typeof existingCustomFields?.[key] === 'number'
+                    ? !!existingCustomFields?.[key]
+                    : existingCustomFields?.[key];
             if (existingValue !== undefined) {
                 const valuesMatch =
                     JSON.stringify(inputCustomFields?.[key]) === JSON.stringify(existingValue);
                 const undefinedMatchesNull = existingValue === null && inputCustomFields?.[key] === undefined;
-                if (!valuesMatch && !undefinedMatchesNull) {
+                const defaultValueMatch =
+                    inputCustomFields?.[key] === undefined && def.defaultValue === existingValue;
+                if (!valuesMatch && !undefinedMatchesNull && !defaultValueMatch) {
                     return false;
                 }
             } else if (def.type === 'relation') {

+ 1 - 1
packages/core/src/service/initializer.service.ts

@@ -61,7 +61,7 @@ export class InitializerService {
         const delayMs = 100;
         for (let attempt = 0; attempt < retries; attempt++) {
             try {
-                const result = await this.connection.getRepository(Administrator).find();
+                const result = await this.connection.rawConnection.getRepository(Administrator).find();
                 return;
             } catch (e) {
                 if (attempt < retries - 1) {

+ 8 - 7
packages/core/src/service/services/administrator.service.ts

@@ -158,7 +158,7 @@ export class AdministratorService {
         if (input.roleIds) {
             const isSoleSuperAdmin = await this.isSoleSuperadmin(ctx, input.id);
             if (isSoleSuperAdmin) {
-                const superAdminRole = await this.roleService.getSuperAdminRole();
+                const superAdminRole = await this.roleService.getSuperAdminRole(ctx);
                 if (!input.roleIds.find(id => idsAreEqual(id, superAdminRole.id))) {
                     throw new InternalServerError('error.superadmin-must-have-superadmin-role');
                 }
@@ -234,7 +234,7 @@ export class AdministratorService {
      * with SuperAdmin permissions.
      */
     private async isSoleSuperadmin(ctx: RequestContext, id: ID) {
-        const superAdminRole = await this.roleService.getSuperAdminRole();
+        const superAdminRole = await this.roleService.getSuperAdminRole(ctx);
         const allAdmins = await this.connection.getRepository(ctx, Administrator).find({
             relations: ['user', 'user.roles'],
         });
@@ -258,7 +258,7 @@ export class AdministratorService {
     private async ensureSuperAdminExists() {
         const { superadminCredentials } = this.configService.authOptions;
 
-        const superAdminUser = await this.connection.getRepository(User).findOne({
+        const superAdminUser = await this.connection.rawConnection.getRepository(User).findOne({
             where: {
                 identifier: superadminCredentials.identifier,
             },
@@ -274,7 +274,7 @@ export class AdministratorService {
                 roleIds: [superAdminRole.id],
             });
         } else {
-            const superAdministrator = await this.connection.getRepository(Administrator).findOne({
+            const superAdministrator = await this.connection.rawConnection.getRepository(Administrator).findOne({
                 where: {
                     user: superAdminUser,
                 },
@@ -286,18 +286,19 @@ export class AdministratorService {
                     lastName: 'Admin',
                 });
                 const createdAdministrator = await this.connection
+                    .rawConnection
                     .getRepository(Administrator)
                     .save(administrator);
                 createdAdministrator.user = superAdminUser;
-                await this.connection.getRepository(Administrator).save(createdAdministrator);
+                await this.connection.rawConnection.getRepository(Administrator).save(createdAdministrator);
             } else if (superAdministrator.deletedAt != null) {
                 superAdministrator.deletedAt = null;
-                await this.connection.getRepository(Administrator).save(superAdministrator);
+                await this.connection.rawConnection.getRepository(Administrator).save(superAdministrator);
             }
 
             if (superAdminUser.deletedAt != null) {
                 superAdminUser.deletedAt = null;
-                await this.connection.getRepository(User).save(superAdminUser);
+                await this.connection.rawConnection.getRepository(User).save(superAdminUser);
             }
         }
     }

+ 35 - 16
packages/core/src/service/services/channel.service.ts

@@ -23,6 +23,7 @@ import { ConfigService } from '../../config/config.service';
 import { TransactionalConnection } from '../../connection/transactional-connection';
 import { VendureEntity } from '../../entity/base/base.entity';
 import { Channel } from '../../entity/channel/channel.entity';
+import { Order } from '../../entity/order/order.entity';
 import { ProductVariantPrice } from '../../entity/product-variant/product-variant-price.entity';
 import { Session } from '../../entity/session/session.entity';
 import { Zone } from '../../entity/zone/zone.entity';
@@ -64,7 +65,7 @@ export class ChannelService {
     }
 
     /**
-     * Creates a channels cache, that can be used to reduce number of channel queries to database 
+     * Creates a channels cache, that can be used to reduce number of channel queries to database
      *
      * @internal
      */
@@ -85,7 +86,7 @@ export class ChannelService {
         entity: T,
         ctx: RequestContext,
     ): Promise<T> {
-        const defaultChannel = await this.getDefaultChannel();
+        const defaultChannel = await this.getDefaultChannel(ctx);
         const channelIds = unique([ctx.channelId, defaultChannel.id]);
         entity.channels = channelIds.map(id => ({ id })) as any;
         this.eventBus.publish(new ChangeChannelEvent(ctx, entity, [ctx.channelId], 'assigned'));
@@ -102,8 +103,16 @@ export class ChannelService {
         entityId: ID,
         channelIds: ID[],
     ): Promise<T> {
+        const relations = ['channels'];
+        // This is a work-around for https://github.com/vendure-ecommerce/vendure/issues/1391
+        // A better API would be to allow the consumer of this method to supply an entity instance
+        // so that this join could be done prior to invoking this method.
+        // TODO: overload the assignToChannels method to allow it to take an entity instance
+        if (entityType === (Order as any)) {
+            relations.push('lines', 'shippingLines');
+        }
         const entity = await this.connection.getEntityOrThrow(ctx, entityType, entityId, {
-            relations: ['channels'],
+            relations,
         });
         for (const id of channelIds) {
             const channel = await this.connection.getEntityOrThrow(ctx, Channel, id);
@@ -137,21 +146,27 @@ export class ChannelService {
         this.eventBus.publish(new ChangeChannelEvent(ctx, entity, channelIds, 'removed', entityType));
         return entity;
     }
-
     /**
      * @description
      * Given a channel token, returns the corresponding Channel if it exists, else will throw
      * a {@link ChannelNotFoundError}.
      */
-    async getChannelFromToken(token: string): Promise<Channel> {
-        const allChannels = await this.allChannels.value();
-        if (allChannels.length === 1 || token === '') {
+    async getChannelFromToken(token: string): Promise<Channel>;
+    async getChannelFromToken(ctx: RequestContext, token: string): Promise<Channel>;
+    async getChannelFromToken(ctxOrToken: RequestContext | string, token?: string): Promise<Channel> {
+        const [ctx, channelToken] =
+            // tslint:disable-next-line:no-non-null-assertion
+            ctxOrToken instanceof RequestContext ? [ctxOrToken, token!] : [undefined, ctxOrToken];
+
+        const allChannels = await this.allChannels.value(ctx);
+
+        if (allChannels.length === 1 || channelToken === '') {
             // there is only the default channel, so return it
-            return this.getDefaultChannel();
+            return this.getDefaultChannel(ctx);
         }
-        const channel = allChannels.find(c => c.token === token);
+        const channel = allChannels.find(c => c.token === channelToken);
         if (!channel) {
-            throw new ChannelNotFoundError(token);
+            throw new ChannelNotFoundError(channelToken);
         }
         return channel;
     }
@@ -160,8 +175,8 @@ export class ChannelService {
      * @description
      * Returns the default Channel.
      */
-    async getDefaultChannel(): Promise<Channel> {
-        const allChannels = await this.allChannels.value();
+    async getDefaultChannel(ctx?: RequestContext): Promise<Channel> {
+        const allChannels = await this.allChannels.value(ctx);
         const defaultChannel = allChannels.find(channel => channel.code === DEFAULT_CHANNEL_CODE);
 
         if (!defaultChannel) {
@@ -277,7 +292,7 @@ export class ChannelService {
      */
     private async ensureCacheExists() {
         if (this.allChannels) {
-            return
+            return;
         }
 
         this.allChannels = await this.createCache();
@@ -289,7 +304,7 @@ export class ChannelService {
      */
     private async ensureDefaultChannelExists() {
         const { defaultChannelToken } = this.configService;
-        const defaultChannel = await this.connection.getRepository(Channel).findOne({
+        const defaultChannel = await this.connection.rawConnection.getRepository(Channel).findOne({
             where: {
                 code: DEFAULT_CHANNEL_CODE,
             },
@@ -303,10 +318,14 @@ export class ChannelService {
                 currencyCode: CurrencyCode.USD,
                 token: defaultChannelToken,
             });
-            await this.connection.getRepository(Channel).save(newDefaultChannel, { reload: false });
+            await this.connection.rawConnection
+                .getRepository(Channel)
+                .save(newDefaultChannel, { reload: false });
         } else if (defaultChannelToken && defaultChannel.token !== defaultChannelToken) {
             defaultChannel.token = defaultChannelToken;
-            await this.connection.getRepository(Channel).save(defaultChannel, { reload: false });
+            await this.connection.rawConnection
+                .getRepository(Channel)
+                .save(defaultChannel, { reload: false });
         }
     }
 

+ 8 - 4
packages/core/src/service/services/collection.service.ts

@@ -90,6 +90,7 @@ export class CollectionService implements OnModuleInit {
             .pipe(debounceTime(50))
             .subscribe(async event => {
                 const collections = await this.connection
+                    .rawConnection
                     .getRepository(Collection)
                     .createQueryBuilder('collection')
                     .select('collection.id', 'id')
@@ -352,7 +353,7 @@ export class CollectionService implements OnModuleInit {
         const ancestors = await getParent(collectionId);
 
         return this.connection
-            .getRepository(Collection)
+            .getRepository(ctx, Collection)
             .findByIds(ancestors.map(c => c.id))
             .then(categories => {
                 const resultCategories: Array<Collection | Translated<Collection>> = [];
@@ -562,6 +563,7 @@ export class CollectionService implements OnModuleInit {
         const postIds = collection.productVariants.map(v => v.id);
         try {
             await this.connection
+                .rawConnection
                 .getRepository(Collection)
                 // Only update the exact changed properties, to avoid VERY hard-to-debug
                 // non-deterministic race conditions e.g. when the "position" is changed
@@ -592,7 +594,9 @@ export class CollectionService implements OnModuleInit {
             return [];
         }
         const { collectionFilters } = this.configService.catalogOptions;
-        let qb = this.connection.getRepository(ProductVariant).createQueryBuilder('productVariant');
+        let qb = this.connection.rawConnection
+            .getRepository(ProductVariant)
+            .createQueryBuilder('productVariant');
 
         for (const filterType of collectionFilters) {
             const filtersOfType = filters.filter(f => f.code === filterType.code);
@@ -680,7 +684,7 @@ export class CollectionService implements OnModuleInit {
         // We purposefully do not use the ctx in saving the new root Collection
         // so that even if the outer transaction fails, the root collection will still
         // get persisted.
-        const rootTranslation = await this.connection.getRepository(CollectionTranslation).save(
+        const rootTranslation = await this.connection.rawConnection.getRepository(CollectionTranslation).save(
             new CollectionTranslation({
                 languageCode: this.configService.defaultLanguageCode,
                 name: ROOT_COLLECTION_NAME,
@@ -689,7 +693,7 @@ export class CollectionService implements OnModuleInit {
             }),
         );
 
-        const newRoot = await this.connection.getRepository(Collection).save(
+        const newRoot = await this.connection.rawConnection.getRepository(Collection).save(
             new Collection({
                 isRoot: true,
                 position: 0,

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

@@ -235,7 +235,7 @@ export class CustomerService {
             // Customer already exists, bring to this Channel
             const updatedCustomer = patchEntity(existingCustomer, input);
             updatedCustomer.channels.push(ctx.channel);
-            return this.connection.getRepository(Customer).save(updatedCustomer);
+            return this.connection.getRepository(ctx, Customer).save(updatedCustomer);
         } else if (existingCustomer || existingUser) {
             // Not sure when this situation would occur
             return new EmailAddressConflictAdminError();

+ 12 - 4
packages/core/src/service/services/facet-value.service.ts

@@ -43,13 +43,21 @@ export class FacetValueService {
         private eventBus: EventBus,
     ) {}
 
-    findAll(lang: LanguageCode): Promise<Array<Translated<FacetValue>>> {
-        return this.connection
-            .getRepository(FacetValue)
+    /**
+     * @deprecated Use {@link FacetValueService.findAll findAll(ctx, lang)} instead
+     */
+    findAll(lang: LanguageCode): Promise<Array<Translated<FacetValue>>>;
+    findAll(ctx: RequestContext, lang: LanguageCode): Promise<Array<Translated<FacetValue>>>;
+    findAll(ctxOrLang: RequestContext | LanguageCode, lang?: LanguageCode): Promise<Array<Translated<FacetValue>>> {
+        const [repository, languageCode] = ctxOrLang instanceof RequestContext 
+            ? [this.connection.getRepository(ctxOrLang, FacetValue), lang!]
+            : [this.connection.rawConnection.getRepository(FacetValue), ctxOrLang];
+
+        return repository
             .find({
                 relations: ['facet'],
             })
-            .then(facetValues => facetValues.map(facetValue => translateDeep(facetValue, lang, ['facet'])));
+            .then(facetValues => facetValues.map(facetValue => translateDeep(facetValue, languageCode, ['facet'])));
     }
 
     findOne(ctx: RequestContext, id: ID): Promise<Translated<FacetValue> | undefined> {

+ 22 - 10
packages/core/src/service/services/facet.service.ts

@@ -81,17 +81,29 @@ export class FacetService {
             .then(facet => facet && translateDeep(facet, ctx.languageCode, ['values', ['values', 'facet']]));
     }
 
-    findByCode(facetCode: string, lang: LanguageCode): Promise<Translated<Facet> | undefined> {
+    /**
+     * @deprecated Use {@link FacetService.findByCode findByCode(ctx, facetCode, lang)} instead
+     */
+    findByCode(facetCode: string, lang: LanguageCode): Promise<Translated<Facet> | undefined>;
+    findByCode(ctx: RequestContext, facetCode: string, lang: LanguageCode): Promise<Translated<Facet> | undefined>;
+    findByCode(
+        ctxOrFacetCode: RequestContext | string, 
+        facetCodeOrLang: string | LanguageCode, 
+        lang?: LanguageCode
+    ): Promise<Translated<Facet> | undefined> {
         const relations = ['values', 'values.facet'];
-        return this.connection
-            .getRepository(Facet)
-            .findOne({
-                where: {
-                    code: facetCode,
-                },
-                relations,
-            })
-            .then(facet => facet && translateDeep(facet, lang, ['values', ['values', 'facet']]));
+        const [repository, facetCode, languageCode] = ctxOrFacetCode instanceof RequestContext 
+            ? [this.connection.getRepository(ctxOrFacetCode, Facet), facetCodeOrLang, lang!]
+            : [this.connection.rawConnection.getRepository(Facet), ctxOrFacetCode, facetCodeOrLang as LanguageCode];
+
+
+        return repository.findOne({
+            where: {
+                code: facetCode,
+            },
+            relations,
+        })
+        .then(facet => facet && translateDeep(facet, languageCode, ['values', ['values', 'facet']]));
     }
 
     /**

+ 3 - 3
packages/core/src/service/services/global-settings.service.ts

@@ -32,20 +32,20 @@ export class GlobalSettingsService {
      */
     async initGlobalSettings() {
         try {
-            const result = await this.connection.getRepository(GlobalSettings).find();
+            const result = await this.connection.rawConnection.getRepository(GlobalSettings).find();
             if (result.length === 0) {
                 throw new Error('No global settings');
             }
             if (1 < result.length) {
                 // Strange edge case, see https://github.com/vendure-ecommerce/vendure/issues/987
                 const toDelete = result.slice(1);
-                await this.connection.getRepository(GlobalSettings).remove(toDelete);
+                await this.connection.rawConnection.getRepository(GlobalSettings).remove(toDelete);
             }
         } catch (err) {
             const settings = new GlobalSettings({
                 availableLanguages: [this.configService.defaultLanguageCode],
             });
-            await this.connection.getRepository(GlobalSettings).save(settings, { reload: false });
+            await this.connection.rawConnection.getRepository(GlobalSettings).save(settings, { reload: false });
         }
     }
 

+ 25 - 4
packages/core/src/service/services/order.service.ts

@@ -89,6 +89,7 @@ import { ProductVariant } from '../../entity/product-variant/product-variant.ent
 import { Promotion } from '../../entity/promotion/promotion.entity';
 import { Refund } from '../../entity/refund/refund.entity';
 import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
+import { Allocation } from '../../entity/stock-movement/allocation.entity';
 import { Surcharge } from '../../entity/surcharge/surcharge.entity';
 import { User } from '../../entity/user/user.entity';
 import { EventBus } from '../../event-bus/event-bus';
@@ -908,10 +909,10 @@ export class OrderService {
      * * Shipping or billing address changes
      *
      * Setting the `dryRun` input property to `true` will apply all changes, including updating the price of the
-     * Order, except history entry and additional payment actions. 
-     * 
+     * Order, except history entry and additional payment actions.
+     *
      * __Using dryRun option, you must wrap function call in transaction manually.__
-     * 
+     *
      */
     async modifyOrder(
         ctx: RequestContext,
@@ -1320,7 +1321,7 @@ export class OrderService {
         const fullOrder = await this.findOne(ctx, order.id);
 
         const soldItems = items.filter(i => !!i.fulfillment);
-        const allocatedItems = items.filter(i => !i.fulfillment);
+        const allocatedItems = await this.getAllocatedItems(ctx, items);
         await this.stockMovementService.createCancellationsForOrderItems(ctx, soldItems);
         await this.stockMovementService.createReleasesForOrderItems(ctx, allocatedItems);
         items.forEach(i => (i.cancelled = true));
@@ -1358,6 +1359,26 @@ export class OrderService {
         return orderItemsAreAllCancelled(orderWithItems);
     }
 
+    private async getAllocatedItems(ctx: RequestContext, items: OrderItem[]): Promise<OrderItem[]> {
+        const allocatedItems: OrderItem[] = [];
+        const allocationMap = new Map<ID, Allocation | false>();
+        for (const item of items) {
+            let allocation = allocationMap.get(item.lineId);
+            if (!allocation) {
+                allocation = await this.connection
+                    .getRepository(ctx, Allocation)
+                    .createQueryBuilder('allocation')
+                    .where('allocation.orderLine = :lineId', { lineId: item.lineId })
+                    .getOne();
+                allocationMap.set(item.lineId, allocation || false);
+            }
+            if (allocation && !item.fulfillment) {
+                allocatedItems.push(item);
+            }
+        }
+        return allocatedItems;
+    }
+
     /**
      * @description
      * Creates a {@link Refund} against the order and in doing so invokes the `createRefund()` method of the

+ 2 - 2
packages/core/src/service/services/product-variant.service.ts

@@ -424,7 +424,7 @@ export class ProductVariantService {
             );
         }
 
-        const defaultChannelId = (await this.channelService.getDefaultChannel()).id;
+        const defaultChannelId = (await this.channelService.getDefaultChannel(ctx)).id;
         await this.createOrUpdateProductVariantPrice(ctx, createdVariant.id, input.price, ctx.channelId);
         if (!idsAreEqual(ctx.channelId, defaultChannelId)) {
             // When creating a ProductVariant _not_ in the default Channel, we still need to
@@ -689,7 +689,7 @@ export class ProductVariantService {
         if (!hasPermission) {
             throw new ForbiddenError();
         }
-        const defaultChannel = await this.channelService.getDefaultChannel();
+        const defaultChannel = await this.channelService.getDefaultChannel(ctx);
         if (idsAreEqual(input.channelId, defaultChannel.id)) {
             throw new UserInputError('error.products-cannot-be-removed-from-default-channel');
         }

+ 3 - 3
packages/core/src/service/services/promotion.service.ts

@@ -177,7 +177,7 @@ export class PromotionService {
         ctx: RequestContext,
         input: AssignPromotionsToChannelInput,
     ): Promise<Promotion[]> {
-        const defaultChannel = await this.channelService.getDefaultChannel();
+        const defaultChannel = await this.channelService.getDefaultChannel(ctx);
         if (!idsAreEqual(ctx.channelId, defaultChannel.id)) {
             throw new IllegalOperationError(`promotion-channels-can-only-be-changed-from-default-channel`);
         }
@@ -195,7 +195,7 @@ export class PromotionService {
     }
 
     async removePromotionsFromChannel(ctx: RequestContext, input: RemovePromotionsFromChannelInput) {
-        const defaultChannel = await this.channelService.getDefaultChannel();
+        const defaultChannel = await this.channelService.getDefaultChannel(ctx);
         if (!idsAreEqual(ctx.channelId, defaultChannel.id)) {
             throw new IllegalOperationError(`promotion-channels-can-only-be-changed-from-default-channel`);
         }
@@ -230,7 +230,7 @@ export class PromotionService {
                 deletedAt: null,
             },
         });
-        if (!promotion) {
+        if (!promotion || promotion.couponCode !== couponCode) {
             return new CouponCodeInvalidError(couponCode);
         }
         if (promotion.endsAt && +promotion.endsAt < +new Date()) {

+ 13 - 9
packages/core/src/service/services/role.service.ts

@@ -89,8 +89,8 @@ export class RoleService {
      * @description
      * Returns the special SuperAdmin Role, which always exists in Vendure.
      */
-    getSuperAdminRole(): Promise<Role> {
-        return this.getRoleByCode(SUPER_ADMIN_ROLE_CODE).then(role => {
+    getSuperAdminRole(ctx?: RequestContext): Promise<Role> {
+        return this.getRoleByCode(ctx, SUPER_ADMIN_ROLE_CODE).then(role => {
             if (!role) {
                 throw new InternalServerError(`error.super-admin-role-not-found`);
             }
@@ -102,8 +102,8 @@ export class RoleService {
      * @description
      * Returns the special Customer Role, which always exists in Vendure.
      */
-    getCustomerRole(): Promise<Role> {
-        return this.getRoleByCode(CUSTOMER_ROLE_CODE).then(role => {
+    getCustomerRole(ctx?: RequestContext): Promise<Role> {
+        return this.getRoleByCode(ctx, CUSTOMER_ROLE_CODE).then(role => {
             if (!role) {
                 throw new InternalServerError(`error.customer-role-not-found`);
             }
@@ -228,8 +228,12 @@ export class RoleService {
         }
     }
 
-    private getRoleByCode(code: string): Promise<Role | undefined> {
-        return this.connection.getRepository(Role).findOne({
+    private getRoleByCode(ctx: RequestContext | undefined, code: string) {
+        const repository = ctx 
+            ? this.connection.getRepository(ctx, Role) 
+            : this.connection.rawConnection.getRepository(Role);
+
+        return repository.findOne({
             where: { code },
         });
     }
@@ -242,7 +246,7 @@ export class RoleService {
         try {
             const superAdminRole = await this.getSuperAdminRole();
             superAdminRole.permissions = assignablePermissions;
-            await this.connection.getRepository(Role).save(superAdminRole, { reload: false });
+            await this.connection.rawConnection.getRepository(Role).save(superAdminRole, { reload: false });
         } catch (err) {
             const defaultChannel = await this.channelService.getDefaultChannel();
             await this.createRoleForChannels(
@@ -284,13 +288,13 @@ export class RoleService {
      * permissions are removed from those Roles.
      */
     private async ensureRolesHaveValidPermissions() {
-        const roles = await this.connection.getRepository(Role).find();
+        const roles = await this.connection.rawConnection.getRepository(Role).find();
         const assignablePermissions = this.getAllAssignablePermissions();
         for (const role of roles) {
             const invalidPermissions = role.permissions.filter(p => !assignablePermissions.includes(p));
             if (invalidPermissions.length) {
                 role.permissions = role.permissions.filter(p => assignablePermissions.includes(p));
-                await this.connection.getRepository(Role).save(role);
+                await this.connection.rawConnection.getRepository(Role).save(role);
             }
         }
     }

+ 7 - 7
packages/core/src/service/services/session.service.ts

@@ -113,7 +113,7 @@ export class SessionService implements EntitySubscriberInterface {
             invalidated: false,
         });
         // save the new session
-        const newSession = await this.connection.getRepository(AnonymousSession).save(session);
+        const newSession = await this.connection.rawConnection.getRepository(AnonymousSession).save(session);
         const serializedSession = this.serializeSession(newSession);
         await this.sessionCacheStrategy.set(serializedSession);
         return serializedSession;
@@ -172,7 +172,7 @@ export class SessionService implements EntitySubscriberInterface {
      * Looks for a valid session with the given token and returns one if found.
      */
     private async findSessionByToken(token: string): Promise<Session | undefined> {
-        const session = await this.connection
+        const session = await this.connection.rawConnection
             .getRepository(Session)
             .createQueryBuilder('session')
             .leftJoinAndSelect('session.user', 'user')
@@ -198,7 +198,7 @@ export class SessionService implements EntitySubscriberInterface {
         order: Order,
     ): Promise<CachedSession> {
         const session = await this.connection
-            .getRepository(Session)
+            .getRepository(ctx, Session)
             .findOne(serializedSession.id, { relations: ['user', 'user.roles', 'user.roles.channels'] });
         if (session) {
             session.activeOrder = order;
@@ -217,7 +217,7 @@ export class SessionService implements EntitySubscriberInterface {
     async unsetActiveOrder(ctx: RequestContext, serializedSession: CachedSession): Promise<CachedSession> {
         if (serializedSession.activeOrderId) {
             const session = await this.connection
-                .getRepository(Session)
+                .getRepository(ctx, Session)
                 .findOne(serializedSession.id, { relations: ['user', 'user.roles', 'user.roles.channels'] });
             if (session) {
                 session.activeOrder = null;
@@ -235,12 +235,12 @@ export class SessionService implements EntitySubscriberInterface {
      * Sets the `activeChannel` on the given cached session object and updates the cache.
      */
     async setActiveChannel(serializedSession: CachedSession, channel: Channel): Promise<CachedSession> {
-        const session = await this.connection
+        const session = await this.connection.rawConnection
             .getRepository(Session)
             .findOne(serializedSession.id, { relations: ['user', 'user.roles', 'user.roles.channels'] });
         if (session) {
             session.activeChannel = channel;
-            await this.connection.getRepository(Session).save(session, { reload: false });
+            await this.connection.rawConnection.getRepository(Session).save(session, { reload: false });
             const updatedSerializedSession = this.serializeSession(session);
             await this.sessionCacheStrategy.set(updatedSerializedSession);
             return updatedSerializedSession;
@@ -285,7 +285,7 @@ export class SessionService implements EntitySubscriberInterface {
         if (session.expires.getTime() - now < this.sessionDurationInMs / 2) {
             const newExpiryDate = this.getExpiryDate(this.sessionDurationInMs);
             session.expires = newExpiryDate;
-            await this.connection
+            await this.connection.rawConnection
                 .getRepository(Session)
                 .update({ id: session.id }, { expires: newExpiryDate });
         }

+ 2 - 2
packages/core/src/service/services/shipping-method.service.ts

@@ -212,7 +212,7 @@ export class ShippingMethodService {
      * Ensures that all ShippingMethods have a valid fulfillmentHandlerCode
      */
     private async verifyShippingMethods() {
-        const activeShippingMethods = await this.connection.getRepository(ShippingMethod).find({
+        const activeShippingMethods = await this.connection.rawConnection.getRepository(ShippingMethod).find({
             where: { deletedAt: null },
         });
         for (const method of activeShippingMethods) {
@@ -220,7 +220,7 @@ export class ShippingMethodService {
             const verifiedHandlerCode = this.ensureValidFulfillmentHandlerCode(method.code, handlerCode);
             if (handlerCode !== verifiedHandlerCode) {
                 method.fulfillmentHandlerCode = verifiedHandlerCode;
-                await this.connection.getRepository(ShippingMethod).save(method);
+                await this.connection.rawConnection.getRepository(ShippingMethod).save(method);
             }
         }
     }

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

@@ -69,7 +69,7 @@ export class UserService {
     ): Promise<User | PasswordValidationError> {
         const user = new User();
         user.identifier = identifier;
-        const customerRole = await this.roleService.getCustomerRole();
+        const customerRole = await this.roleService.getCustomerRole(ctx);
         user.roles = [customerRole];
         const addNativeAuthResult = await this.addNativeAuthenticationMethod(ctx, user, identifier, password);
         if (isGraphQlErrorResult(addNativeAuthResult)) {

+ 3 - 3
packages/create/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/create",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "license": "MIT",
   "bin": {
     "create": "./index.js"
@@ -28,13 +28,13 @@
     "@types/handlebars": "^4.1.0",
     "@types/listr": "^0.14.2",
     "@types/semver": "^6.2.2",
-    "@vendure/core": "^1.6.2",
+    "@vendure/core": "^1.6.3",
     "rimraf": "^3.0.2",
     "ts-node": "^10.2.1",
     "typescript": "4.3.5"
   },
   "dependencies": {
-    "@vendure/common": "^1.6.2",
+    "@vendure/common": "^1.6.3",
     "chalk": "^4.1.0",
     "commander": "^7.1.0",
     "cross-spawn": "^7.0.3",

+ 9 - 9
packages/dev-server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "dev-server",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "main": "index.js",
   "license": "MIT",
   "private": true,
@@ -14,18 +14,18 @@
     "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"
   },
   "dependencies": {
-    "@vendure/admin-ui-plugin": "^1.6.2",
-    "@vendure/asset-server-plugin": "^1.6.2",
-    "@vendure/common": "^1.6.2",
-    "@vendure/core": "^1.6.2",
-    "@vendure/elasticsearch-plugin": "^1.6.2",
-    "@vendure/email-plugin": "^1.6.2",
+    "@vendure/admin-ui-plugin": "^1.6.3",
+    "@vendure/asset-server-plugin": "^1.6.3",
+    "@vendure/common": "^1.6.3",
+    "@vendure/core": "^1.6.3",
+    "@vendure/elasticsearch-plugin": "^1.6.3",
+    "@vendure/email-plugin": "^1.6.3",
     "typescript": "4.3.5"
   },
   "devDependencies": {
     "@types/csv-stringify": "^3.1.0",
-    "@vendure/testing": "^1.6.2",
-    "@vendure/ui-devkit": "^1.6.2",
+    "@vendure/testing": "^1.6.3",
+    "@vendure/ui-devkit": "^1.6.3",
     "commander": "^7.1.0",
     "concurrently": "^5.0.0",
     "csv-stringify": "^5.3.3",

+ 63 - 0
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -4,6 +4,7 @@ import { pick } from '@vendure/common/lib/pick';
 import {
     DefaultJobQueuePlugin,
     DefaultLogger,
+    FacetValue,
     facetValueCollectionFilter,
     LogLevel,
     mergeConfig,
@@ -39,6 +40,8 @@ import {
     UpdateCollection,
     UpdateProduct,
     UpdateProductVariants,
+    UpdateProductVariantsMutation,
+    UpdateProductVariantsMutationVariables,
     UpdateTaxRate,
 } from '../../core/e2e/graphql/generated-e2e-admin-types';
 import { SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types';
@@ -115,11 +118,21 @@ const INDEX_PREFIX = 'e2e-tests';
 describe('Elasticsearch plugin', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig(), {
+            customFields: {
+                ProductVariant: [
+                    {
+                        name: 'material',
+                        type: 'relation',
+                        entity: FacetValue,
+                    },
+                ],
+            },
             plugins: [
                 ElasticsearchPlugin.init({
                     indexPrefix: INDEX_PREFIX,
                     port: elasticsearchPort,
                     host: elasticsearchHost,
+                    hydrateProductVariantRelations: ['customFields.material'],
                     customProductVariantMappings: {
                         inStock: {
                             graphQlType: 'Boolean!',
@@ -127,6 +140,19 @@ describe('Elasticsearch plugin', () => {
                                 return variant.stockOnHand > 0;
                             },
                         },
+                        materialCode: {
+                            graphQlType: 'String!',
+                            valueFn: variant => {
+                                const materialFacetValue: FacetValue | undefined = (
+                                    variant.customFields as any
+                                ).material;
+                                let result = '';
+                                if (materialFacetValue) {
+                                    result = `${materialFacetValue.code}`;
+                                }
+                                return result;
+                            },
+                        },
                     },
                     customProductMappings: {
                         answer: {
@@ -1347,6 +1373,43 @@ describe('Elasticsearch plugin', () => {
             });
         });
 
+        // https://github.com/vendure-ecommerce/vendure/issues/1638
+        it('hydrates variant custom field relation', async () => {
+            const { updateProductVariants } = await adminClient.query<
+                UpdateProductVariantsMutation,
+                UpdateProductVariantsMutationVariables
+            >(UPDATE_PRODUCT_VARIANTS, {
+                input: [
+                    {
+                        id: 'T_20',
+                        customFields: {
+                            materialId: 'T_1',
+                        },
+                    },
+                ],
+            });
+            expect(updateProductVariants[0]!.id).toBe('T_20');
+
+            await adminClient.query<Reindex.Mutation>(REINDEX);
+            await awaitRunningJobs(adminClient);
+            const query = `{
+            search(input: { groupByProduct: false, term: "tripod" }) {
+                items {
+                  productVariantName
+                  productVariantId
+                  customMappings {
+                    ...on CustomProductVariantMappings {
+                      materialCode
+                    }
+                  }
+                }
+              }
+            }`;
+            const { search } = await shopClient.query(gql(query));
+
+            expect(search.items.map((i: any) => i.customMappings)).toEqual([{ materialCode: 'electronics' }]);
+        });
+
         it('product mappings', async () => {
             const query = `{
             search(input: { take: 1, groupByProduct: true, sort: { name: ASC } }) {

+ 3 - 3
packages/elasticsearch-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/elasticsearch-plugin",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -25,8 +25,8 @@
     "fast-deep-equal": "^3.1.3"
   },
   "devDependencies": {
-    "@vendure/common": "^1.6.2",
-    "@vendure/core": "^1.6.2",
+    "@vendure/common": "^1.6.3",
+    "@vendure/core": "^1.6.3",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"
   }

+ 16 - 4
packages/elasticsearch-plugin/src/indexing/indexer.controller.ts

@@ -11,6 +11,7 @@ import {
     EntityRelationPaths,
     FacetValue,
     ID,
+    InternalServerError,
     LanguageCode,
     Logger,
     Product,
@@ -96,11 +97,11 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
 
     onModuleInit(): any {
         this.client = getClient(this.options);
-        this.productRelations = this.getReindexRelationsRelations(
+        this.productRelations = this.getReindexRelations(
             defaultProductRelations,
             this.options.hydrateProductRelations,
         );
-        this.variantRelations = this.getReindexRelationsRelations(
+        this.variantRelations = this.getReindexRelations(
             defaultVariantRelations,
             this.options.hydrateProductVariantRelations,
         );
@@ -633,13 +634,24 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
      * throw an error trying to join a 2nd-level deep relation without the first level also
      * being joined.
      */
-    private getReindexRelationsRelations<T extends Product | ProductVariant>(
+    private getReindexRelations<T extends Product | ProductVariant>(
         defaultRelations: Array<EntityRelationPaths<T>>,
         hydratedRelations: Array<EntityRelationPaths<T>>,
     ): Array<EntityRelationPaths<T>> {
         const uniqueRelations = unique([...defaultRelations, ...hydratedRelations]);
         for (const relation of hydratedRelations) {
-            const path = relation.split('.');
+            let path = relation.split('.');
+            if (path[0] === 'customFields') {
+                if (2 < path.length) {
+                    throw new InternalServerError(
+                        [
+                            `hydrateProductRelations / hydrateProductVariantRelations does not currently support nested custom field relations`,
+                            `Received: "${relation}"`,
+                        ].join('\n'),
+                    );
+                }
+                path = [path.join('.')];
+            }
             const pathToPart: string[] = [];
             for (const part of path) {
                 pathToPart.push(part);

+ 1 - 1
packages/elasticsearch-plugin/src/options.ts

@@ -275,7 +275,7 @@ export interface ElasticsearchOptions {
      *       graphQlType: '[String!]',
      *       // Here we can be sure that the `product.assets` array is populated
      *       // with an Asset object
-     *       valueFn: (product) => product.assets.map(asset => asset.preview),
+     *       valueFn: (product) => product.assets.map(a => a.asset.preview),
      *     }
      *   }
      * }

+ 3 - 3
packages/email-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/email-plugin",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -35,8 +35,8 @@
     "@types/fs-extra": "^9.0.1",
     "@types/handlebars": "^4.1.0",
     "@types/mjml": "^4.0.4",
-    "@vendure/common": "^1.6.2",
-    "@vendure/core": "^1.6.2",
+    "@vendure/common": "^1.6.3",
+    "@vendure/core": "^1.6.3",
     "rimraf": "^3.0.2",
     "typescript": "4.3.5"
   }

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

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/job-queue-plugin",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "license": "MIT",
   "main": "package/index.js",
   "types": "package/index.d.ts",
@@ -24,8 +24,8 @@
   "devDependencies": {
     "@google-cloud/pubsub": "^2.8.0",
     "@types/redis": "^2.8.28",
-    "@vendure/common": "^1.6.2",
-    "@vendure/core": "^1.6.2",
+    "@vendure/common": "^1.6.3",
+    "@vendure/core": "^1.6.3",
     "bullmq": "^1.40.1",
     "redis": "^3.0.2",
     "rimraf": "^3.0.2",

+ 18 - 0
packages/payments-plugin/e2e/graphql/admin-queries.ts

@@ -1,3 +1,4 @@
+import { CHANNEL_FRAGMENT } from '@vendure/core/e2e/graphql/fragments';
 import gql from 'graphql-tag';
 
 export const PAYMENT_METHOD_FRAGMENT = gql`
@@ -94,3 +95,20 @@ export const GET_ORDER_PAYMENTS = gql`
         }
     }
 `;
+
+export const CREATE_CHANNEL = gql`
+    mutation CreateChannel($input: CreateChannelInput!) {
+        createChannel(input: $input) {
+            ... on Channel {
+                id
+                code
+                token
+                currencyCode
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+`;

File diff suppressed because it is too large
+ 503 - 489
packages/payments-plugin/e2e/graphql/generated-admin-types.ts


File diff suppressed because it is too large
+ 714 - 699
packages/payments-plugin/e2e/graphql/generated-shop-types.ts


+ 10 - 0
packages/payments-plugin/e2e/graphql/shop-queries.ts

@@ -12,6 +12,7 @@ export const TEST_ORDER_FRAGMENT = gql`
         shippingWithTax
         total
         totalWithTax
+        currencyCode
         couponCodes
         discounts {
             adjustmentSource
@@ -196,3 +197,12 @@ export const GET_ORDER_BY_CODE = gql`
     }
     ${TEST_ORDER_FRAGMENT}
 `;
+
+export const GET_ACTIVE_ORDER = gql`
+    query GetActiveOrder {
+        activeOrder {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;

+ 249 - 0
packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts

@@ -0,0 +1,249 @@
+/* tslint:disable:no-non-null-assertion */
+import { mergeConfig } from '@vendure/core';
+import { CreateProduct, CreateProductVariants } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
+import { CREATE_PRODUCT, CREATE_PRODUCT_VARIANTS } from '@vendure/core/e2e/graphql/shared-definitions';
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+import gql from 'graphql-tag';
+import nock from 'nock';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { StripePlugin } from '../src/stripe';
+import { stripePaymentMethodHandler } from '../src/stripe/stripe.handler';
+
+import { CREATE_CHANNEL, CREATE_PAYMENT_METHOD, GET_CUSTOMER_LIST } from './graphql/admin-queries';
+import {
+    CreateChannelMutation,
+    CreateChannelMutationVariables,
+    CreatePaymentMethod,
+    CurrencyCode,
+    GetCustomerList,
+    GetCustomerListQuery,
+    LanguageCode,
+} from './graphql/generated-admin-types';
+import {
+    AddItemToOrder,
+    GetActiveOrderQuery,
+    TestOrderFragmentFragment,
+} from './graphql/generated-shop-types';
+import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-queries';
+import { setShipping } from './payment-helpers';
+
+export const CREATE_STRIPE_PAYMENT_INTENT = gql`
+    mutation createStripePaymentIntent {
+        createStripePaymentIntent
+    }
+`;
+
+describe('Stripe payments', () => {
+    const devConfig = mergeConfig(testConfig(), {
+        plugins: [
+            StripePlugin.init({
+                apiKey: 'test-api-key',
+                webhookSigningSecret: 'test-signing-secret',
+                storeCustomersInStripe: true,
+            }),
+        ],
+    });
+    const { shopClient, adminClient, server } = createTestEnvironment(devConfig);
+    let started = false;
+    let customers: GetCustomerListQuery['customers']['items'];
+    let order: TestOrderFragmentFragment;
+    let serverPort: number;
+    beforeAll(async () => {
+        serverPort = devConfig.apiOptions.port;
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 2,
+        });
+        started = true;
+        await adminClient.asSuperAdmin();
+        ({
+            customers: { items: customers },
+        } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(GET_CUSTOMER_LIST, {
+            options: {
+                take: 2,
+            },
+        }));
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('Should start successfully', async () => {
+        expect(started).toEqual(true);
+        expect(customers).toHaveLength(2);
+    });
+
+    it('Should prepare an order', async () => {
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
+            ADD_ITEM_TO_ORDER,
+            {
+                productVariantId: 'T_1',
+                quantity: 2,
+            },
+        );
+        order = addItemToOrder as TestOrderFragmentFragment;
+        expect(order.code).toBeDefined();
+    });
+
+    it('Should add a Stripe paymentMethod', async () => {
+        const { createPaymentMethod } = await adminClient.query<
+            CreatePaymentMethod.Mutation,
+            CreatePaymentMethod.Variables
+        >(CREATE_PAYMENT_METHOD, {
+            input: {
+                code: `stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
+                name: 'Stripe payment test',
+                description: 'This is a Stripe test payment method',
+                enabled: true,
+                handler: {
+                    code: stripePaymentMethodHandler.code,
+                    arguments: [],
+                },
+            },
+        });
+        expect(createPaymentMethod.code).toBe(`stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`);
+
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        await setShipping(shopClient);
+    });
+
+    it('if no customer id exists, makes a call to create', async () => {
+        let createCustomerPayload: { name: string; email: string } | undefined;
+        const emptyList = { data: [] };
+        nock('https://api.stripe.com/')
+            .get(/\/v1\/customers.*/)
+            .reply(200, emptyList);
+        nock('https://api.stripe.com/')
+            .post('/v1/customers', body => {
+                createCustomerPayload = body;
+                return true;
+            })
+            .reply(201, {
+                id: 'new-customer-id',
+            });
+        nock('https://api.stripe.com/').post('/v1/payment_intents').reply(200, {
+            client_secret: 'test-client-secret',
+        });
+
+        const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
+        expect(createCustomerPayload).toEqual({
+            email: 'hayden.zieme12@hotmail.com',
+            name: 'Hayden Zieme',
+        });
+    });
+
+    it('should send correct payload to create payment intent', async () => {
+        let createPaymentIntentPayload: any;
+        const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+        nock('https://api.stripe.com/')
+            .post('/v1/payment_intents', body => {
+                createPaymentIntentPayload = body;
+                return true;
+            })
+            .reply(200, {
+                client_secret: 'test-client-secret',
+            });
+        const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
+        expect(createPaymentIntentPayload).toEqual({
+            amount: activeOrder?.totalWithTax.toString(),
+            currency: activeOrder?.currencyCode?.toLowerCase(),
+            customer: 'new-customer-id',
+            'automatic_payment_methods[enabled]': 'true',
+            'metadata[channelToken]': E2E_DEFAULT_CHANNEL_TOKEN,
+            'metadata[orderId]': '1',
+            'metadata[orderCode]': activeOrder?.code,
+        });
+        expect(createStripePaymentIntent).toEqual('test-client-secret');
+    });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/1630
+    describe('currencies with no fractional units', () => {
+        let japanProductId: string;
+        beforeAll(async () => {
+            const JAPAN_CHANNEL_TOKEN = 'japan-channel-token';
+            const { createChannel } = await adminClient.query<
+                CreateChannelMutation,
+                CreateChannelMutationVariables
+            >(CREATE_CHANNEL, {
+                input: {
+                    code: 'japan-channel',
+                    currencyCode: CurrencyCode.JPY,
+                    token: JAPAN_CHANNEL_TOKEN,
+                    defaultLanguageCode: LanguageCode.en,
+                    defaultShippingZoneId: 'T_1',
+                    defaultTaxZoneId: 'T_1',
+                    pricesIncludeTax: true,
+                },
+            });
+
+            adminClient.setChannelToken(JAPAN_CHANNEL_TOKEN);
+            shopClient.setChannelToken(JAPAN_CHANNEL_TOKEN);
+
+            const { createProduct } = await adminClient.query<
+                CreateProduct.Mutation,
+                CreateProduct.Variables
+            >(CREATE_PRODUCT, {
+                input: {
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'Channel Product',
+                            slug: 'channel-product',
+                            description: 'Channel product',
+                        },
+                    ],
+                },
+            });
+            const { createProductVariants } = await adminClient.query<
+                CreateProductVariants.Mutation,
+                CreateProductVariants.Variables
+            >(CREATE_PRODUCT_VARIANTS, {
+                input: [
+                    {
+                        productId: createProduct.id,
+                        sku: 'PV1',
+                        optionIds: [],
+                        price: 5000,
+                        stockOnHand: 100,
+                        translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
+                    },
+                ],
+            });
+            japanProductId = createProductVariants[0]!.id;
+        });
+
+        it('prepares order', async () => {
+            await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: japanProductId,
+                quantity: 1,
+            });
+            expect((addItemToOrder as any).totalWithTax).toBe(5000);
+        });
+
+        it('sends correct amount when creating payment intent', async () => {
+            let createPaymentIntentPayload: any;
+            const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+            nock('https://api.stripe.com/')
+                .post('/v1/payment_intents', body => {
+                    createPaymentIntentPayload = body;
+                    return true;
+                })
+                .reply(200, {
+                    client_secret: 'test-client-secret',
+                });
+            const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
+            expect(createPaymentIntentPayload.amount).toBe((activeOrder!.totalWithTax / 100).toString());
+            expect(createPaymentIntentPayload.currency).toBe('jpy');
+        });
+    });
+});

+ 4 - 4
packages/payments-plugin/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@vendure/payments-plugin",
-    "version": "1.6.2",
+    "version": "1.6.3",
     "license": "MIT",
     "main": "package/index.js",
     "types": "package/index.d.ts",
@@ -28,9 +28,9 @@
     "devDependencies": {
         "@mollie/api-client": "^3.6.0",
         "@types/braintree": "^2.22.15",
-        "@vendure/common": "^1.6.2",
-        "@vendure/core": "^1.6.2",
-        "@vendure/testing": "^1.6.2",
+        "@vendure/common": "^1.6.3",
+        "@vendure/core": "^1.6.3",
+        "@vendure/testing": "^1.6.3",
         "braintree": "^3.0.0",
         "nock": "^13.1.4",
         "rimraf": "^3.0.2",

+ 2 - 2
packages/payments-plugin/src/stripe/raw-body.middleware.ts

@@ -1,14 +1,14 @@
 import { json } from 'body-parser';
 import { ServerResponse } from 'http';
 
-import { IncomingMessageWithRawBody } from './types';
+import { RequestWithRawBody } from './types';
 
 /**
  * Middleware which adds the raw request body to the incoming message object. This is needed by
  * Stripe to properly verify webhook events.
  */
 export const rawBodyMiddleware = json({
-    verify(req: IncomingMessageWithRawBody, _: ServerResponse, buf: Buffer) {
+    verify(req: RequestWithRawBody, _: ServerResponse, buf: Buffer) {
         if (Buffer.isBuffer(buf)) {
             req.rawBody = Buffer.from(buf);
         }

+ 11 - 14
packages/payments-plugin/src/stripe/stripe.controller.ts

@@ -1,6 +1,5 @@
 import { Controller, Headers, HttpStatus, Post, Req, Res } from '@nestjs/common';
 import {
-    ChannelService,
     InternalServerError,
     LanguageCode,
     Logger,
@@ -8,16 +7,17 @@ import {
     OrderService,
     PaymentMethod,
     RequestContext,
-    TransactionalConnection
+    RequestContextService,
+    TransactionalConnection,
 } from '@vendure/core';
 import { OrderStateTransitionError } from '@vendure/core/dist/common/error/generated-graphql-shop-errors';
 import { Response } from 'express';
 import Stripe from 'stripe';
+
 import { loggerCtx } from './constants';
 import { stripePaymentMethodHandler } from './stripe.handler';
 import { StripeService } from './stripe.service';
-import { IncomingMessageWithRawBody } from './types';
-
+import { RequestWithRawBody } from './types';
 
 const missingHeaderErrorMessage = 'Missing stripe-signature header';
 const signatureErrorMessage = 'Error verifying Stripe webhook signature';
@@ -27,15 +27,15 @@ const noPaymentIntentErrorMessage = 'No payment intent in the event payload';
 export class StripeController {
     constructor(
         private connection: TransactionalConnection,
-        private channelService: ChannelService,
         private orderService: OrderService,
         private stripeService: StripeService,
+        private requestContextService: RequestContextService,
     ) {}
 
     @Post('stripe')
     async webhook(
         @Headers('stripe-signature') signature: string | undefined,
-        @Req() request: IncomingMessageWithRawBody,
+        @Req() request: RequestWithRawBody,
         @Res() response: Response,
     ): Promise<void> {
         if (!signature) {
@@ -75,7 +75,7 @@ export class StripeController {
             return;
         }
 
-        const ctx = await this.createContext(channelToken);
+        const ctx = await this.createContext(channelToken, request);
 
         const order = await this.orderService.findOneByCode(ctx, orderCode);
         if (!order) {
@@ -119,14 +119,11 @@ export class StripeController {
         response.status(HttpStatus.OK).send('Ok');
     }
 
-    private async createContext(channelToken: string): Promise<RequestContext> {
-        const channel = await this.channelService.getChannelFromToken(channelToken);
-
-        return new RequestContext({
+    private async createContext(channelToken: string, req: RequestWithRawBody): Promise<RequestContext> {
+        return this.requestContextService.create({
             apiType: 'admin',
-            isAuthorized: true,
-            authorizedAsOwnerOnly: false,
-            channel,
+            channelOrToken: channelToken,
+            req,
             languageCode: LanguageCode.en,
         });
     }

+ 32 - 3
packages/payments-plugin/src/stripe/stripe.service.ts

@@ -1,5 +1,12 @@
 import { Inject, Injectable } from '@nestjs/common';
-import { Customer, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
+import {
+    CurrencyCode,
+    Customer,
+    Logger,
+    Order,
+    RequestContext,
+    TransactionalConnection,
+} from '@vendure/core';
 import Stripe from 'stripe';
 
 import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
@@ -7,7 +14,7 @@ import { StripePluginOptions } from './types';
 
 @Injectable()
 export class StripeService {
-    private stripe: Stripe;
+    protected stripe: Stripe;
 
     constructor(
         private connection: TransactionalConnection,
@@ -25,8 +32,20 @@ export class StripeService {
             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: order.totalWithTax,
+            amount: amountInMinorUnits,
             currency: order.currencyCode.toLowerCase(),
             customer: customerId,
             automatic_payment_methods: {
@@ -112,4 +131,14 @@ 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;
+    }
 }

+ 2 - 1
packages/payments-plugin/src/stripe/types.ts

@@ -1,4 +1,5 @@
 import '@vendure/core/dist/entity/custom-entity-fields';
+import { Request } from 'express';
 import { IncomingMessage } from 'http';
 
 // Note: deep import is necessary here because CustomCustomerFields is also extended in the Braintree
@@ -39,6 +40,6 @@ export interface StripePluginOptions {
     storeCustomersInStripe?: boolean;
 }
 
-export interface IncomingMessageWithRawBody extends IncomingMessage {
+export interface RequestWithRawBody extends Request {
     rawBody: Buffer;
 }

+ 3 - 3
packages/testing/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/testing",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "description": "End-to-end testing tools for Vendure projects",
   "keywords": [
     "vendure",
@@ -34,7 +34,7 @@
   },
   "dependencies": {
     "@types/node-fetch": "^2.5.4",
-    "@vendure/common": "^1.6.2",
+    "@vendure/common": "^1.6.3",
     "faker": "^4.1.0",
     "form-data": "^3.0.0",
     "graphql": "15.5.1",
@@ -45,7 +45,7 @@
   "devDependencies": {
     "@types/mysql": "^2.15.15",
     "@types/pg": "^7.14.5",
-    "@vendure/core": "^1.6.2",
+    "@vendure/core": "^1.6.3",
     "mysql": "^2.18.1",
     "pg": "^8.4.0",
     "rimraf": "^3.0.0",

+ 4 - 4
packages/ui-devkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/ui-devkit",
-  "version": "1.6.2",
+  "version": "1.6.3",
   "description": "A library for authoring Vendure Admin UI extensions",
   "keywords": [
     "vendure",
@@ -40,8 +40,8 @@
     "@angular/cli": "12.2.16",
     "@angular/compiler": "12.2.16",
     "@angular/compiler-cli": "12.2.16",
-    "@vendure/admin-ui": "^1.6.2",
-    "@vendure/common": "^1.6.2",
+    "@vendure/admin-ui": "^1.6.3",
+    "@vendure/common": "^1.6.3",
     "chalk": "^4.1.0",
     "chokidar": "^3.5.1",
     "fs-extra": "^10.0.0",
@@ -52,7 +52,7 @@
     "@rollup/plugin-node-resolve": "^11.2.0",
     "@types/fs-extra": "^9.0.8",
     "@types/glob": "^7.1.3",
-    "@vendure/core": "^1.6.2",
+    "@vendure/core": "^1.6.3",
     "rimraf": "^3.0.2",
     "rollup": "^2.40.0",
     "rollup-plugin-terser": "^7.0.2",

Some files were not shown because too many files changed in this diff