Bladeren bron

Merge branch 'minor' into major

Michael Bromley 3 jaren geleden
bovenliggende
commit
982ab93936
66 gewijzigde bestanden met toevoegingen van 1772 en 697 verwijderingen
  1. 46 0
      CHANGELOG.md
  2. 2 6
      docs/content/developer-guide/customizing-models.md
  3. 65 0
      docs/content/developer-guide/deployment.md
  4. 1 1
      docs/content/plugins/extending-the-admin-ui/using-other-frameworks/_index.md
  5. 1 1
      docs/content/storefront/building-a-storefront/_index.md
  6. 1 0
      packages/admin-ui-plugin/src/plugin.ts
  7. 21 16
      packages/admin-ui/i18n-coverage.json
  8. 311 289
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  9. 1 0
      packages/admin-ui/src/lib/core/src/data/definitions/order-definitions.ts
  10. 2 1
      packages/admin-ui/src/lib/core/src/data/providers/promotion-data.service.ts
  11. 1 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  12. 11 3
      packages/admin-ui/src/lib/core/src/shared/components/object-tree/object-tree.component.ts
  13. 35 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/generic/relation-generic-input.component.html
  14. 4 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/generic/relation-generic-input.component.scss
  15. 47 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/generic/relation-generic-input.component.ts
  16. 5 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.html
  17. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  18. 25 9
      packages/admin-ui/src/lib/order/src/components/cancel-order-dialog/cancel-order-dialog.component.html
  19. 5 1
      packages/admin-ui/src/lib/order/src/components/cancel-order-dialog/cancel-order-dialog.component.ts
  20. 22 0
      packages/admin-ui/src/lib/order/src/components/order-editor/order-editor.component.html
  21. 39 4
      packages/admin-ui/src/lib/order/src/components/order-editor/order-editor.component.ts
  22. 2 2
      packages/admin-ui/src/lib/order/src/components/refund-order-dialog/refund-order-dialog.component.html
  23. 6 0
      packages/admin-ui/src/lib/order/src/components/refund-order-dialog/refund-order-dialog.component.scss
  24. 5 1
      packages/admin-ui/src/lib/order/src/components/refund-order-dialog/refund-order-dialog.component.ts
  25. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  26. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  27. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  28. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  29. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  30. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/it.json
  31. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  32. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  33. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json
  34. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/ru.json
  35. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/uk.json
  36. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  37. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  38. 25 10
      packages/admin-ui/src/lib/static/vendure-ui-config.json
  39. 30 1
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  40. 3 3
      packages/common/src/generated-shop-types.ts
  41. 311 290
      packages/common/src/generated-types.ts
  42. 10 0
      packages/common/src/shared-types.ts
  43. 61 4
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  44. 3 3
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  45. 32 1
      packages/core/e2e/order-merge.e2e-spec.ts
  46. 251 0
      packages/core/e2e/order-modification.e2e-spec.ts
  47. 50 1
      packages/core/e2e/order-process.e2e-spec.ts
  48. 1 0
      packages/core/src/api/config/configure-graphql-module.ts
  49. 4 0
      packages/core/src/api/schema/admin-api/order.api.graphql
  50. 22 0
      packages/core/src/api/schema/common/common-error-results.graphql
  51. 0 29
      packages/core/src/api/schema/shop-api/shop-error-results.graphql
  52. 35 1
      packages/core/src/common/error/generated-graphql-admin-errors.ts
  53. 1 1
      packages/core/src/common/finite-state-machine/finite-state-machine.ts
  54. 1 0
      packages/core/src/config/default-config.ts
  55. 4 0
      packages/core/src/config/promotion/index.ts
  56. 22 0
      packages/core/src/config/vendure-config.ts
  57. 96 2
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  58. 1 1
      packages/core/src/service/helpers/utils/order-utils.ts
  59. 1 0
      packages/core/src/service/index.ts
  60. 55 6
      packages/core/src/service/services/order.service.ts
  61. 30 1
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  62. 30 1
      packages/payments-plugin/e2e/graphql/generated-admin-types.ts
  63. 3 3
      packages/payments-plugin/e2e/graphql/generated-shop-types.ts
  64. 3 3
      packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts
  65. 0 0
      schema-admin.json
  66. 0 0
      schema-shop.json

+ 46 - 0
CHANGELOG.md

