Bläddra i källkod

Merge branch 'master' into minor

Michael Bromley 2 år sedan
förälder
incheckning
a0d001d3a3
53 ändrade filer med 587 tillägg och 166 borttagningar
  1. 1 1
      docs/content/_index.md
  2. 1 1
      docs/content/deployment/deploying-admin-ui.md
  3. 4 4
      docs/content/developer-guide/authentication.md
  4. 2 2
      docs/content/developer-guide/configuration.md
  5. 3 3
      docs/content/developer-guide/customizing-models.md
  6. 2 2
      docs/content/developer-guide/error-handling.md
  7. 3 3
      docs/content/developer-guide/importing-product-data.md
  8. 1 1
      docs/content/developer-guide/job-queue/index.md
  9. 1 1
      docs/content/developer-guide/shipping.md
  10. 2 2
      docs/content/developer-guide/taxes.md
  11. 1 1
      docs/content/developer-guide/testing.md
  12. 2 2
      docs/content/developer-guide/translations.md
  13. 2 2
      docs/content/developer-guide/uploading-files.md
  14. 4 4
      docs/content/developer-guide/vendure-worker.md
  15. 1 1
      docs/content/plugins/_index.md
  16. 3 3
      docs/content/plugins/extending-the-admin-ui/_index.md
  17. 1 1
      docs/content/plugins/plugin-architecture/_index.md
  18. 1 1
      docs/content/plugins/plugin-examples/_index.md
  19. 1 1
      docs/content/plugins/plugin-lifecycle/index.md
  20. 2 2
      docs/content/plugins/writing-a-vendure-plugin.md
  21. 2 2
      docs/content/storefront/building-a-storefront/_index.md
  22. 2 2
      docs/content/storefront/managing-sessions.md
  23. 3 3
      docs/content/storefront/order-workflow/_index.md
  24. 2 2
      docs/content/storefront/shop-api-guide.md
  25. 1 1
      docs/content/user-guide/customers/index.md
  26. 1 1
      docs/content/user-guide/orders/_index.md
  27. 1 1
      docs/content/user-guide/settings/channels.md
  28. 1 1
      docs/content/user-guide/settings/shipping-methods.md
  29. 2 2
      docs/content/user-guide/settings/taxes.md
  30. 3 2
      packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.ts
  31. 0 2
      packages/asset-server-plugin/src/s3-asset-storage-strategy.ts
  32. 52 0
      packages/core/e2e/fast-importer.e2e-spec.ts
  33. 55 4
      packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts
  34. 39 0
      packages/core/e2e/list-query-builder.e2e-spec.ts
  35. 23 1
      packages/core/e2e/order-modification.e2e-spec.ts
  36. 29 0
      packages/core/e2e/order-promotion.e2e-spec.ts
  37. 15 3
      packages/core/src/data-import/providers/importer/fast-importer.service.ts
  38. 24 1
      packages/core/src/service/helpers/list-query-builder/list-query-builder.ts
  39. 1 1
      packages/core/src/service/services/order.service.ts
  40. 1 2
      packages/core/src/service/services/payment.service.ts
  41. 2 1
      packages/core/src/service/services/promotion.service.ts
  42. 1 0
      packages/dev-server/.gitignore
  43. 7 5
      packages/dev-server/dev-config.ts
  44. 1 1
      packages/harden-plugin/src/harden.plugin.ts
  45. 18 0
      packages/payments-plugin/e2e/mollie-dev-server.ts
  46. 146 62
      packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts
  47. 32 7
      packages/payments-plugin/e2e/payment-helpers.ts
  48. 2 0
      packages/payments-plugin/package.json
  49. 11 2
      packages/payments-plugin/src/mollie/mollie.helpers.ts
  50. 1 1
      packages/payments-plugin/src/mollie/mollie.plugin.ts
  51. 30 4
      packages/payments-plugin/src/mollie/mollie.service.ts
  52. 1 1
      packages/testing/package.json
  53. 40 13
      yarn.lock

+ 1 - 1
docs/content/_index.md