@@ -1,3 +1,49 @@
+## 1.5.0 (2022-03-15)
+
+
+#### Fixes
+
+* **admin-ui** Fix circular dependency error ([ddc8941](https://github.com/vendure-ecommerce/vendure/commit/ddc8941))
+* **core** Add OrderTestingService to core exports (#1469) ([a827055](https://github.com/vendure-ecommerce/vendure/commit/a827055)), closes [#1469](https://github.com/vendure-ecommerce/vendure/issues/1469)
+* **core** Correctly populate Collections in channel ([b42bf1e](https://github.com/vendure-ecommerce/vendure/commit/b42bf1e))
+* **core** Export all Promotion conditions & actions ([56b30fa](https://github.com/vendure-ecommerce/vendure/commit/56b30fa)), closes [#1308](https://github.com/vendure-ecommerce/vendure/issues/1308)
+* **core** Fix FK error when merging orders with an existing session ([7cedf49](https://github.com/vendure-ecommerce/vendure/commit/7cedf49)), closes [#1454](https://github.com/vendure-ecommerce/vendure/issues/1454)
+* **core** Fix regression in accessing OrderLine.items when not defined ([3fcf5dc](https://github.com/vendure-ecommerce/vendure/commit/3fcf5dc))
+* **core** Prevent error cause by order in outdated state ([2266293](https://github.com/vendure-ecommerce/vendure/commit/2266293))
+* **core** Support usage of GQL interfaces on relational custom field (#1460) ([c608516](https://github.com/vendure-ecommerce/vendure/commit/c608516)), closes [#1460](https://github.com/vendure-ecommerce/vendure/issues/1460)
+* **core** Use sessionDuration when creating anonymous sessions ([2960a09](https://github.com/vendure-ecommerce/vendure/commit/2960a09)), closes [#1425](https://github.com/vendure-ecommerce/vendure/issues/1425)
+* **email-plugin** Add currency code in mock email confirmation (#1448) ([ef8b244](https://github.com/vendure-ecommerce/vendure/commit/ef8b244)), closes [#1448](https://github.com/vendure-ecommerce/vendure/issues/1448)
+* **email-plugin** Correctly resolve urls for OrderLine featured assets ([15f9b44](https://github.com/vendure-ecommerce/vendure/commit/15f9b44))
+* **payments-plugin** Mollie payment intent + Stripe unauthorized settlement fix (#1437) ([37e5f58](https://github.com/vendure-ecommerce/vendure/commit/37e5f58)), closes [#1437](https://github.com/vendure-ecommerce/vendure/issues/1437) [#1432](https://github.com/vendure-ecommerce/vendure/issues/1432) [#1340](https://github.com/vendure-ecommerce/vendure/issues/1340)
+
+#### Features
+
+* **admin-ui-plugin** Make refund/cancellation reasons configurable ([1ab0119](https://github.com/vendure-ecommerce/vendure/commit/1ab0119)), closes [#893](https://github.com/vendure-ecommerce/vendure/issues/893)
+* **admin-ui** Add asset preview links to asset gallery & asset detail ([b09bc1f](https://github.com/vendure-ecommerce/vendure/commit/b09bc1f)), closes [#1305](https://github.com/vendure-ecommerce/vendure/issues/1305)
+* **admin-ui** Allow couponCodes to be set when modifying Order ([8083219](https://github.com/vendure-ecommerce/vendure/commit/8083219)), closes [#1308](https://github.com/vendure-ecommerce/vendure/issues/1308)
+* **admin-ui** Allow custom ng compiler args to be passed to admin ui compiler (#1386) ([d47df21](https://github.com/vendure-ecommerce/vendure/commit/d47df21)), closes [#1386](https://github.com/vendure-ecommerce/vendure/issues/1386)
+* **admin-ui** Enable filtering CustomerList by postalCode ([f3a2654](https://github.com/vendure-ecommerce/vendure/commit/f3a2654)), closes [#1389](https://github.com/vendure-ecommerce/vendure/issues/1389)
+* **admin-ui** Implement deletion of addresses from customer detail ([4a81f7c](https://github.com/vendure-ecommerce/vendure/commit/4a81f7c))
+* **admin-ui** Implement generic custom field relation selector ([f3ea8a3](https://github.com/vendure-ecommerce/vendure/commit/f3ea8a3))
+* **admin-ui** Improve cancel modal to allow full order cancellation ([3b90888](https://github.com/vendure-ecommerce/vendure/commit/3b90888)), closes [#1414](https://github.com/vendure-ecommerce/vendure/issues/1414)
+* **admin-ui** More flexible assets component (#1358) ([259e352](https://github.com/vendure-ecommerce/vendure/commit/259e352)), closes [#1358](https://github.com/vendure-ecommerce/vendure/issues/1358) [#1357](https://github.com/vendure-ecommerce/vendure/issues/1357)
+* **core** Add `inList` op to enable filtering on custom field lists ([94da850](https://github.com/vendure-ecommerce/vendure/commit/94da850)), closes [#1332](https://github.com/vendure-ecommerce/vendure/issues/1332)
+* **core** Add a job queue name prefix as a config option (#1359) ([921f8e0](https://github.com/vendure-ecommerce/vendure/commit/921f8e0)), closes [#1359](https://github.com/vendure-ecommerce/vendure/issues/1359) [#1350](https://github.com/vendure-ecommerce/vendure/issues/1350)
+* **core** Add option to CancelOrderInput to cancel of shipping ([9eebae3](https://github.com/vendure-ecommerce/vendure/commit/9eebae3)), closes [#1414](https://github.com/vendure-ecommerce/vendure/issues/1414)
+* **core** Add order event (#1306) ([c682c0e](https://github.com/vendure-ecommerce/vendure/commit/c682c0e)), closes [#1306](https://github.com/vendure-ecommerce/vendure/issues/1306)
+* **core** Add OrderLineEvent, to notify on changes to Order.lines ([16e099f](https://github.com/vendure-ecommerce/vendure/commit/16e099f)), closes [#1316](https://github.com/vendure-ecommerce/vendure/issues/1316)
+* **core** Add PasswordValidationStrategy to enable password policies ([dc4bc2d](https://github.com/vendure-ecommerce/vendure/commit/dc4bc2d)), closes [#863](https://github.com/vendure-ecommerce/vendure/issues/863)
+* **core** Allow channel to be specified in `populate()` function ([03b9fe1](https://github.com/vendure-ecommerce/vendure/commit/03b9fe1)), closes [#877](https://github.com/vendure-ecommerce/vendure/issues/877)
+* **core** Allow couponCodes to be set when modifying Order ([af3a705](https://github.com/vendure-ecommerce/vendure/commit/af3a705)), closes [#1308](https://github.com/vendure-ecommerce/vendure/issues/1308)
+* **core** Allow schema introspection to be disabled ([052d494](https://github.com/vendure-ecommerce/vendure/commit/052d494)), closes [#1353](https://github.com/vendure-ecommerce/vendure/issues/1353)
+* **core** Enable filtering customers by postalCode ([6692b95](https://github.com/vendure-ecommerce/vendure/commit/6692b95)), closes [#1389](https://github.com/vendure-ecommerce/vendure/issues/1389)
+* **core** Expose & document DataImportModule providers ([640f087](https://github.com/vendure-ecommerce/vendure/commit/640f087)), closes [#1336](https://github.com/vendure-ecommerce/vendure/issues/1336)
+* **core** Expose RequestContextService and add `create()` method ([335dfb5](https://github.com/vendure-ecommerce/vendure/commit/335dfb5))
+* **core** Include Customer in CustomerAddressEvent ([67f60ac](https://github.com/vendure-ecommerce/vendure/commit/67f60ac)), closes [#1369](https://github.com/vendure-ecommerce/vendure/issues/1369)
+* **core** Loosen constraints on adding payment to Order ([7a42b01](https://github.com/vendure-ecommerce/vendure/commit/7a42b01)), closes [#963](https://github.com/vendure-ecommerce/vendure/issues/963)
+* **payments-plugin** Add Stripe integration (#1417) ([238be6b](https://github.com/vendure-ecommerce/vendure/commit/238be6b)), closes [#1417](https://github.com/vendure-ecommerce/vendure/issues/1417)
+* **ui-devkit** Allow yarn or npm to be specified to run ng compiler ([db66657](https://github.com/vendure-ecommerce/vendure/commit/db66657))
+
 ## <small>1.4.7 (2022-02-22)</small>
 
 

+ 2 - 6
docs/content/developer-guide/customizing-models.md

@@ -401,11 +401,7 @@ mutation {
 {{% alert %}}
 **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 instead see the message:
+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.
 
-```text
-No input component configured for "<entity>" type
-```
-
-In this case, you will need to 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 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" >}})
 {{< /alert >}}

+ 65 - 0
docs/content/developer-guide/deployment.md

@@ -39,6 +39,7 @@ and you should expect to see `UTC` or `Etc/UTC`.
 For a production Vendure server, there are a few security-related points to consider when deploying:
 
 * Set the [Superadmin credentials]({{< relref "auth-options" >}}#superadmincredentials) to something other than the default.
+* Disable introspection in the [ApiOptions]({{ relref "api-options" }}#introspection) (this option is available in v1.5+).
 * Consider taking steps to harden your GraphQL APIs against DOS attacks. Use the [ApiOptions]({{< relref "api-options" >}}) to set up appropriate Express middleware for things like [request timeouts](https://github.com/expressjs/express/issues/3330) and [rate limits](https://www.npmjs.com/package/express-rate-limit). A tool such as [graphql-query-complexity](https://github.com/slicknode/graphql-query-complexity) can be used to mitigate resource-intensive GraphQL queries. 
 * You may wish to restrict the Admin API to only be accessed from trusted IPs. This could be achieved for instance by configuring an nginx reverse proxy that sits in front of the Vendure server.
 * By default, Vendure uses auto-increment integer IDs as entity primary keys. While easier to work with in development, sequential primary keys can leak information such as the number of orders or customers in the system. For this reason you should consider using the [UuidIdStrategy]({{< relref "entity-id-strategy" >}}#uuididstrategy) for production.
@@ -203,3 +204,67 @@ compileUiExtensions({
     process.exit(0);
   });
 ```
+
+
+## Docker & Kubernetes
+
+For a production ready Vendure server running on Kubernetes you can use the following Dockerfile and Kubernetes configuration. 
+
+### Docker
+
+Assuming a project which has been scaffolded using `@vendure/create`, create a 
+Dockerfile in the root directory that looks like this:
+
+```Dockerfile
+FROM node:16
+WORKDIR /usr/src/app
+COPY . .
+RUN yarn install --production
+RUN yarn build
+```
+
+Build your Docker container using `docker build -t vendure-shop:latest .`
+
+### Kubernetes Deployment
+
+This deployment starts the shop container we created above as both worker and server.
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: vendure-shop
+spec:
+  selector:
+    matchLabels:
+      app: vendure-shop
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: vendure-shop
+    spec:
+      containers:
+        - name: server
+          image: vendure-shop:latest
+          command:
+            - node
+          args:
+            - "dist/index.js"
+          env:
+          # your env config here
+          ports:
+            - containerPort: 3000
+
+        - name: worker
+          image: vendure-shop:latest
+          imagePullPolicy: Always
+          command:
+            - node
+          args:
+            - "dist/index-worker.js"
+          env:
+          # your env config here
+          ports:
+            - containerPort: 3000
+```

+ 1 - 1
docs/content/plugins/extending-the-admin-ui/using-other-frameworks/_index.md

@@ -142,7 +142,7 @@ The `compileUiExtensions()` function returns a `compile()` function which will b
 Next, these source files will be run through the Angular compiler, the output of which will be visible in the console.
 
 {{< alert "warning" >}}
-**Note:** The first time the compiler is run, an additional step ([compatibility compiler](https://angular.io/guide/ivy#ivy-and-libraries)) is run to make sure all dependencies work with the latest version of Angular. This step can take up to a few minutes.
+**Note:** The first time the compiler is run, an additional step ([compatibility compiler](https://v12.angular.io/guide/ivy#ivy-and-libraries)) is run to make sure all dependencies work with the latest version of Angular. This step can take up to a few minutes.
 {{< /alert >}}
 
 Now go to the Admin UI app in your browser and log in. You should now be able to manually enter the URL `http://localhost:3000/admin/extensions/react-ui` and you should see the default Create React App demo page:

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

@@ -25,7 +25,7 @@ For step-by-step instructions see our [Vue Storefront integration blog post]({{<
  
 {{< figure src="./vercel-commerce-screenshot.webp" >}}
 
-[Next.js](https://nextjs.org/) is a popular React-based framework which many Vendure developers have chosen as the basis of their storefront application. The team behind Next.js have created an e-commerce-specific solution, [Next.js Commerce](https://nextjs.org/commerce), and it includes an official [Vendure integration](https://github.com/vercel/commerce/tree/main/framework/vendure)
+[Next.js](https://nextjs.org/) is a popular React-based framework which many Vendure developers have chosen as the basis of their storefront application. The team behind Next.js have created an e-commerce-specific solution, [Next.js Commerce](https://nextjs.org/commerce), and it includes an official [Vendure integration](https://github.com/vercel/commerce/tree/main/packages/vendure)
 
 [Next.js Commerce Vendure integration demo](https://vendure.vercel.store/)
 

+ 1 - 0
packages/admin-ui-plugin/src/plugin.ts

@@ -246,6 +246,7 @@ export class AdminUiPlugin implements NestModule {
                 'hideVersion',
                 AdminUiPlugin.options.adminUiConfig?.hideVersion || false,
             ),
+            cancellationReasons: propOrDefault('cancellationReasons', undefined),
         };
     }
 

+ 21 - 16
packages/admin-ui/i18n-coverage.json

@@ -1,66 +1,71 @@
 {
-  "generatedOn": "2022-03-03T10:38:41.224Z",
-  "lastCommit": "08d339378d3d16daee27f003eb187243816869e5",
+  "generatedOn": "2022-03-14T12:52:29.280Z",
+  "lastCommit": "1ab01194344fd5310fa4d7cd487c22de826d2744",
   "translationStatus": {
     "cs": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 591,
       "percentage": 92
     },
+    "de": {
+      "tokenCount": 640,
+      "translatedCount": 570,
+      "percentage": 89
+    },
     "en": {
-      "tokenCount": 641,
-      "translatedCount": 638,
+      "tokenCount": 640,
+      "translatedCount": 640,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 623,
       "percentage": 97
     },
     "fr": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 613,
       "percentage": 96
     },
     "it": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 621,
       "percentage": 97
     },
     "pl": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 405,
       "percentage": 63
     },
     "pt_BR": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 589,
       "percentage": 92
     },
     "pt_PT": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 634,
       "percentage": 99
     },
     "ru": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 620,
       "percentage": 97
     },
     "uk": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 620,
       "percentage": 97
     },
     "zh_Hans": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 557,
       "percentage": 87
     },
     "zh_Hant": {
-      "tokenCount": 641,
+      "tokenCount": 640,
       "translatedCount": 385,
       "percentage": 60
     }
   }
-}
+}

File diff suppressed because it is too large
+ 311 - 289
packages/admin-ui/src/lib/core/src/common/generated-types.ts


+ 1 - 0
packages/admin-ui/src/lib/core/src/data/definitions/order-definitions.ts

@@ -127,6 +127,7 @@ export const ORDER_DETAIL_FRAGMENT = gql`
         state
         nextStates
         active
+        couponCodes
         customer {
             id
             firstName

+ 2 - 1
packages/admin-ui/src/lib/core/src/data/providers/promotion-data.service.ts

@@ -13,7 +13,7 @@ import { BaseDataService } from './base-data.service';
 export class PromotionDataService {
     constructor(private baseDataService: BaseDataService) {}
 
-    getPromotions(take: number = 10, skip: number = 0) {
+    getPromotions(take: number = 10, skip: number = 0, filter?: Codegen.PromotionFilterParameter) {
         return this.baseDataService.query<
             Codegen.GetPromotionListQuery,
             Codegen.GetPromotionListQueryVariables
@@ -21,6 +21,7 @@ export class PromotionDataService {
             options: {
                 take,
                 skip,
+                filter,
             },
         });
     }

+ 1 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -188,6 +188,7 @@ export * from './shared/dynamic-form-inputs/product-selector-form-input/product-
 export * from './shared/dynamic-form-inputs/register-dynamic-input-components';
 export * from './shared/dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component';
 export * from './shared/dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component';
+export * from './shared/dynamic-form-inputs/relation-form-input/generic/relation-generic-input.component';
 export * from './shared/dynamic-form-inputs/relation-form-input/product/relation-product-input.component';
 export * from './shared/dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component';
 export * from './shared/dynamic-form-inputs/relation-form-input/relation-card/relation-card.component';

+ 11 - 3
packages/admin-ui/src/lib/core/src/shared/components/object-tree/object-tree.component.ts

@@ -1,4 +1,12 @@
-import { ChangeDetectionStrategy, Component, Input, OnInit, Optional, SkipSelf } from '@angular/core';
+import {
+    ChangeDetectionStrategy,
+    Component,
+    Input,
+    OnChanges,
+    OnInit,
+    Optional,
+    SkipSelf,
+} from '@angular/core';
 
 /**
  * @description
@@ -17,7 +25,7 @@ import { ChangeDetectionStrategy, Component, Input, OnInit, Optional, SkipSelf }
     styleUrls: ['./object-tree.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class ObjectTreeComponent implements OnInit {
+export class ObjectTreeComponent implements OnChanges {
     @Input() value: { [key: string]: any } | string;
     @Input() isArrayItem = false;
     depth: number;
@@ -32,7 +40,7 @@ export class ObjectTreeComponent implements OnInit {
         }
     }
 
-    ngOnInit() {
+    ngOnChanges() {
         this.entries = this.getEntries(this.value);
         this.expanded = this.depth === 0 || this.isArrayItem;
         this.valueIsArray = Object.keys(this.value).every(v => Number.isInteger(+v));

+ 35 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/generic/relation-generic-input.component.html

@@ -0,0 +1,35 @@
+<vdr-relation-card
+    (select)="selectRelationId()"
+    (remove)="remove()"
+    placeholderIcon="objects"
+    [entity]="parentFormControl.value"
+    [selectLabel]="'common.select-relation-id' | translate"
+    [removable]="!config.list"
+    [readonly]="readonly"
+>
+    {{ parentFormControl.value | json }}
+    <ng-template vdrRelationCardPreview>
+        <div class="placeholder">
+            <clr-icon shape="objects" size="50"></clr-icon>
+        </div>
+    </ng-template>
+    <ng-template vdrRelationCardDetail let-entity="entity">
+        <div class="">
+            {{ config.entity }}: <strong>{{ entity.id }}</strong>
+        </div>
+        <vdr-object-tree [value]="{ properties: parentFormControl.value }"></vdr-object-tree>
+    </ng-template>
+</vdr-relation-card>
+
+<ng-template #selector let-select="select">
+    <div class="id-select-wrapper">
+        <clr-input-container>
+            <input [(ngModel)]="relationId" type="text" clrInput [readonly]="readonly" />
+        </clr-input-container>
+        <div>
+            <button class="btn btn-primary m0" (click)="select(relationId)">
+                {{ 'common.confirm' | translate }}
+            </button>
+        </div>
+    </div>
+</ng-template>

+ 4 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/generic/relation-generic-input.component.scss

@@ -0,0 +1,4 @@
+.id-select-wrapper {
+    display: flex;
+    align-items: flex-end;
+}

+ 47 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/generic/relation-generic-input.component.ts

@@ -0,0 +1,47 @@
+import { ChangeDetectionStrategy, Component, Input, TemplateRef, ViewChild } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+
+import { RelationCustomFieldConfig } from '../../../../common/generated-types';
+import { ModalService } from '../../../../providers/modal/modal.service';
+import { RelationSelectorDialogComponent } from '../relation-selector-dialog/relation-selector-dialog.component';
+
+@Component({
+    selector: 'vdr-relation-generic-input',
+    templateUrl: './relation-generic-input.component.html',
+    styleUrls: ['./relation-generic-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RelationGenericInputComponent {
+    @Input() readonly: boolean;
+    @Input() parentFormControl: FormControl;
+    @Input() config: RelationCustomFieldConfig;
+    relationId: string;
+
+    @ViewChild('selector') template: TemplateRef<any>;
+
+    constructor(private modalService: ModalService) {}
+
+    selectRelationId() {
+        this.modalService
+            .fromComponent(RelationSelectorDialogComponent, {
+                size: 'md',
+                closable: true,
+                locals: {
+                    title: _('common.select-relation-id'),
+                    selectorTemplate: this.template,
+                },
+            })
+            .subscribe(result => {
+                if (result) {
+                    this.parentFormControl.setValue({ id: result });
+                    this.parentFormControl.markAsDirty();
+                }
+            });
+    }
+
+    remove() {
+        this.parentFormControl.setValue(null);
+        this.parentFormControl.markAsDirty();
+    }
+}

+ 5 - 1
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.html

@@ -24,6 +24,10 @@
         [readonly]="readonly"
     ></vdr-relation-product-variant-input>
     <ng-template ngSwitchDefault>
-        No input component configured for "{{ config.entity }}" type
+        <vdr-relation-generic-input
+            [parentFormControl]="formControl"
+               [config]="config"
+               [readonly]="readonly"
+        ></vdr-relation-generic-input>
     </ng-template>
 </div>

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -98,6 +98,7 @@ import { PasswordFormInputComponent } from './dynamic-form-inputs/password-form-
 import { ProductSelectorFormInputComponent } from './dynamic-form-inputs/product-selector-form-input/product-selector-form-input.component';
 import { RelationAssetInputComponent } from './dynamic-form-inputs/relation-form-input/asset/relation-asset-input.component';
 import { RelationCustomerInputComponent } from './dynamic-form-inputs/relation-form-input/customer/relation-customer-input.component';
+import { RelationGenericInputComponent } from './dynamic-form-inputs/relation-form-input/generic/relation-generic-input.component';
 import { RelationProductVariantInputComponent } from './dynamic-form-inputs/relation-form-input/product-variant/relation-product-variant-input.component';
 import { RelationProductInputComponent } from './dynamic-form-inputs/relation-form-input/product/relation-product-input.component';
 import {
@@ -253,6 +254,7 @@ const DYNAMIC_FORM_INPUTS = [
     RelationCardPreviewDirective,
     RelationCardDetailDirective,
     RelationSelectorDialogComponent,
+    RelationGenericInputComponent,
     TextareaFormInputComponent,
     RichTextFormInputComponent,
     JsonEditorFormInputComponent,

+ 25 - 9
packages/admin-ui/src/lib/order/src/components/cancel-order-dialog/cancel-order-dialog.component.html

@@ -20,7 +20,7 @@
                 [class.is-cancelled]="line.quantity === 0"
             >
                 <td class="align-middle thumb">
-                    <img [src]="line.featuredAsset | assetPreview:'tiny'" />
+                    <img [src]="line.featuredAsset | assetPreview: 'tiny'" />
                 </td>
                 <td class="align-middle name">{{ line.productVariant.name }}</td>
                 <td class="align-middle sku">{{ line.productVariant.sku }}</td>
@@ -44,14 +44,30 @@
         </table>
     </div>
     <div class="cancellation-details">
-        <clr-radio-wrapper>
-          <input type="radio" clrRadio [value]="true" [(ngModel)]="cancelAll" name="options" (ngModelChange)="radioChanged()" />
-          <label>{{ 'order.cancel-entire-order' | translate }}</label>
-        </clr-radio-wrapper>
-        <clr-radio-wrapper>
-          <input type="radio" clrRadio [value]="false" [(ngModel)]="cancelAll" name="options" (ngModelChange)="radioChanged()" />
-          <label>{{ 'order.cancel-specified-items' | translate }}</label>
-        </clr-radio-wrapper>
+        <ng-container *ngIf="order.active !== true">
+            <clr-radio-wrapper>
+                <input
+                    type="radio"
+                    clrRadio
+                    [value]="true"
+                    [(ngModel)]="cancelAll"
+                    name="options"
+                    (ngModelChange)="radioChanged()"
+                />
+                <label>{{ 'order.cancel-entire-order' | translate }}</label>
+            </clr-radio-wrapper>
+            <clr-radio-wrapper>
+                <input
+                    type="radio"
+                    clrRadio
+                    [value]="false"
+                    [(ngModel)]="cancelAll"
+                    name="options"
+                    (ngModelChange)="radioChanged()"
+                />
+                <label>{{ 'order.cancel-specified-items' | translate }}</label>
+            </clr-radio-wrapper>
+        </ng-container>
         <label class="clr-control-label">{{ 'order.cancellation-reason' | translate }}</label>
         <ng-select
             [items]="reasons"

+ 5 - 1
packages/admin-ui/src/lib/order/src/components/cancel-order-dialog/cancel-order-dialog.component.ts

@@ -3,6 +3,7 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
     CancelOrderInput,
     Dialog,
+    getAppConfig,
     I18nService,
     OrderDetailFragment,
     OrderLineInput,
@@ -20,7 +21,10 @@ export class CancelOrderDialogComponent implements OnInit, Dialog<CancelOrderInp
     resolveWith: (result?: CancelOrderInput) => void;
     reason: string;
     lineQuantities: { [lineId: string]: number } = {};
-    reasons: string[] = [_('order.cancel-reason-customer-request'), _('order.cancel-reason-not-available')];
+    reasons: string[] = getAppConfig().cancellationReasons ?? [
+        _('order.cancel-reason-customer-request'),
+        _('order.cancel-reason-not-available'),
+    ];
 
     get selectionCount(): number {
         return Object.values(this.lineQuantities).reduce((sum, n) => sum + n, 0);

+ 22 - 0
packages/admin-ui/src/lib/order/src/components/order-editor/order-editor.component.html

@@ -224,6 +224,28 @@
                         </button>
                     </clr-accordion-content>
                 </clr-accordion-panel>
+                <clr-accordion-panel>
+                    <clr-accordion-title>{{ 'order.set-coupon-codes' | translate }}</clr-accordion-title>
+                    <clr-accordion-content *clrIfExpanded>
+                        <ng-select
+                            [items]="availableCouponCodes$ | async"
+                            appendTo="body"
+                            bindLabel="code"
+                            bindValue="code"
+                            [addTag]="false"
+                            [multiple]="true"
+                            [hideSelected]="true"
+                            [minTermLength]="2"
+                            typeToSearchText=""
+                            [typeahead]="couponCodeInput$"
+                            [formControl]="couponCodesControl"
+                        >
+                            <ng-template ng-option-tmp let-item="item">
+                                <vdr-chip>{{ item.code }}</vdr-chip> {{ item.promotionName }}
+                            </ng-template>
+                        </ng-select>
+                    </clr-accordion-content>
+                </clr-accordion-panel>
 
                 <clr-accordion-panel>
                     <clr-accordion-title>{{ 'order.add-surcharge' | translate }}</clr-accordion-title>

+ 39 - 4
packages/admin-ui/src/lib/order/src/components/order-editor/order-editor.component.ts

@@ -22,8 +22,16 @@ import {
     SurchargeInput,
 } from '@vendure/admin-ui/core';
 import { assertNever, notNullOrUndefined } from '@vendure/common/lib/shared-utils';
-import { EMPTY, Observable, of } from 'rxjs';
-import { mapTo, shareReplay, switchMap, takeUntil } from 'rxjs/operators';
+import { concat, EMPTY, Observable, of, Subject } from 'rxjs';
+import {
+    distinctUntilChanged,
+    map,
+    mapTo,
+    shareReplay,
+    startWith,
+    switchMap,
+    takeUntil,
+} from 'rxjs/operators';
 
 import { OrderTransitionService } from '../../providers/order-transition.service';
 import {
@@ -59,8 +67,11 @@ export class OrderEditorComponent
     implements OnInit, OnDestroy
 {
     availableCountries$: Observable<GetAvailableCountriesQuery['countries']['items']>;
+    availableCouponCodes$: Observable<Array<{ code: string; promotionName: string }>>;
+    couponCodeInput$ = new Subject<string>();
     addressCustomFields: CustomFieldConfig[];
     detailForm = new FormGroup({});
+    couponCodesControl = new FormControl();
     orderLineCustomFieldsFormArray: FormArray;
     addItemCustomFieldsFormArray: FormArray;
     addItemCustomFieldsForm: FormGroup;
@@ -121,6 +132,9 @@ export class OrderEditorComponent
         this.modifyOrderInput.orderId = this.route.snapshot.paramMap.get('id') as string;
         this.orderLineCustomFields = this.getCustomFieldConfig('OrderLine');
         this.entity$.pipe(takeUntil(this.destroy$)).subscribe(order => {
+            if (order.couponCodes.length) {
+                this.couponCodesControl.setValue(order.couponCodes);
+            }
             this.surchargeForm = new FormGroup({
                 description: new FormControl('', Validators.required),
                 sku: new FormControl(''),
@@ -181,6 +195,22 @@ export class OrderEditorComponent
                 this.orderLineCustomFieldsFormArray.push(formGroup);
             }
         });
+        this.availableCouponCodes$ = concat(
+            this.couponCodeInput$.pipe(
+                distinctUntilChanged(),
+                switchMap(
+                    term =>
+                        this.dataService.promotion.getPromotions(10, 0, {
+                            couponCode: { contains: term },
+                        }).single$,
+                ),
+                map(({ promotions }) =>
+                    // tslint:disable-next-line:no-non-null-assertion
+                    promotions.items.map(p => ({ code: p.couponCode!, promotionName: p.name })),
+                ),
+                startWith([]),
+            ),
+        );
         this.addItemCustomFieldsFormArray = new FormArray([]);
         this.addItemCustomFieldsForm = new FormGroup({});
         for (const customField of this.orderLineCustomFields) {
@@ -222,7 +252,8 @@ export class OrderEditorComponent
             !!surcharges?.length ||
             !!adjustOrderLines?.length ||
             (this.shippingAddressForm.dirty && this.shippingAddressForm.valid) ||
-            (this.billingAddressForm.dirty && this.billingAddressForm.valid)
+            (this.billingAddressForm.dirty && this.billingAddressForm.valid) ||
+            this.couponCodesControl.dirty
         );
     }
 
@@ -355,6 +386,7 @@ export class OrderEditorComponent
                 ? { updateShippingAddress: this.shippingAddressForm.value }
                 : {}),
             dryRun: true,
+            couponCodes: this.couponCodesControl.dirty ? this.couponCodesControl.value : undefined,
             note: this.note ?? '',
             options: {
                 recalculateShipping: this.recalculateShipping,
@@ -383,7 +415,10 @@ export class OrderEditorComponent
                         case 'OrderLimitError':
                         case 'OrderModificationStateError':
                         case 'PaymentMethodMissingError':
-                        case 'RefundPaymentIdMissingError': {
+                        case 'RefundPaymentIdMissingError':
+                        case 'CouponCodeLimitError':
+                        case 'CouponCodeExpiredError':
+                        case 'CouponCodeInvalidError': {
                             this.notificationService.error(modifyOrder.message);
                             return of(false as const);
                         }

+ 2 - 2
packages/admin-ui/src/lib/order/src/components/refund-order-dialog/refund-order-dialog.component.html

@@ -53,7 +53,7 @@
                         </ng-container>
                     </div>
                 </td>
-                <td class="align-middle fulfil">
+                <td class="align-middle quantity-col">
                     <input
                         *ngIf="lineCanBeRefundedOrCancelled(line)"
                         [(ngModel)]="lineQuantities[line.id].quantity"
@@ -88,7 +88,7 @@
             </tr>
         </table>
     </div>
-    <div class="refund-details mt4">
+    <div class="refund-details mt4" [class.faded]="!isRefunding() && !isCancelling()">
         <div>
             <label class="clr-control-label">{{ 'order.refund-cancellation-reason' | translate }}</label>
             <ng-select

+ 6 - 0
packages/admin-ui/src/lib/order/src/components/refund-order-dialog/refund-order-dialog.component.scss

@@ -18,6 +18,9 @@
         color: var(--color-grey-300);
     }
 }
+.quantity-col {
+    background-color: var(--color-warning-100);
+}
 .cancel-checkbox-wrapper {
     display: flex;
     align-items: center;
@@ -31,6 +34,9 @@ clr-checkbox-wrapper {
 .refund-details {
     display: flex;
     justify-content: space-between;
+    &.faded {
+        opacity: 0.5;
+    }
 }
 .totals {
     margin-top: 48px;

+ 5 - 1
packages/admin-ui/src/lib/order/src/components/refund-order-dialog/refund-order-dialog.component.ts

@@ -3,6 +3,7 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
     CancelOrderInput,
     Dialog,
+    getAppConfig,
     I18nService,
     OrderDetailFragment,
     OrderLineInput,
@@ -30,7 +31,10 @@ export class RefundOrderDialogComponent
     lineQuantities: { [lineId: string]: SelectionLine } = {};
     refundShipping = false;
     adjustment = 0;
-    reasons: string[] = [_('order.refund-reason-customer-request'), _('order.refund-reason-not-available')];
+    reasons = getAppConfig().cancellationReasons ?? [
+        _('order.refund-reason-customer-request'),
+        _('order.refund-reason-not-available'),
+    ];
 
     constructor(private i18nService: I18nService) {
         this.reasons = this.reasons.map(r => this.i18nService.translate(r));

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/cs.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "Vybrat...",
     "select-display-language": "Vyberte jazyk",
+    "select-relation-id": "",
     "select-today": "Vybrat dnešní datum",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "",
     "search-by-order-code": "Hledat na základě kódu objednávky",
     "select-state": "Vyberte stav",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "Označit jako {state}",
     "settle-payment": "Vypořádání platby",
     "settle-payment-error": "Nelze vyřídit platbu",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -239,6 +239,7 @@
     "sample-formatting": "",
     "select": "Auswählen...",
     "select-display-language": "Anzeigesprache wählen",
+    "select-relation-id": "",
     "select-today": "Heute auswählen",
     "set-language": "",
     "short-date": "",
@@ -541,6 +542,7 @@
     "search-by-customer-last-name": "",
     "search-by-order-code": "Suche nach Bestellnummer",
     "select-state": "Status auswählen",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "Abwicklungsstatus wählen",
     "settle-payment": "Zahlung durchführen",
     "settle-payment-error": "Die Zahlung konnte nicht durchgeführt werden",

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -242,6 +242,7 @@
     "sample-formatting": "Sample formatting",
     "select": "Select...",
     "select-display-language": "Select display language",
+    "select-relation-id": "Select relation ID",
     "select-today": "Select today",
     "set-language": "Set language",
     "short-date": "Short date",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "Search by customer last name",
     "search-by-order-code": "Search by order code",
     "select-state": "Select state",
+    "set-coupon-codes": "Set coupon codes",
     "set-fulfillment-state": "Mark as {state}",
     "settle-payment": "Settle payment",
     "settle-payment-error": "Could not settle payment",
@@ -672,4 +674,4 @@
     "job-result": "Job result",
     "job-state": "Job state"
   }
-}
+}

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "Seleccionar...",
     "select-display-language": "Seleccionar idioma de interfaz",
+    "select-relation-id": "",
     "select-today": "Hoy",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "Buscar por el apellido del cliente",
     "search-by-order-code": "Buscar por el código de pedido",
     "select-state": "Seleccionar estado",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "Fijar como {state}",
     "settle-payment": "Liquidar pago",
     "settle-payment-error": "No pudo liquidar el pago",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/fr.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "Selectionner...",
     "select-display-language": "Choisir la langue d'affichage",
+    "select-relation-id": "",
     "select-today": "Choisir aujourd'hui",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "Rechercher par nom du client",
     "search-by-order-code": "Rehercher par numéro de commande",
     "select-state": "Sélectionner un état",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "Marquer {state}",
     "settle-payment": "Régler le paiement",
     "settle-payment-error": "Règlement du paiement échoué",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/it.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "Seleziona...",
     "select-display-language": "Seleziona lingua",
+    "select-relation-id": "",
     "select-today": "Seleziona oggi",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "Cerca per cognome cliente",
     "search-by-order-code": "Cerca per codice ordine",
     "select-state": "Seleziona stato",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "Segna come {state}",
     "settle-payment": "Incassa pagamento",
     "settle-payment-error": "Non è stato possibile incassare il pagamento",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "Wybrano...",
     "select-display-language": "Wybierz język",
+    "select-relation-id": "",
     "select-today": "Wybierz dzisiaj",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "",
     "search-by-order-code": "Szukaj po numerze zamówienia",
     "select-state": "",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "",
     "settle-payment": "Rozlicz płatność",
     "settle-payment-error": "Nie można rozliczyć płatności",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "Selecione...",
     "select-display-language": "Selecionar idioma de exibição",
+    "select-relation-id": "",
     "select-today": "Selecione hoje",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "",
     "search-by-order-code": "Buscar por código do pedido",
     "select-state": "Selecionar estado",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "Marcar como {state}",
     "settle-payment": "Liquidar pagamento",
     "settle-payment-error": "Não posso liquidar pagamento",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/pt_PT.json

@@ -242,6 +242,7 @@
     "sample-formatting": "Formatação de amostra",
     "select": "Seleccione...",
     "select-display-language": "Seleccionar idioma",
+    "select-relation-id": "",
     "select-today": "Seleccione a data de hoje",
     "set-language": "Definir idioma",
     "short-date": "Data abreviada",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "Pesquisar pelo apelido do cliente",
     "search-by-order-code": "Pesqusiar pelo código da encomenda",
     "select-state": "Seleccionar estado",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "Marcar como {state}",
     "settle-payment": "Liquidar pagamento",
     "settle-payment-error": "Não foi possível liquidar o pagamento",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/ru.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "Выбрать...",
     "select-display-language": "Выберите язык отображения",
+    "select-relation-id": "",
     "select-today": "Выберите сегодня",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "Поиск по фамилии клиента",
     "search-by-order-code": "Поиск по коду заказа",
     "select-state": "Выберите состояние",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "Отметить как {state}",
     "settle-payment": "Расчет платежа",
     "settle-payment-error": "Не удалось провести оплату",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/uk.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "Вибрати...",
     "select-display-language": "Виберіть мову відображення",
+    "select-relation-id": "",
     "select-today": "Виберіть сьогодні",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "Пошук за прізвищем клієнта",
     "search-by-order-code": "Пошук по коду замовлення",
     "select-state": "Виберіть стан",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "Помітити як {state}",
     "settle-payment": "Розрахунок платежу",
     "settle-payment-error": "Не вдалося провести оплату",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "选择...",
     "select-display-language": "选择显示语言",
+    "select-relation-id": "",
     "select-today": "选择今天",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "",
     "search-by-order-code": "输入要搜索的订单编号",
     "select-state": "",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "",
     "settle-payment": "结算付款",
     "settle-payment-error": "结算付款失败",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -242,6 +242,7 @@
     "sample-formatting": "",
     "select": "選擇...",
     "select-display-language": "選擇顯示語言",
+    "select-relation-id": "",
     "select-today": "選擇今天",
     "set-language": "",
     "short-date": "",
@@ -544,6 +545,7 @@
     "search-by-customer-last-name": "",
     "search-by-order-code": "輸入要搜索的訂單編號",
     "select-state": "",
+    "set-coupon-codes": "",
     "set-fulfillment-state": "",
     "settle-payment": "結算付款",
     "settle-payment-error": "結算付款失敗",

+ 25 - 10
packages/admin-ui/src/lib/static/vendure-ui-config.json

@@ -1,12 +1,27 @@
 {
-  "apiHost": "http://localhost",
-  "apiPort": "3000",
-  "adminApiPath": "admin-api",
-  "tokenMethod": "bearer",
-  "authTokenHeaderKey": "vendure-auth-token",
-  "defaultLanguage": "en",
-  "availableLanguages": ["en", "es", "zh_Hant", "zh_Hans", "pl", "de", "pt_BR", "pt_PT", "cs", "fr", "ru", "uk", "it"],
-  "brand": "",
-  "hideVendureBranding": false,
-  "hideVersion": false
+    "apiHost": "http://localhost",
+    "apiPort": "3000",
+    "adminApiPath": "admin-api",
+    "tokenMethod": "bearer",
+    "authTokenHeaderKey": "vendure-auth-token",
+    "defaultLanguage": "en",
+    "availableLanguages": [
+        "en",
+        "es",
+        "zh_Hant",
+        "zh_Hans",
+        "pl",
+        "de",
+        "pt_BR",
+        "pt_PT",
+        "cs",
+        "fr",
+        "ru",
+        "uk",
+        "it"
+    ],
+    "brand": "",
+    "hideVendureBranding": false,
+    "hideVersion": false,
+    "cancellationReasons": ["order.cancel-reason-customer-request", "order.cancel-reason-not-available"]
 }

+ 30 - 1
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -546,6 +546,28 @@ export type CountryTranslationInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeExpiredError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeInvalidError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeLimitError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+    limit: Scalars['Int'];
+};
+
 export type CreateAddressInput = {
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
@@ -1390,6 +1412,9 @@ export enum ErrorCode {
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
     INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
+    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
+    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
+    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
 }
 
 export type ErrorResult = {
@@ -2193,6 +2218,7 @@ export type ModifyOrderInput = {
     note?: Maybe<Scalars['String']>;
     refund?: Maybe<AdministratorRefundInput>;
     options?: Maybe<ModifyOrderOptions>;
+    couponCodes?: Maybe<Array<Scalars['String']>>;
 };
 
 export type ModifyOrderOptions = {
@@ -2208,7 +2234,10 @@ export type ModifyOrderResult =
     | RefundPaymentIdMissingError
     | OrderLimitError
     | NegativeQuantityError
-    | InsufficientStockError;
+    | InsufficientStockError
+    | CouponCodeExpiredError
+    | CouponCodeInvalidError
+    | CouponCodeLimitError;
 
 export type MoveCollectionInput = {
     collectionId: Scalars['ID'];

+ 3 - 3
packages/common/src/generated-shop-types.ts

@@ -885,15 +885,15 @@ export enum ErrorCode {
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
     INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
+    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
+    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
+    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ORDER_MODIFICATION_ERROR = 'ORDER_MODIFICATION_ERROR',
     INELIGIBLE_SHIPPING_METHOD_ERROR = 'INELIGIBLE_SHIPPING_METHOD_ERROR',
     ORDER_PAYMENT_STATE_ERROR = 'ORDER_PAYMENT_STATE_ERROR',
     INELIGIBLE_PAYMENT_METHOD_ERROR = 'INELIGIBLE_PAYMENT_METHOD_ERROR',
     PAYMENT_FAILED_ERROR = 'PAYMENT_FAILED_ERROR',
     PAYMENT_DECLINED_ERROR = 'PAYMENT_DECLINED_ERROR',
-    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
-    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
-    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ALREADY_LOGGED_IN_ERROR = 'ALREADY_LOGGED_IN_ERROR',
     MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
     PASSWORD_VALIDATION_ERROR = 'PASSWORD_VALIDATION_ERROR',

File diff suppressed because it is too large
+ 311 - 290
packages/common/src/generated-types.ts


+ 10 - 0
packages/common/src/shared-types.ts

@@ -293,6 +293,16 @@ export interface AdminUiConfig {
      * @default false
      */
     hideVersion?: boolean;
+    /**
+     * @description
+     * Allows you to provide default reasons for a refund or cancellation. This will be used in the
+     * refund/cancel dialog. The values can be literal strings (e.g. "Not in stock") or translation
+     * tokens (see [Adding Admin UI Translations](/docs/plugins/extending-the-admin-ui/adding-ui-translations/)).
+     *
+     * @since 1.5.0
+     * @default ['order.cancel-reason-customer-request', 'order.cancel-reason-not-available']
+     */
+    cancellationReasons?: string[];
 }
 
 /**

+ 61 - 4
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -546,6 +546,28 @@ export type CountryTranslationInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeExpiredError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeInvalidError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeLimitError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+    limit: Scalars['Int'];
+};
+
 export type CreateAddressInput = {
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
@@ -1390,6 +1412,9 @@ export enum ErrorCode {
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
     INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
+    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
+    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
+    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
 }
 
 export type ErrorResult = {
@@ -2193,6 +2218,7 @@ export type ModifyOrderInput = {
     note?: Maybe<Scalars['String']>;
     refund?: Maybe<AdministratorRefundInput>;
     options?: Maybe<ModifyOrderOptions>;
+    couponCodes?: Maybe<Array<Scalars['String']>>;
 };
 
 export type ModifyOrderOptions = {
@@ -2208,7 +2234,10 @@ export type ModifyOrderResult =
     | RefundPaymentIdMissingError
     | OrderLimitError
     | NegativeQuantityError
-    | InsufficientStockError;
+    | InsufficientStockError
+    | CouponCodeExpiredError
+    | CouponCodeInvalidError
+    | CouponCodeLimitError;
 
 export type MoveCollectionInput = {
     collectionId: Scalars['ID'];
@@ -6459,12 +6488,28 @@ export type GetFulfillmentHandlersQuery = {
     >;
 };
 
-export type OrderWithModificationsFragment = Pick<Order, 'id' | 'state' | 'total' | 'totalWithTax'> & {
+export type OrderWithModificationsFragment = Pick<
+    Order,
+    | 'id'
+    | 'state'
+    | 'subTotal'
+    | 'subTotalWithTax'
+    | 'shipping'
+    | 'shippingWithTax'
+    | 'total'
+    | 'totalWithTax'
+> & {
     lines: Array<
         Pick<
             OrderLine,
-            'id' | 'quantity' | 'linePrice' | 'linePriceWithTax' | 'discountedLinePriceWithTax'
+            | 'id'
+            | 'quantity'
+            | 'linePrice'
+            | 'linePriceWithTax'
+            | 'discountedLinePriceWithTax'
+            | 'proratedLinePriceWithTax'
         > & {
+            discounts: Array<Pick<Discount, 'description' | 'amountWithTax'>>;
             productVariant: Pick<ProductVariant, 'id' | 'name'>;
             items: Array<Pick<OrderItem, 'id' | 'createdAt' | 'updatedAt' | 'cancelled' | 'unitPrice'>>;
         }
@@ -6485,6 +6530,8 @@ export type OrderWithModificationsFragment = Pick<Order, 'id' | 'state' | 'total
             refund?: Maybe<Pick<Refund, 'id' | 'state' | 'total' | 'paymentId'>>;
         }
     >;
+    promotions: Array<Pick<Promotion, 'id' | 'name' | 'couponCode'>>;
+    discounts: Array<Pick<Discount, 'description' | 'adjustmentSource' | 'amount' | 'amountWithTax'>>;
     shippingAddress?: Maybe<
         Pick<OrderAddress, 'streetLine1' | 'city' | 'postalCode' | 'province' | 'countryCode' | 'country'>
     >;
@@ -6512,7 +6559,10 @@ export type ModifyOrderMutation = {
         | Pick<RefundPaymentIdMissingError, 'errorCode' | 'message'>
         | Pick<OrderLimitError, 'errorCode' | 'message'>
         | Pick<NegativeQuantityError, 'errorCode' | 'message'>
-        | Pick<InsufficientStockError, 'errorCode' | 'message'>;
+        | Pick<InsufficientStockError, 'errorCode' | 'message'>
+        | Pick<CouponCodeExpiredError, 'errorCode' | 'message'>
+        | Pick<CouponCodeInvalidError, 'errorCode' | 'message'>
+        | Pick<CouponCodeLimitError, 'errorCode' | 'message'>;
 };
 
 export type AddManualPaymentMutationVariables = Exact<{
@@ -8829,6 +8879,11 @@ export namespace GetFulfillmentHandlers {
 export namespace OrderWithModifications {
     export type Fragment = OrderWithModificationsFragment;
     export type Lines = NonNullable<NonNullable<OrderWithModificationsFragment['lines']>[number]>;
+    export type Discounts = NonNullable<
+        NonNullable<
+            NonNullable<NonNullable<OrderWithModificationsFragment['lines']>[number]>['discounts']
+        >[number]
+    >;
     export type ProductVariant = NonNullable<
         NonNullable<NonNullable<OrderWithModificationsFragment['lines']>[number]>['productVariant']
     >;
@@ -8863,6 +8918,8 @@ export namespace OrderWithModifications {
     export type Refund = NonNullable<
         NonNullable<NonNullable<OrderWithModificationsFragment['modifications']>[number]>['refund']
     >;
+    export type Promotions = NonNullable<NonNullable<OrderWithModificationsFragment['promotions']>[number]>;
+    export type _Discounts = NonNullable<NonNullable<OrderWithModificationsFragment['discounts']>[number]>;
     export type ShippingAddress = NonNullable<OrderWithModificationsFragment['shippingAddress']>;
     export type BillingAddress = NonNullable<OrderWithModificationsFragment['billingAddress']>;
 }

+ 3 - 3
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -852,15 +852,15 @@ export enum ErrorCode {
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
     INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
+    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
+    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
+    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ORDER_MODIFICATION_ERROR = 'ORDER_MODIFICATION_ERROR',
     INELIGIBLE_SHIPPING_METHOD_ERROR = 'INELIGIBLE_SHIPPING_METHOD_ERROR',
     ORDER_PAYMENT_STATE_ERROR = 'ORDER_PAYMENT_STATE_ERROR',
     INELIGIBLE_PAYMENT_METHOD_ERROR = 'INELIGIBLE_PAYMENT_METHOD_ERROR',
     PAYMENT_FAILED_ERROR = 'PAYMENT_FAILED_ERROR',
     PAYMENT_DECLINED_ERROR = 'PAYMENT_DECLINED_ERROR',
-    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
-    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
-    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ALREADY_LOGGED_IN_ERROR = 'ALREADY_LOGGED_IN_ERROR',
     MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
     PASSWORD_VALIDATION_ERROR = 'PASSWORD_VALIDATION_ERROR',

+ 32 - 1
packages/core/e2e/order-merge.e2e-spec.ts

@@ -17,15 +17,25 @@ import path from 'path';
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
+import {
+    AttemptLogin,
+    AttemptLoginMutation,
+    AttemptLoginMutationVariables,
+    GetCustomerList,
+} from './graphql/generated-e2e-admin-types';
 import * as Codegen from './graphql/generated-e2e-admin-types';
 import {
+    AddItemToOrder,
+    AddItemToOrderMutation,
     AddItemToOrderMutation,
     AddItemToOrderMutationVariables,
+    GetActiveOrderPaymentsQuery,
+    GetNextOrderStatesQuery,
     TestOrderFragmentFragment,
     UpdatedOrderFragment,
 } from './graphql/generated-e2e-shop-types';
 import { ATTEMPT_LOGIN, GET_CUSTOMER_LIST } from './graphql/shared-definitions';
-import { TEST_ORDER_FRAGMENT } from './graphql/shop-definitions';
+import { GET_ACTIVE_ORDER_PAYMENTS, GET_NEXT_STATES, TEST_ORDER_FRAGMENT } from './graphql/shop-definitions';
 import { sortById } from './utils/test-order-utils';
 
 /**
@@ -229,6 +239,27 @@ describe('Order merging', () => {
             })),
         ).toEqual([{ productVariantId: 'T_8', quantity: 1 }]);
     });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/1454
+    it('does not throw FK error when merging with a cart with an existing session', async () => {
+        await shopClient.asUserWithCredentials(customers[7].emailAddress, 'test');
+        // Create an Order linked with the current session
+        const { nextOrderStates } = await shopClient.query<GetNextOrderStatesQuery>(GET_NEXT_STATES);
+
+        // unset last auth token to simulate a guest user in a different browser
+        shopClient.setAuthToken('');
+        await shopClient.query<AddItemToOrderMutation, AddItemToOrderWithCustomFields>(
+            ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS,
+            { productVariantId: '1', quantity: 2 },
+        );
+
+        const { login } = await shopClient.query<AttemptLoginMutation, AttemptLoginMutationVariables>(
+            ATTEMPT_LOGIN,
+            { username: customers[7].emailAddress, password: 'test' },
+        );
+
+        expect(login.id).toBe(customers[7].user?.id);
+    });
 });
 
 export const ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS = gql`

+ 251 - 0
packages/core/e2e/order-modification.e2e-spec.ts

@@ -1,10 +1,15 @@
 /* tslint:disable:no-non-null-assertion */
 import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
+import { summate } from '@vendure/common/lib/shared-utils';
 import {
     defaultShippingCalculator,
     defaultShippingEligibilityChecker,
+    freeShipping,
+    manualFulfillmentHandler,
     mergeConfig,
+    orderFixedDiscount,
+    orderPercentageDiscount,
     productsPercentageDiscount,
     ShippingCalculator,
 } from '@vendure/core';
@@ -1520,6 +1525,7 @@ describe('Order modification', () => {
                 originalTotalWithTax - order.lines[0].proratedUnitPriceWithTax,
             );
             expect(modifyOrder.payments![0].refunds![0].total).toBe(order.lines[0].proratedUnitPriceWithTax);
+            expect(modifyOrder.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder));
         });
     });
 
@@ -1810,6 +1816,227 @@ describe('Order modification', () => {
         });
     });
 
+    describe('couponCode handling', () => {
+        const CODE_50PC_OFF = '50PC';
+        const CODE_FREE_SHIPPING = 'FREESHIP';
+        let order: TestOrderWithPaymentsFragment;
+        beforeAll(async () => {
+            await adminClient.query<CreatePromotionMutation, CreatePromotionMutationVariables>(
+                CREATE_PROMOTION,
+                {
+                    input: {
+                        name: '50% off',
+                        couponCode: CODE_50PC_OFF,
+                        enabled: true,
+                        conditions: [],
+                        actions: [
+                            {
+                                code: orderPercentageDiscount.code,
+                                arguments: [{ name: 'discount', value: '50' }],
+                            },
+                        ],
+                    },
+                },
+            );
+            await adminClient.query<CreatePromotionMutation, CreatePromotionMutationVariables>(
+                CREATE_PROMOTION,
+                {
+                    input: {
+                        name: 'Free shipping',
+                        couponCode: CODE_FREE_SHIPPING,
+                        enabled: true,
+                        conditions: [],
+                        actions: [{ code: freeShipping.code, arguments: [] }],
+                    },
+                },
+            );
+
+            // create an order and check out
+            await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
+            await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
+                productVariantId: 'T_1',
+                quantity: 1,
+                customFields: {
+                    color: 'green',
+                },
+            } as any);
+            await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
+                productVariantId: 'T_4',
+                quantity: 2,
+            });
+            await proceedToArrangingPayment(shopClient);
+            const result = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+            orderGuard.assertSuccess(result);
+            order = result;
+            const result2 = await adminTransitionOrderToState(order.id, 'Modifying');
+            orderGuard.assertSuccess(result2);
+            expect(result2.state).toBe('Modifying');
+        });
+
+        it('invalid coupon code returns ErrorResult', async () => {
+            const { modifyOrder } = await adminClient.query<
+                ModifyOrderMutation,
+                ModifyOrderMutationVariables
+            >(MODIFY_ORDER, {
+                input: {
+                    dryRun: false,
+                    orderId: order.id,
+                    couponCodes: ['BAD_CODE'],
+                },
+            });
+            orderGuard.assertErrorResult(modifyOrder);
+            expect(modifyOrder.message).toBe('Coupon code "BAD_CODE" is not valid');
+        });
+
+        it('valid coupon code applies Promotion', async () => {
+            const { modifyOrder } = await adminClient.query<
+                ModifyOrderMutation,
+                ModifyOrderMutationVariables
+            >(MODIFY_ORDER, {
+                input: {
+                    dryRun: false,
+                    orderId: order.id,
+                    refund: {
+                        paymentId: order.payments![0].id,
+                    },
+                    couponCodes: [CODE_50PC_OFF],
+                },
+            });
+            orderGuard.assertSuccess(modifyOrder);
+            expect(modifyOrder.subTotalWithTax).toBe(order.subTotalWithTax * 0.5);
+        });
+
+        it('adds order.discounts', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.discounts.length).toBe(1);
+            expect(orderWithModifications?.discounts[0].description).toBe('50% off');
+        });
+
+        it('adds order.promotions', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.promotions.length).toBe(1);
+            expect(orderWithModifications?.promotions[0].name).toBe('50% off');
+        });
+
+        it('creates correct refund amount', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.payments![0].refunds.length).toBe(1);
+            expect(orderWithModifications!.totalWithTax).toBe(
+                getOrderPaymentsTotalWithRefunds(orderWithModifications!),
+            );
+            expect(orderWithModifications?.payments![0].refunds[0].total).toBe(
+                order.totalWithTax - orderWithModifications!.totalWithTax,
+            );
+        });
+
+        it('creates history entry for applying couponCode', async () => {
+            const { order: history } = await adminClient.query<
+                GetOrderHistory.Query,
+                GetOrderHistory.Variables
+            >(GET_ORDER_HISTORY, {
+                id: order.id,
+                options: { filter: { type: { eq: HistoryEntryType.ORDER_COUPON_APPLIED } } },
+            });
+            orderGuard.assertSuccess(history);
+
+            expect(history.history.items.length).toBe(1);
+            expect(pick(history.history.items[0]!, ['type', 'data'])).toEqual({
+                type: HistoryEntryType.ORDER_COUPON_APPLIED,
+                data: { couponCode: CODE_50PC_OFF, promotionId: 'T_4' },
+            });
+        });
+
+        it('removes coupon code', async () => {
+            const { modifyOrder } = await adminClient.query<
+                ModifyOrderMutation,
+                ModifyOrderMutationVariables
+            >(MODIFY_ORDER, {
+                input: {
+                    dryRun: false,
+                    orderId: order.id,
+                    couponCodes: [],
+                },
+            });
+            orderGuard.assertSuccess(modifyOrder);
+            expect(modifyOrder.subTotalWithTax).toBe(order.subTotalWithTax);
+        });
+
+        it('removes order.discounts', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.discounts.length).toBe(0);
+        });
+
+        it('removes order.promotions', async () => {
+            const { order: orderWithModifications } = await adminClient.query<
+                GetOrderWithModificationsQuery,
+                GetOrderWithModificationsQueryVariables
+            >(GET_ORDER_WITH_MODIFICATIONS, { id: order.id });
+            expect(orderWithModifications?.promotions.length).toBe(0);
+        });
+
+        it('creates history entry for removing couponCode', async () => {
+            const { order: history } = await adminClient.query<
+                GetOrderHistory.Query,
+                GetOrderHistory.Variables
+            >(GET_ORDER_HISTORY, {
+                id: order.id,
+                options: { filter: { type: { eq: HistoryEntryType.ORDER_COUPON_REMOVED } } },
+            });
+            orderGuard.assertSuccess(history);
+
+            expect(history.history.items.length).toBe(1);
+            expect(pick(history.history.items[0]!, ['type', 'data'])).toEqual({
+                type: HistoryEntryType.ORDER_COUPON_REMOVED,
+                data: { couponCode: CODE_50PC_OFF },
+            });
+        });
+
+        it('correct refund for free shipping couponCode', async () => {
+            await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
+                productVariantId: 'T_1',
+                quantity: 1,
+            } as any);
+            await proceedToArrangingPayment(shopClient);
+            const result = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+            orderGuard.assertSuccess(result);
+            const order2 = result;
+            const shippingWithTax = order2.shippingWithTax;
+            const result2 = await adminTransitionOrderToState(order2.id, 'Modifying');
+            orderGuard.assertSuccess(result2);
+            expect(result2.state).toBe('Modifying');
+
+            const { modifyOrder } = await adminClient.query<
+                ModifyOrderMutation,
+                ModifyOrderMutationVariables
+            >(MODIFY_ORDER, {
+                input: {
+                    dryRun: false,
+                    orderId: order2.id,
+                    refund: {
+                        paymentId: order2.payments![0].id,
+                    },
+                    couponCodes: [CODE_FREE_SHIPPING],
+                },
+            });
+            orderGuard.assertSuccess(modifyOrder);
+            expect(modifyOrder.shippingWithTax).toBe(0);
+            expect(modifyOrder!.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder!));
+            expect(modifyOrder.payments![0].refunds[0].total).toBe(shippingWithTax);
+        });
+    });
+
     async function adminTransitionOrderToState(id: string, state: string) {
         const result = await adminClient.query<
             Codegen.AdminTransitionMutation,
@@ -1881,12 +2108,20 @@ describe('Order modification', () => {
         await adminTransitionOrderToState(order.id, 'Modifying');
         return order;
     }
+
+    function getOrderPaymentsTotalWithRefunds(_order: OrderWithModificationsFragment) {
+        return _order.payments?.reduce((sum, p) => sum + p.amount - summate(p?.refunds, 'total'), 0) ?? 0;
+    }
 });
 
 export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
     fragment OrderWithModifications on Order {
         id
         state
+        subTotal
+        subTotalWithTax
+        shipping
+        shippingWithTax
         total
         totalWithTax
         lines {
@@ -1895,6 +2130,11 @@ export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
             linePrice
             linePriceWithTax
             discountedLinePriceWithTax
+            proratedLinePriceWithTax
+            discounts {
+                description
+                amountWithTax
+            }
             productVariant {
                 id
                 name
@@ -1953,6 +2193,17 @@ export const ORDER_WITH_MODIFICATION_FRAGMENT = gql`
                 paymentId
             }
         }
+        promotions {
+            id
+            name
+            couponCode
+        }
+        discounts {
+            description
+            adjustmentSource
+            amount
+            amountWithTax
+        }
         shippingAddress {
             streetLine1
             city

+ 50 - 1
packages/core/e2e/order-process.e2e-spec.ts

@@ -18,6 +18,8 @@ import {
     SetShippingMethod,
     TestOrderFragmentFragment,
     TransitionToState,
+    TransitionToStateMutation,
+    TransitionToStateMutationVariables,
 } from './graphql/generated-e2e-shop-types';
 import { ADMIN_TRANSITION_TO_STATE, GET_ORDER } from './graphql/shared-definitions';
 import {
@@ -40,7 +42,7 @@ const transitionErrorSpy = jest.fn();
 
 describe('Order process', () => {
     const VALIDATION_ERROR_MESSAGE = 'Customer must have a company email address';
-    const customOrderProcess: CustomOrderProcess<'ValidatingCustomer'> = {
+    const customOrderProcess: CustomOrderProcess<'ValidatingCustomer' | 'PaymentProcessing'> = {
         init(injector) {
             initSpy(injector.get(TransactionalConnection).rawConnection.name);
         },
@@ -52,6 +54,12 @@ describe('Order process', () => {
             ValidatingCustomer: {
                 to: ['ArrangingPayment', 'AddingItems'],
             },
+            ArrangingPayment: {
+                to: ['PaymentProcessing'],
+            },
+            PaymentProcessing: {
+                to: ['PaymentAuthorized', 'PaymentSettled'],
+            },
         },
         onTransitionStart(fromState, toState, data) {
             transitionStartSpy(fromState, toState, data);
@@ -271,6 +279,47 @@ describe('Order process', () => {
                 'AddingItems',
             ]);
         });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/963
+        it('allows addPaymentToOrder from a custom state', async () => {
+            await shopClient.query<SetShippingMethod.Mutation, SetShippingMethod.Variables>(
+                SET_SHIPPING_METHOD,
+                { id: 'T_1' },
+            );
+            const result0 = await shopClient.query<
+                TransitionToStateMutation,
+                TransitionToStateMutationVariables
+            >(TRANSITION_TO_STATE, {
+                state: 'ValidatingCustomer',
+            });
+            orderErrorGuard.assertSuccess(result0.transitionOrderToState);
+            const result1 = await shopClient.query<
+                TransitionToStateMutation,
+                TransitionToStateMutationVariables
+            >(TRANSITION_TO_STATE, {
+                state: 'ArrangingPayment',
+            });
+            orderErrorGuard.assertSuccess(result1.transitionOrderToState);
+            const result2 = await shopClient.query<
+                TransitionToStateMutation,
+                TransitionToStateMutationVariables
+            >(TRANSITION_TO_STATE, {
+                state: 'PaymentProcessing',
+            });
+            orderErrorGuard.assertSuccess(result2.transitionOrderToState);
+            expect(result2.transitionOrderToState.state).toBe('PaymentProcessing');
+            const { addPaymentToOrder } = await shopClient.query<
+                AddPaymentToOrder.Mutation,
+                AddPaymentToOrder.Variables
+            >(ADD_PAYMENT, {
+                input: {
+                    method: testSuccessfulPaymentMethod.code,
+                    metadata: {},
+                },
+            });
+            orderErrorGuard.assertSuccess(addPaymentToOrder);
+            expect(addPaymentToOrder.state).toBe('PaymentSettled');
+        });
     });
 
     describe('Admin API transition constraints', () => {

+ 1 - 0
packages/core/src/api/config/configure-graphql-module.ts

@@ -119,6 +119,7 @@ async function createGraphQLOptions(
             ...configService.apiOptions.apolloServerPlugins,
         ],
         validationRules: options.validationRules,
+        introspection: configService.apiOptions.introspection ?? true,
     } as GqlModuleOptions;
 
     /**

+ 4 - 0
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -136,6 +136,7 @@ input ModifyOrderInput {
     note: String
     refund: AdministratorRefundInput
     options: ModifyOrderOptions
+    couponCodes: [String!]
 }
 
 input AddItemInput {
@@ -367,4 +368,7 @@ union ModifyOrderResult =
     | OrderLimitError
     | NegativeQuantityError
     | InsufficientStockError
+    | CouponCodeExpiredError
+    | CouponCodeInvalidError
+    | CouponCodeLimitError
 union AddManualPaymentToOrderResult = Order | ManualPaymentStateError

+ 22 - 0
packages/core/src/api/schema/common/common-error-results.graphql

@@ -46,3 +46,25 @@ type InsufficientStockError implements ErrorResult {
     quantityAvailable: Int!
     order: Order!
 }
+
+"Returned if the provided coupon code is invalid"
+type CouponCodeInvalidError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    couponCode: String!
+}
+
+"Returned if the provided coupon code is invalid"
+type CouponCodeExpiredError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    couponCode: String!
+}
+
+"Returned if the provided coupon code is invalid"
+type CouponCodeLimitError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    couponCode: String!
+    limit: Int!
+}

+ 0 - 29
packages/core/src/api/schema/shop-api/shop-error-results.graphql

@@ -37,35 +37,6 @@ type PaymentDeclinedError implements ErrorResult {
     paymentErrorMessage: String!
 }
 
-"Returned if the provided coupon code is invalid"
-type CouponCodeInvalidError implements ErrorResult {
-    errorCode: ErrorCode!
-    message: String!
-    couponCode: String!
-}
-
-"Returned if the provided coupon code is invalid"
-type CouponCodeInvalidError implements ErrorResult {
-    errorCode: ErrorCode!
-    message: String!
-    couponCode: String!
-}
-
-"Returned if the provided coupon code is invalid"
-type CouponCodeExpiredError implements ErrorResult {
-    errorCode: ErrorCode!
-    message: String!
-    couponCode: String!
-}
-
-"Returned if the provided coupon code is invalid"
-type CouponCodeLimitError implements ErrorResult {
-    errorCode: ErrorCode!
-    message: String!
-    couponCode: String!
-    limit: Int!
-}
-
 "Returned when attempting to set the Customer for an Order when already logged in."
 type AlreadyLoggedInError implements ErrorResult {
     errorCode: ErrorCode!

+ 35 - 1
packages/core/src/common/error/generated-graphql-admin-errors.ts

@@ -59,6 +59,40 @@ export class ChannelDefaultLanguageError extends ErrorResult {
   }
 }
 
+export class CouponCodeExpiredError extends ErrorResult {
+  readonly __typename = 'CouponCodeExpiredError';
+  readonly errorCode = 'COUPON_CODE_EXPIRED_ERROR' as any;
+  readonly message = 'COUPON_CODE_EXPIRED_ERROR';
+  constructor(
+    public couponCode: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class CouponCodeInvalidError extends ErrorResult {
+  readonly __typename = 'CouponCodeInvalidError';
+  readonly errorCode = 'COUPON_CODE_INVALID_ERROR' as any;
+  readonly message = 'COUPON_CODE_INVALID_ERROR';
+  constructor(
+    public couponCode: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
+export class CouponCodeLimitError extends ErrorResult {
+  readonly __typename = 'CouponCodeLimitError';
+  readonly errorCode = 'COUPON_CODE_LIMIT_ERROR' as any;
+  readonly message = 'COUPON_CODE_LIMIT_ERROR';
+  constructor(
+    public couponCode: Scalars['String'],
+    public limit: Scalars['Int'],
+  ) {
+    super();
+  }
+}
+
 export class CreateFulfillmentError extends ErrorResult {
   readonly __typename = 'CreateFulfillmentError';
   readonly errorCode = 'CREATE_FULFILLMENT_ERROR' as any;
@@ -444,7 +478,7 @@ export class SettlePaymentError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set(['AlreadyRefundedError', 'CancelActiveOrderError', 'ChannelDefaultLanguageError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FulfillmentStateTransitionError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
+const errorTypeNames = new Set(['AlreadyRefundedError', 'CancelActiveOrderError', 'ChannelDefaultLanguageError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FulfillmentStateTransitionError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }

+ 1 - 1
packages/core/src/common/finite-state-machine/finite-state-machine.ts

@@ -76,7 +76,7 @@ export class FSM<T extends string, Data = any> {
      * Returns an array of state to which the machine may transition from the current state.
      */
     getNextStates(): ReadonlyArray<T> {
-        return this.config.transitions[this._currentState].to;
+        return this.config.transitions[this._currentState]?.to ?? [];
     }
 
     /**

+ 1 - 0
packages/core/src/config/default-config.ts

@@ -65,6 +65,7 @@ export const defaultConfig: RuntimeVendureConfig = {
             credentials: true,
         },
         middleware: [],
+        introspection: true,
         apolloServerPlugins: [],
     },
     authOptions: {

+ 4 - 0
packages/core/src/config/promotion/index.ts

@@ -15,10 +15,14 @@ export * from './promotion-condition';
 export * from './actions/facet-values-percentage-discount-action';
 export * from './actions/order-percentage-discount-action';
 export * from './actions/product-percentage-discount-action';
+export * from './actions/free-shipping-action';
+export * from './actions/buy-x-get-y-free-action';
+export * from './actions/order-fixed-discount-action';
 export * from './conditions/has-facet-values-condition';
 export * from './conditions/min-order-amount-condition';
 export * from './conditions/contains-products-condition';
 export * from './conditions/customer-group-condition';
+export * from './conditions/buy-x-get-y-free-condition';
 export * from './utils/facet-value-checker';
 
 export const defaultPromotionActions = [

+ 22 - 0
packages/core/src/config/vendure-config.ts

@@ -177,6 +177,26 @@ export interface ApiOptions {
      * @default []
      */
     apolloServerPlugins?: PluginDefinition[];
+    /**
+     * @description
+     * Controls whether introspection of the GraphQL APIs is enabled. For production, it is recommended to disable
+     * introspection, since exposing your entire schema can allow an attacker to trivially learn all operations
+     * and much more easily find any potentially exploitable queries.
+     *
+     * **Note:** when introspection is disabled, tooling which relies on it for things like autocompletion
+     * will not work.
+     *
+     * @example
+     * ```TypeScript
+     * {
+     *   introspection: process.env.NODE_ENV !== 'production'
+     * }
+     * ```
+     *
+     * @default true
+     * @since 1.5.0
+     */
+    introspection?: boolean;
 }
 
 /**
@@ -799,6 +819,8 @@ export interface JobQueueOptions {
      *
      * For example, we might have a staging and a production deployment in the same account/project and
      * each one will need its own task queue. We can achieve this with a prefix.
+     *
+     * @since 1.5.0
      */
     prefix?: string;
 }

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

@@ -1,5 +1,10 @@
 import { Injectable } from '@nestjs/common';
-import { ModifyOrderInput, ModifyOrderResult, RefundOrderInput } from '@vendure/common/lib/generated-types';
+import {
+    HistoryEntryType,
+    ModifyOrderInput,
+    ModifyOrderResult,
+    RefundOrderInput,
+} from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { summate } from '@vendure/common/lib/shared-utils';
 
@@ -7,6 +12,9 @@ import { RequestContext } from '../../../api/common/request-context';
 import { isGraphQlErrorResult, JustErrorResults } from '../../../common/error/error-result';
 import { EntityNotFoundError, InternalServerError, UserInputError } from '../../../common/error/errors';
 import {
+    CouponCodeExpiredError,
+    CouponCodeInvalidError,
+    CouponCodeLimitError,
     NoChangesSpecifiedError,
     OrderModificationStateError,
     RefundPaymentIdMissingError,
@@ -16,6 +24,7 @@ import {
     NegativeQuantityError,
     OrderLimitError,
 } from '../../../common/error/generated-graphql-shop-errors';
+import { AdjustmentSource } from '../../../common/types/adjustment-source';
 import { idsAreEqual } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
 import { TransactionalConnection } from '../../../connection/transactional-connection';
@@ -31,10 +40,13 @@ import { Surcharge } from '../../../entity/surcharge/surcharge.entity';
 import { EventBus } from '../../../event-bus/event-bus';
 import { OrderLineEvent } from '../../../event-bus/index';
 import { CountryService } from '../../services/country.service';
+import { HistoryService } from '../../services/history.service';
 import { PaymentService } from '../../services/payment.service';
 import { ProductVariantService } from '../../services/product-variant.service';
+import { PromotionService } from '../../services/promotion.service';
 import { StockMovementService } from '../../services/stock-movement.service';
 import { CustomFieldRelationService } from '../custom-field-relation/custom-field-relation.service';
+import { EntityHydrator } from '../entity-hydrator/entity-hydrator.service';
 import { OrderCalculator } from '../order-calculator/order-calculator';
 import { patchEntity } from '../utils/patch-entity';
 import { translateDeep } from '../utils/translate-entity';
@@ -61,7 +73,10 @@ export class OrderModifier {
         private stockMovementService: StockMovementService,
         private productVariantService: ProductVariantService,
         private customFieldRelationService: CustomFieldRelationService,
+        private promotionService: PromotionService,
         private eventBus: EventBus,
+        private entityHydrator: EntityHydrator,
+        private historyService: HistoryService,
     ) {}
 
     /**
@@ -397,6 +412,43 @@ export class OrderModifier {
             modification.billingAddressChange = input.updateBillingAddress;
         }
 
+        if (input.couponCodes) {
+            for (const couponCode of input.couponCodes) {
+                const validationResult = await this.promotionService.validateCouponCode(
+                    ctx,
+                    couponCode,
+                    order.customer && order.customer.id,
+                );
+                if (isGraphQlErrorResult(validationResult)) {
+                    return validationResult as
+                        | CouponCodeExpiredError
+                        | CouponCodeInvalidError
+                        | CouponCodeLimitError;
+                }
+                if (!order.couponCodes.includes(couponCode)) {
+                    // This is a new coupon code that hadn't been applied before
+                    await this.historyService.createHistoryEntryForOrder({
+                        ctx,
+                        orderId: order.id,
+                        type: HistoryEntryType.ORDER_COUPON_APPLIED,
+                        data: { couponCode, promotionId: validationResult.id },
+                    });
+                }
+            }
+            for (const existingCouponCode of order.couponCodes) {
+                if (!input.couponCodes.includes(existingCouponCode)) {
+                    // An existing coupon code has been removed
+                    await this.historyService.createHistoryEntryForOrder({
+                        ctx,
+                        orderId: order.id,
+                        type: HistoryEntryType.ORDER_COUPON_REMOVED,
+                        data: { couponCode: existingCouponCode },
+                    });
+                }
+            }
+            order.couponCodes = input.couponCodes;
+        }
+
         const updatedOrderLines = order.lines.filter(l => updatedOrderLineIds.includes(l.id));
         const promotions = await this.connection.getRepository(ctx, Promotion).find({
             where: { enabled: true, deletedAt: null },
@@ -426,6 +478,7 @@ export class OrderModifier {
             if (shippingDelta < 0) {
                 refundInput.shipping = shippingDelta * -1;
             }
+            refundInput.adjustment += await this.getAdjustmentFromNewlyAppliedPromotions(ctx, order);
             const existingPayments = await this.getOrderPayments(ctx, order.id);
             const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
             if (payment) {
@@ -449,7 +502,18 @@ export class OrderModifier {
             .getRepository(ctx, OrderModification)
             .save(modification);
         await this.connection.getRepository(ctx, Order).save(order);
-        await this.connection.getRepository(ctx, OrderItem).save(modification.orderItems, { reload: false });
+        if (input.couponCodes) {
+            // When coupon codes have changed, this will likely affect the adjustments applied to
+            // OrderItems. So in this case we need to save all of them.
+            const orderItems = order.lines.reduce((all, line) => all.concat(line.items), [] as OrderItem[]);
+            await this.connection.getRepository(ctx, OrderItem).save(orderItems, { reload: false });
+            await this.promotionService.addPromotionsToOrder(ctx, order);
+        } else {
+            // Otherwise, just save those OrderItems that were specifically added/removed
+            await this.connection
+                .getRepository(ctx, OrderItem)
+                .save(modification.orderItems, { reload: false });
+        }
         await this.connection.getRepository(ctx, ShippingLine).save(order.shippingLines, { reload: false });
         return { order, modification: createdModification };
     }
@@ -461,10 +525,40 @@ export class OrderModifier {
             !input.surcharges?.length &&
             !input.updateShippingAddress &&
             !input.updateBillingAddress &&
+            !input.couponCodes &&
             !(input as any).customFields;
         return noChanges;
     }
 
+    private async getAdjustmentFromNewlyAppliedPromotions(ctx: RequestContext, order: Order) {
+        await this.entityHydrator.hydrate(ctx, order, { relations: ['promotions'] });
+        const existingPromotions = order.promotions;
+        const newPromotionDiscounts = order.discounts
+            .filter(discount => {
+                const promotionId = AdjustmentSource.decodeSourceId(discount.adjustmentSource).id;
+                return !existingPromotions.find(p => idsAreEqual(p.id, promotionId));
+            })
+            .filter(discount => {
+                // Filter out any discounts that originate from ShippingLine discounts,
+                // since they are already correctly accounted for in the refund calculation.
+                for (const shippingLine of order.shippingLines) {
+                    if (
+                        shippingLine.discounts.find(
+                            shippingDiscount =>
+                                shippingDiscount.adjustmentSource === discount.adjustmentSource,
+                        )
+                    ) {
+                        return false;
+                    }
+                }
+                return true;
+            });
+        if (newPromotionDiscounts.length) {
+            return -summate(newPromotionDiscounts, 'amountWithTax');
+        }
+        return 0;
+    }
+
     private getOrderPayments(ctx: RequestContext, orderId: ID): Promise<Payment[]> {
         return this.connection.getRepository(ctx, Payment).find({
             relations: ['refunds'],

+ 1 - 1
packages/core/src/service/helpers/utils/order-utils.ts

@@ -9,7 +9,7 @@ import { PaymentState } from '../payment-state-machine/payment-state';
  */
 export function orderTotalIsCovered(order: Order, state: PaymentState | PaymentState[]): boolean {
     const paymentsTotal = totalCoveredByPayments(order, state);
-    return paymentsTotal === order.totalWithTax;
+    return paymentsTotal >= order.totalWithTax;
 }
 
 /**

+ 1 - 0
packages/core/src/service/index.ts

@@ -33,6 +33,7 @@ export * from './services/fulfillment.service';
 export * from './services/global-settings.service';
 export * from './services/history.service';
 export * from './services/order.service';
+export * from './services/order-testing.service';
 export * from './services/payment-method.service';
 export * from './services/payment.service';
 export * from './services/product-option-group.service';

+ 55 - 6
packages/core/src/service/services/order.service.ts

@@ -77,6 +77,7 @@ import { TransactionalConnection } from '../../connection/transactional-connecti
 import { Customer } from '../../entity/customer/customer.entity';
 import { Fulfillment } from '../../entity/fulfillment/fulfillment.entity';
 import { HistoryEntry } from '../../entity/history-entry/history-entry.entity';
+import { Session } from '../../entity/index';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { OrderLine } from '../../entity/order-line/order-line.entity';
 import { OrderModification } from '../../entity/order-modification/order-modification.entity';
@@ -959,7 +960,7 @@ export class OrderService {
         input: PaymentInput,
     ): Promise<ErrorResultUnion<AddPaymentToOrderResult, Order>> {
         const order = await this.getOrderOrThrow(ctx, orderId);
-        if (order.state !== 'ArrangingPayment') {
+        if (!this.canAddPaymentToOrder(order)) {
             return new OrderPaymentStateError();
         }
         order.payments = await this.getOrderPayments(ctx, order.id);
@@ -990,6 +991,27 @@ export class OrderService {
         return this.transitionOrderIfTotalIsCovered(ctx, order);
     }
 
+    /**
+     * @description
+     * We can add a Payment to the order if:
+     * 1. the Order is in the `ArrangingPayment` state or
+     * 2. the Order's current state can transition to `PaymentAuthorized` and `PaymentSettled`
+     */
+    private canAddPaymentToOrder(order: Order): boolean {
+        if (order.state === 'ArrangingPayment') {
+            return true;
+        }
+        const canTransitionToPaymentAuthorized = this.orderStateMachine.canTransition(
+            order.state,
+            'PaymentAuthorized',
+        );
+        const canTransitionToPaymentSettled = this.orderStateMachine.canTransition(
+            order.state,
+            'PaymentSettled',
+        );
+        return canTransitionToPaymentAuthorized && canTransitionToPaymentSettled;
+    }
+
     private async transitionOrderIfTotalIsCovered(
         ctx: RequestContext,
         order: Order,
@@ -1438,6 +1460,37 @@ export class OrderService {
         }
     }
 
+    /**
+     * @description
+     * Deletes an Order, ensuring that any Sessions that reference this Order are dereferenced before deletion.
+     *
+     * @since 1.5.0
+     */
+    async deleteOrder(ctx: RequestContext, orderOrId: ID | Order) {
+        const orderToDelete =
+            orderOrId instanceof Order
+                ? orderOrId
+                : await this.connection
+                      .getRepository(ctx, Order)
+                      .findOneOrFail(orderOrId, { relations: ['lines'] });
+        // If there is a Session referencing the Order to be deleted, we must first remove that
+        // reference in order to avoid a foreign key error. See https://github.com/vendure-ecommerce/vendure/issues/1454
+        const sessions = await this.connection
+            .getRepository(ctx, Session)
+            .find({ where: { activeOrderId: orderToDelete.id } });
+        if (sessions.length) {
+            await this.connection
+                .getRepository(ctx, Session)
+                .update(sessions.map(s => s.id) as string[], { activeOrder: null });
+        }
+
+        // TODO: v2 - Will not be needed after adding `{ onDelete: 'CASCADE' }` constraint to ShippingLine.order
+        for (const shippingLine of orderToDelete.shippingLines) {
+            await this.connection.getRepository(ctx, ShippingLine).delete(shippingLine.id);
+        }
+        await this.connection.getRepository(ctx, Order).delete(orderToDelete.id);
+    }
+
     /**
      * @description
      * When a guest user with an anonymous Order signs in and has an existing Order associated with that Customer,
@@ -1460,11 +1513,7 @@ export class OrderService {
         const { orderToDelete, linesToInsert, linesToDelete, linesToModify } = mergeResult;
         let { order } = mergeResult;
         if (orderToDelete) {
-            // TODO: v2 - Will not be needed after adding `{ onDelete: 'CASCADE' }` constraint to ShippingLine.order
-            for (const shippingLine of orderToDelete.shippingLines) {
-                await this.connection.getRepository(ctx, ShippingLine).delete(shippingLine.id);
-            }
-            await this.connection.getRepository(ctx, Order).delete(orderToDelete.id);
+            await this.deleteOrder(ctx, orderToDelete);
         }
         if (order && linesToInsert) {
             const orderId = order.id;

+ 30 - 1
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -546,6 +546,28 @@ export type CountryTranslationInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeExpiredError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeInvalidError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeLimitError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+    limit: Scalars['Int'];
+};
+
 export type CreateAddressInput = {
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
@@ -1390,6 +1412,9 @@ export enum ErrorCode {
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
     INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
+    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
+    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
+    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
 }
 
 export type ErrorResult = {
@@ -2193,6 +2218,7 @@ export type ModifyOrderInput = {
     note?: Maybe<Scalars['String']>;
     refund?: Maybe<AdministratorRefundInput>;
     options?: Maybe<ModifyOrderOptions>;
+    couponCodes?: Maybe<Array<Scalars['String']>>;
 };
 
 export type ModifyOrderOptions = {
@@ -2208,7 +2234,10 @@ export type ModifyOrderResult =
     | RefundPaymentIdMissingError
     | OrderLimitError
     | NegativeQuantityError
-    | InsufficientStockError;
+    | InsufficientStockError
+    | CouponCodeExpiredError
+    | CouponCodeInvalidError
+    | CouponCodeLimitError;
 
 export type MoveCollectionInput = {
     collectionId: Scalars['ID'];

+ 30 - 1
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -546,6 +546,28 @@ export type CountryTranslationInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeExpiredError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeInvalidError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+};
+
+/** Returned if the provided coupon code is invalid */
+export type CouponCodeLimitError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    couponCode: Scalars['String'];
+    limit: Scalars['Int'];
+};
+
 export type CreateAddressInput = {
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
@@ -1390,6 +1412,9 @@ export enum ErrorCode {
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
     INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
+    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
+    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
+    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
 }
 
 export type ErrorResult = {
@@ -2193,6 +2218,7 @@ export type ModifyOrderInput = {
     note?: Maybe<Scalars['String']>;
     refund?: Maybe<AdministratorRefundInput>;
     options?: Maybe<ModifyOrderOptions>;
+    couponCodes?: Maybe<Array<Scalars['String']>>;
 };
 
 export type ModifyOrderOptions = {
@@ -2208,7 +2234,10 @@ export type ModifyOrderResult =
     | RefundPaymentIdMissingError
     | OrderLimitError
     | NegativeQuantityError
-    | InsufficientStockError;
+    | InsufficientStockError
+    | CouponCodeExpiredError
+    | CouponCodeInvalidError
+    | CouponCodeLimitError;
 
 export type MoveCollectionInput = {
     collectionId: Scalars['ID'];

+ 3 - 3
packages/payments-plugin/e2e/graphql/generated-shop-types.ts

@@ -852,15 +852,15 @@ export enum ErrorCode {
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
     INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
+    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
+    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
+    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ORDER_MODIFICATION_ERROR = 'ORDER_MODIFICATION_ERROR',
     INELIGIBLE_SHIPPING_METHOD_ERROR = 'INELIGIBLE_SHIPPING_METHOD_ERROR',
     ORDER_PAYMENT_STATE_ERROR = 'ORDER_PAYMENT_STATE_ERROR',
     INELIGIBLE_PAYMENT_METHOD_ERROR = 'INELIGIBLE_PAYMENT_METHOD_ERROR',
     PAYMENT_FAILED_ERROR = 'PAYMENT_FAILED_ERROR',
     PAYMENT_DECLINED_ERROR = 'PAYMENT_DECLINED_ERROR',
-    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
-    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
-    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ALREADY_LOGGED_IN_ERROR = 'ALREADY_LOGGED_IN_ERROR',
     MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
     PASSWORD_VALIDATION_ERROR = 'PASSWORD_VALIDATION_ERROR',

+ 3 - 3
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts

@@ -885,15 +885,15 @@ export enum ErrorCode {
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
     INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
+    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
+    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
+    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ORDER_MODIFICATION_ERROR = 'ORDER_MODIFICATION_ERROR',
     INELIGIBLE_SHIPPING_METHOD_ERROR = 'INELIGIBLE_SHIPPING_METHOD_ERROR',
     ORDER_PAYMENT_STATE_ERROR = 'ORDER_PAYMENT_STATE_ERROR',
     INELIGIBLE_PAYMENT_METHOD_ERROR = 'INELIGIBLE_PAYMENT_METHOD_ERROR',
     PAYMENT_FAILED_ERROR = 'PAYMENT_FAILED_ERROR',
     PAYMENT_DECLINED_ERROR = 'PAYMENT_DECLINED_ERROR',
-    COUPON_CODE_INVALID_ERROR = 'COUPON_CODE_INVALID_ERROR',
-    COUPON_CODE_EXPIRED_ERROR = 'COUPON_CODE_EXPIRED_ERROR',
-    COUPON_CODE_LIMIT_ERROR = 'COUPON_CODE_LIMIT_ERROR',
     ALREADY_LOGGED_IN_ERROR = 'ALREADY_LOGGED_IN_ERROR',
     MISSING_PASSWORD_ERROR = 'MISSING_PASSWORD_ERROR',
     PASSWORD_VALIDATION_ERROR = 'PASSWORD_VALIDATION_ERROR',

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


File diff suppressed because it is too large
+ 0 - 0
schema-shop.json


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