@@ -25,7 +25,7 @@ Learn how to run your store in our [Administrator Guide]({{< relref "user-guide"
 
 
 Vendure is a headless e-commerce framework.
 Vendure is a headless e-commerce framework.
 
 
-* *Headless* is a term which means that it does not concern itself with rendering the HTML pages of a website. Rather, it exposes a GraphQL API which which can be *queried* for data ("Give me a list of available products") or issued with *mutation* instructions ("Add product '123' to the current order") by a *client application*. Thus the client is responsible for how the e-commerce "storefront" looks and how it works. Vendure is responsible for the rest.
+* *Headless* is a term that means it does not concern itself with rendering the HTML pages of a website. Rather, it exposes a GraphQL API which can be *queried* for data ("Give me a list of available products") or issued with *mutation* instructions ("Add product '123' to the current order") by a *client application*. Thus the client is responsible for how the e-commerce "storefront" looks and how it works. Vendure is responsible for the rest.
 * Vendure is a *framework* in that it supplies core e-commerce functionality, but is open to further extension by the developer.
 * Vendure is a *framework* in that it supplies core e-commerce functionality, but is open to further extension by the developer.
 
 
 ## Who should use Vendure?
 ## Who should use Vendure?

+ 1 - 1
docs/content/deployment/deploying-admin-ui.md

@@ -5,7 +5,7 @@ showtoc: true
 
 
 ## Deploying the Admin UI
 ## Deploying the Admin UI
 
 
-If you have customized the Admin UI with extensions, you should [compile your extensions ahead-of-time as part of the deployment process]({{< relref "/docs/plugins/extending-the-admin-ui" >}}#compiling-as-a-deployment-step).
+If you have customized the Admin UI with extensions, you should [compile your extensions ahead of time as part of the deployment process]({{< relref "/docs/plugins/extending-the-admin-ui" >}}#compiling-as-a-deployment-step).
 
 
 ### Deploying a stand-alone Admin UI
 ### Deploying a stand-alone Admin UI
 
 

+ 4 - 4
docs/content/developer-guide/authentication.md

@@ -10,7 +10,7 @@ Authentication is the process of determining the identity of a user. Common ways
 By default, Vendure uses a username/email address and password to authenticate users, but also supports a wide range of authentication methods via configurable AuthenticationStrategies.
 By default, Vendure uses a username/email address and password to authenticate users, but also supports a wide range of authentication methods via configurable AuthenticationStrategies.
 
 
 {{< alert "primary" >}}
 {{< alert "primary" >}}
-See the [Managing Sessions guide]({{< relref "managing-sessions" >}}) for how to manage authenticated session in your storefront/client applications.
+See the [Managing Sessions guide]({{< relref "managing-sessions" >}}) for how to manage authenticated sessions in your storefront/client applications.
 {{< /alert >}}
 {{< /alert >}}
 
 
 ## Adding support for external authentication
 ## Adding support for external authentication
@@ -76,7 +76,7 @@ Google.
 To do this you'll need to install the `google-auth-library` npm package as described in the ["Authenticate with a backend server" guide](https://developers.google.com/identity/sign-in/web/backend-auth).
 To do this you'll need to install the `google-auth-library` npm package as described in the ["Authenticate with a backend server" guide](https://developers.google.com/identity/sign-in/web/backend-auth).
 
 
 ```TypeScript
 ```TypeScript
-{
+import {
  AuthenticationStrategy,
  AuthenticationStrategy,
   ExternalAuthenticationService,
   ExternalAuthenticationService,
   Injector,
   Injector,
@@ -161,7 +161,7 @@ This example demonstrates how to implement a Facebook login flow.
 
 
 ### Storefront setup
 ### Storefront setup
 
 
-In this example we are assuming the use of the [Facebook SDK for JavaScript](https://developers.facebook.com/docs/javascript/) in the storefront.
+In this example, we are assuming the use of the [Facebook SDK for JavaScript](https://developers.facebook.com/docs/javascript/) in the storefront.
 
 
 An implementation in React might look like this:
 An implementation in React might look like this:
 
 
@@ -329,7 +329,7 @@ This example uses [Keycloak](https://www.keycloak.org/), a popular open-source i
 
 
 ### Configure a login page & Admin UI
 ### Configure a login page & Admin UI
 
 
-In this example we'll assume the login page is hosted at `http://intranet/login`. We'll also assume that a "login to Vendure" button has been added to that page and that the page is using the [Keycloak JavaScript adapter](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter), which can be used to get the current user's authorization token:
+In this example, we'll assume the login page is hosted at `http://intranet/login`. We'll also assume that a "login to Vendure" button has been added to that page and that the page is using the [Keycloak JavaScript adapter](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter), which can be used to get the current user's authorization token:
 
 
 ```JavaScript
 ```JavaScript
 vendureLoginButton.addEventListener('click', () => {
 vendureLoginButton.addEventListener('click', () => {

+ 2 - 2
docs/content/developer-guide/configuration.md

@@ -11,7 +11,7 @@ The `VendureConfig` object is organised into sections, grouping related settings
 
 
 ## Working with the VendureConfig object
 ## Working with the VendureConfig object
 
 
-Since the VendureConfig is just a JavaScript object, it can be managed and manipulated according to your needs. For example:
+Since the `VendureConfig` is just a JavaScript object, it can be managed and manipulated according to your needs. For example:
 
 
 ### Using environment variables
 ### Using environment variables
 
 
@@ -79,7 +79,7 @@ export const config: VendureConfig = {
 
 
 ## Important Configuration Settings
 ## Important Configuration Settings
 
 
-In this guide we will take a look at those configuration options needed for getting the server up-and-running.
+In this guide, we will take a look at those configuration options needed for getting the server up and running.
 
 
 {{< alert "primary" >}}
 {{< alert "primary" >}}
 A description of every available configuration option can be found in the [VendureConfig API documentation]({{< relref "vendure-config" >}}).
 A description of every available configuration option can be found in the [VendureConfig API documentation]({{< relref "vendure-config" >}}).

+ 3 - 3
docs/content/developer-guide/customizing-models.md

@@ -362,7 +362,7 @@ Customer: [
 
 
 ### Relations
 ### Relations
 
 
-It is possible to set up custom fields which hold references to other entities using the `'relation'` type:
+It is possible to set up custom fields that hold references to other entities using the `'relation'` type:
 
 
 ```TypeScript
 ```TypeScript
 Customer: [
 Customer: [
@@ -405,7 +405,7 @@ mutation {
 {{% alert %}}
 {{% alert %}}
 **UI for relation type**
 **UI for relation type**
 
 
-The Admin UI app has built-in selection components for "relation" custom fields which reference certain common entity types, such as Asset, Product, ProductVariant and Customer. If you are relating to an entity not covered by the built-in selection components, you will see a generic relation component which allows you to manually enter the ID of the entity you wish to select.
+The Admin UI app has built-in selection components for "relation" custom fields that reference certain common entity types, such as Asset, Product, ProductVariant and Customer. If you are relating to an entity not covered by the built-in selection components, you will see a generic relation component which allows you to manually enter the ID of the entity you wish to select.
 
 
-If the generic selector is not suitable, or is you wish to replace one of the built-in selector components, you can create a UI extension which defines a custom field control for that custom field. You can read more about this in the [custom form input guide]({{< relref "custom-form-inputs" >}})
+If the generic selector is not suitable, or is you wish to replace one of the built-in selector components, you can create a UI extension that defines a custom field control for that custom field. You can read more about this in the [custom form input guide]({{< relref "custom-form-inputs" >}})
 {{< /alert >}}
 {{< /alert >}}

+ 2 - 2
docs/content/developer-guide/error-handling.md

@@ -10,7 +10,7 @@ Errors in Vendure can be divided into two categories:
 * Unexpected errors
 * Unexpected errors
 * Expected errors
 * Expected errors
 
 
-These two types have different meanings and are handled differently to one another.
+These two types have different meanings and are handled differently from one another.
 
 
 ## Unexpected Errors
 ## Unexpected Errors
 
 
@@ -81,7 +81,7 @@ type OrderStateTransitionError implements ErrorResult {
 }
 }
 ```
 ```
 
 
-Operations which may return ErrorResults use a GraphQL `union` as their return type:
+Operations that may return ErrorResults use a GraphQL `union` as their return type:
 
 
 ```GraphQL
 ```GraphQL
 type Mutation {
 type Mutation {

+ 3 - 3
docs/content/developer-guide/importing-product-data.md

@@ -165,8 +165,8 @@ export const initialData: InitialData = {
 * `roles`: Defines which user roles are available.
 * `roles`: Defines which user roles are available.
   * `code`: Role code name.
   * `code`: Role code name.
   * `description`: Role description.
   * `description`: Role description.
-  * `permissions`: List of permissions to applied to the role.
-* `defaultLanguage`: Sets the language which will be used for all translatable entities created by the initial data e.g. Products, ProductVariants, Collections etc. Should correspond to the language used in your product csv file.
+  * `permissions`: List of permissions to apply to the role.
+* `defaultLanguage`: Sets the language that will be used for all translatable entities created by the initial data e.g. Products, ProductVariants, Collections etc. Should correspond to the language used in your product csv file.
 * `countries`: Defines which countries are available.
 * `countries`: Defines which countries are available.
   * `name`: The name of the country in the language specified by `defaultLanguage`
   * `name`: The name of the country in the language specified by `defaultLanguage`
   * `code`: A standardized code for the country, e.g. [ISO 3166-1](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes)
   * `code`: A standardized code for the country, e.g. [ISO 3166-1](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes)
@@ -219,7 +219,7 @@ populate(
 );
 );
 ```
 ```
 **Attention:** When removing the `DefaultJobQueuePlugin` from the plugins list as in the code snippet above, one should manually rebuild the search index in order for the newly added products to appear.
 **Attention:** When removing the `DefaultJobQueuePlugin` from the plugins list as in the code snippet above, one should manually rebuild the search index in order for the newly added products to appear.
-In the Admin UI this can be done by navigating to the Products page and clicking the gear icon next to the search input.
+In the Admin UI, this can be done by navigating to the Products page and clicking the gear icon next to the search input.
 
 
 ### Custom populate scripts
 ### Custom populate scripts
 
 

+ 1 - 1
docs/content/developer-guide/job-queue/index.md

@@ -40,7 +40,7 @@ The actual queue part is defined by the configured [JobQueueStrategy]({{< relref
 
 
 If no strategy is defined, Vendure uses an [in-memory store]({{< relref "in-memory-job-queue-strategy" >}}) of the contents of each queue. While this has the advantage of requiring no external dependencies, it is not suitable for production because when the server is stopped, the entire queue will be lost and any pending jobs will never be processed. Moreover, it cannot be used when running the worker as a separate process.
 If no strategy is defined, Vendure uses an [in-memory store]({{< relref "in-memory-job-queue-strategy" >}}) of the contents of each queue. While this has the advantage of requiring no external dependencies, it is not suitable for production because when the server is stopped, the entire queue will be lost and any pending jobs will never be processed. Moreover, it cannot be used when running the worker as a separate process.
 
 
-A better alternative is to use the [DefaultJobQueuePlugin]({{< relref "default-job-queue-plugin" >}}) (which will be used in a standard `@vendure/create` installation), which configures Vendure to use the [SqlJobQueueStrategy]({{< relref "sql-job-queue-strategy" >}}). This strategy uses the database as a queue, and means that event if the Vendure server stops, pending jobs will be persisted and upon re-start, they will be processed.
+A better alternative is to use the [DefaultJobQueuePlugin]({{< relref "default-job-queue-plugin" >}}) (which will be used in a standard `@vendure/create` installation), which configures Vendure to use the [SqlJobQueueStrategy]({{< relref "sql-job-queue-strategy" >}}). This strategy uses the database as a queue, and means that even if the Vendure server stops, pending jobs will be persisted and upon re-start, they will be processed.
 
 
 It is also possible to implement your own JobQueueStrategy to take advantage of other technologies. Examples include RabbitMQ, Google Cloud Pub Sub & Amazon SQS. It may make sense to implement a custom strategy based on one of these if the default database-based approach does not meet your performance requirements.
 It is also possible to implement your own JobQueueStrategy to take advantage of other technologies. Examples include RabbitMQ, Google Cloud Pub Sub & Amazon SQS. It may make sense to implement a custom strategy based on one of these if the default database-based approach does not meet your performance requirements.
 
 

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

@@ -6,7 +6,7 @@ showtoc: true
 
 
 Shipping in Vendure is handled by [ShippingMethods]({{< relref "shipping-method" >}}). Multiple ShippingMethods can be set up and then your storefront can query [`eligibleShippingMethods`]({{< relref "/docs/graphql-api/shop/queries" >}}#eligibleshippingmethods) to find out which ones can be applied to the active order.
 Shipping in Vendure is handled by [ShippingMethods]({{< relref "shipping-method" >}}). Multiple ShippingMethods can be set up and then your storefront can query [`eligibleShippingMethods`]({{< relref "/docs/graphql-api/shop/queries" >}}#eligibleshippingmethods) to find out which ones can be applied to the active order.
 
 
-A ShippingMethod is composed of a **checker** and a **calculator**. When querying `eligibleShippingMethods`, each of the defined ShippingMethods' checker functions are executed to find out whether the order is eligible for that method, and if so, the calculator is executed to determine the shipping cost.
+A ShippingMethod is composed of a **checker** and a **calculator**. When querying `eligibleShippingMethods`, each of the defined ShippingMethods' checker functions is executed to find out whether the order is eligible for that method, and if so, the calculator is executed to determine the shipping cost.
 
 
 ## Creating a custom checker
 ## Creating a custom checker
 
 

+ 2 - 2
docs/content/developer-guide/taxes.md

@@ -8,7 +8,7 @@ Most e-commerce applications need to correctly handle taxes such as sales tax or
 
 
 * **Tax categories** Each ProductVariant is assigned to a specific TaxCategory. In some tax systems, the tax rate differs depending on the type of good. For example, VAT in the UK has 3 rates, "standard" (most goods), "reduced" (e.g. child car seats) and "zero" (e.g. books).
 * **Tax categories** Each ProductVariant is assigned to a specific TaxCategory. In some tax systems, the tax rate differs depending on the type of good. For example, VAT in the UK has 3 rates, "standard" (most goods), "reduced" (e.g. child car seats) and "zero" (e.g. books).
 * **Tax rates** This is the tax rate applied to a specific tax category for a specific [Zone]({{< relref "zone" >}}). E.g., the tax rate for "standard" goods in the UK Zone is 20%.
 * **Tax rates** This is the tax rate applied to a specific tax category for a specific [Zone]({{< relref "zone" >}}). E.g., the tax rate for "standard" goods in the UK Zone is 20%.
-* **Channel tax settings** Each Channel can specify whether the prices of produce variants are inclusive of tax or not, and also specify the default Zone to use for tax calculations.
+* **Channel tax settings** Each Channel can specify whether the prices of product variants are inclusive of tax or not, and also specify the default Zone to use for tax calculations.
 * **TaxZoneStrategy** Determines the active tax Zone used when calculating what TaxRate to apply. By default, it uses the default tax Zone from the Channel settings.
 * **TaxZoneStrategy** Determines the active tax Zone used when calculating what TaxRate to apply. By default, it uses the default tax Zone from the Channel settings.
 * **TaxLineCalculationStrategy** This determines the taxes applied when adding an item to an Order. If you want to integrate a 3rd-party tax API or other async lookup, this is where it would be done.
 * **TaxLineCalculationStrategy** This determines the taxes applied when adding an item to an Order. If you want to integrate a 3rd-party tax API or other async lookup, this is where it would be done.
 
 
@@ -35,7 +35,7 @@ query {
 }
 }
 ```
 ```
 
 
-In your storefront you can therefore choose whether to display the prices with or without tax, according to the laws and conventions of the area in which your business operates.
+In your storefront, you can therefore choose whether to display the prices with or without tax, according to the laws and conventions of the area in which your business operates.
 
 
 ## Calculating taxes on OrderItems
 ## Calculating taxes on OrderItems
 
 

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

@@ -18,7 +18,7 @@ The `@vendure/testing` package gives you some simple but powerful tooling for cr
 ### Install dependencies
 ### Install dependencies
 
 
 * [`@vendure/testing`](https://www.npmjs.com/package/@vendure/testing)
 * [`@vendure/testing`](https://www.npmjs.com/package/@vendure/testing)
-* [`jest`](https://www.npmjs.com/package/jest) You'll need to install a testing framework. In this example we will use [Jest](https://jestjs.io/), but any other framework such as Jasmine should work too.
+* [`jest`](https://www.npmjs.com/package/jest) You'll need to install a testing framework. In this example, we will use [Jest](https://jestjs.io/), but any other framework such as Jasmine should work too.
 * [`graphql-tag`](https://www.npmjs.com/package/graphql-tag) This is not strictly required but makes it much easier to create the DocumentNodes needed to query your server.
 * [`graphql-tag`](https://www.npmjs.com/package/graphql-tag) This is not strictly required but makes it much easier to create the DocumentNodes needed to query your server.
 
 
 Please see the [Jest documentation](https://jestjs.io/docs/en/getting-started) on how to get set up. The remainder of this article will assume a working Jest setup configured to work with TypeScript.
 Please see the [Jest documentation](https://jestjs.io/docs/en/getting-started) on how to get set up. The remainder of this article will assume a working Jest setup configured to work with TypeScript.

+ 2 - 2
docs/content/developer-guide/translations.md

@@ -6,7 +6,7 @@ showtoc: true
 # Translation
 # Translation
 
 
 Using [`addTranslation`]({{< relref "i18n-service" >}}#addtranslation) inside the `onApplicationBootstrap` ([Nestjs lifecycle hooks](https://docs.nestjs.com/fundamentals/lifecycle-events)) of a Plugin is the easiest way to add new translations.
 Using [`addTranslation`]({{< relref "i18n-service" >}}#addtranslation) inside the `onApplicationBootstrap` ([Nestjs lifecycle hooks](https://docs.nestjs.com/fundamentals/lifecycle-events)) of a Plugin is the easiest way to add new translations.
-While vendure is only using `error`, `errorResult` and `message` resource keys you are free to use your own.
+While Vendure is only using `error`, `errorResult` and `message` resource keys you are free to use your own.
 
 
 ## Translatable Error
 ## Translatable Error
 This example shows how to create a custom translatable error
 This example shows how to create a custom translatable error
@@ -59,7 +59,7 @@ To receive an error in a specific language you need to use the `languageCode` qu
 
 
 ## Use translations
 ## Use translations
 
 
-Vendures uses the internationalization-framework [i18next](https://www.i18next.com/).
+Vendure uses the internationalization-framework [i18next](https://www.i18next.com/).
 
 
 Therefore you are free to use the i18next translate function to [access keys](https://www.i18next.com/translation-function/essentials#accessing-keys) \
 Therefore you are free to use the i18next translate function to [access keys](https://www.i18next.com/translation-function/essentials#accessing-keys) \
 `i18next.t('error.any-message');`
 `i18next.t('error.any-message');`

+ 2 - 2
docs/content/developer-guide/uploading-files.md

@@ -9,7 +9,7 @@ Vendure handles file uploads with the [GraphQL multipart request specification](
 
 
 ## Upload clients
 ## Upload clients
 
 
-Here is a [list of client implementations](https://github.com/jaydenseric/graphql-multipart-request-spec#client) which will allow you to upload files using the spec. If you are using Apollo Client, then you should install the [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client) npm package.
+Here is a [list of client implementations](https://github.com/jaydenseric/graphql-multipart-request-spec#client) that will allow you to upload files using the spec. If you are using Apollo Client, then you should install the [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client) npm package.
 
 
 For testing, it is even possible to use a [plain curl request](https://github.com/jaydenseric/graphql-multipart-request-spec#single-file).
 For testing, it is even possible to use a [plain curl request](https://github.com/jaydenseric/graphql-multipart-request-spec#single-file).
 
 
@@ -85,7 +85,7 @@ In a later step, we will refactor this config to encapsulate it in a plugin.
 
 
 ### Schema definition
 ### Schema definition
 
 
-Next we will define the schema for the mutation:
+Next, we will define the schema for the mutation:
 
 
 ```TypeScript
 ```TypeScript
 // api-extensions.ts
 // api-extensions.ts

+ 4 - 4
docs/content/developer-guide/vendure-worker.md

@@ -5,7 +5,7 @@ showtoc: true
 
 
 # Vendure Worker
 # Vendure Worker
  
  
-The Vendure Worker is a process which is responsible for running computationally intensive or otherwise long-running tasks in the background. For example updating a search index or sending emails. Running such tasks in the background allows the server to stay responsive, since a response can be returned immediately without waiting for the slower tasks to complete. 
+The Vendure Worker is a process responsible for running computationally intensive or otherwise long-running tasks in the background. For example, updating a search index or sending emails. Running such tasks in the background allows the server to stay responsive, since a response can be returned immediately without waiting for the slower tasks to complete. 
 
 
 Put another way, the Worker executes jobs registered with the [JobQueueService]({{< relref "job-queue-service" >}}).
 Put another way, the Worker executes jobs registered with the [JobQueueService]({{< relref "job-queue-service" >}}).
 
 
@@ -31,8 +31,8 @@ The Worker is a [NestJS standalone application](https://docs.nestjs.com/standalo
 
 
 ## Multiple workers
 ## Multiple workers
 
 
-It is possible to run multiple workers in parallel, in order to better handle heavy loads. Using the [JobQueueOptions.activeQueues]({{< relref "job-queue-options" >}}#activequeues) configuration, it is even possible to have particular workers dedicated to one or more specific type of job.
-For example, if you application does video transcoding, you might want to set up a dedicated worker just for that task:
+It is possible to run multiple workers in parallel to better handle heavy loads. Using the [JobQueueOptions.activeQueues]({{< relref "job-queue-options" >}}#activequeues) configuration, it is even possible to have particular workers dedicated to one or more specific types of jobs.
+For example, if your application does video transcoding, you might want to set up a dedicated worker just for that task:
 
 
 ```TypeScript
 ```TypeScript
 import { bootstrapWorker, mergeConfig } from '@vendure/core';
 import { bootstrapWorker, mergeConfig } from '@vendure/core';
@@ -74,7 +74,7 @@ If you are authoring a [Vendure plugin]({{< relref "/docs/plugins" >}}) to imple
 
 
 ## ProcessContext
 ## ProcessContext
 
 
-Sometimes your code may need to be aware of whether it is being run on the as part of a server or worker process. In this case you can inject the [ProcessContext]({{< relref "process-context" >}}) provider and query it like this:
+Sometimes your code may need to be aware of whether it is being run as part of a server or worker process. In this case you can inject the [ProcessContext]({{< relref "process-context" >}}) provider and query it like this:
 
 
 ```TypeScript
 ```TypeScript
 import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
 import { Injectable, OnApplicationBootstrap } from '@nestjs/common';

+ 1 - 1
docs/content/plugins/_index.md

@@ -10,7 +10,7 @@ Plugins are the method by which the built-in functionality of Vendure can be ext
 * Modify the [VendureConfig]({{< ref "/docs/typescript-api/configuration" >}}#vendureconfig) object.
 * Modify the [VendureConfig]({{< ref "/docs/typescript-api/configuration" >}}#vendureconfig) object.
 * Extend the GraphQL API, including modifying existing types and adding completely new queries and mutations.
 * Extend the GraphQL API, including modifying existing types and adding completely new queries and mutations.
 * Define new database entities and interact directly with the database.
 * Define new database entities and interact directly with the database.
-* Run code before the server bootstraps, such as starting webservers.
+* Run code before the server bootstraps, such as starting web servers.
 * Respond to events such as new orders being placed.
 * Respond to events such as new orders being placed.
 * Trigger background tasks to run on the worker process.
 * Trigger background tasks to run on the worker process.
 * ... and more!
 * ... and more!

+ 3 - 3
docs/content/plugins/extending-the-admin-ui/_index.md

@@ -10,12 +10,12 @@ When creating a plugin, you may wish to extend the Admin UI in order to expose a
 This is possible by defining [AdminUiExtensions]({{< ref "admin-ui-extension" >}}).
 This is possible by defining [AdminUiExtensions]({{< ref "admin-ui-extension" >}}).
 
 
 {{< alert "primary" >}}
 {{< alert "primary" >}}
-For a complete working example of a Vendure plugin which extends the Admin UI, see the [real-world-vendure Reviews plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/reviews)
+For a complete working example of a Vendure plugin that extends the Admin UI, see the [real-world-vendure Reviews plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/reviews)
 {{< /alert >}}
 {{< /alert >}}
 
 
 ## How It Works
 ## How It Works
 
 
-A UI extension is an [Angular module](https://angular.io/guide/ngmodules) which gets compiled into the Admin UI application bundle by the [`compileUiExtensions`]({{< relref "compile-ui-extensions" >}}) function exported by the `@vendure/ui-devkit` package. Internally, the ui-devkit package makes use of the Angular CLI to compile an optimized set of JavaScript bundles containing your extensions.
+A UI extension is an [Angular module](https://angular.io/guide/ngmodules) that gets compiled into the Admin UI application bundle by the [`compileUiExtensions`]({{< relref "compile-ui-extensions" >}}) function exported by the `@vendure/ui-devkit` package. Internally, the ui-devkit package makes use of the Angular CLI to compile an optimized set of JavaScript bundles containing your extensions.
 
 
 ## Use Your Favourite Framework
 ## Use Your Favourite Framework
 
 
@@ -26,7 +26,7 @@ The Vendure Admin UI is built with Angular, and writing UI extensions in Angular
 
 
 ## Lazy vs Shared Modules
 ## Lazy vs Shared Modules
 
 
-Angular uses the concept of modules ([NgModules](https://angular.io/guide/ngmodules)) for organizing related code. These modules can be lazily-loaded, which means that the code is not loaded when the app starts, but only later once that code is required. This keeps the main bundle small and improves performance.
+Angular uses the concept of modules ([NgModules](https://angular.io/guide/ngmodules)) for organizing related code. These modules can be lazily loaded, which means that the code is not loaded when the app starts, but only later once that code is required. This keeps the main bundle small and improves performance.
 
 
 When creating your UI extensions, you can set your module to be either `lazy` or `shared`. Shared modules are loaded _eagerly_, i.e. their code is bundled up with the main app and loaded as soon as the app loads.
 When creating your UI extensions, you can set your module to be either `lazy` or `shared`. Shared modules are loaded _eagerly_, i.e. their code is bundled up with the main app and loaded as soon as the app loads.
 
 

+ 1 - 1
docs/content/plugins/plugin-architecture/_index.md

@@ -8,7 +8,7 @@ showtoc: true
 
 
 {{< figure src="plugin_architecture.png" >}}
 {{< figure src="plugin_architecture.png" >}}
 
 
-A plugin in Vendure is a specialized Nestjs Module which is decorated with the [`VendurePlugin` class decorator]({{< relref "vendure-plugin" >}}). This diagram illustrates how a plugin can integrate with and extend Vendure.
+A plugin in Vendure is a specialized Nestjs Module that is decorated with the [`VendurePlugin` class decorator]({{< relref "vendure-plugin" >}}). This diagram illustrates how a plugin can integrate with and extend Vendure.
  
  
 1. A Plugin may define logic to be run by the [Vendure Worker]({{< relref "/docs/developer-guide/vendure-worker" >}}). This is suitable for long-running or resource-intensive tasks.
 1. A Plugin may define logic to be run by the [Vendure Worker]({{< relref "/docs/developer-guide/vendure-worker" >}}). This is suitable for long-running or resource-intensive tasks.
 2. A Plugin can modify any aspect of server configuration via the [`configuration` metadata property]({{< relref "vendure-plugin-metadata" >}}#configuration).
 2. A Plugin can modify any aspect of server configuration via the [`configuration` metadata property]({{< relref "vendure-plugin-metadata" >}}#configuration).

+ 1 - 1
docs/content/plugins/plugin-examples/_index.md

@@ -6,7 +6,7 @@ showtoc: true
 
 
 # Plugin Examples 
 # Plugin Examples 
 
 
-Here are some simplified examples of plugins which serve to illustrate what can be done with Vendure plugins. *Note: implementation details are skipped in these examples for the sake of brevity. A complete example with explanation can be found in [Writing A Vendure Plugin]({{< relref "writing-a-vendure-plugin" >}}).*
+Here are some simplified examples of plugins that serve to illustrate what can be done with Vendure plugins. *Note: implementation details are skipped in these examples for the sake of brevity. A complete example with explanations can be found in [Writing A Vendure Plugin]({{< relref "writing-a-vendure-plugin" >}}).*
 
 
 {{< alert "primary" >}}
 {{< alert "primary" >}}
   For a complete working example of a Vendure plugin, see the [real-world-vendure Reviews plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/reviews)
   For a complete working example of a Vendure plugin, see the [real-world-vendure Reviews plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/reviews)

+ 1 - 1
docs/content/plugins/plugin-lifecycle/index.md

@@ -17,7 +17,7 @@ Note that lifecycle hooks are run in _both_ the server and worker contexts.
 
 
 ## Configure
 ## Configure
 
 
-Another hook which is not strictly a lifecycle hook, but which can be useful to know is the [`configure` method](https://docs.nestjs.com/middleware#applying-middleware) which is used by NestJS to apply middleware. This method is called _only_ for the server and _not_ for the worker, since middleware relates to the network stack, and the worker has no network part.
+Another hook that is not strictly a lifecycle hook, but which can be useful to know is the [`configure` method](https://docs.nestjs.com/middleware#applying-middleware) which is used by NestJS to apply middleware. This method is called _only_ for the server and _not_ for the worker, since middleware relates to the network stack, and the worker has no network part.
 
 
 ## Example
 ## Example
 
 

+ 2 - 2
docs/content/plugins/writing-a-vendure-plugin.md

@@ -15,7 +15,7 @@ This is a complete example of how to implement a simple plugin step-by-step.
 
 
 ## Example: RandomCatPlugin
 ## Example: RandomCatPlugin
 
 
-Let's learn about Vendure plugins by writing a plugin which defines a new database entity and a GraphQL mutation.
+Let's learn about Vendure plugins by writing a plugin that defines a new database entity and a GraphQL mutation.
 
 
 This plugin will add a new mutation, `addRandomCat`, to the GraphQL API which allows us to conveniently link a random cat image from [http://random.cat](http://random.cat) to any product in our catalog.
 This plugin will add a new mutation, `addRandomCat`, to the GraphQL API which allows us to conveniently link a random cat image from [http://random.cat](http://random.cat) to any product in our catalog.
 
 
@@ -74,7 +74,7 @@ To use decorators with TypeScript, you must set the "emitDecoratorMetadata" and
 
 
 ### Step 3: Define the new mutation
 ### Step 3: Define the new mutation
 
 
-Next we will define how the GraphQL API should be extended:
+Next, we will define how the GraphQL API should be extended:
 
 
 ```TypeScript
 ```TypeScript
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';

+ 2 - 2
docs/content/storefront/building-a-storefront/_index.md

@@ -6,13 +6,13 @@ showtoc: true
 
 
 # Building a Storefront
 # Building a Storefront
 
 
-The storefront is the application which customers use to buy things from your store.
+The storefront is the application that customers use to buy things from your store.
 
 
 One of the benefits of Vendure's headless architecture is that you can build your storefront using any technology you like, and in the future you can update your storefront without requiring any changes to the Vendure server itself!
 One of the benefits of Vendure's headless architecture is that you can build your storefront using any technology you like, and in the future you can update your storefront without requiring any changes to the Vendure server itself!
 
 
 ## Storefront starters
 ## Storefront starters
 
 
-To get you up-and-running with your storefront implementation, we offer a number of integrations with popular front-end frameworks such as Next.js, Vue Storefront, Remix & Angular. See all of our [storefront integrations]({{< relref "integration" >}}).
+To get you up and running with your storefront implementation, we offer a number of integrations with popular front-end frameworks such as Next.js, Vue Storefront, Remix & Angular. See all of our [storefront integrations]({{< relref "integration" >}}).
 
 
 ## Custom-building
 ## Custom-building
 
 

+ 2 - 2
docs/content/storefront/managing-sessions.md

@@ -32,9 +32,9 @@ const config = {
 
 
 ## Bearer-token sessions
 ## Bearer-token sessions
 
 
-In environments when cookies cannot be easily used (e.g. in some server environments or mobile apps), then the bearer-token method can be used.
+In environments where cookies cannot be easily used (e.g. in some server environments or mobile apps), then the bearer-token method can be used.
 
 
-Using bearer tokens involes a bit more work on your part: you'll need to manually read response headers to get the token, and once you have it you'll have to manually add it to the headers of each request. 
+Using bearer tokens involves a bit more work on your part: you'll need to manually read response headers to get the token, and once you have it you'll have to manually add it to the headers of each request. 
 
 
 The workflow would be as follows:
 The workflow would be as follows:
 
 

+ 3 - 3
docs/content/storefront/order-workflow/_index.md

@@ -31,7 +31,7 @@ So if the customer adds 2 *Widgets* to the Order, there will be **one OrderLine*
 
 
 The [GraphQL Shop API Guide]({{< relref "/docs/storefront/shop-api-guide" >}}#order-flow) lists the GraphQL operations you will need to implement this workflow in your storefront client application.
 The [GraphQL Shop API Guide]({{< relref "/docs/storefront/shop-api-guide" >}}#order-flow) lists the GraphQL operations you will need to implement this workflow in your storefront client application.
 
 
-In this section we'll cover some examples of how these operations would look in your storefront.
+In this section, we'll cover some examples of how these operations would look in your storefront.
 
 
 ### Manipulating the Order
 ### Manipulating the Order
 
 
@@ -117,7 +117,7 @@ query ActiveOrder {
 
 
 ### Checking out
 ### Checking out
 
 
-During the checkout process, we'll need to make sure a Customer is assigned to the Order. If the Customer is already signed-in, then this can be skipped since Vendure will have already assigned them. If not, then you'd execute:
+During the checkout process, we'll need to make sure a Customer is assigned to the Order. If the Customer is already signed in, then this can be skipped since Vendure will have already assigned them. If not, then you'd execute:
 
 
 ```GraphQL
 ```GraphQL
 mutation SetCustomerForOrder($input: CreateCustomerInput!){
 mutation SetCustomerForOrder($input: CreateCustomerInput!){
@@ -230,4 +230,4 @@ query OrderByCode($code: String!) {
 
 
 In the above examples, the active Order is always associated with the current session and is therefore implicit - which is why there is no need to pass an ID to each of the above operations.
 In the above examples, the active Order is always associated with the current session and is therefore implicit - which is why there is no need to pass an ID to each of the above operations.
 
 
-Sometimes you _do_ want to be able to explicitly specify the Order you wish to operate on. In this case you need to define a custom [ActiveOrderStrategy]({{< relref "active-order-strategy" >}}).
+Sometimes you _do_ want to be able to explicitly specify the Order you wish to operate on. In this case, you need to define a custom [ActiveOrderStrategy]({{< relref "active-order-strategy" >}}).

+ 2 - 2
docs/content/storefront/shop-api-guide.md

@@ -14,9 +14,9 @@ This guide only lists some of the more common operations you'll need for your st
 
 
 ## Universal Parameters
 ## Universal Parameters
 
 
-There are a couple of query parameters which are valid for all GraphQL operations:
+There are a couple of query parameters that are valid for all GraphQL operations:
 
 
-* `languageCode`: This sets the current langauge for the request. Any translatable types (e.g. Products, Facets, Collections) will be returned in that language, if a translation is defined for that language. If not, they will fall back to the default language. The value should be one of the ISO 639-1 codes defined by the [`LanguageCode` enum]({{< relref "language-code" >}}).
+* `languageCode`: This sets the current language for the request. Any translatable types (e.g. Products, Facets, Collections) will be returned in that language, if a translation is defined for that language. If not, they will fall back to the default language. The value should be one of the ISO 639-1 codes defined by the [`LanguageCode` enum]({{< relref "language-code" >}}).
 
 
   ```text
   ```text
   POST http://localhost:3000/shop-api?languageCode=de
   POST http://localhost:3000/shop-api?languageCode=de

+ 1 - 1
docs/content/user-guide/customers/index.md

@@ -10,7 +10,7 @@ A Customer is anybody who has:
 * Placed an order
 * Placed an order
 * Registered an account
 * Registered an account
 
 
-The Customers section allows you to view and search your customers. Clicking "edit" in the list view brings up the detail view, allowing you to edit the customer details and view orders and history.
+The Customers section allows you to view and search for your customers. Clicking "edit" in the list view brings up the detail view, allowing you to edit the customer details and view orders and history.
 
 
 ## Guest, Registered, Verified
 ## Guest, Registered, Verified
 
 

+ 1 - 1
docs/content/user-guide/orders/_index.md

@@ -25,7 +25,7 @@ When a new Order arrives, you would:
 
 
 ## Refunds
 ## Refunds
 
 
-You can refund one or more items from an Order by clicking this menu item, which is available one the payments are settled:
+You can refund one or more items from an Order by clicking this menu item, which is available once the payments are settled:
 
 
 {{< figure src="./screen-refund-button.webp" >}}
 {{< figure src="./screen-refund-button.webp" >}}
 
 

+ 1 - 1
docs/content/user-guide/settings/channels.md

@@ -6,7 +6,7 @@ title: "Channels"
 
 
 Channels allow you to split your store into multiple sub-stores, each of which can have its own selection of inventory, customers, orders, shipping methods etc.
 Channels allow you to split your store into multiple sub-stores, each of which can have its own selection of inventory, customers, orders, shipping methods etc.
 
 
-There various reasons why you might want to do this:
+There are various reasons why you might want to do this:
 
 
 * Creating distinct stores for different countries, each with country-specific pricing, shipping and payment rules.
 * Creating distinct stores for different countries, each with country-specific pricing, shipping and payment rules.
 * Implementing a multi-tenant application where many merchants have their own store, each confined to its own channel.
 * Implementing a multi-tenant application where many merchants have their own store, each confined to its own channel.

+ 1 - 1
docs/content/user-guide/settings/shipping-methods.md

@@ -36,7 +36,7 @@ By default, Vendure comes with a simple flat-rate shipping calculator. Your deve
 
 
 ## Fulfillment handler
 ## Fulfillment handler
 
 
-By "fulfillment" we mean how do we physically get the goods into the hands of the customer. Common fulfillment methods include:
+By "fulfillment" we mean how we physically get the goods into the hands of the customer. Common fulfillment methods include:
 
 
 * Courier services such as FedEx, DPD, DHL, etc.
 * Courier services such as FedEx, DPD, DHL, etc.
 * Collection by customer
 * Collection by customer

+ 2 - 2
docs/content/user-guide/settings/taxes.md

@@ -21,7 +21,7 @@ For example, in the UK there are three rates of VAT:
 * Reduced rate (5%)
 * Reduced rate (5%)
 * Zero rate (0%)
 * Zero rate (0%)
 
 
-Most types of product would fall into the "standard rate" category, but for instance books are classified as "zero rate".
+Most types of products would fall into the "standard rate" category, but for instance books are classified as "zero rate".
 
 
 ## Tax Rate
 ## Tax Rate
 
 
@@ -29,4 +29,4 @@ Tax rates set the rate of tax for a given **tax category** destined for a partic
 
 
 ## Tax Compliance
 ## Tax Compliance
 
 
-Please note that tax compliance is a complex topic that varies significantly between countries. Vendure does not (and cannot) offer a complete out-of-the box tax solution which is guaranteed to be compliant for your use-case. What we strive to do is to provide a very flexible set of tools that your developers can use to tailor tax calculations exactly to your needs. These are covered in the [Developer's guide to taxes]({{< relref "/docs/developer-guide/taxes" >}}). 
+Please note that tax compliance is a complex topic that varies significantly between countries. Vendure does not (and cannot) offer a complete out-of-the-box tax solution which is guaranteed to be compliant with your use-case. What we strive to do is to provide a very flexible set of tools that your developers can use to tailor tax calculations exactly to your needs. These are covered in the [Developer's guide to taxes]({{< relref "/docs/developer-guide/taxes" >}}). 

+ 3 - 2
packages/admin-ui/src/lib/catalog/src/components/asset-list/asset-list.component.ts

@@ -25,7 +25,8 @@ import { debounceTime, finalize, map, switchMap, takeUntil } from 'rxjs/operator
 })
 })
 export class AssetListComponent
 export class AssetListComponent
     extends BaseListComponent<GetAssetList.Query, GetAssetList.Items, GetAssetList.Variables>
     extends BaseListComponent<GetAssetList.Query, GetAssetList.Items, GetAssetList.Variables>
-    implements OnInit {
+    implements OnInit
+{
     searchTerm$ = new BehaviorSubject<string | undefined>(undefined);
     searchTerm$ = new BehaviorSubject<string | undefined>(undefined);
     filterByTags$ = new BehaviorSubject<TagFragment[] | undefined>(undefined);
     filterByTags$ = new BehaviorSubject<TagFragment[] | undefined>(undefined);
     uploading = false;
     uploading = false;
@@ -41,7 +42,7 @@ export class AssetListComponent
     ) {
     ) {
         super(router, route);
         super(router, route);
         super.setQueryFn(
         super.setQueryFn(
-            (...args: any[]) => this.dataService.product.getAssetList(...args),
+            (...args: any[]) => this.dataService.product.getAssetList(...args).refetchOnChannelChange(),
             data => data.assets,
             data => data.assets,
             (skip, take) => {
             (skip, take) => {
                 const searchTerm = this.searchTerm$.value;
                 const searchTerm = this.searchTerm$.value;

+ 0 - 2
packages/asset-server-plugin/src/s3-asset-storage-strategy.ts

@@ -75,7 +75,6 @@ export interface S3Config {
  *   AssetServerPlugin.init({
  *   AssetServerPlugin.init({
  *     route: 'assets',
  *     route: 'assets',
  *     assetUploadDir: path.join(__dirname, 'assets'),
  *     assetUploadDir: path.join(__dirname, 'assets'),
- *     port: 5002,
  *     namingStrategy: new DefaultAssetNamingStrategy(),
  *     namingStrategy: new DefaultAssetNamingStrategy(),
  *     storageStrategyFactory: configureS3AssetStorage({
  *     storageStrategyFactory: configureS3AssetStorage({
  *       bucket: 'my-s3-bucket',
  *       bucket: 'my-s3-bucket',
@@ -102,7 +101,6 @@ export interface S3Config {
  *   AssetServerPlugin.init({
  *   AssetServerPlugin.init({
  *     route: 'assets',
  *     route: 'assets',
  *     assetUploadDir: path.join(__dirname, 'assets'),
  *     assetUploadDir: path.join(__dirname, 'assets'),
- *     port: 5002,
  *     namingStrategy: new DefaultAssetNamingStrategy(),
  *     namingStrategy: new DefaultAssetNamingStrategy(),
  *     storageStrategyFactory: configureS3AssetStorage({
  *     storageStrategyFactory: configureS3AssetStorage({
  *       bucket: 'my-minio-bucket',
  *       bucket: 'my-minio-bucket',

+ 52 - 0
packages/core/e2e/fast-importer.e2e-spec.ts

@@ -0,0 +1,52 @@
+import { CreateProductInput, ProductTranslationInput } from '@vendure/common/lib/generated-types';
+import { createTestEnvironment } from '@vendure/testing';
+import path from 'path';
+
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { initialData } from '../mock-data/data-sources/initial-data';
+import { FastImporterService, LanguageCode } from '../src';
+
+import { GetProductWithVariants } from './graphql/generated-e2e-admin-types';
+import { GET_PRODUCT_WITH_VARIANTS } from './graphql/shared-definitions';
+
+describe('FastImporterService resolver', () => {
+    const { server, adminClient } = createTestEnvironment(testConfig());
+
+    let fastImporterService: FastImporterService;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+        });
+        await adminClient.asSuperAdmin();
+        fastImporterService = server.app.get(FastImporterService);
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('creates normalized slug', async () => {
+        const productTranslation: ProductTranslationInput = {
+            languageCode: LanguageCode.en,
+            name: 'test product',
+            slug: 'test product',
+            description: 'test description',
+        };
+        const createProductInput: CreateProductInput = {
+            translations: [productTranslation],
+        };
+        await fastImporterService.initialize();
+        const productId = await fastImporterService.createProduct(createProductInput);
+
+        const { product } = await adminClient.query<
+            GetProductWithVariants.Query,
+            GetProductWithVariants.Variables
+        >(GET_PRODUCT_WITH_VARIANTS, {
+            id: productId as string,
+        });
+
+        expect(product?.slug).toMatch('test-product');
+    });
+});

+ 55 - 4
packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts

@@ -6,6 +6,7 @@ import {
     Ctx,
     Ctx,
     Customer,
     Customer,
     CustomerService,
     CustomerService,
+    HasCustomFields,
     ListQueryBuilder,
     ListQueryBuilder,
     LocaleString,
     LocaleString,
     Order,
     Order,
@@ -21,12 +22,30 @@ import {
     VendurePlugin,
     VendurePlugin,
 } from '@vendure/core';
 } from '@vendure/core';
 import gql from 'graphql-tag';
 import gql from 'graphql-tag';
-import { Column, Entity, JoinColumn, JoinTable, ManyToOne, OneToMany, OneToOne } from 'typeorm';
+import { Column, Entity, JoinColumn, JoinTable, ManyToOne, OneToMany, OneToOne, Relation } from 'typeorm';
 
 
 import { Calculated } from '../../../src/common/calculated-decorator';
 import { Calculated } from '../../../src/common/calculated-decorator';
 
 
 @Entity()
 @Entity()
-export class TestEntity extends VendureEntity implements Translatable {
+export class CustomFieldRelationTestEntity extends VendureEntity {
+    constructor(input: Partial<CustomFieldRelationTestEntity>) {
+        super(input);
+    }
+
+    @Column()
+    data: string;
+
+    @ManyToOne(() => TestEntity)
+    parent: Relation<TestEntity>;
+}
+
+class TestEntityCustomFields {
+    @OneToMany(() => CustomFieldRelationTestEntity, child => child.parent)
+    relation: Relation<CustomFieldRelationTestEntity[]>;
+}
+
+@Entity()
+export class TestEntity extends VendureEntity implements Translatable, HasCustomFields {
     constructor(input: Partial<TestEntity>) {
     constructor(input: Partial<TestEntity>) {
         super(input);
         super(input);
     }
     }
@@ -106,6 +125,9 @@ export class TestEntity extends VendureEntity implements Translatable {
 
 
     @Column({ nullable: true })
     @Column({ nullable: true })
     nullableDate: Date;
     nullableDate: Date;
+
+    @Column(() => TestEntityCustomFields)
+    customFields: TestEntityCustomFields;
 }
 }
 
 
 @Entity()
 @Entity()
@@ -120,6 +142,8 @@ export class TestEntityTranslation extends VendureEntity implements Translation<
 
 
     @ManyToOne(type => TestEntity, base => base.translations)
     @ManyToOne(type => TestEntity, base => base.translations)
     base: TestEntity;
     base: TestEntity;
+
+    customFields: {};
 }
 }
 
 
 @Entity()
 @Entity()
@@ -147,7 +171,7 @@ export class ListQueryResolver {
         return this.listQueryBuilder
         return this.listQueryBuilder
             .build(TestEntity, args.options, {
             .build(TestEntity, args.options, {
                 ctx,
                 ctx,
-                relations: ['orderRelation', 'orderRelation.customer'],
+                relations: ['orderRelation', 'orderRelation.customer', 'customFields.relation'],
                 customPropertyMap: {
                 customPropertyMap: {
                     customerLastName: 'orderRelation.customer.lastName',
                     customerLastName: 'orderRelation.customer.lastName',
                 },
                 },
@@ -193,6 +217,15 @@ const apiExtensions = gql`
         name: String!
         name: String!
     }
     }
 
 
+    type CustomFieldRelationTestEntity implements Node {
+        id: ID!
+        data: String!
+    }
+
+    type TestEntityCustomFields {
+        relation: [CustomFieldRelationTestEntity!]!
+    }
+
     type TestEntity implements Node {
     type TestEntity implements Node {
         id: ID!
         id: ID!
         createdAt: DateTime!
         createdAt: DateTime!
@@ -213,6 +246,7 @@ const apiExtensions = gql`
         nullableNumber: Int
         nullableNumber: Int
         nullableId: ID
         nullableId: ID
         nullableDate: DateTime
         nullableDate: DateTime
+        customFields: TestEntityCustomFields!
     }
     }
 
 
     type TestEntityList implements PaginatedList {
     type TestEntityList implements PaginatedList {
@@ -238,7 +272,7 @@ const apiExtensions = gql`
 
 
 @VendurePlugin({
 @VendurePlugin({
     imports: [PluginCommonModule],
     imports: [PluginCommonModule],
-    entities: [TestEntity, TestEntityPrice, TestEntityTranslation],
+    entities: [TestEntity, TestEntityPrice, TestEntityTranslation, CustomFieldRelationTestEntity],
     adminApiExtensions: {
     adminApiExtensions: {
         schema: apiExtensions,
         schema: apiExtensions,
         resolvers: [ListQueryResolver],
         resolvers: [ListQueryResolver],
@@ -334,6 +368,12 @@ export class ListQueryPlugin implements OnApplicationBootstrap {
                 F: { [LanguageCode.de]: 'baum' },
                 F: { [LanguageCode.de]: 'baum' },
             };
             };
 
 
+            const nestedData: Record<string, Array<{ data: string }>> = {
+                A: [{ data: 'A' }],
+                B: [{ data: 'B' }],
+                C: [{ data: 'C' }],
+            };
+
             for (const testEntity of testEntities) {
             for (const testEntity of testEntities) {
                 await this.connection.getRepository(TestEntityPrice).save([
                 await this.connection.getRepository(TestEntityPrice).save([
                     new TestEntityPrice({
                     new TestEntityPrice({
@@ -360,6 +400,17 @@ export class ListQueryPlugin implements OnApplicationBootstrap {
                         );
                         );
                     }
                     }
                 }
                 }
+
+                if (nestedData[testEntity.label]) {
+                    for (const nestedContent of nestedData[testEntity.label]) {
+                        await this.connection.getRepository(CustomFieldRelationTestEntity).save(
+                            new CustomFieldRelationTestEntity({
+                                parent: testEntity,
+                                data: nestedContent.data,
+                            }),
+                        );
+                    }
+                }
             }
             }
         } else {
         } else {
             const testEntities = await this.connection.getRepository(TestEntity).find();
             const testEntities = await this.connection.getRepository(TestEntity).find();

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

@@ -1246,6 +1246,28 @@ describe('ListQueryBuilder', () => {
             ]);
             ]);
         });
         });
     });
     });
+
+    describe('relations in customFields', () => {
+        it('should resolve relations in customFields successfully', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_CUSTOM_FIELD_RELATION, {
+                options: {
+                    filter: {
+                        label: { eq: 'A' },
+                    },
+                },
+            });
+
+            expect(testEntities.items).toEqual([
+                {
+                    id: 'T_1',
+                    label: 'A',
+                    customFields: {
+                        relation: [{ id: 'T_1', data: 'A' }],
+                    },
+                },
+            ]);
+        });
+    });
 });
 });
 
 
 const GET_LIST = gql`
 const GET_LIST = gql`
@@ -1311,3 +1333,20 @@ const GET_ARRAY_LIST = gql`
         }
         }
     }
     }
 `;
 `;
+
+const GET_LIST_WITH_CUSTOM_FIELD_RELATION = gql`
+    query GetTestWithCustomFieldRelation($options: TestEntityListOptions) {
+        testEntities(options: $options) {
+            items {
+                id
+                label
+                customFields {
+                    relation {
+                        id
+                        data
+                    }
+                }
+            }
+        }
+    }
+`;

+ 23 - 1
packages/core/e2e/order-modification.e2e-spec.ts

@@ -19,7 +19,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 import path from 'path';
 
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 
 import {
 import {
     failsToSettlePaymentMethod,
     failsToSettlePaymentMethod,
@@ -1929,6 +1929,28 @@ describe('Order modification', () => {
             const variant2 = await getVariant('T_3');
             const variant2 = await getVariant('T_3');
             expect(variant2.stockOnHand).toBe(100);
             expect(variant2.stockOnHand).toBe(100);
             expect(variant2.stockAllocated).toBe(1);
             expect(variant2.stockAllocated).toBe(1);
+
+            const result = await adminTransitionOrderToState(orderId5, 'ArrangingAdditionalPayment');
+            orderGuard.assertSuccess(result);
+            expect(result!.state).toBe('ArrangingAdditionalPayment');
+            const { addManualPaymentToOrder } = await adminClient.query<
+                AddManualPayment.Mutation,
+                AddManualPayment.Variables
+            >(ADD_MANUAL_PAYMENT, {
+                input: {
+                    orderId: orderId5,
+                    method: 'test',
+                    transactionId: 'manual-extra-payment',
+                    metadata: {
+                        foo: 'bar',
+                    },
+                },
+            });
+            orderGuard.assertSuccess(addManualPaymentToOrder);
+            const result2 = await adminTransitionOrderToState(orderId5, 'PaymentSettled');
+            orderGuard.assertSuccess(result2);
+            const result3 = await adminTransitionOrderToState(orderId5, 'Modifying');
+            orderGuard.assertSuccess(result3);
         });
         });
 
 
         it('updates stock when removing item before fulfillment', async () => {
         it('updates stock when removing item before fulfillment', async () => {

+ 29 - 0
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -1538,6 +1538,35 @@ describe('Promotions applied to Orders', () => {
                 expect(activeOrder!.couponCodes).toEqual([]);
                 expect(activeOrder!.couponCodes).toEqual([]);
                 expect(activeOrder!.totalWithTax).toBe(6000);
                 expect(activeOrder!.totalWithTax).toBe(6000);
             });
             });
+
+            it('does not remove valid couponCode when setting guest customer', async () => {
+                await shopClient.asAnonymousUser();
+                await createNewActiveOrder();
+
+                const { applyCouponCode } = await shopClient.query<
+                    ApplyCouponCode.Mutation,
+                    ApplyCouponCode.Variables
+                >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
+                orderResultGuard.assertSuccess(applyCouponCode);
+
+                expect(applyCouponCode!.totalWithTax).toBe(0);
+                expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
+
+                await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
+                    SET_CUSTOMER,
+                    {
+                        input: {
+                            emailAddress: 'new-guest@test.com',
+                            firstName: 'New Guest',
+                            lastName: 'Customer',
+                        },
+                    },
+                );
+
+                const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
+                expect(activeOrder!.couponCodes).toEqual([TEST_COUPON_CODE]);
+                expect(applyCouponCode!.totalWithTax).toBe(0);
+            });
         });
         });
 
 
         describe('signed-in customer', () => {
         describe('signed-in customer', () => {

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

@@ -5,6 +5,7 @@ import {
     CreateProductOptionInput,
     CreateProductOptionInput,
     CreateProductVariantInput,
     CreateProductVariantInput,
 } from '@vendure/common/lib/generated-types';
 } from '@vendure/common/lib/generated-types';
+import { normalizeString } from '@vendure/common/lib/normalize-string';
 import { ID } from '@vendure/common/lib/shared-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { unique } from '@vendure/common/lib/unique';
 import { unique } from '@vendure/common/lib/unique';
 
 
@@ -71,6 +72,11 @@ export class FastImporterService {
 
 
     async createProduct(input: CreateProductInput): Promise<ID> {
     async createProduct(input: CreateProductInput): Promise<ID> {
         this.ensureInitialized();
         this.ensureInitialized();
+        // https://github.com/vendure-ecommerce/vendure/issues/2053
+        // normalizes slug without validation for faster performance
+        input.translations.map(translation => {
+            translation.slug = normalizeString(translation.slug as string, '-');
+        });
         const product = await this.translatableSaver.create({
         const product = await this.translatableSaver.create({
             ctx: this.importCtx,
             ctx: this.importCtx,
             input,
             input,
@@ -95,7 +101,9 @@ export class FastImporterService {
                         position: i,
                         position: i,
                     }),
                     }),
             );
             );
-            await this.connection.getRepository(this.importCtx, ProductAsset).save(productAssets, { reload: false });
+            await this.connection
+                .getRepository(this.importCtx, ProductAsset)
+                .save(productAssets, { reload: false });
         }
         }
         return product.id;
         return product.id;
     }
     }
@@ -177,7 +185,9 @@ export class FastImporterService {
                         position: i,
                         position: i,
                     }),
                     }),
             );
             );
-            await this.connection.getRepository(this.importCtx, ProductVariantAsset).save(variantAssets, { reload: false });
+            await this.connection
+                .getRepository(this.importCtx, ProductVariantAsset)
+                .save(variantAssets, { reload: false });
         }
         }
         if (input.stockOnHand != null && input.stockOnHand !== 0) {
         if (input.stockOnHand != null && input.stockOnHand !== 0) {
             await this.stockMovementService.adjustProductVariantStock(
             await this.stockMovementService.adjustProductVariantStock(
@@ -194,7 +204,9 @@ export class FastImporterService {
                 channelId,
                 channelId,
             });
             });
             variantPrice.variant = createdVariant;
             variantPrice.variant = createdVariant;
-            await this.connection.getRepository(this.importCtx, ProductVariantPrice).save(variantPrice, { reload: false });
+            await this.connection
+                .getRepository(this.importCtx, ProductVariantPrice)
+                .save(variantPrice, { reload: false });
         }
         }
 
 
         return createdVariant.id;
         return createdVariant.id;

+ 24 - 1
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -492,12 +492,35 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         for (const entry of entitiesIdsWithRelations) {
         for (const entry of entitiesIdsWithRelations) {
             const finalEntity = entityMap.get(entry.entity.id);
             const finalEntity = entityMap.get(entry.entity.id);
             if (finalEntity) {
             if (finalEntity) {
-                finalEntity[entry.relation] = entry.entity[entry.relation];
+                this.assignDeep(entry.relation, entry.entity, finalEntity);
             }
             }
         }
         }
         return Array.from(entityMap.values());
         return Array.from(entityMap.values());
     }
     }
 
 
+    private assignDeep<T>(relation: string | keyof T, source: T, target: T) {
+        if (typeof relation === 'string') {
+            const parts = relation.split('.');
+            let resolvedTarget: any = target;
+            let resolvedSource: any = source;
+
+            for (const part of parts.slice(0, parts.length - 1)) {
+                if (!resolvedTarget[part]) {
+                    resolvedTarget[part] = {};
+                }
+                if (!resolvedSource[part]) {
+                    return;
+                }
+                resolvedTarget = resolvedTarget[part];
+                resolvedSource = resolvedSource[part];
+            }
+
+            resolvedTarget[parts[parts.length - 1]] = resolvedSource[parts[parts.length - 1]];
+        } else {
+            target[relation] = source[relation];
+        }
+    }
+
     /**
     /**
      * If a customPropertyMap is provided, we need to take the path provided and convert it to the actual
      * If a customPropertyMap is provided, we need to take the path provided and convert it to the actual
      * relation aliases being used by the SelectQueryBuilder.
      * relation aliases being used by the SelectQueryBuilder.

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

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

+ 1 - 2
packages/core/src/service/services/payment.service.ts

@@ -287,8 +287,7 @@ export class PaymentService {
             const paymentToRefund =
             const paymentToRefund =
                 (refundedPaymentIds.length === 0 &&
                 (refundedPaymentIds.length === 0 &&
                     refundablePayments.find(p => idsAreEqual(p.id, selectedPayment.id))) ||
                     refundablePayments.find(p => idsAreEqual(p.id, selectedPayment.id))) ||
-                refundablePayments.find(p => !refundedPaymentIds.includes(p.id)) ||
-                refundablePayments[0];
+                refundablePayments.find(p => !refundedPaymentIds.includes(p.id));
             if (!paymentToRefund) {
             if (!paymentToRefund) {
                 throw new InternalServerError(`Could not find a Payment to refund`);
                 throw new InternalServerError(`Could not find a Payment to refund`);
             }
             }

+ 2 - 1
packages/core/src/service/services/promotion.service.ts

@@ -324,7 +324,8 @@ export class PromotionService {
             .leftJoin('order.promotions', 'promotion')
             .leftJoin('order.promotions', 'promotion')
             .where('promotion.id = :promotionId', { promotionId })
             .where('promotion.id = :promotionId', { promotionId })
             .andWhere('order.customer = :customerId', { customerId })
             .andWhere('order.customer = :customerId', { customerId })
-            .andWhere('order.state != :state', { state: 'Cancelled' as OrderState });
+            .andWhere('order.state != :state', { state: 'Cancelled' as OrderState })
+            .andWhere('order.active = :active', { active: false });
 
 
         return qb.getCount();
         return qb.getCount();
     }
     }

+ 1 - 0
packages/dev-server/.gitignore

@@ -9,3 +9,4 @@ load-testing/results/**/*.csv
 load-testing/static/assets
 load-testing/static/assets
 dev-config-override.ts
 dev-config-override.ts
 vendure.log
 vendure.log
+scripts/dev-test

+ 7 - 5
packages/dev-server/dev-config.ts

@@ -13,6 +13,7 @@ import {
 import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin';
 import { ElasticsearchPlugin } from '@vendure/elasticsearch-plugin';
 import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
 import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
 import { BullMQJobQueuePlugin } from '@vendure/job-queue-plugin/package/bullmq';
 import { BullMQJobQueuePlugin } from '@vendure/job-queue-plugin/package/bullmq';
+import 'dotenv/config';
 import path from 'path';
 import path from 'path';
 import { ConnectionOptions } from 'typeorm';
 import { ConnectionOptions } from 'typeorm';
 
 
@@ -101,11 +102,12 @@ function getDbConfig(): ConnectionOptions {
             return {
             return {
                 synchronize: true,
                 synchronize: true,
                 type: 'postgres',
                 type: 'postgres',
-                host: '127.0.0.1',
-                port: 5432,
-                username: 'admin',
-                password: 'secret',
-                database: 'vendure-dev',
+                host: process.env.DB_HOST || 'localhost',
+                port: Number(process.env.DB_PORT) || 5432,
+                username: process.env.DB_USERNAME || 'postgres',
+                password: process.env.DB_PASSWORD || 'postgres',
+                database: process.env.DB_NAME || 'vendure',
+                schema: process.env.DB_SCHEMA || 'public',
             };
             };
         case 'sqlite':
         case 'sqlite':
             console.log('Using sqlite connection');
             console.log('Using sqlite connection');

+ 1 - 1
packages/harden-plugin/src/harden.plugin.ts

@@ -35,7 +35,7 @@ import { HardenPluginOptions } from './types';
  *   plugins: [
  *   plugins: [
  *      HardenPlugin.init({
  *      HardenPlugin.init({
  *        maxQueryComplexity: 650,
  *        maxQueryComplexity: 650,
- *        apiMode: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
+ *        apiMode: process.env.APP_ENV === 'dev' ? 'dev' : 'prod',
  *      }),
  *      }),
  *   ],
  *   ],
  * };
  * };

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

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

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

@@ -1,17 +1,30 @@
 import { OrderStatus } from '@mollie/api-client';
 import { OrderStatus } from '@mollie/api-client';
-import { ChannelService, mergeConfig, OrderService, RequestContext } from '@vendure/core';
+import {
+    ChannelService,
+    DefaultLogger,
+    LogLevel,
+    mergeConfig,
+    OrderService,
+    RequestContext,
+} from '@vendure/core';
 import {
 import {
     SettlePaymentMutation,
     SettlePaymentMutation,
     SettlePaymentMutationVariables,
     SettlePaymentMutationVariables,
 } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
 } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
 import { SETTLE_PAYMENT } from '@vendure/core/e2e/graphql/shared-definitions';
 import { SETTLE_PAYMENT } from '@vendure/core/e2e/graphql/shared-definitions';
-import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN, SimpleGraphQLClient, TestServer } from '@vendure/testing';
+import {
+    createTestEnvironment,
+    E2E_DEFAULT_CHANNEL_TOKEN,
+    SimpleGraphQLClient,
+    TestServer,
+} from '@vendure/testing';
 import nock from 'nock';
 import nock from 'nock';
 import fetch from 'node-fetch';
 import fetch from 'node-fetch';
 import path from 'path';
 import path from 'path';
 
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { UPDATE_PRODUCT_VARIANTS } from '../../core/e2e/graphql/shared-definitions';
 import { MolliePlugin } from '../src/mollie';
 import { MolliePlugin } from '../src/mollie';
 import { molliePaymentHandler } from '../src/mollie/mollie.handler';
 import { molliePaymentHandler } from '../src/mollie/mollie.handler';
 
 
@@ -19,7 +32,13 @@ import { CREATE_PAYMENT_METHOD, GET_CUSTOMER_LIST, GET_ORDER_PAYMENTS } from './
 import { CreatePaymentMethod, GetCustomerList, GetCustomerListQuery } from './graphql/generated-admin-types';
 import { CreatePaymentMethod, GetCustomerList, GetCustomerListQuery } from './graphql/generated-admin-types';
 import { AddItemToOrder, GetOrderByCode, TestOrderFragmentFragment } from './graphql/generated-shop-types';
 import { AddItemToOrder, GetOrderByCode, TestOrderFragmentFragment } from './graphql/generated-shop-types';
 import { ADD_ITEM_TO_ORDER, GET_ORDER_BY_CODE } from './graphql/shop-queries';
 import { ADD_ITEM_TO_ORDER, GET_ORDER_BY_CODE } from './graphql/shop-queries';
-import { CREATE_MOLLIE_PAYMENT_INTENT, GET_MOLLIE_PAYMENT_METHODS, refundOne, setShipping } from './payment-helpers';
+import {
+    addManualPayment,
+    CREATE_MOLLIE_PAYMENT_INTENT,
+    GET_MOLLIE_PAYMENT_METHODS,
+    refundOrderLine,
+    setShipping,
+} from './payment-helpers';
 
 
 describe('Mollie payments', () => {
 describe('Mollie payments', () => {
     const mockData = {
     const mockData = {
@@ -36,11 +55,13 @@ describe('Mollie payments', () => {
             },
             },
             lines: [],
             lines: [],
             _embedded: {
             _embedded: {
-                payments: [{
-                    id: 'tr_mockPayment',
-                    status: 'paid',
-                    resource: 'payment',
-                }],
+                payments: [
+                    {
+                        id: 'tr_mockPayment',
+                        status: 'paid',
+                        resource: 'payment',
+                    },
+                ],
             },
             },
             resource: 'order',
             resource: 'order',
             mode: 'test',
             mode: 'test',
@@ -78,7 +99,8 @@ describe('Mollie payments', () => {
                                 type: 'application/hal+json',
                                 type: 'application/hal+json',
                             },
                             },
                         },
                         },
-                    }],
+                    },
+                ],
             },
             },
             _links: {
             _links: {
                 self: {
                 self: {
@@ -99,11 +121,10 @@ describe('Mollie payments', () => {
     let customers: GetCustomerListQuery['customers']['items'];
     let customers: GetCustomerListQuery['customers']['items'];
     let order: TestOrderFragmentFragment;
     let order: TestOrderFragmentFragment;
     let serverPort: number;
     let serverPort: number;
+    const SURCHARGE_AMOUNT = -20000;
     beforeAll(async () => {
     beforeAll(async () => {
         const devConfig = mergeConfig(testConfig(), {
         const devConfig = mergeConfig(testConfig(), {
             plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
             plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
-            // Uncomment next line to debug e2e
-            // logger: new DefaultLogger({level: LogLevel.Verbose})
         });
         });
         const env = createTestEnvironment(devConfig);
         const env = createTestEnvironment(devConfig);
         serverPort = devConfig.apiOptions.port;
         serverPort = devConfig.apiOptions.port;
@@ -137,10 +158,13 @@ describe('Mollie payments', () => {
 
 
     it('Should prepare an order', async () => {
     it('Should prepare an order', async () => {
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
-            productVariantId: 'T_5',
-            quantity: 10,
-        });
+        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
+            ADD_ITEM_TO_ORDER,
+            {
+                productVariantId: 'T_5',
+                quantity: 10,
+            },
+        );
         order = addItemToOrder as TestOrderFragmentFragment;
         order = addItemToOrder as TestOrderFragmentFragment;
         // Add surcharge
         // Add surcharge
         const ctx = new RequestContext({
         const ctx = new RequestContext({
@@ -151,14 +175,16 @@ describe('Mollie payments', () => {
         });
         });
         await server.app.get(OrderService).addSurchargeToOrder(ctx, 1, {
         await server.app.get(OrderService).addSurchargeToOrder(ctx, 1, {
             description: 'Negative test surcharge',
             description: 'Negative test surcharge',
-            listPrice: -20000,
+            listPrice: SURCHARGE_AMOUNT,
         });
         });
         expect(order.code).toBeDefined();
         expect(order.code).toBeDefined();
     });
     });
 
 
     it('Should add a Mollie paymentMethod', async () => {
     it('Should add a Mollie paymentMethod', async () => {
-        const { createPaymentMethod } = await adminClient.query<CreatePaymentMethod.Mutation,
-            CreatePaymentMethod.Variables>(CREATE_PAYMENT_METHOD, {
+        const { createPaymentMethod } = await adminClient.query<
+            CreatePaymentMethod.Mutation,
+            CreatePaymentMethod.Variables
+        >(CREATE_PAYMENT_METHOD, {
             input: {
             input: {
                 code: mockData.methodCode,
                 code: mockData.methodCode,
                 name: 'Mollie payment test',
                 name: 'Mollie payment test',
@@ -199,22 +225,48 @@ describe('Mollie payments', () => {
         expect(result.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
         expect(result.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
     });
     });
 
 
+    it('Should fail to get payment url when items are out of stock', async () => {
+        let { updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
+            input: {
+                id: 'T_5',
+                trackInventory: 'TRUE',
+                outOfStockThreshold: 0,
+                stockOnHand: 1,
+            },
+        });
+        expect(updateProductVariants[0].stockOnHand).toBe(1);
+        const { createMolliePaymentIntent: result } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+            },
+        });
+        expect(result.message).toContain('The following variants are out of stock');
+        // Set stock back to not tracking
+        ({ updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
+            input: {
+                id: 'T_5',
+                trackInventory: 'FALSE',
+            },
+        }));
+        expect(updateProductVariants[0].trackInventory).toBe('FALSE');
+    });
+
     it('Should get payment url without Mollie method', async () => {
     it('Should get payment url without Mollie method', async () => {
-        let mollieRequest;
+        let mollieRequest: any | undefined;
         nock('https://api.mollie.com/')
         nock('https://api.mollie.com/')
-            .post(/.*/, body => {
+            .post('/v2/orders', body => {
                 mollieRequest = body;
                 mollieRequest = body;
                 return true;
                 return true;
             })
             })
             .reply(200, mockData.mollieOrderResponse);
             .reply(200, mockData.mollieOrderResponse);
-        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-        await setShipping(shopClient);
         const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
         const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
             input: {
             input: {
                 paymentMethodCode: mockData.methodCode,
                 paymentMethodCode: mockData.methodCode,
             },
             },
         });
         });
-        expect(createMolliePaymentIntent).toEqual({ url: 'https://www.mollie.com/payscreen/select-method/mock-payment' });
+        expect(createMolliePaymentIntent).toEqual({
+            url: 'https://www.mollie.com/payscreen/select-method/mock-payment',
+        });
         expect(mollieRequest?.orderNumber).toEqual(order.code);
         expect(mollieRequest?.orderNumber).toEqual(order.code);
         expect(mollieRequest?.redirectUrl).toEqual(`${mockData.redirectUrl}/${order.code}`);
         expect(mollieRequest?.redirectUrl).toEqual(`${mockData.redirectUrl}/${order.code}`);
         expect(mollieRequest?.webhookUrl).toEqual(
         expect(mollieRequest?.webhookUrl).toEqual(
@@ -232,27 +284,46 @@ describe('Mollie payments', () => {
     });
     });
 
 
     it('Should get payment url with Mollie method', async () => {
     it('Should get payment url with Mollie method', async () => {
-        let mollieRequest;
+        nock('https://api.mollie.com/').post('/v2/orders').reply(200, mockData.mollieOrderResponse);
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        await setShipping(shopClient);
+        const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+            input: {
+                paymentMethodCode: mockData.methodCode,
+                molliePaymentMethodCode: 'ideal',
+            },
+        });
+        expect(createMolliePaymentIntent).toEqual({
+            url: 'https://www.mollie.com/payscreen/select-method/mock-payment',
+        });
+    });
+
+    it('Should get payment url with deducted amount if a payment is already made', async () => {
+        let mollieRequest: any | undefined;
         nock('https://api.mollie.com/')
         nock('https://api.mollie.com/')
-            .post(/.*/, body => {
+            .post('/v2/orders', body => {
                 mollieRequest = body;
                 mollieRequest = body;
                 return true;
                 return true;
             })
             })
             .reply(200, mockData.mollieOrderResponse);
             .reply(200, mockData.mollieOrderResponse);
-        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-        await setShipping(shopClient);
-        const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+        await addManualPayment(server, 1, 10000);
+        await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
             input: {
             input: {
                 paymentMethodCode: mockData.methodCode,
                 paymentMethodCode: mockData.methodCode,
-                molliePaymentMethodCode: 'ideal',
             },
             },
         });
         });
-        expect(createMolliePaymentIntent).toEqual({ url: 'https://www.mollie.com/payscreen/select-method/mock-payment' });
+        expect(mollieRequest.amount?.value).toBe('909.90'); // minus 100,00 from manual payment
+        let totalLineAmount = 0;
+        for (const line of mollieRequest?.lines) {
+            totalLineAmount += Number(line.totalAmount.value);
+        }
+        // Sum of lines should equal order total
+        expect(mollieRequest.amount.value).toEqual(totalLineAmount.toFixed(2));
     });
     });
 
 
     it('Should immediately settle payment for standard payment methods', async () => {
     it('Should immediately settle payment for standard payment methods', async () => {
         nock('https://api.mollie.com/')
         nock('https://api.mollie.com/')
-            .get(/.*/)
+            .get('/v2/orders/ord_mockId')
             .reply(200, {
             .reply(200, {
                 ...mockData.mollieOrderResponse,
                 ...mockData.mollieOrderResponse,
                 orderNumber: order.code,
                 orderNumber: order.code,
@@ -275,7 +346,10 @@ describe('Mollie payments', () => {
     });
     });
 
 
     it('Should have Mollie metadata on payment', async () => {
     it('Should have Mollie metadata on payment', async () => {
-        const { order: { payments: [{ metadata }] } } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
+        const {
+            order: { payments },
+        } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
+        const metadata = payments[1].metadata;
         expect(metadata.mode).toBe(mockData.mollieOrderResponse.mode);
         expect(metadata.mode).toBe(mockData.mollieOrderResponse.mode);
         expect(metadata.method).toBe(mockData.mollieOrderResponse.method);
         expect(metadata.method).toBe(mockData.mollieOrderResponse.method);
         expect(metadata.profileId).toBe(mockData.mollieOrderResponse.profileId);
         expect(metadata.profileId).toBe(mockData.mollieOrderResponse.profileId);
@@ -284,39 +358,48 @@ describe('Mollie payments', () => {
     });
     });
 
 
     it('Should fail to refund', async () => {
     it('Should fail to refund', async () => {
-        let mollieRequest;
         nock('https://api.mollie.com/')
         nock('https://api.mollie.com/')
             .get('/v2/orders/ord_mockId?embed=payments')
             .get('/v2/orders/ord_mockId?embed=payments')
-            .twice()
             .reply(200, mockData.mollieOrderResponse);
             .reply(200, mockData.mollieOrderResponse);
         nock('https://api.mollie.com/')
         nock('https://api.mollie.com/')
-            .post(/.*/, body => {
-                mollieRequest = body;
-                return true;
-            })
+            .post('/v2/payments/tr_mockPayment/refunds')
             .reply(200, { status: 'failed', resource: 'payment' });
             .reply(200, { status: 'failed', resource: 'payment' });
-        const refund = await refundOne(adminClient, order.lines[0].id, order.payments[0].id);
+        const refund = await refundOrderLine(
+            adminClient,
+            order.lines[0].id,
+            1,
+            // tslint:disable-next-line:no-non-null-assertion
+            order!.payments[1].id,
+            SURCHARGE_AMOUNT,
+        );
         expect(refund.state).toBe('Failed');
         expect(refund.state).toBe('Failed');
     });
     });
 
 
-    it('Should successfully refund', async () => {
+    it('Should successfully refund the Mollie payment', async () => {
         let mollieRequest;
         let mollieRequest;
         nock('https://api.mollie.com/')
         nock('https://api.mollie.com/')
-            .post(/.*/, body => {
+            .get('/v2/orders/ord_mockId?embed=payments')
+            .reply(200, mockData.mollieOrderResponse);
+        nock('https://api.mollie.com/')
+            .post('/v2/payments/tr_mockPayment/refunds', body => {
                 mollieRequest = body;
                 mollieRequest = body;
                 return true;
                 return true;
             })
             })
             .reply(200, { status: 'pending', resource: 'payment' });
             .reply(200, { status: 'pending', resource: 'payment' });
-        const refund = await refundOne(adminClient, order.lines[0].id, order.payments[0].id);
-        expect(mollieRequest?.amount.value).toBe('119.99');
-        expect(refund.total).toBe(11999);
+        const refund = await refundOrderLine(
+            adminClient,
+            order.lines[0].id,
+            10,
+            order.payments[1].id,
+            SURCHARGE_AMOUNT,
+        );
+        expect(mollieRequest?.amount.value).toBe('909.90'); // Only refund mollie amount, not the gift card
+        expect(refund.total).toBe(90990);
         expect(refund.state).toBe('Settled');
         expect(refund.state).toBe('Settled');
     });
     });
 
 
     it('Should get available paymentMethods', async () => {
     it('Should get available paymentMethods', async () => {
-        nock('https://api.mollie.com/')
-            .get(/.*/)
-            .reply(200, mockData.molliePaymentMethodsResponse);
+        nock('https://api.mollie.com/').get('/v2/methods').reply(200, mockData.molliePaymentMethodsResponse);
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         const { molliePaymentMethods } = await shopClient.query(GET_MOLLIE_PAYMENT_METHODS, {
         const { molliePaymentMethods } = await shopClient.query(GET_MOLLIE_PAYMENT_METHODS, {
             input: {
             input: {
@@ -332,10 +415,13 @@ describe('Mollie payments', () => {
 
 
     it('Should prepare a new order', async () => {
     it('Should prepare a new order', async () => {
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         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,
-        });
+        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
+            ADD_ITEM_TO_ORDER,
+            {
+                productVariantId: 'T_1',
+                quantity: 2,
+            },
+        );
         order = addItemToOrder as TestOrderFragmentFragment;
         order = addItemToOrder as TestOrderFragmentFragment;
         await setShipping(shopClient);
         await setShipping(shopClient);
         expect(order.code).toBeDefined();
         expect(order.code).toBeDefined();
@@ -343,7 +429,7 @@ describe('Mollie payments', () => {
 
 
     it('Should authorize payment for pay-later payment methods', async () => {
     it('Should authorize payment for pay-later payment methods', async () => {
         nock('https://api.mollie.com/')
         nock('https://api.mollie.com/')
-            .get(/.*/)
+            .get('/v2/orders/ord_mockId')
             .reply(200, {
             .reply(200, {
                 ...mockData.mollieOrderResponse,
                 ...mockData.mollieOrderResponse,
                 orderNumber: order.code,
                 orderNumber: order.code,
@@ -368,7 +454,7 @@ describe('Mollie payments', () => {
     it('Should settle payment via settlePayment mutation', async () => {
     it('Should settle payment via settlePayment mutation', async () => {
         // Mock the getOrder Mollie call
         // Mock the getOrder Mollie call
         nock('https://api.mollie.com/')
         nock('https://api.mollie.com/')
-            .get(/.*/)
+            .get('/v2/orders/ord_mockId')
             .reply(200, {
             .reply(200, {
                 ...mockData.mollieOrderResponse,
                 ...mockData.mollieOrderResponse,
                 orderNumber: order.code,
                 orderNumber: order.code,
@@ -382,13 +468,13 @@ describe('Mollie payments', () => {
                 return true;
                 return true;
             })
             })
             .reply(200, { resource: 'shipment', lines: [] });
             .reply(200, { resource: 'shipment', lines: [] });
-        const { settlePayment } = await adminClient.query<SettlePaymentMutation, SettlePaymentMutationVariables>(
-            SETTLE_PAYMENT,
-            {
-                // tslint:disable-next-line:no-non-null-assertion
-                id: order.payments![0].id,
-            },
-        );
+        const { settlePayment } = await adminClient.query<
+            SettlePaymentMutation,
+            SettlePaymentMutationVariables
+        >(SETTLE_PAYMENT, {
+            // tslint:disable-next-line:no-non-null-assertion
+            id: order.payments![0].id,
+        });
         const { orderByCode } = await shopClient.query<GetOrderByCode.Query, GetOrderByCode.Variables>(
         const { orderByCode } = await shopClient.query<GetOrderByCode.Query, GetOrderByCode.Variables>(
             GET_ORDER_BY_CODE,
             GET_ORDER_BY_CODE,
             {
             {
@@ -400,6 +486,4 @@ describe('Mollie payments', () => {
         expect(createShipmentBody).toBeDefined();
         expect(createShipmentBody).toBeDefined();
         expect(order.state).toBe('PaymentSettled');
         expect(order.state).toBe('PaymentSettled');
     });
     });
-
-
 });
 });

+ 32 - 7
packages/payments-plugin/e2e/payment-helpers.ts

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

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

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

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

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

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

@@ -26,7 +26,7 @@ export interface MolliePluginOptions {
 /**
 /**
  * @description
  * @description
  * Plugin to enable payments through the [Mollie platform](https://docs.mollie.com/).
  * Plugin to enable payments through the [Mollie platform](https://docs.mollie.com/).
- * This plugin uses the Payments API from Mollie, not the Orders API.
+ * This plugin uses the Order API from Mollie, not the Payments API.
  *
  *
  * ## Requirements
  * ## Requirements
  *
  *

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

@@ -17,8 +17,11 @@ import {
     OrderStateTransitionError,
     OrderStateTransitionError,
     PaymentMethod,
     PaymentMethod,
     PaymentMethodService,
     PaymentMethodService,
+    ProductVariant,
+    ProductVariantService,
     RequestContext,
     RequestContext,
 } from '@vendure/core';
 } from '@vendure/core';
+import { totalCoveredByPayments } from '@vendure/core/dist/service/helpers/utils/order-utils';
 
 
 import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
 import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
 import {
 import {
@@ -61,6 +64,7 @@ export class MollieService {
         private orderService: OrderService,
         private orderService: OrderService,
         private channelService: ChannelService,
         private channelService: ChannelService,
         private entityHydrator: EntityHydrator,
         private entityHydrator: EntityHydrator,
+        private variantService: ProductVariantService,
     ) {
     ) {
     }
     }
 
 
@@ -83,7 +87,7 @@ export class MollieService {
             return new PaymentIntentError('No active order found for session');
             return new PaymentIntentError('No active order found for session');
         }
         }
         await this.entityHydrator.hydrate(ctx, order,
         await this.entityHydrator.hydrate(ctx, order,
-            { relations: ['customer', 'surcharges', 'lines.productVariant', 'shippingLines.shippingMethod'] }
+            { relations: ['customer', 'surcharges', 'lines.productVariant', 'shippingLines.shippingMethod', 'payments'] }
         );
         );
         if (!order.lines?.length) {
         if (!order.lines?.length) {
             return new PaymentIntentError('Cannot create payment intent for empty order');
             return new PaymentIntentError('Cannot create payment intent for empty order');
@@ -97,6 +101,12 @@ export class MollieService {
         if (!paymentMethod) {
         if (!paymentMethod) {
             return new PaymentIntentError(`No paymentMethod found with code ${paymentMethodCode}`);
             return new PaymentIntentError(`No paymentMethod found with code ${paymentMethodCode}`);
         }
         }
+        const variantsWithInsufficientSaleableStock = await this.getVariantsWithInsufficientStock(ctx, order);
+        if (variantsWithInsufficientSaleableStock.length) {
+            return new PaymentIntentError(
+                `The following variants are out of stock: ${variantsWithInsufficientSaleableStock.map(v => v.name).join(', ')}`
+            );
+        }
         const apiKey = paymentMethod.handler.args.find(arg => arg.name === 'apiKey')?.value;
         const apiKey = paymentMethod.handler.args.find(arg => arg.name === 'apiKey')?.value;
         let redirectUrl = paymentMethod.handler.args.find(arg => arg.name === 'redirectUrl')?.value;
         let redirectUrl = paymentMethod.handler.args.find(arg => arg.name === 'redirectUrl')?.value;
         if (!apiKey || !redirectUrl) {
         if (!apiKey || !redirectUrl) {
@@ -112,14 +122,16 @@ export class MollieService {
         if (!billingAddress) {
         if (!billingAddress) {
             return new InvalidInputError(`Order doesn't have a complete shipping address or billing address. At least city, streetline1 and country are needed to create a payment intent.`);
             return new InvalidInputError(`Order doesn't have a complete shipping address or billing address. At least city, streetline1 and country are needed to create a payment intent.`);
         }
         }
+        const alreadyPaid = totalCoveredByPayments(order);
+        const amountToPay = order.totalWithTax - alreadyPaid;
         const orderInput: CreateParameters = {
         const orderInput: CreateParameters = {
             orderNumber: order.code,
             orderNumber: order.code,
-            amount: toAmount(order.totalWithTax, order.currencyCode),
+            amount: toAmount(amountToPay, order.currencyCode),
             redirectUrl: `${redirectUrl}/${order.code}`,
             redirectUrl: `${redirectUrl}/${order.code}`,
             webhookUrl: `${vendureHost}/payments/mollie/${ctx.channel.token}/${paymentMethod.id}`,
             webhookUrl: `${vendureHost}/payments/mollie/${ctx.channel.token}/${paymentMethod.id}`,
             billingAddress,
             billingAddress,
             locale: getLocale(billingAddress.country, ctx.languageCode),
             locale: getLocale(billingAddress.country, ctx.languageCode),
-            lines: toMollieOrderLines(order),
+            lines: toMollieOrderLines(order, alreadyPaid),
         };
         };
         if (molliePaymentMethodCode) {
         if (molliePaymentMethodCode) {
             orderInput.method = molliePaymentMethodCode as MollieClientMethod;
             orderInput.method = molliePaymentMethodCode as MollieClientMethod;
@@ -158,7 +170,7 @@ export class MollieService {
         if (!order) {
         if (!order) {
             throw Error(`Unable to find order ${mollieOrder.orderNumber}, unable to process Mollie order ${mollieOrder.id}`);
             throw Error(`Unable to find order ${mollieOrder.orderNumber}, unable to process Mollie order ${mollieOrder.id}`);
         }
         }
-        if (mollieOrder.status === OrderStatus.paid ) {
+        if (mollieOrder.status === OrderStatus.paid) {
             // Paid is only used by 1-step payments without Authorized state. This will settle immediately
             // Paid is only used by 1-step payments without Authorized state. This will settle immediately
             await this.addPayment(ctx, order, mollieOrder, paymentMethod.code, 'Settled');
             await this.addPayment(ctx, order, mollieOrder, paymentMethod.code, 'Settled');
             return;
             return;
@@ -254,6 +266,20 @@ export class MollieService {
         }));
         }));
     }
     }
 
 
+    async getVariantsWithInsufficientStock(ctx: RequestContext, order: Order): Promise<ProductVariant[]> {
+        const variantsWithInsufficientSaleableStock: ProductVariant[] = [];
+        for (const line of order.lines) {
+            const availableStock = await this.variantService.getSaleableStockLevel(
+                ctx,
+                line.productVariant,
+            );
+            if (line.quantity > availableStock) {
+                variantsWithInsufficientSaleableStock.push(line.productVariant);
+            }
+        }
+        return variantsWithInsufficientSaleableStock;
+    }
+
     private async getPaymentMethod(ctx: RequestContext, paymentMethodCode: string): Promise<PaymentMethod | undefined> {
     private async getPaymentMethod(ctx: RequestContext, paymentMethodCode: string): Promise<PaymentMethod | undefined> {
         const paymentMethods = await this.paymentMethodService.findAll(ctx);
         const paymentMethods = await this.paymentMethodService.findAll(ctx);
         return paymentMethods.items.find(pm => pm.code === paymentMethodCode);
         return paymentMethods.items.find(pm => pm.code === paymentMethodCode);

+ 1 - 1
packages/testing/package.json

@@ -40,7 +40,7 @@
     "graphql": "15.5.1",
     "graphql": "15.5.1",
     "graphql-tag": "^2.10.1",
     "graphql-tag": "^2.10.1",
     "node-fetch": "^2.6.0",
     "node-fetch": "^2.6.0",
-    "sql.js": "1.3.2"
+    "sql.js": "1.8.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/mysql": "^2.15.15",
     "@types/mysql": "^2.15.15",

+ 40 - 13
yarn.lock

@@ -4190,6 +4190,13 @@
     "@types/node" "*"
     "@types/node" "*"
     rxjs "^6.5.1"
     rxjs "^6.5.1"
 
 
+"@types/localtunnel@2.0.1":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@types/localtunnel/-/localtunnel-2.0.1.tgz#c28a067de1da81b0b4730a9fc2f98e067f973996"
+  integrity sha512-0h/ggh+tp9uKHc2eEOLdMgWW0cNwsQfn6iEE1Y44FszNB4BQyL5N6xvd5BnChZksB0YgVqa5MKxJt0dFoOKRxw==
+  dependencies:
+    "@types/node" "*"
+
 "@types/long@^4.0.0", "@types/long@^4.0.1":
 "@types/long@^4.0.0", "@types/long@^4.0.1":
   version "4.0.1"
   version "4.0.1"
   resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
   resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
@@ -12418,6 +12425,16 @@ loader-utils@^1.4.0:
     emojis-list "^3.0.0"
     emojis-list "^3.0.0"
     json5 "^1.0.1"
     json5 "^1.0.1"
 
 
+localtunnel@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/localtunnel/-/localtunnel-2.0.1.tgz#8f7c593f3005647f7675e6e69af9bf746571a631"
+  integrity sha512-LiaI5wZdz0xFkIQpXbNI62ZnNn8IMsVhwxHmhA+h4vj8R9JG/07bQHWwQlyy7b95/5fVOCHJfIHv+a5XnkvaJA==
+  dependencies:
+    axios "0.21.1"
+    debug "4.3.1"
+    openurl "1.1.1"
+    yargs "16.2.0"
+
 locate-path@^2.0.0:
 locate-path@^2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
   resolved "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@@ -14351,6 +14368,11 @@ opencollective-postinstall@^2.0.2:
   resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259"
   resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259"
   integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==
   integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==
 
 
+openurl@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/openurl/-/openurl-1.1.1.tgz#3875b4b0ef7a52c156f0db41d4609dbb0f94b387"
+  integrity sha512-d/gTkTb1i1GKz5k3XE3XFV/PxQ1k45zDqGP2OA7YhgsaLoqm6qRvARAZOFer1fcXritWlGBRCu/UgeS4HAnXAA==
+
 opn@^5.5.0:
 opn@^5.5.0:
   version "5.5.0"
   version "5.5.0"
   resolved "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc"
   resolved "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc"
@@ -17636,6 +17658,11 @@ sql.js@1.3.2:
   resolved "https://registry.npmjs.org/sql.js/-/sql.js-1.3.2.tgz#ce0c7b9d884a7f50476e4c2fb29093e10a83ab09"
   resolved "https://registry.npmjs.org/sql.js/-/sql.js-1.3.2.tgz#ce0c7b9d884a7f50476e4c2fb29093e10a83ab09"
   integrity sha512-vRdzoj4TCrCX8yI2mv0OVVEuOOz2IlhEfw1x1Q65BhpmLep46iu+M04zxln4u2mHRk+wj7avFq2L3/1gQS1orQ==
   integrity sha512-vRdzoj4TCrCX8yI2mv0OVVEuOOz2IlhEfw1x1Q65BhpmLep46iu+M04zxln4u2mHRk+wj7avFq2L3/1gQS1orQ==
 
 
+sql.js@1.8.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/sql.js/-/sql.js-1.8.0.tgz#cb45d957e17a2239662fe2f614c9b678990867a6"
+  integrity sha512-3HD8pSkZL+5YvYUI8nlvNILs61ALqq34xgmF+BHpqxe68yZIJ1H+sIVIODvni25+CcxHUxDyrTJUL0lE/m7afw==
+
 sqlite3@^5.0.0:
 sqlite3@^5.0.0:
   version "5.0.2"
   version "5.0.2"
   resolved "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.2.tgz#00924adcc001c17686e0a6643b6cbbc2d3965083"
   resolved "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.2.tgz#00924adcc001c17686e0a6643b6cbbc2d3965083"
@@ -19754,6 +19781,19 @@ yargs-parser@^5.0.1:
     camelcase "^3.0.0"
     camelcase "^3.0.0"
     object.assign "^4.1.0"
     object.assign "^4.1.0"
 
 
+yargs@16.2.0, yargs@^16.0.0, yargs@^16.0.3, yargs@^16.1.0, yargs@^16.1.1, yargs@^16.2.0:
+  version "16.2.0"
+  resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
+  integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==
+  dependencies:
+    cliui "^7.0.2"
+    escalade "^3.1.1"
+    get-caller-file "^2.0.5"
+    require-directory "^2.1.1"
+    string-width "^4.2.0"
+    y18n "^5.0.5"
+    yargs-parser "^20.2.2"
+
 yargs@^13.3.0, yargs@^13.3.2:
 yargs@^13.3.0, yargs@^13.3.2:
   version "13.3.2"
   version "13.3.2"
   resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
   resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
@@ -19787,19 +19827,6 @@ yargs@^15.3.1:
     y18n "^4.0.0"
     y18n "^4.0.0"
     yargs-parser "^18.1.2"
     yargs-parser "^18.1.2"
 
 
-yargs@^16.0.0, yargs@^16.0.3, yargs@^16.1.0, yargs@^16.1.1, yargs@^16.2.0:
-  version "16.2.0"
-  resolved "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
-  integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==
-  dependencies:
-    cliui "^7.0.2"
-    escalade "^3.1.1"
-    get-caller-file "^2.0.5"
-    require-directory "^2.1.1"
-    string-width "^4.2.0"
-    y18n "^5.0.5"
-    yargs-parser "^20.2.2"
-
 yargs@^17.0.0:
 yargs@^17.0.0:
   version "17.1.1"
   version "17.1.1"
   resolved "https://registry.npmjs.org/yargs/-/yargs-17.1.1.tgz#c2a8091564bdb196f7c0a67c1d12e5b85b8067ba"
   resolved "https://registry.npmjs.org/yargs/-/yargs-17.1.1.tgz#c2a8091564bdb196f7c0a67c1d12e5b85b8067ba"