Преглед изворни кода

Merge branch 'minor' into major

Michael Bromley пре 3 година
родитељ
комит
22fc4acfe1
64 измењених фајлова са 2308 додато и 577 уклоњено
  1. 19 0
      CHANGELOG.md
  2. 1 1
      docs/content/developer-guide/authentication.md
  3. 5 1
      docs/content/developer-guide/deployment.md
  4. 3 1
      docs/content/developer-guide/promotions.md
  5. 1 0
      packages/admin-ui-plugin/src/plugin.ts
  6. 4 0
      packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts
  7. 7 0
      packages/admin-ui/src/lib/core/src/data/data.module.ts
  8. 1 0
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts
  9. 5 2
      packages/admin-ui/src/lib/customer/src/providers/routing/customer-resolver.ts
  10. 68 52
      packages/admin-ui/src/lib/login/src/components/login/login.component.html
  11. 134 39
      packages/admin-ui/src/lib/login/src/components/login/login.component.scss
  12. 30 1
      packages/admin-ui/src/lib/login/src/components/login/login.component.ts
  13. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  14. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  15. 1 0
      packages/admin-ui/src/lib/static/styles/_variables.scss
  16. 1 1
      packages/asset-server-plugin/package.json
  17. 6 2
      packages/asset-server-plugin/src/s3-asset-storage-strategy.ts
  18. 7 0
      packages/common/src/shared-types.ts
  19. 524 0
      packages/core/e2e/active-order-strategy.e2e-spec.ts
  20. 17 1
      packages/core/e2e/draft-order.e2e-spec.ts
  21. 1 1
      packages/core/e2e/facet.e2e-spec.ts
  22. 113 0
      packages/core/e2e/fixtures/test-plugins/token-active-order-plugin.ts
  23. 294 298
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  24. 23 0
      packages/core/e2e/graphql/shared-definitions.ts
  25. 274 3
      packages/core/e2e/order-modification.e2e-spec.ts
  26. 1 16
      packages/core/e2e/product.e2e-spec.ts
  27. 1 8
      packages/core/e2e/promotion.e2e-spec.ts
  28. 22 0
      packages/core/e2e/zone.e2e-spec.ts
  29. 22 5
      packages/core/src/api/api-internal-modules.ts
  30. 2 0
      packages/core/src/api/config/configure-graphql-module.ts
  31. 109 0
      packages/core/src/api/config/generate-active-order-types.ts
  32. 20 0
      packages/core/src/api/resolvers/entity/zone-entity.resolver.ts
  33. 105 33
      packages/core/src/api/resolvers/shop/shop-order.resolver.ts
  34. 2 0
      packages/core/src/config/config.module.ts
  35. 3 0
      packages/core/src/config/default-config.ts
  36. 2 0
      packages/core/src/config/index.ts
  37. 84 0
      packages/core/src/config/order/active-order-strategy.ts
  38. 65 0
      packages/core/src/config/order/default-active-order-strategy.ts
  39. 24 0
      packages/core/src/config/vendure-config.ts
  40. 5 5
      packages/core/src/connection/transactional-connection.ts
  41. 3 3
      packages/core/src/i18n/messages/de.json
  42. 2 1
      packages/core/src/i18n/messages/en.json
  43. 61 2
      packages/core/src/service/helpers/active-order/active-order.service.ts
  44. 10 1
      packages/core/src/service/helpers/external-authentication/external-authentication.service.ts
  45. 29 42
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  46. 3 0
      packages/core/src/service/initializer.service.ts
  47. 2 1
      packages/core/src/service/services/channel.service.ts
  48. 4 2
      packages/core/src/service/services/collection.service.ts
  49. 2 1
      packages/core/src/service/services/country.service.ts
  50. 2 1
      packages/core/src/service/services/customer-group.service.ts
  51. 2 1
      packages/core/src/service/services/customer.service.ts
  52. 14 5
      packages/core/src/service/services/facet-value.service.ts
  53. 3 1
      packages/core/src/service/services/facet.service.ts
  54. 4 2
      packages/core/src/service/services/history.service.ts
  55. 4 1
      packages/core/src/service/services/payment-method.service.ts
  56. 32 27
      packages/core/src/service/services/payment.service.ts
  57. 2 1
      packages/core/src/service/services/product-option-group.service.ts
  58. 2 1
      packages/core/src/service/services/product-option.service.ts
  59. 2 1
      packages/core/src/service/services/role.service.ts
  60. 2 1
      packages/core/src/service/services/tax-category.service.ts
  61. 32 8
      packages/core/src/service/services/tax-rate.service.ts
  62. 2 2
      packages/core/src/service/services/zone.service.ts
  63. 76 0
      packages/dev-server/test-plugins/custom-active-order-plugin.ts
  64. 1 0
      scripts/codegen/generate-graphql-types.ts

+ 19 - 0
CHANGELOG.md

@@ -1,3 +1,22 @@
+## <small>1.8.3 (2022-11-10)</small>
+
+
+#### Fixes
+
+* **admin-ui** Fix critical FacetValue deletion issue ([1e443e2](https://github.com/vendure-ecommerce/vendure/commit/1e443e2))
+* **asset-server-plugin** Better error message for s3 bucket errors ([adf58b4](https://github.com/vendure-ecommerce/vendure/commit/adf58b4))
+* **asset-server-plugin** Update Sharp version to fix mac m1 issue ([b76515b](https://github.com/vendure-ecommerce/vendure/commit/b76515b)), closes [#1866](https://github.com/vendure-ecommerce/vendure/issues/1866)
+* **core** Add resolver for `Zone.members` field ([3b67e61](https://github.com/vendure-ecommerce/vendure/commit/3b67e61))
+* **core** Allow ext. auth to find customer on any channel ([2445a89](https://github.com/vendure-ecommerce/vendure/commit/2445a89)), closes [#961](https://github.com/vendure-ecommerce/vendure/issues/961)
+* **core** Ensure deleted entities in events include ids ([265bb15](https://github.com/vendure-ecommerce/vendure/commit/265bb15))
+* **core** Fix foreign key violation error when removing draft order line ([403ab2c](https://github.com/vendure-ecommerce/vendure/commit/403ab2c)), closes [#1855](https://github.com/vendure-ecommerce/vendure/issues/1855)
+* **core** Fix multiple refunds bug when modifying orders ([f18fedd](https://github.com/vendure-ecommerce/vendure/commit/f18fedd)), closes [#1753](https://github.com/vendure-ecommerce/vendure/issues/1753)
+* **core** Fix order incorrect refund amount when modifying Order ([b1486e8](https://github.com/vendure-ecommerce/vendure/commit/b1486e8)), closes [#1865](https://github.com/vendure-ecommerce/vendure/issues/1865)
+* **core** Fix regression from OrderItem ordering change ([1d552b3](https://github.com/vendure-ecommerce/vendure/commit/1d552b3))
+* **core** Improved feedback on FacetValue deletion confirmation ([03419cb](https://github.com/vendure-ecommerce/vendure/commit/03419cb))
+* **core** Make order modification items deterministic ([14d0a22](https://github.com/vendure-ecommerce/vendure/commit/14d0a22)), closes [#1760](https://github.com/vendure-ecommerce/vendure/issues/1760)
+* **core** Publish event when deleting FacetValue ([0ece03b](https://github.com/vendure-ecommerce/vendure/commit/0ece03b))
+
 ## <small>1.8.2 (2022-11-01)</small>
 
 

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

@@ -329,7 +329,7 @@ This example uses [Keycloak](https://www.keycloak.org/), a popular open-source i
 
 ### Configure a login page & Admin UI
 
-In this example we'll assume the login page is hosted at `http://intranet/login`. We'll also assume that a "login to Vendure" button has been added to that pagem and that the page is using the [Keycloak JavaScript adapter](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter), which can be used to get the current user's authorization token:
+In this example we'll assume the login page is hosted at `http://intranet/login`. We'll also assume that a "login to Vendure" button has been added to that page and that the page is using the [Keycloak JavaScript adapter](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter), which can be used to get the current user's authorization token:
 
 ```JavaScript
 vendureLoginButton.addEventListener('click', () => {

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

@@ -14,7 +14,11 @@ The bare minimum requirements are:
 
 A typical pattern is to run the Vendure app on the server, e.g. at `http://localhost:3000` and then use [nginx as a reverse proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) to direct requests from the Internet to the Vendure application.
 
-Here is a good guide to setting up a production-ready server for an app such as Vendure: https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-ubuntu-18-04
+Here is a [general guide to setting up a production-ready server](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-ubuntu-18-04) for an app such as Vendure.
+
+{{< alert >}}
+You can find more information & discussion about platform-specific deployments in our [GitHub Discussions Deployment category](https://github.com/vendure-ecommerce/vendure/discussions/categories/deployment).
+{{< /alert >}}
 
 ## Database Timezone
 

+ 3 - 1
docs/content/developer-guide/promotions.md

@@ -190,7 +190,9 @@ export const freeGiftAction = new PromotionItemAction({
     // This part is responsible for ensuring the variants marked as 
     // "free gifts" have their price reduced to zero.  
     if (lineContainsIds(args.productVariantIds, orderLine)) {
-      const unitPrice = orderLine.unitPrice;
+      const unitPrice = orderLine.productVariant.listPriceIncludesTax
+        ? orderLine.unitPriceWithTax
+        : orderLine.unitPrice;
       return -unitPrice;
     }
     return 0;

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

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

+ 4 - 0
packages/admin-ui/src/lib/catalog/src/components/facet-detail/facet-detail.component.ts

@@ -244,6 +244,10 @@ export class FacetDetailComponent
             )
             .subscribe(
                 () => {
+                    const valuesFormArray = this.detailForm.get('values') as FormArray | null;
+                    if (valuesFormArray) {
+                        valuesFormArray.removeAt(index);
+                    }
                     this.notificationService.success(_('common.notify-delete-success'), {
                         entity: 'FacetValue',
                     });

+ 7 - 0
packages/admin-ui/src/lib/core/src/data/data.module.ts

@@ -39,6 +39,13 @@ export function createApollo(
                     },
                 },
             },
+            Facet: {
+                fields: {
+                    values: {
+                        merge: (existing, incoming) => incoming,
+                    },
+                },
+            },
         },
     });
     apolloCache.writeQuery({

+ 1 - 0
packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts

@@ -508,6 +508,7 @@ export class CustomerDetailComponent
             .getCustomer(this.id, {
                 take: this.ordersPerPage,
                 skip: (this.currentOrdersPage - 1) * this.ordersPerPage,
+                sort: { orderPlacedAt: SortOrder.DESC },
             })
             .single$.pipe(
                 map(data => data.customer),

+ 5 - 2
packages/admin-ui/src/lib/customer/src/providers/routing/customer-resolver.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@angular/core';
 import { Router } from '@angular/router';
-import { BaseEntityResolver, CustomerFragment, DataService } from '@vendure/admin-ui/core';
+import { BaseEntityResolver, CustomerFragment, DataService, SortOrder } from '@vendure/admin-ui/core';
 
 @Injectable({
     providedIn: 'root',
@@ -22,7 +22,10 @@ export class CustomerResolver extends BaseEntityResolver<CustomerFragment> {
                 addresses: null,
                 user: null,
             },
-            id => dataService.customer.getCustomer(id).mapStream(data => data.customer),
+            id =>
+                dataService.customer
+                    .getCustomer(id, { sort: { orderPlacedAt: SortOrder.DESC } })
+                    .mapStream(data => data.customer),
         );
     }
 }

+ 68 - 52
packages/admin-ui/src/lib/login/src/components/login/login.component.html

@@ -1,56 +1,72 @@
 <div class="login-wrapper">
-    <form class="login">
-        <label class="title">
-            <img src="assets/logo-300px.png" />
-            <span *ngIf="!hideVendureBranding">vendure</span>
-        </label>
-        <div class="login-group">
-            <input
-                class="username"
-                type="text"
-                name="username"
-                id="login_username"
-                [(ngModel)]="username"
-                [placeholder]="'common.username' | translate"
-            />
-            <input
-                class="password"
-                name="password"
-                type="password"
-                id="login_password"
-                [(ngModel)]="password"
-                [placeholder]="'common.password' | translate"
-            />
-            <clr-alert [clrAlertType]="'danger'"  [clrAlertClosable]="false" [class.visible]="errorMessage" class="login-error">
-                <clr-alert-item>
-                    <span class="alert-text">
-                        {{ errorMessage }}
-                    </span>
-                </clr-alert-item>
-            </clr-alert>
-            <clr-checkbox-wrapper>
-                <input
-                    type="checkbox"
-                    clrCheckbox
-                    id="rememberme"
-                    name="rememberme"
-                    [(ngModel)]="rememberMe"
-                />
-                <label>{{ 'common.remember-me' | translate }}</label>
-            </clr-checkbox-wrapper>
-            <button
-                type="submit"
-                class="btn btn-primary"
-                (click)="logIn()"
-                [disabled]="!username || !password"
-            >
-                {{ 'common.login' | translate }}
-            </button>
+    <div class="login-wrapper-inner">
+        <div class="login-wrapper-image">
+            <div class="login-wrapper-image-content">
+                <div class="login-wrapper-image-title">
+                    {{ 'common.login-image-title' | translate }}
+                </div>
+                <div class="login-wrapper-image-copyright">
+                    <p *ngIf="imageCreator" class="creator">Photo by  {{ imageCreator }} on Unsplash</p>
+                    <p *ngIf="imageLocation" class="location">{{ imageLocation }}</p>
+                </div>
+            </div>
+            <img *ngIf="imageUrl" [src]="imageUrl" [alt]="imageUrl">
         </div>
-        <div class="version">
-            <span *ngIf="brand">{{ brand }} <span *ngIf="!hideVendureBranding || !hideVersion">-</span></span>
-            <span *ngIf="!hideVendureBranding">vendure</span>
-            <span *ngIf="!hideVersion">v{{ version }}</span>
+        <div class="login-wrapper-form">
+            <p class="login-title">
+                {{ 'common.login-title' | translate }}
+            </p>
+            <form class="login-form">
+                <div class="login-group">
+                    <input
+                        class="username"
+                        type="text"
+                        name="username"
+                        id="login_username"
+                        [(ngModel)]="username"
+                        [placeholder]="'common.username' | translate"
+                    />
+                    <input
+                        class="password"
+                        name="password"
+                        type="password"
+                        id="login_password"
+                        [(ngModel)]="password"
+                        [placeholder]="'common.password' | translate"
+                    />
+                    <clr-alert [clrAlertType]="'danger'"  [clrAlertClosable]="false" [class.visible]="errorMessage" class="login-error">
+                        <clr-alert-item>
+                            <span class="alert-text">
+                                {{ errorMessage }}
+                            </span>
+                        </clr-alert-item>
+                    </clr-alert>
+                    <clr-checkbox-wrapper>
+                        <input
+                            type="checkbox"
+                            clrCheckbox
+                            id="rememberme"
+                            name="rememberme"
+                            [(ngModel)]="rememberMe"
+                        />
+                        <label>{{ 'common.remember-me' | translate }}</label>
+                    </clr-checkbox-wrapper>
+                    <button
+                        type="submit"
+                        class="btn btn-primary"
+                        (click)="logIn()"
+                        [disabled]="!username || !password"
+                    >
+                        {{ 'common.login' | translate }}
+                    </button>
+                </div>
+                <div class="version">
+                    <span *ngIf="brand">{{ brand }} <span *ngIf="!hideVendureBranding || !hideVersion">-</span></span>
+                    <span *ngIf="!hideVendureBranding">vendure</span>
+                    <span *ngIf="!hideVersion">v{{ version }}</span>
+                </div>
+            </form>
         </div>
-    </form>
+        <img class="login-wrapper-logo" src="assets/logo-300px.png" />
+    </div>
 </div>

+ 134 - 39
packages/admin-ui/src/lib/login/src/components/login/login.component.scss

@@ -1,60 +1,155 @@
 @import 'variables';
 
 .login-wrapper {
-    background-image: linear-gradient(135deg, var(--color-login-gradient-top), var(--color-login-gradient-bottom)),
-    var(--login-page-bg);
-    background-blend-mode: screen;
-    background-repeat: repeat;
-    background-size: auto;
-    background-position: initial;
+    background: #f0f2f5;
+    background-image: none;
+    height: 100vh;
+    display: flex;
+    align-items: center;
     justify-content: center;
-}
+    padding: 20px;
 
-@media screen and (max-width: $breakpoint-small) {
-    .login-wrapper {
-        justify-content: center;
-        background-color: transparent;
-        .login {
-            margin: 20px;
-            padding: 24px;
+    .login-wrapper-inner {
+        background: #fff;
+        width: 1120px;
+        height: 590px;
+        display: flex;
+        justify-content: flex-start;
+        align-items: stretch;
+        position: relative;
+        border-radius: 3px;
+        overflow: hidden;
+
+        @media (max-width: $breakpoint-medium) {
+            flex-direction: column;
+            height: auto;
+            width: 100%
         }
-    }
-}
 
-.login {
-    margin: 5vh 0;
-    border-radius: 6px;
-    min-height: calc(100vh - 10vh);
-    max-height: 800px;
-}
+        .login-wrapper-image {
+            height: 100%;
+            flex-grow: 1;
+            position: relative;
 
-.title {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    text-align: center;
-    margin-top: 8vh;
+            @media (max-width: $breakpoint-medium) {
+                height: 300px;
+            }
+    
+            img {
+                display: block;
+                width: 100%;
+                height: 100%;
+                object-fit: cover;
+                object-position: center;
+                position: relative;
+                z-index: 1;
+            }
 
-    img {
-        max-width: 100%;
-        width: 150px;
-    }
-    span {
-        padding-top: 12px;
-        font-weight: bold;
-        color: var(--color-primary-500);
-        font-size: 38px;
+            .login-wrapper-image-content {
+                width: 100%;
+                height: 100%;
+                position: absolute;
+                left: 0;
+                bottom: 0;
+                z-index: 10;
+                background: rgb(2,0,36);
+                background: linear-gradient(180deg, rgba(2,0,36,0) 0%, rgba(0,0,0,0.75) 100%);
+                display: flex;
+                flex-direction: column;
+                align-items: flex-start;
+                justify-content: flex-end;
+                padding: 30px;
+
+                .login-wrapper-image-title {
+                    font-size: 1.6rem;
+                    font-weight: bold;
+                    color: white;
+                    margin-bottom: 20px;
+
+                    @media (max-width: $breakpoint-medium) {
+                        font-size: 1.2rem;
+                    }
+                }
+
+                .login-wrapper-image-copyright {
+                    opacity: 0.8;
+                    p {
+                        font-size: 0.6rem;
+                        color: white;
+                        margin: 0 !important;
+                    }
+                }
+            }
+        }
+
+        .login-wrapper-form {
+            height: 100%;
+            width: 400px;
+            padding: 40px;
+            display: flex;
+            flex-direction: column;
+            align-items: stretch;
+            justify-content: center;
+            box-shadow: 0px 20px 25px rgba(0,0,0,0.1);
+            overflow: hidden;
+            border-radius: 5px;
+            flex-shrink: 0;
+
+            @media (max-width: $breakpoint-medium) {
+                height: auto;
+                width: 100%;
+                padding: 20px;
+            }
+
+            .login-title {
+                font-weight: bold;
+                font-size: 1.2rem;
+                margin-bottom: 20px;
+                color: #afafaf; 
+            }
+
+            .login-group {
+
+                input.username,
+                input.password {
+                    display: block;
+                    width: 100%;
+                    margin-bottom: 15px;
+                    padding: 12px 16px !important;
+                    background: #fff;
+                    font-size: 14px;
+                    line-height: 22px;
+                    color: #52667a;
+                    outline: none;
+                    -webkit-appearance: none;
+                }
+
+                .btn {
+                    width: 100% !important;
+                    margin-top: 20px !important
+                }
+            }
+        }
+
+        .login-wrapper-logo {
+            width: 60px;
+            height: auto;
+            position: absolute;
+            right: 20px;
+            top: 20px;
+        }
     }
 }
 
 .version {
     flex: 1;
+    flex-grow: 1;
     display: flex;
     align-items: flex-end;
     justify-content: center;
     color: var(--color-grey-300);
 
-    span + span {
+    span+span {
         margin-left: 5px;
     }
 }
@@ -95,4 +190,4 @@
     60% {
         transform: translate3d(4px, 0, 0);
     }
-}
+}

+ 30 - 1
packages/admin-ui/src/lib/login/src/components/login/login.component.ts

@@ -1,3 +1,4 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
 import { Component } from '@angular/core';
 import { Router } from '@angular/router';
 import { ADMIN_UI_VERSION, AuthService, AUTH_REDIRECT_PARAM, getAppConfig } from '@vendure/admin-ui/core';
@@ -16,8 +17,18 @@ export class LoginComponent {
     brand = getAppConfig().brand;
     hideVendureBranding = getAppConfig().hideVendureBranding;
     hideVersion = getAppConfig().hideVersion;
+    customImageUrl = getAppConfig().loginImage;
+    imageUrl = '';
+    imageLocation = '';
+    imageCreator = '';
 
-    constructor(private authService: AuthService, private router: Router) {}
+    constructor(private authService: AuthService, private router: Router, private httpClient: HttpClient) {
+        if (this.customImageUrl) {
+            this.imageUrl = this.customImageUrl;
+        } else {
+            this.loadImage();
+        }
+    }
 
     logIn(): void {
         this.errorMessage = undefined;
@@ -35,6 +46,24 @@ export class LoginComponent {
         });
     }
 
+    loadImage() {
+        this.httpClient
+            .get('https://login-image.vendure.io')
+            .toPromise()
+            .then(res => {
+                this.updateImage(res);
+            });
+    }
+
+    updateImage(res) {
+        const user: any = (res as any).user;
+        const location: any = (res as any).location;
+
+        this.imageUrl = (res as any).urls.regular;
+        this.imageCreator = user.name;
+        this.imageLocation = location.name;
+    }
+
     /**
      * Attempts to read a redirect param from the current url and parse it into a
      * route from which the user was redirected after a 401 error.

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

@@ -289,7 +289,9 @@
     "username": "Benutzername",
     "view-next-month": "Nächsten Monat anzeigen",
     "view-previous-month": "Vorherigen Monat anzeigen",
-    "with-selected": "Auswahl..."
+    "with-selected": "Auswahl...",
+    "login-title": "Anmelden bei Vendure",
+    "login-image-title": "Hallo! Willkommen zurück. Schön, dich zu sehen."
   },
   "customer": {
     "add-customer-to-group": "Kunde zu Gruppe hinzufügen",

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

@@ -289,7 +289,9 @@
     "username": "Username",
     "view-next-month": "View next month",
     "view-previous-month": "View previous month",
-    "with-selected": "With {count} selected..."
+    "with-selected": "With {count} selected...",
+    "login-title": "Log in to Vendure",
+    "login-image-title": "Hi! Welcome back. Good to see you."
   },
   "customer": {
     "add-customer-to-group": "Add customer to group",

+ 1 - 0
packages/admin-ui/src/lib/static/styles/_variables.scss

@@ -4,3 +4,4 @@
 
 // breakpoints
 $breakpoint-small: 768px;
+$breakpoint-medium: 992px;

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

@@ -35,6 +35,6 @@
   "dependencies": {
     "file-type": "^16.5.3",
     "fs-extra": "^10.0.0",
-    "sharp": "~0.30.7"
+    "sharp": "~0.31.2"
   }
 }

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

@@ -294,14 +294,18 @@ export class S3AssetStorageStrategy implements AssetStorageStrategy {
             bucketExists = true;
             Logger.verbose(`Found S3 bucket "${bucket}"`, loggerCtx);
         } catch (e: any) {
-            Logger.verbose(`Could not find bucket "${bucket}". Attempting to create...`);
+            Logger.verbose(`Could not find bucket "${bucket}: ${e.message ?? ''}". Attempting to create...`);
         }
         if (!bucketExists) {
             try {
                 await this.s3.createBucket({ Bucket: bucket, ACL: 'private' }).promise();
                 Logger.verbose(`Created S3 bucket "${bucket}"`, loggerCtx);
             } catch (e: any) {
-                Logger.error(`Could not find nor create the S3 bucket "${bucket}"`, loggerCtx, e.stack);
+                Logger.error(
+                    `Could not find nor create the S3 bucket "${bucket}: ${e.message ?? ''}"`,
+                    loggerCtx,
+                    e.stack,
+                );
             }
         }
     }

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

@@ -303,6 +303,13 @@ export interface AdminUiConfig {
      * @default false
      */
     hideVersion?: boolean;
+    /**
+     * @description
+     * The custom login image
+     *
+     * @since 1.9.0
+     */
+    loginImage?: string;
     /**
      * @description
      * Allows you to provide default reasons for a refund or cancellation. This will be used in the

+ 524 - 0
packages/core/e2e/active-order-strategy.e2e-spec.ts

@@ -0,0 +1,524 @@
+import { DefaultLogger, mergeConfig, orderPercentageDiscount } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
+import { TokenActiveOrderPlugin } from './fixtures/test-plugins/token-active-order-plugin';
+import {
+    CreatePromotionMutation,
+    CreatePromotionMutationVariables,
+    GetCustomerListQuery,
+} from './graphql/generated-e2e-admin-types';
+import {
+    AddItemToOrderMutation,
+    AddItemToOrderMutationVariables,
+    GetActiveOrderQuery,
+} from './graphql/generated-e2e-shop-types';
+import { CREATE_PROMOTION, GET_CUSTOMER_LIST } from './graphql/shared-definitions';
+import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-definitions';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+
+describe('custom ActiveOrderStrategy', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            logger: new DefaultLogger(),
+            plugins: [TokenActiveOrderPlugin],
+            paymentOptions: {
+                paymentMethodHandlers: [testSuccessfulPaymentMethod],
+            },
+            customFields: {
+                Order: [
+                    {
+                        name: 'message',
+                        type: 'string',
+                        nullable: true,
+                    },
+                ],
+            },
+        }),
+    );
+
+    let customers: GetCustomerListQuery['customers']['items'];
+
+    beforeAll(async () => {
+        await server.init({
+            initialData: {
+                ...initialData,
+                paymentMethods: [
+                    {
+                        name: testSuccessfulPaymentMethod.code,
+                        handler: { code: testSuccessfulPaymentMethod.code, arguments: [] },
+                    },
+                ],
+            },
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 3,
+        });
+        await adminClient.asSuperAdmin();
+        const result = await adminClient.query<GetCustomerListQuery>(GET_CUSTOMER_LIST);
+        customers = result.customers.items;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('activeOrder with no createActiveOrder defined returns null', async () => {
+        const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+
+        expect(activeOrder).toBeNull();
+    });
+
+    it(
+        'addItemToOrder with no createActiveOrder throws',
+        assertThrowsWithMessage(async () => {
+            await shopClient.query<AddItemToOrderMutation, AddItemToOrderMutationVariables>(
+                ADD_ITEM_TO_ORDER,
+                {
+                    productVariantId: 'T_1',
+                    quantity: 1,
+                },
+            );
+        }, 'No active Order could be determined nor created'),
+    );
+
+    it('activeOrder with valid input', async () => {
+        const { createOrder } = await shopClient.query(gql`
+            mutation CreateCustomOrder {
+                createOrder(customerId: "${customers[1].id}") {
+                    id
+                    orderToken
+                }
+            }
+        `);
+
+        expect(createOrder).toEqual({
+            id: 'T_1',
+            orderToken: 'token-2',
+        });
+
+        await shopClient.asUserWithCredentials(customers[1].emailAddress, 'test');
+        const { activeOrder } = await shopClient.query(ACTIVE_ORDER_BY_TOKEN, {
+            input: {
+                orderToken: { token: 'token-2' },
+            },
+        });
+
+        expect(activeOrder).toEqual({
+            id: 'T_1',
+            orderToken: 'token-2',
+        });
+    });
+
+    it('activeOrder with invalid input', async () => {
+        await shopClient.asUserWithCredentials(customers[1].emailAddress, 'test');
+        const { activeOrder } = await shopClient.query(ACTIVE_ORDER_BY_TOKEN, {
+            input: {
+                orderToken: { token: 'invalid' },
+            },
+        });
+
+        expect(activeOrder).toBeNull();
+    });
+
+    it('activeOrder with invalid condition', async () => {
+        // wrong customer logged in
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        const { activeOrder } = await shopClient.query(ACTIVE_ORDER_BY_TOKEN, {
+            input: {
+                orderToken: { token: 'token-2' },
+            },
+        });
+
+        expect(activeOrder).toBeNull();
+    });
+
+    describe('happy path', () => {
+        const activeOrderInput = `activeOrderInput: { orderToken: { token: "token-2" } }`;
+        const TEST_COUPON_CODE = 'TESTCOUPON';
+        let firstOrderLineId: string;
+
+        beforeAll(async () => {
+            await shopClient.asUserWithCredentials(customers[1].emailAddress, 'test');
+            const result = await adminClient.query<CreatePromotionMutation, CreatePromotionMutationVariables>(
+                CREATE_PROMOTION,
+                {
+                    input: {
+                        enabled: true,
+                        name: 'Free with test coupon',
+                        couponCode: TEST_COUPON_CODE,
+                        conditions: [],
+                        actions: [
+                            {
+                                code: orderPercentageDiscount.code,
+                                arguments: [{ name: 'discount', value: '100' }],
+                            },
+                        ],
+                    },
+                },
+            );
+        });
+
+        it('addItemToOrder', async () => {
+            const { addItemToOrder } = await shopClient.query(gql`
+                mutation {
+                    addItemToOrder(productVariantId: "T_1", quantity: 1, ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            lines {
+                                id
+                                productVariant { id }
+                            }
+                        }
+                    }
+                }
+            `);
+
+            expect(addItemToOrder).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                lines: [
+                    {
+                        id: 'T_1',
+                        productVariant: { id: 'T_1' },
+                    },
+                ],
+            });
+            firstOrderLineId = addItemToOrder.lines[0].id;
+        });
+
+        it('adjustOrderLine', async () => {
+            const { adjustOrderLine } = await shopClient.query(gql`
+                mutation {
+                    adjustOrderLine(orderLineId: "${firstOrderLineId}", quantity: 2, ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            lines {
+                                quantity
+                                productVariant { id }
+                            }
+                        }
+                    }
+                }
+            `);
+
+            expect(adjustOrderLine).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                lines: [
+                    {
+                        quantity: 2,
+                        productVariant: { id: 'T_1' },
+                    },
+                ],
+            });
+        });
+
+        it('removeOrderLine', async () => {
+            const { removeOrderLine } = await shopClient.query(gql`
+                mutation {
+                    removeOrderLine(orderLineId: "${firstOrderLineId}", ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            lines {
+                                id
+                            }
+                        }
+                    }
+                }
+            `);
+            expect(removeOrderLine).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                lines: [],
+            });
+        });
+
+        it('removeAllOrderLines', async () => {
+            const { addItemToOrder } = await shopClient.query(gql`
+                mutation {
+                    addItemToOrder(productVariantId: "T_1", quantity: 1, ${activeOrderInput}) {
+                        ...on Order { lines { id } }
+                    }
+                }
+            `);
+            expect(addItemToOrder.lines.length).toBe(1);
+
+            const { removeAllOrderLines } = await shopClient.query(gql`
+                mutation {
+                    removeAllOrderLines(${activeOrderInput}) {
+                    ...on Order {
+                        id
+                        orderToken
+                        lines { id }
+                    }
+                }
+                }
+            `);
+            expect(removeAllOrderLines.lines.length).toBe(0);
+        });
+
+        it('applyCouponCode', async () => {
+            await shopClient.query(gql`
+                mutation {
+                    addItemToOrder(productVariantId: "T_1", quantity: 1, ${activeOrderInput}) {
+                        ...on Order { lines { id } }
+                    }
+                }
+            `);
+            const { applyCouponCode } = await shopClient.query(gql`
+                mutation {
+                    applyCouponCode(couponCode: "${TEST_COUPON_CODE}", ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            couponCodes
+                            discounts {
+                                description
+                            }
+                        }
+                    }
+                }
+            `);
+            expect(applyCouponCode).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                couponCodes: [TEST_COUPON_CODE],
+                discounts: [{ description: 'Free with test coupon' }],
+            });
+        });
+
+        it('removeCouponCode', async () => {
+            const { removeCouponCode } = await shopClient.query(gql`
+                mutation {
+                    removeCouponCode(couponCode: "${TEST_COUPON_CODE}", ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            couponCodes
+                            discounts {
+                                description
+                            }
+                        }
+                    }
+                }
+            `);
+            expect(removeCouponCode).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                couponCodes: [],
+                discounts: [],
+            });
+        });
+
+        it('setOrderShippingAddress', async () => {
+            const { setOrderShippingAddress } = await shopClient.query(gql`
+                mutation {
+                    setOrderShippingAddress(input: {
+                        streetLine1: "Shipping Street"
+                        countryCode: "AT"
+                    }, ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            shippingAddress {
+                                streetLine1
+                                country
+                            }
+                        }
+                    }
+                }
+            `);
+            expect(setOrderShippingAddress).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                shippingAddress: {
+                    streetLine1: 'Shipping Street',
+                    country: 'Austria',
+                },
+            });
+        });
+
+        it('setOrderBillingAddress', async () => {
+            const { setOrderBillingAddress } = await shopClient.query(gql`
+                mutation {
+                    setOrderBillingAddress(input: {
+                        streetLine1: "Billing Street"
+                        countryCode: "AT"
+                    }, ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            billingAddress {
+                                streetLine1
+                                country
+                            }
+                        }
+                    }
+                }
+            `);
+            expect(setOrderBillingAddress).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                billingAddress: {
+                    streetLine1: 'Billing Street',
+                    country: 'Austria',
+                },
+            });
+        });
+
+        it('eligibleShippingMethods', async () => {
+            const { eligibleShippingMethods } = await shopClient.query(gql`
+                query {
+                    eligibleShippingMethods(${activeOrderInput}) {
+                    id
+                    name
+                    priceWithTax
+                }
+                }
+            `);
+            expect(eligibleShippingMethods).toEqual([
+                {
+                    id: 'T_1',
+                    name: 'Standard Shipping',
+                    priceWithTax: 500,
+                },
+                {
+                    id: 'T_2',
+                    name: 'Express Shipping',
+                    priceWithTax: 1000,
+                },
+            ]);
+        });
+
+        it('setOrderShippingMethod', async () => {
+            const { setOrderShippingMethod } = await shopClient.query(gql`
+                mutation {
+                    setOrderShippingMethod(shippingMethodId: "T_1", ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            shippingLines {
+                                price
+                            }
+                        }
+                    }
+                }
+            `);
+            expect(setOrderShippingMethod).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                shippingLines: [{ price: 500 }],
+            });
+        });
+
+        it('setOrderCustomFields', async () => {
+            const { setOrderCustomFields } = await shopClient.query(gql`
+                mutation {
+                    setOrderCustomFields(input: { customFields: { message: "foo" } }, ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            customFields { message }
+                        }
+                    }
+                }
+            `);
+            expect(setOrderCustomFields).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                customFields: { message: 'foo' },
+            });
+        });
+
+        it('eligiblePaymentMethods', async () => {
+            const { eligiblePaymentMethods } = await shopClient.query(gql`
+                query {
+                    eligiblePaymentMethods(${activeOrderInput}) {
+                    id
+                    name
+                    code
+                }
+                }
+            `);
+            expect(eligiblePaymentMethods).toEqual([
+                {
+                    id: 'T_1',
+                    name: 'test-payment-method',
+                    code: 'test-payment-method',
+                },
+            ]);
+        });
+
+        it('nextOrderStates', async () => {
+            const { nextOrderStates } = await shopClient.query(gql`
+                query {
+                    nextOrderStates(${activeOrderInput})
+                }
+            `);
+            expect(nextOrderStates).toEqual(['ArrangingPayment', 'Cancelled']);
+        });
+
+        it('transitionOrderToState', async () => {
+            const { transitionOrderToState } = await shopClient.query(gql`
+                mutation {
+                    transitionOrderToState(state: "ArrangingPayment", ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            state
+                        }
+                    }
+                }
+            `);
+            expect(transitionOrderToState).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                state: 'ArrangingPayment',
+            });
+        });
+
+        it('addPaymentToOrder', async () => {
+            const { addPaymentToOrder } = await shopClient.query(gql`
+                mutation {
+                    addPaymentToOrder(input: { method: "test-payment-method", metadata: {}}, ${activeOrderInput}) {
+                        ...on Order {
+                            id
+                            orderToken
+                            state
+                            payments {
+                                state
+                            }
+                        }
+                    }
+                }
+            `);
+            expect(addPaymentToOrder).toEqual({
+                id: 'T_1',
+                orderToken: 'token-2',
+                payments: [
+                    {
+                        state: 'Settled',
+                    },
+                ],
+                state: 'PaymentSettled',
+            });
+        });
+    });
+});
+
+export const ACTIVE_ORDER_BY_TOKEN = gql`
+    query ActiveOrderByToken($input: ActiveOrderInput) {
+        activeOrder(activeOrderInput: $input) {
+            id
+            orderToken
+        }
+    }
+`;

+ 17 - 1
packages/core/e2e/draft-order.e2e-spec.ts

@@ -114,7 +114,7 @@ describe('Draft Orders resolver', () => {
         draftOrder = addItemToDraftOrder;
     });
 
-    it('adjustDraftOrderLine', async () => {
+    it('adjustDraftOrderLine up', async () => {
         const { adjustDraftOrderLine } = await adminClient.query<
             Codegen.AdjustDraftOrderLineMutation,
             Codegen.AdjustDraftOrderLineMutationVariables
@@ -130,6 +130,22 @@ describe('Draft Orders resolver', () => {
         expect(adjustDraftOrderLine.lines[0].quantity).toBe(5);
     });
 
+    it('adjustDraftOrderLine down', async () => {
+        const { adjustDraftOrderLine } = await adminClient.query<
+            Codegen.AdjustDraftOrderLineMutation,
+            Codegen.AdjustDraftOrderLineMutationVariables
+        >(ADJUST_DRAFT_ORDER_LINE, {
+            orderId: draftOrder.id,
+            input: {
+                orderLineId: draftOrder.lines[0]!.id,
+                quantity: 2,
+            },
+        });
+
+        orderGuard.assertSuccess(adjustDraftOrderLine);
+        expect(adjustDraftOrderLine.lines[0].quantity).toBe(2);
+    });
+
     it('removeDraftOrderLine', async () => {
         const { removeDraftOrderLine } = await adminClient.query<
             Codegen.RemoveDraftOrderLineMutation,

+ 1 - 1
packages/core/e2e/facet.e2e-spec.ts

@@ -308,7 +308,7 @@ describe('Facet resolver', () => {
             expect(result1.deleteFacetValues).toEqual([
                 {
                     result: DeletionResult.NOT_DELETED,
-                    message: `The selected FacetValue is assigned to 1 Product, 1 ProductVariant`,
+                    message: `The FacetValue "compact" is assigned to 1 Product, 1 ProductVariant`,
                 },
             ]);
 

+ 113 - 0
packages/core/e2e/fixtures/test-plugins/token-active-order-plugin.ts

@@ -0,0 +1,113 @@
+import { Args, Mutation, Parent, ResolveField, Resolver } from '@nestjs/graphql';
+import { ID } from '@vendure/common/lib/shared-types';
+import {
+    ActiveOrderStrategy,
+    Ctx,
+    CustomerService,
+    idsAreEqual,
+    Injector,
+    Order,
+    OrderService,
+    PluginCommonModule,
+    RequestContext,
+    Transaction,
+    TransactionalConnection,
+    VendurePlugin,
+} from '@vendure/core';
+import { CustomOrderFields } from '@vendure/core/dist/entity/custom-entity-fields';
+import { UserInputError } from 'apollo-server-express';
+import gql from 'graphql-tag';
+
+declare module '@vendure/core/dist/entity/custom-entity-fields' {
+    interface CustomOrderFields {
+        orderToken: string;
+    }
+}
+
+class TokenActiveOrderStrategy implements ActiveOrderStrategy {
+    readonly name = 'orderToken';
+
+    private connection: TransactionalConnection;
+    private orderService: OrderService;
+
+    init(injector: Injector) {
+        this.connection = injector.get(TransactionalConnection);
+        this.orderService = injector.get(OrderService);
+    }
+
+    defineInputType = () => gql`
+        input OrderTokenActiveOrderInput {
+            token: String
+        }
+    `;
+
+    async determineActiveOrder(ctx: RequestContext, input: { token: string }) {
+        const qb = this.connection
+            .getRepository(ctx, Order)
+            .createQueryBuilder('order')
+            .leftJoinAndSelect('order.customer', 'customer')
+            .leftJoinAndSelect('customer.user', 'user')
+            .where('order.customFields.orderToken = :orderToken', { orderToken: input.token });
+
+        const order = await qb.getOne();
+        if (!order) {
+            return;
+        }
+        const orderUserId = order.customer && order.customer.user && order.customer.user.id;
+        if (order.customer && idsAreEqual(orderUserId, ctx.activeUserId)) {
+            return order;
+        }
+    }
+}
+
+@Resolver('Order')
+export class OrderTokenResolver {
+    @ResolveField()
+    orderToken(@Parent() order: Order) {
+        return order.customFields.orderToken;
+    }
+}
+
+@Resolver()
+export class CreateOrderResolver {
+    constructor(private orderService: OrderService, private customerService: CustomerService) {}
+
+    @Mutation()
+    @Transaction()
+    async createOrder(@Ctx() ctx: RequestContext, @Args() args: { customerId: ID }) {
+        const customer = await this.customerService.findOne(ctx, args.customerId);
+        if (!customer) {
+            throw new UserInputError('No customer found');
+        }
+        const order = await this.orderService.create(ctx, customer.user?.id);
+        return this.orderService.updateCustomFields(ctx, order.id, {
+            orderToken: `token-${args.customerId}`,
+        });
+    }
+}
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    configuration: config => {
+        config.customFields.Order.push({
+            name: 'orderToken',
+            type: 'string',
+            internal: true,
+        });
+        config.orderOptions.activeOrderStrategy = new TokenActiveOrderStrategy();
+        return config;
+    },
+    shopApiExtensions: {
+        schema: gql`
+            extend type Mutation {
+                createOrder(customerId: ID!): Order!
+            }
+
+            extend type Order {
+                orderToken: String!
+            }
+        `,
+        resolvers: [OrderTokenResolver, CreateOrderResolver],
+    },
+})
+export class TokenActiveOrderPlugin {}

Разлика између датотеке није приказан због своје велике величине
+ 294 - 298
packages/core/e2e/graphql/generated-e2e-admin-types.ts


+ 23 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -977,3 +977,26 @@ export const TRANSITION_PAYMENT_TO_STATE = gql`
     }
     ${PAYMENT_FRAGMENT}
 `;
+
+export const GET_PRODUCT_VARIANT_LIST = gql`
+    query GetProductVariantLIST($options: ProductVariantListOptions, $productId: ID) {
+        productVariants(options: $options, productId: $productId) {
+            items {
+                id
+                name
+                sku
+                price
+                priceWithTax
+            }
+            totalItems
+        }
+    }
+`;
+
+export const DELETE_PROMOTION = gql`
+    mutation DeletePromotion($id: ID!) {
+        deletePromotion(id: $id) {
+            result
+        }
+    }
+`;

+ 274 - 3
packages/core/e2e/order-modification.e2e-spec.ts

@@ -8,6 +8,7 @@ import {
     freeShipping,
     manualFulfillmentHandler,
     mergeConfig,
+    minimumOrderAmount,
     orderFixedDiscount,
     orderPercentageDiscount,
     productsPercentageDiscount,
@@ -48,8 +49,10 @@ import {
     CREATE_FULFILLMENT,
     CREATE_PROMOTION,
     CREATE_SHIPPING_METHOD,
+    DELETE_PROMOTION,
     GET_ORDER,
     GET_ORDER_HISTORY,
+    GET_PRODUCT_VARIANT_LIST,
     GET_STOCK_MOVEMENT,
     UPDATE_CHANNEL,
     UPDATE_PRODUCT_VARIANTS,
@@ -135,7 +138,7 @@ describe('Order modification', () => {
                 ],
             },
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
-            customerCount: 2,
+            customerCount: 3,
         });
         await adminClient.asSuperAdmin();
 
@@ -1302,6 +1305,150 @@ describe('Order modification', () => {
         });
     });
 
+    // https://github.com/vendure-ecommerce/vendure/issues/1753
+    describe('refunds for multiple payments', () => {
+        let orderId2: string;
+        let orderLineId: string;
+        let additionalPaymentId: string;
+
+        beforeAll(async () => {
+            await adminClient.query<Codegen.CreatePromotionMutation, Codegen.CreatePromotionMutationVariables>(CREATE_PROMOTION, {
+                input: {
+                    name: '$5 off',
+                    couponCode: '5OFF',
+                    enabled: true,
+                    conditions: [],
+                    actions: [
+                        {
+                            code: orderFixedDiscount.code,
+                            arguments: [{ name: 'discount', value: '500' }],
+                        },
+                    ],
+                },
+            });
+            await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
+            await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
+                productVariantId: 'T_5',
+                quantity: 1,
+            } as any);
+            await proceedToArrangingPayment(shopClient);
+            const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+            orderGuard.assertSuccess(order);
+            orderLineId = order.lines[0].id;
+            orderId2 = order.id;
+
+            const transitionOrderToState = await adminTransitionOrderToState(orderId2, 'Modifying');
+            orderGuard.assertSuccess(transitionOrderToState);
+            const { modifyOrder } = await adminClient.query<Codegen.ModifyOrderMutation, Codegen.ModifyOrderMutationVariables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: orderId2,
+                        adjustOrderLines: [{ orderLineId, quantity: 2 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            await adminTransitionOrderToState(orderId2, 'ArrangingAdditionalPayment');
+
+            const { addManualPaymentToOrder } = await adminClient.query<
+                Codegen.AddManualPaymentMutation,
+                Codegen.AddManualPaymentMutationVariables
+            >(ADD_MANUAL_PAYMENT, {
+                input: {
+                    orderId: orderId2,
+                    method: 'test',
+                    transactionId: 'ABC123',
+                    metadata: {
+                        foo: 'bar',
+                    },
+                },
+            });
+            orderGuard.assertSuccess(addManualPaymentToOrder);
+            additionalPaymentId = addManualPaymentToOrder.payments?.[1].id!;
+
+            const transitionOrderToState2 = await adminTransitionOrderToState(orderId2, 'PaymentSettled');
+            orderGuard.assertSuccess(transitionOrderToState2);
+
+            expect(transitionOrderToState2.state).toBe('PaymentSettled');
+        });
+
+        it('apply couponCode to create first refund', async () => {
+            const transitionOrderToState = await adminTransitionOrderToState(orderId2, 'Modifying');
+            orderGuard.assertSuccess(transitionOrderToState);
+            const { modifyOrder } = await adminClient.query<Codegen.ModifyOrderMutation, Codegen.ModifyOrderMutationVariables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: orderId2,
+                        couponCodes: ['5OFF'],
+                        refund: {
+                            paymentId: additionalPaymentId,
+                            reason: 'test',
+                        },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            expect(modifyOrder.payments?.length).toBe(2);
+            expect(modifyOrder?.payments?.find(p => p.id === additionalPaymentId)?.refunds).toEqual([
+                {
+                    id: 'T_4',
+                    paymentId: additionalPaymentId,
+                    state: 'Pending',
+                    total: 600,
+                },
+            ]);
+            expect(modifyOrder.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder));
+        });
+
+        it('reduce quantity to create second refund', async () => {
+            const { modifyOrder } = await adminClient.query<Codegen.ModifyOrderMutation, Codegen.ModifyOrderMutationVariables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: orderId2,
+                        adjustOrderLines: [{ orderLineId, quantity: 1 }],
+                        refund: {
+                            paymentId: additionalPaymentId,
+                            reason: 'test 2',
+                        },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            expect(modifyOrder?.payments?.find(p => p.id === additionalPaymentId)?.refunds).toEqual([
+                {
+                    id: 'T_4',
+                    paymentId: additionalPaymentId,
+                    state: 'Pending',
+                    total: 600,
+                },
+                {
+                    id: 'T_5',
+                    paymentId: additionalPaymentId,
+                    state: 'Pending',
+                    total: 16649,
+                },
+            ]);
+            expect(modifyOrder?.payments?.find(p => p.id !== additionalPaymentId)?.refunds).toEqual([
+                {
+                    id: 'T_6',
+                    paymentId: 'T_15',
+                    state: 'Pending',
+                    total: 300,
+                },
+            ]);
+            expect(modifyOrder.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder));
+        });
+    });
+
     // https://github.com/vendure-ecommerce/vendure/issues/688 - 4th point
     it('correct additional payment when discounts applied', async () => {
         await adminClient.query<Codegen.CreatePromotionMutation, Codegen.CreatePromotionMutationVariables>(
@@ -1462,8 +1609,8 @@ describe('Order modification', () => {
         });
     });
 
-    // https://github.com/vendure-ecommerce/vendure/issues/890
     describe('refund handling when promotions are active on order', () => {
+        // https://github.com/vendure-ecommerce/vendure/issues/890
         it('refunds correct amount when order-level promotion applied', async () => {
             await adminClient.query<
                 Codegen.CreatePromotionMutation,
@@ -1527,6 +1674,130 @@ describe('Order modification', () => {
             expect(modifyOrder.payments![0].refunds![0].total).toBe(order.lines[0].proratedUnitPriceWithTax);
             expect(modifyOrder.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder));
         });
+
+        // github.com/vendure-ecommerce/vendure/issues/1865
+        describe('issue 1865', () => {
+            const promoDiscount = 5000;
+            let promoId: string;
+            let orderId2: string;
+            beforeAll(async () => {
+                const { createPromotion } = await adminClient.query<
+                    Codegen.CreatePromotionMutation,
+                    Codegen.CreatePromotionMutationVariables
+                >(CREATE_PROMOTION, {
+                    input: {
+                        name: '50 off orders over 100',
+                        enabled: true,
+                        conditions: [
+                            {
+                                code: minimumOrderAmount.code,
+                                arguments: [
+                                    { name: 'amount', value: '10000' },
+                                    { name: 'taxInclusive', value: 'true' },
+                                ],
+                            },
+                        ],
+                        actions: [
+                            {
+                                code: orderFixedDiscount.code,
+                                arguments: [{ name: 'discount', value: JSON.stringify(promoDiscount) }],
+                            },
+                        ],
+                    },
+                });
+                promoId = (createPromotion as any).id;
+            });
+
+            afterAll(async () => {
+                await adminClient.query<Codegen.DeletePromotionMutation, Codegen.DeletePromotionMutationVariables>(
+                    DELETE_PROMOTION,
+                    {
+                        id: promoId,
+                    },
+                );
+            });
+
+            it('refund handling when order-level promotion becomes invalid on modification', async () => {
+                const { productVariants } = await adminClient.query<
+                    Codegen.GetProductVariantListQuery,
+                    Codegen.GetProductVariantListQueryVariables
+                >(GET_PRODUCT_VARIANT_LIST, {
+                    options: {
+                        filter: {
+                            name: { contains: 'football' },
+                        },
+                    },
+                });
+                const football = productVariants.items[0];
+
+                await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
+                    productVariantId: football.id,
+                    quantity: 2,
+                } as any);
+                await proceedToArrangingPayment(shopClient);
+                const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+                orderGuard.assertSuccess(order);
+                orderId2 = order.id;
+
+                expect(order.discounts.length).toBe(1);
+                expect(order.discounts[0].amountWithTax).toBe(-promoDiscount);
+                const shippingPrice = order.shippingWithTax;
+                const expectedTotal = football.priceWithTax * 2 + shippingPrice - promoDiscount;
+                expect(order.totalWithTax).toBe(expectedTotal);
+
+                const originalTotalWithTax = order.totalWithTax;
+
+                const transitionOrderToState = await adminTransitionOrderToState(order.id, 'Modifying');
+                orderGuard.assertSuccess(transitionOrderToState);
+
+                expect(transitionOrderToState.state).toBe('Modifying');
+
+                const { modifyOrder } = await adminClient.query<Codegen.ModifyOrderMutation, Codegen.ModifyOrderMutationVariables>(
+                    MODIFY_ORDER,
+                    {
+                        input: {
+                            dryRun: false,
+                            orderId: order.id,
+                            adjustOrderLines: [{ orderLineId: order.lines[0].id, quantity: 1 }],
+                            refund: {
+                                paymentId: order.payments![0].id,
+                                reason: 'requested',
+                            },
+                        },
+                    },
+                );
+                orderGuard.assertSuccess(modifyOrder);
+
+                const expectedNewTotal = order.lines[0].unitPriceWithTax + shippingPrice;
+                expect(modifyOrder.totalWithTax).toBe(expectedNewTotal);
+                expect(modifyOrder.payments![0].refunds![0].total).toBe(expectedTotal - expectedNewTotal);
+                expect(modifyOrder.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder));
+            });
+
+            it('transition back to original state', async () => {
+                const transitionOrderToState2 = await adminTransitionOrderToState(orderId2, 'PaymentSettled');
+                orderGuard.assertSuccess(transitionOrderToState2);
+                expect(transitionOrderToState2!.state).toBe('PaymentSettled');
+            });
+
+            it('order no longer has promotions', async () => {
+                const { order } = await adminClient.query<
+                    Codegen.GetOrderWithModificationsQuery,
+                    Codegen.GetOrderWithModificationsQueryVariables
+                >(GET_ORDER_WITH_MODIFICATIONS, { id: orderId2 });
+
+                expect(order?.promotions).toEqual([]);
+            });
+
+            it('order no longer has discounts', async () => {
+                const { order } = await adminClient.query<
+                    Codegen.GetOrderWithModificationsQuery,
+                    Codegen.GetOrderWithModificationsQueryVariables
+                >(GET_ORDER_WITH_MODIFICATIONS, { id: orderId2 });
+
+                expect(order?.discounts).toEqual([]);
+            });
+        });
     });
 
     // https://github.com/vendure-ecommerce/vendure/issues/1197
@@ -1951,7 +2222,7 @@ describe('Order modification', () => {
             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' },
+                data: { couponCode: CODE_50PC_OFF, promotionId: 'T_6' },
             });
         });
 

+ 1 - 16
packages/core/e2e/product.e2e-spec.ts

@@ -1,7 +1,6 @@
 import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
-import { DefaultLogger } from '@vendure/core';
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
@@ -22,6 +21,7 @@ import {
     GET_ASSET_LIST,
     GET_PRODUCT_LIST,
     GET_PRODUCT_SIMPLE,
+    GET_PRODUCT_VARIANT_LIST,
     GET_PRODUCT_WITH_VARIANTS,
     UPDATE_CHANNEL,
     UPDATE_GLOBAL_SETTINGS,
@@ -2126,21 +2126,6 @@ export const GET_PRODUCT_VARIANT = gql`
     }
 `;
 
-export const GET_PRODUCT_VARIANT_LIST = gql`
-    query GetProductVariantLIST($options: ProductVariantListOptions, $productId: ID) {
-        productVariants(options: $options, productId: $productId) {
-            items {
-                id
-                name
-                sku
-                price
-                priceWithTax
-            }
-            totalItems
-        }
-    }
-`;
-
 export const GET_PRODUCT_WITH_VARIANT_LIST = gql`
     query GetProductWithVariantList($id: ID, $variantListOptions: ProductVariantListOptions) {
         product(id: $id) {

+ 1 - 8
packages/core/e2e/promotion.e2e-spec.ts

@@ -19,6 +19,7 @@ import {
     ASSIGN_PROMOTIONS_TO_CHANNEL,
     CREATE_CHANNEL,
     CREATE_PROMOTION,
+    DELETE_PROMOTION,
     REMOVE_PROMOTIONS_FROM_CHANNEL,
 } from './graphql/shared-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
@@ -383,14 +384,6 @@ function generateTestAction(code: string): PromotionAction<any> {
     });
 }
 
-const DELETE_PROMOTION = gql`
-    mutation DeletePromotion($id: ID!) {
-        deletePromotion(id: $id) {
-            result
-        }
-    }
-`;
-
 export const GET_PROMOTION_LIST = gql`
     query GetPromotionList($options: PromotionListOptions) {
         promotions(options: $options) {

+ 22 - 0
packages/core/e2e/zone.e2e-spec.ts

@@ -53,6 +53,14 @@ describe('Zone resolver', () => {
         expect(result.zone!.name).toBe('Oceania');
     });
 
+    it('zone.members field resolver', async () => {
+        const { activeChannel } = await adminClient.query<Codegen.GetActiveChannelWithZoneMembersQuery>(
+            GET_ACTIVE_CHANNEL_WITH_ZONE_MEMBERS,
+        );
+
+        expect(activeChannel.defaultShippingZone?.members.length).toBe(2);
+    });
+
     it('updateZone', async () => {
         const result = await adminClient.query<
             Codegen.UpdateZoneMutation,
@@ -235,6 +243,20 @@ export const GET_ZONE = gql`
     ${ZONE_FRAGMENT}
 `;
 
+export const GET_ACTIVE_CHANNEL_WITH_ZONE_MEMBERS = gql`
+    query GetActiveChannelWithZoneMembers {
+        activeChannel {
+            id
+            defaultShippingZone {
+                id
+                members {
+                    name
+                }
+            }
+        }
+    }
+`;
+
 export const CREATE_ZONE = gql`
     mutation CreateZone($input: CreateZoneInput!) {
         createZone(input: $input) {

+ 22 - 5
packages/core/src/api/api-internal-modules.ts

@@ -40,27 +40,43 @@ import { AdministratorEntityResolver } from './resolvers/entity/administrator-en
 import { AssetEntityResolver } from './resolvers/entity/asset-entity.resolver';
 import { CollectionEntityResolver } from './resolvers/entity/collection-entity.resolver';
 import { CountryEntityResolver } from './resolvers/entity/country-entity.resolver';
-import { CustomerAdminEntityResolver, CustomerEntityResolver, } from './resolvers/entity/customer-entity.resolver';
+import {
+    CustomerAdminEntityResolver,
+    CustomerEntityResolver,
+} from './resolvers/entity/customer-entity.resolver';
 import { CustomerGroupEntityResolver } from './resolvers/entity/customer-group-entity.resolver';
 import { FacetEntityResolver } from './resolvers/entity/facet-entity.resolver';
 import { FacetValueEntityResolver } from './resolvers/entity/facet-value-entity.resolver';
-import { FulfillmentAdminEntityResolver, FulfillmentEntityResolver, } from './resolvers/entity/fulfillment-entity.resolver';
+import {
+    FulfillmentAdminEntityResolver,
+    FulfillmentEntityResolver,
+} from './resolvers/entity/fulfillment-entity.resolver';
 import { JobEntityResolver } from './resolvers/entity/job-entity.resolver';
 import { OrderAdminEntityResolver, OrderEntityResolver } from './resolvers/entity/order-entity.resolver';
 import { OrderItemEntityResolver } from './resolvers/entity/order-item-entity.resolver';
 import { OrderLineEntityResolver } from './resolvers/entity/order-line-entity.resolver';
-import { PaymentAdminEntityResolver, PaymentEntityResolver, } from './resolvers/entity/payment-entity.resolver';
+import {
+    PaymentAdminEntityResolver,
+    PaymentEntityResolver,
+} from './resolvers/entity/payment-entity.resolver';
 import { PaymentMethodEntityResolver } from './resolvers/entity/payment-method-entity.resolver';
-import { ProductAdminEntityResolver, ProductEntityResolver, } from './resolvers/entity/product-entity.resolver';
+import {
+    ProductAdminEntityResolver,
+    ProductEntityResolver,
+} from './resolvers/entity/product-entity.resolver';
 import { ProductOptionEntityResolver } from './resolvers/entity/product-option-entity.resolver';
 import { ProductOptionGroupEntityResolver } from './resolvers/entity/product-option-group-entity.resolver';
-import { ProductVariantAdminEntityResolver, ProductVariantEntityResolver, } from './resolvers/entity/product-variant-entity.resolver';
+import {
+    ProductVariantAdminEntityResolver,
+    ProductVariantEntityResolver,
+} from './resolvers/entity/product-variant-entity.resolver';
 import { RefundEntityResolver } from './resolvers/entity/refund-entity.resolver';
 import { RoleEntityResolver } from './resolvers/entity/role-entity.resolver';
 import { ShippingLineEntityResolver } from './resolvers/entity/shipping-line-entity.resolver';
 import { ShippingMethodEntityResolver } from './resolvers/entity/shipping-method-entity.resolver';
 import { TaxRateEntityResolver } from './resolvers/entity/tax-rate-entity.resolver';
 import { UserEntityResolver } from './resolvers/entity/user-entity.resolver';
+import { ZoneEntityResolver } from './resolvers/entity/zone-entity.resolver';
 import { ShopAuthResolver } from './resolvers/shop/shop-auth.resolver';
 import { ShopCustomerResolver } from './resolvers/shop/shop-customer.resolver';
 import { ShopEnvironmentResolver } from './resolvers/shop/shop-environment.resolver';
@@ -125,6 +141,7 @@ export const entityResolvers = [
     UserEntityResolver,
     TaxRateEntityResolver,
     ShippingMethodEntityResolver,
+    ZoneEntityResolver,
 ];
 
 export const adminEntityResolvers = [

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

@@ -20,6 +20,7 @@ import { AssetInterceptorPlugin } from '../middleware/asset-interceptor-plugin';
 import { IdCodecPlugin } from '../middleware/id-codec-plugin';
 import { TranslateErrorsPlugin } from '../middleware/translate-errors-plugin';
 
+import { generateActiveOrderTypes } from './generate-active-order-types';
 import { generateAuthenticationTypes } from './generate-auth-types';
 import { generateErrorCodeEnum } from './generate-error-code-enum';
 import { generateListOptions } from './generate-list-options';
@@ -158,6 +159,7 @@ async function createGraphQLOptions(
         }
         if (apiType === 'shop') {
             schema = addRegisterCustomerCustomFieldsInput(schema, customFields.Customer || []);
+            schema = generateActiveOrderTypes(schema, configService.orderOptions.activeOrderStrategy);
         }
         schema = generatePermissionEnum(schema, configService.authOptions.customPermissions);
 

+ 109 - 0
packages/core/src/api/config/generate-active-order-types.ts

@@ -0,0 +1,109 @@
+import { stitchSchemas, ValidationLevel } from '@graphql-tools/stitch';
+import { Mutation, Query } from '@vendure/common/lib/generated-shop-types';
+import {
+    buildASTSchema,
+    GraphQLInputFieldConfigMap,
+    GraphQLInputObjectType,
+    GraphQLSchema,
+    isInputObjectType,
+} from 'graphql';
+
+import { InternalServerError } from '../../common/error/errors';
+import { ActiveOrderStrategy, ACTIVE_ORDER_INPUT_FIELD_NAME } from '../../config/index';
+
+/**
+ * This function is responsible for constructing the `ActiveOrderInput` GraphQL input type.
+ * It does so based on the inputs defined by the configured ActiveOrderStrategy defineInputType
+ * methods, dynamically building a mapped input type of the format:
+ *
+ *```
+ * {
+ *     [strategy_name]: strategy_input_type
+ * }
+ * ```
+ */
+export function generateActiveOrderTypes(
+    schema: GraphQLSchema,
+    activeOrderStrategies: ActiveOrderStrategy | ActiveOrderStrategy[],
+): GraphQLSchema {
+    const fields: GraphQLInputFieldConfigMap = {};
+    const strategySchemas: GraphQLSchema[] = [];
+    const strategiesArray = Array.isArray(activeOrderStrategies)
+        ? activeOrderStrategies
+        : [activeOrderStrategies];
+    for (const strategy of strategiesArray) {
+        if (typeof strategy.defineInputType === 'function') {
+            const inputSchema = buildASTSchema(strategy.defineInputType());
+
+            const inputType = Object.values(inputSchema.getTypeMap()).find(
+                (type): type is GraphQLInputObjectType => isInputObjectType(type),
+            );
+            if (!inputType) {
+                throw new InternalServerError(
+                    `${strategy.constructor.name}.defineInputType() does not define a GraphQL Input type`,
+                );
+            }
+            fields[strategy.name] = { type: inputType };
+            strategySchemas.push(inputSchema);
+        }
+    }
+    if (Object.keys(fields).length === 0) {
+        return schema;
+    }
+    const activeOrderInput = new GraphQLInputObjectType({
+        name: 'ActiveOrderInput',
+        fields,
+    });
+
+    const activeOrderOperations: Array<{ name: keyof Query | keyof Mutation; isMutation: boolean }> = [
+        { name: 'activeOrder', isMutation: false },
+        { name: 'eligibleShippingMethods', isMutation: false },
+        { name: 'eligiblePaymentMethods', isMutation: false },
+        { name: 'nextOrderStates', isMutation: false },
+        { name: 'addItemToOrder', isMutation: true },
+        { name: 'adjustOrderLine', isMutation: true },
+        { name: 'removeOrderLine', isMutation: true },
+        { name: 'removeAllOrderLines', isMutation: true },
+        { name: 'applyCouponCode', isMutation: true },
+        { name: 'removeCouponCode', isMutation: true },
+        { name: 'addPaymentToOrder', isMutation: true },
+        { name: 'setCustomerForOrder', isMutation: true },
+        { name: 'setOrderShippingAddress', isMutation: true },
+        { name: 'setOrderBillingAddress', isMutation: true },
+        { name: 'setOrderShippingMethod', isMutation: true },
+        { name: 'setOrderCustomFields', isMutation: true },
+        { name: 'transitionOrderToState', isMutation: true },
+    ];
+
+    const queryType = schema.getQueryType();
+    const mutationType = schema.getMutationType();
+    const strategyNames = strategiesArray.map(s => s.name).join(', ');
+    const description = `Inputs for the configured ${
+        strategiesArray.length === 1 ? 'ActiveOrderStrategy' : 'ActiveOrderStrategies'
+    } ${strategyNames}`;
+    for (const operation of activeOrderOperations) {
+        const field = operation.isMutation
+            ? mutationType?.getFields()[operation.name]
+            : queryType?.getFields()[operation.name];
+        if (!field) {
+            throw new InternalServerError(
+                `Could not find a GraphQL type definition for the field ${operation.name}`,
+            );
+        }
+        field.args.push({
+            name: ACTIVE_ORDER_INPUT_FIELD_NAME,
+            type: activeOrderInput,
+            description,
+            defaultValue: null,
+            extensions: null,
+            astNode: null,
+            deprecationReason: null,
+        });
+    }
+
+    return stitchSchemas({
+        subschemas: [schema, ...strategySchemas],
+        types: [activeOrderInput],
+        typeMergingOptions: { validationSettings: { validationLevel: ValidationLevel.Off } },
+    });
+}

+ 20 - 0
packages/core/src/api/resolvers/entity/zone-entity.resolver.ts

@@ -0,0 +1,20 @@
+import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
+
+import { Country, Zone } from '../../../entity/index';
+import { ZoneService } from '../../../service/index';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('Zone')
+export class ZoneEntityResolver {
+    constructor(private zoneService: ZoneService) {}
+
+    @ResolveField()
+    async members(@Ctx() ctx: RequestContext, @Parent() zone: Zone): Promise<Country[]> {
+        if (Array.isArray(zone.members)) {
+            return zone.members;
+        }
+        const zoneWithMembers = await this.zoneService.findOne(ctx, zone.id);
+        return zoneWithMembers?.members ?? [];
+    }
+}

+ 105 - 33
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -36,7 +36,7 @@ import {
 } from '../../../common/error/generated-graphql-shop-errors';
 import { Translated } from '../../../common/types/locale-types';
 import { idsAreEqual } from '../../../common/utils';
-import { ConfigService, LogLevel } from '../../../config';
+import { ACTIVE_ORDER_INPUT_FIELD_NAME, ConfigService, LogLevel } from '../../../config';
 import { Country } from '../../../entity';
 import { Order } from '../../../entity/order/order.entity';
 import { ActiveOrderService, CountryService } from '../../../service';
@@ -50,6 +50,8 @@ import { RelationPaths, Relations } from '../../decorators/relations.decorator';
 import { Ctx } from '../../decorators/request-context.decorator';
 import { Transaction } from '../../decorators/transaction.decorator';
 
+type ActiveOrderArgs = { [ACTIVE_ORDER_INPUT_FIELD_NAME]?: any };
+
 @Resolver()
 export class ShopOrderResolver {
     constructor(
@@ -97,9 +99,13 @@ export class ShopOrderResolver {
     async activeOrder(
         @Ctx() ctx: RequestContext,
         @Relations(Order) relations: RelationPaths<Order>,
+        @Args() args: ActiveOrderArgs,
     ): Promise<Order | undefined> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            );
             if (sessionOrder) {
                 return this.orderService.findOne(ctx, sessionOrder.id);
             } else {
@@ -140,10 +146,13 @@ export class ShopOrderResolver {
     @Allow(Permission.Owner)
     async setOrderShippingAddress(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationSetOrderShippingAddressArgs,
+        @Args() args: MutationSetOrderShippingAddressArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<ActiveOrderResult, Order>> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            );
             if (sessionOrder) {
                 return this.orderService.setShippingAddress(ctx, sessionOrder.id, args.input);
             }
@@ -156,10 +165,13 @@ export class ShopOrderResolver {
     @Allow(Permission.Owner)
     async setOrderBillingAddress(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationSetOrderBillingAddressArgs,
+        @Args() args: MutationSetOrderBillingAddressArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<ActiveOrderResult, Order>> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            );
             if (sessionOrder) {
                 return this.orderService.setBillingAddress(ctx, sessionOrder.id, args.input);
             }
@@ -169,9 +181,15 @@ export class ShopOrderResolver {
 
     @Query()
     @Allow(Permission.Owner)
-    async eligibleShippingMethods(@Ctx() ctx: RequestContext): Promise<ShippingMethodQuote[]> {
+    async eligibleShippingMethods(
+        @Ctx() ctx: RequestContext,
+        @Args() args: ActiveOrderArgs,
+    ): Promise<ShippingMethodQuote[]> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            );
             if (sessionOrder) {
                 return this.orderService.getEligibleShippingMethods(ctx, sessionOrder.id);
             }
@@ -181,9 +199,15 @@ export class ShopOrderResolver {
 
     @Query()
     @Allow(Permission.Owner)
-    async eligiblePaymentMethods(@Ctx() ctx: RequestContext): Promise<PaymentMethodQuote[]> {
+    async eligiblePaymentMethods(
+        @Ctx() ctx: RequestContext,
+        @Args() args: ActiveOrderArgs,
+    ): Promise<PaymentMethodQuote[]> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            );
             if (sessionOrder) {
                 return this.orderService.getEligiblePaymentMethods(ctx, sessionOrder.id);
             }
@@ -196,10 +220,13 @@ export class ShopOrderResolver {
     @Allow(Permission.Owner)
     async setOrderShippingMethod(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationSetOrderShippingMethodArgs,
+        @Args() args: MutationSetOrderShippingMethodArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<SetOrderShippingMethodResult, Order>> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            );
             if (sessionOrder) {
                 return this.orderService.setShippingMethod(ctx, sessionOrder.id, args.shippingMethodId);
             }
@@ -212,10 +239,13 @@ export class ShopOrderResolver {
     @Allow(Permission.Owner)
     async setOrderCustomFields(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationSetOrderCustomFieldsArgs,
+        @Args() args: MutationSetOrderCustomFieldsArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<ActiveOrderResult, Order>> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            );
             if (sessionOrder) {
                 return this.orderService.updateCustomFields(ctx, sessionOrder.id, args.input.customFields);
             }
@@ -225,9 +255,16 @@ export class ShopOrderResolver {
 
     @Query()
     @Allow(Permission.Owner)
-    async nextOrderStates(@Ctx() ctx: RequestContext): Promise<ReadonlyArray<string>> {
+    async nextOrderStates(
+        @Ctx() ctx: RequestContext,
+        @Args() args: ActiveOrderArgs,
+    ): Promise<ReadonlyArray<string>> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx, true);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+                true,
+            );
             return this.orderService.getNextOrderStates(sessionOrder);
         }
         return [];
@@ -238,10 +275,14 @@ export class ShopOrderResolver {
     @Allow(Permission.Owner)
     async transitionOrderToState(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationTransitionOrderToStateArgs,
+        @Args() args: MutationTransitionOrderToStateArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<TransitionOrderToStateResult, Order> | undefined> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx, true);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+                true,
+            );
             return await this.orderService.transitionToState(ctx, sessionOrder.id, args.state as OrderState);
         }
     }
@@ -251,9 +292,13 @@ export class ShopOrderResolver {
     @Allow(Permission.UpdateOrder, Permission.Owner)
     async addItemToOrder(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationAddItemToOrderArgs,
+        @Args() args: MutationAddItemToOrderArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>> {
-        const order = await this.activeOrderService.getOrderFromContext(ctx, true);
+        const order = await this.activeOrderService.getActiveOrder(
+            ctx,
+            args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            true,
+        );
         return this.orderService.addItemToOrder(
             ctx,
             order.id,
@@ -268,12 +313,16 @@ export class ShopOrderResolver {
     @Allow(Permission.UpdateOrder, Permission.Owner)
     async adjustOrderLine(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationAdjustOrderLineArgs,
+        @Args() args: MutationAdjustOrderLineArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>> {
         if (args.quantity === 0) {
             return this.removeOrderLine(ctx, { orderLineId: args.orderLineId });
         }
-        const order = await this.activeOrderService.getOrderFromContext(ctx, true);
+        const order = await this.activeOrderService.getActiveOrder(
+            ctx,
+            args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            true,
+        );
         return this.orderService.adjustOrderLine(
             ctx,
             order.id,
@@ -288,9 +337,13 @@ export class ShopOrderResolver {
     @Allow(Permission.UpdateOrder, Permission.Owner)
     async removeOrderLine(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationRemoveOrderLineArgs,
+        @Args() args: MutationRemoveOrderLineArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>> {
-        const order = await this.activeOrderService.getOrderFromContext(ctx, true);
+        const order = await this.activeOrderService.getActiveOrder(
+            ctx,
+            args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            true,
+        );
         return this.orderService.removeItemFromOrder(ctx, order.id, args.orderLineId);
     }
 
@@ -299,8 +352,13 @@ export class ShopOrderResolver {
     @Allow(Permission.UpdateOrder, Permission.Owner)
     async removeAllOrderLines(
         @Ctx() ctx: RequestContext,
+        @Args() args: ActiveOrderArgs,
     ): Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>> {
-        const order = await this.activeOrderService.getOrderFromContext(ctx, true);
+        const order = await this.activeOrderService.getActiveOrder(
+            ctx,
+            args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            true,
+        );
         return this.orderService.removeAllItemsFromOrder(ctx, order.id);
     }
 
@@ -309,9 +367,13 @@ export class ShopOrderResolver {
     @Allow(Permission.UpdateOrder, Permission.Owner)
     async applyCouponCode(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationApplyCouponCodeArgs,
+        @Args() args: MutationApplyCouponCodeArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<ApplyCouponCodeResult, Order>> {
-        const order = await this.activeOrderService.getOrderFromContext(ctx, true);
+        const order = await this.activeOrderService.getActiveOrder(
+            ctx,
+            args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            true,
+        );
         return this.orderService.applyCouponCode(ctx, order.id, args.couponCode);
     }
 
@@ -320,9 +382,13 @@ export class ShopOrderResolver {
     @Allow(Permission.UpdateOrder, Permission.Owner)
     async removeCouponCode(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationApplyCouponCodeArgs,
+        @Args() args: MutationApplyCouponCodeArgs & ActiveOrderArgs,
     ): Promise<Order> {
-        const order = await this.activeOrderService.getOrderFromContext(ctx, true);
+        const order = await this.activeOrderService.getActiveOrder(
+            ctx,
+            args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            true,
+        );
         return this.orderService.removeCouponCode(ctx, order.id, args.couponCode);
     }
 
@@ -331,10 +397,13 @@ export class ShopOrderResolver {
     @Allow(Permission.UpdateOrder, Permission.Owner)
     async addPaymentToOrder(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationAddPaymentToOrderArgs,
+        @Args() args: MutationAddPaymentToOrderArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<AddPaymentToOrderResult, Order>> {
         if (ctx.authorizedAsOwnerOnly) {
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            );
             if (sessionOrder) {
                 const order = await this.orderService.addPaymentToOrder(ctx, sessionOrder.id, args.input);
                 if (isGraphQlErrorResult(order)) {
@@ -357,13 +426,16 @@ export class ShopOrderResolver {
     @Allow(Permission.Owner)
     async setCustomerForOrder(
         @Ctx() ctx: RequestContext,
-        @Args() args: MutationSetCustomerForOrderArgs,
+        @Args() args: MutationSetCustomerForOrderArgs & ActiveOrderArgs,
     ): Promise<ErrorResultUnion<SetCustomerForOrderResult, Order>> {
         if (ctx.authorizedAsOwnerOnly) {
             if (ctx.activeUserId) {
                 return new AlreadyLoggedInError();
             }
-            const sessionOrder = await this.activeOrderService.getOrderFromContext(ctx);
+            const sessionOrder = await this.activeOrderService.getActiveOrder(
+                ctx,
+                args[ACTIVE_ORDER_INPUT_FIELD_NAME],
+            );
             if (sessionOrder) {
                 const customer = await this.customerService.createOrUpdate(ctx, args.input, true);
                 if (isGraphQlErrorResult(customer)) {

+ 2 - 0
packages/core/src/config/config.module.ts

@@ -86,6 +86,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             orderCodeStrategy,
             orderByCodeAccessStrategy,
             stockAllocationStrategy,
+            activeOrderStrategy,
         } = this.configService.orderOptions;
         const { customFulfillmentProcess } = this.configService.shippingOptions;
         const { customPaymentProcess } = this.configService.paymentOptions;
@@ -120,6 +121,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             stockDisplayStrategy,
             ...healthChecks,
             assetImportStrategy,
+            ...(Array.isArray(activeOrderStrategy) ? activeOrderStrategy : [activeOrderStrategy]),
         ];
     }
 

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

@@ -22,6 +22,7 @@ import { DefaultStockDisplayStrategy } from './catalog/default-stock-display-str
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
 import { manualFulfillmentHandler } from './fulfillment/manual-fulfillment-handler';
 import { DefaultLogger } from './logger/default-logger';
+import { DefaultActiveOrderStrategy } from './order/default-active-order-strategy';
 import { DefaultChangedPriceHandlingStrategy } from './order/default-changed-price-handling-strategy';
 import { DefaultOrderItemPriceCalculationStrategy } from './order/default-order-item-price-calculation-strategy';
 import { DefaultOrderPlacedStrategy } from './order/default-order-placed-strategy';
@@ -113,6 +114,7 @@ export const defaultConfig: RuntimeVendureConfig = {
     entityOptions: {
         channelCacheTtl: 30000,
         zoneCacheTtl: 30000,
+        taxRateCacheTtl: 30000,
         metadataModifiers: [],
     },
     promotionOptions: {
@@ -137,6 +139,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         orderByCodeAccessStrategy: new DefaultOrderByCodeAccessStrategy('2h'),
         changedPriceHandlingStrategy: new DefaultChangedPriceHandlingStrategy(),
         orderPlacedStrategy: new DefaultOrderPlacedStrategy(),
+        activeOrderStrategy: new DefaultActiveOrderStrategy(),
     },
     paymentOptions: {
         paymentMethodEligibilityCheckers: [],

+ 2 - 0
packages/core/src/config/index.ts

@@ -30,6 +30,8 @@ export * from './logger/default-logger';
 export * from './logger/noop-logger';
 export * from './logger/vendure-logger';
 export * from './merge-config';
+export * from './order/active-order-strategy';
+export * from './order/default-active-order-strategy';
 export * from './order/changed-price-handling-strategy';
 export * from './order/custom-order-process';
 export * from './order/default-changed-price-handling-strategy';

+ 84 - 0
packages/core/src/config/order/active-order-strategy.ts

@@ -0,0 +1,84 @@
+import { DocumentNode } from 'graphql';
+
+import { RequestContext } from '../../api/index';
+import { InjectableStrategy } from '../../common/index';
+import { Order } from '../../entity/index';
+
+export const ACTIVE_ORDER_INPUT_FIELD_NAME = 'activeOrderInput';
+
+/**
+ * @description
+ * This strategy is used to determine the active Order for all order-related operations in
+ * the Shop API. By default, all the Shop API operations that relate to the active Order (e.g.
+ * `activeOrder`, `addItemToOrder`, `applyCouponCode` etc.) will implicitly create a new Order
+ * and set it on the current Session, and then read the session to obtain the active Order.
+ * This behaviour is defined by the {@link DefaultActiveOrderStrategy}.
+ *
+ * @since 1.9.0
+ */
+export interface ActiveOrderStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * The name of the strategy, e.g. "orderByToken", which will also be used as the
+     * field name in the ActiveOrderInput type.
+     */
+    readonly name: string;
+
+    /**
+     * @description
+     * Defines the type of the GraphQL Input object expected by the `authenticate`
+     * mutation. The final input object will be a map, with the key being the name
+     * of the strategy. The shape of the input object should match the generic `Data`
+     * type argument.
+     *
+     * @example
+     * For example, given the following:
+     *
+     * ```TypeScript
+     * defineInputType() {
+     *   return gql`
+     *      input OrderTokenInput {
+     *        token: String!
+     *      }
+     *   `;
+     * }
+     * ```
+     *
+     * assuming the strategy name is "my_auth", then the resulting call to `authenticate`
+     * would look like:
+     *
+     * ```GraphQL
+     * activeOrder(activeOrderInput: {
+     *   orderByToken: {
+     *     token: "foo"
+     *   }
+     * }) {
+     *   # ...
+     * }
+     * ```
+     *
+     * **Note:** if more than one graphql `input` type is being defined (as in a nested input type), then
+     * the _first_ input will be assumed to be the top-level input.
+     */
+    defineInputType?: () => DocumentNode;
+
+    /**
+     * @description
+     * Certain mutations such as `addItemToOrder` can automatically create a new Order if one does not exist.
+     * In these cases, this method will be called to create the new Order.
+     *
+     * If automatic creation of an Order does not make sense in your strategy, then leave this method
+     * undefined. You'll then need to take care of creating an order manually by defining a custom mutation.
+     */
+    createActiveOrder?: (ctx: RequestContext, inputs: any) => Promise<Order>;
+
+    /**
+     * @description
+     * This method is used to determine the active Order based on the current RequestContext in addition to any
+     * input values provided, as defined by the `defineInputType` method of this strategy.
+     *
+     * Note that this method is invoked frequently so you should aim to keep it efficient. The returned Order,
+     * for example, does not need to have its various relations joined.
+     */
+    determineActiveOrder(ctx: RequestContext, inputs: any): Promise<Order | undefined>;
+}

+ 65 - 0
packages/core/src/config/order/default-active-order-strategy.ts

@@ -0,0 +1,65 @@
+import { RequestContext } from '../../api/common/request-context';
+import { InternalServerError } from '../../common/error/errors';
+import { Injector } from '../../common/injector';
+import { TransactionalConnection } from '../../connection/transactional-connection';
+import { Order } from '../../entity/order/order.entity';
+// import { OrderService } from '../../service/services/order.service';
+// import { SessionService } from '../../service/services/session.service';
+
+import { ActiveOrderStrategy } from './active-order-strategy';
+
+/**
+ * @description
+ * The default {@link ActiveOrderStrategy}, which uses the current {@link Session} to determine
+ * the active Order, and requires no additional input in the Shop API since it is based on the
+ * session which is part of the RequestContext.
+ *
+ * @since 1.9.0
+ */
+export class DefaultActiveOrderStrategy implements ActiveOrderStrategy {
+    private connection: TransactionalConnection;
+    private orderService: import('../../service/services/order.service').OrderService;
+    private sessionService: import('../../service/services/session.service').SessionService;
+
+    name: 'default-active-order-strategy';
+
+    async init(injector: Injector) {
+        this.connection = injector.get(TransactionalConnection);
+        // Lazy import these dependencies to avoid a circular dependency issue in NestJS.
+        const { OrderService } = await import('../../service/services/order.service');
+        const { SessionService } = await import('../../service/services/session.service');
+        this.orderService = injector.get(OrderService);
+        this.sessionService = injector.get(SessionService);
+    }
+
+    createActiveOrder(ctx: RequestContext) {
+        return this.orderService.create(ctx, ctx.activeUserId);
+    }
+
+    async determineActiveOrder(ctx: RequestContext) {
+        if (!ctx.session) {
+            throw new InternalServerError(`error.no-active-session`);
+        }
+        let order = ctx.session.activeOrderId
+            ? await this.connection
+                  .getRepository(ctx, Order)
+                  .createQueryBuilder('order')
+                  .leftJoin('order.channels', 'channel')
+                  .where('order.id = :orderId', { orderId: ctx.session.activeOrderId })
+                  .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
+                  .getOne()
+            : undefined;
+        if (order && order.active === false) {
+            // edge case where an inactive order may not have been
+            // removed from the session, i.e. the regular process was interrupted
+            await this.sessionService.unsetActiveOrder(ctx, ctx.session);
+            order = undefined;
+        }
+        if (!order) {
+            if (ctx.activeUserId) {
+                order = await this.orderService.getActiveOrderForUser(ctx, ctx.activeUserId);
+            }
+        }
+        return order || undefined;
+    }
+}

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

@@ -26,6 +26,7 @@ import { CustomFulfillmentProcess } from './fulfillment/custom-fulfillment-proce
 import { FulfillmentHandler } from './fulfillment/fulfillment-handler';
 import { JobQueueStrategy } from './job-queue/job-queue-strategy';
 import { VendureLogger } from './logger/vendure-logger';
+import { ActiveOrderStrategy } from './order/active-order-strategy';
 import { ChangedPriceHandlingStrategy } from './order/changed-price-handling-strategy';
 import { CustomOrderProcess } from './order/custom-order-process';
 import { OrderByCodeAccessStrategy } from './order/order-by-code-access-strategy';
@@ -557,6 +558,18 @@ export interface OrderOptions {
      * @default DefaultOrderPlacedStrategy
      */
     orderPlacedStrategy?: OrderPlacedStrategy;
+    /**
+     * @description
+     * Defines the strategy used to determine the active Order when interacting with Shop API operations
+     * such as `activeOrder` and `addItemToOrder`. By default, the strategy uses the active Session.
+     *
+     * Note that if multiple strategies are defined, they will be checked in order and the first one that
+     * returns an Order will be used.
+     *
+     * @since 1.9.0
+     * @default DefaultActiveOrderStrategy
+     */
+    activeOrderStrategy?: ActiveOrderStrategy | ActiveOrderStrategy[];
 }
 
 /**
@@ -886,6 +899,17 @@ export interface EntityOptions {
      * @default 30000
      */
     zoneCacheTtl?: number;
+    /**
+     * @description
+     * TaxRates get cached in-memory as they are accessed very frequently. This
+     * setting determines how long the cache lives (in ms) until it is considered stale and
+     * refreshed. For multi-instance deployments (e.g. serverless, load-balanced), a
+     * smaller value here will prevent data inconsistencies between instances.
+     *
+     * @since 1.9.0
+     * @default 30000
+     */
+    taxRateCacheTtl?: number;
     /**
      * @description
      * Allows the metadata of the built-in TypeORM entities to be manipulated. This allows you

+ 5 - 5
packages/core/src/connection/transactional-connection.ts

@@ -113,18 +113,18 @@ export class TransactionalConnection {
      * @example
      * ```TypeScript
      * private async transferCredit(outerCtx: RequestContext, fromId: ID, toId: ID, amount: number) {
-     *   await this.connection.withTransaction(outerCtx, ctx => {
-     *     await this.giftCardService.updateCustomerCredit(fromId, -amount);
-     *
-     *     // Note you must not use outerCtx here, instead use ctx. Otherwise this query
+     *   await this.connection.withTransaction(outerCtx, async ctx => {
+     *     // Note you must not use `outerCtx` here, instead use `ctx`. Otherwise, this query
      *     // will be executed outside of transaction
+     *     await this.giftCardService.updateCustomerCredit(ctx, fromId, -amount);
+     *
      *     await this.connection.getRepository(ctx, GiftCard).update(fromId, { transferred: true })
      *
      *     // If some intermediate logic here throws an Error,
      *     // then all DB transactions will be rolled back and neither Customer's
      *     // credit balance will have changed.
      *
-     *     await this.giftCardService.updateCustomerCredit(toId, amount);
+     *     await this.giftCardService.updateCustomerCredit(ctx, toId, amount);
      *   })
      * }
      * ```

+ 3 - 3
packages/core/src/i18n/messages/de.json

@@ -70,7 +70,7 @@
     "NO_ACTIVE_ORDER_ERROR": "Es gibt keine aktive Bestellung in der aktuellen Sitzung",
     "NOTHING_TO_REFUND_ERROR": "Es gibt nichts zurückzuerstatten",
     "NOT_VERIFIED_ERROR": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest",
-    "ORDER_LIMIT_ERROR": "Der Artikel konnte nicht hinzugefügt werden. Eine Bestellung kann maximal { maxItems } Artikel enthalten", 
+    "ORDER_LIMIT_ERROR": "Der Artikel konnte nicht hinzugefügt werden. Eine Bestellung kann maximal { maxItems } Artikel enthalten",
     "ORDER_MODIFICATION_ERROR": "Der Inhalt der Bestellung kann nur im Status \"AddingItems\" geändert werden",
     "ORDER_PAYMENT_STATE_ERROR": "Eine Zahlung kann nur im Status \"ArrangingPayment\" hinzugefügt werden",
     "ORDER_STATE_TRANSITION_ERROR": "Der Status der Bestellung kan nicht von \"{ fromState }\" zu \"{ toState }\" geändert werden",
@@ -108,9 +108,9 @@
     "facet-force-deleted": "Die Facette wurde gelöscht und ihre FacetValues wurden von {products, plural, =0 {} one {einem Produkt} other {# Produkten}}{both, select, both { und } single {}}{variants, plural, =0 {} one {einer Produktvariante} other {# Produktvariantem}} entfernt",
     "facet-used": "Die ausgewählte Facette enthält FacetValues, die {products, plural, =0 {} one {einem Produkt} other {# Produkten}}{both, select, both { und } single {}}{variants, plural, =0 {} one {einer Produktvariante} other {# Produktvarianten}} zugewiesen sind",
     "facet-value-force-deleted": "Der gewählte FacetValue wurde von {products, plural, =0 {} one {einem Produkt} other {# Produkten}}{both, select, both { und } single {}}{variants, plural, =0 {} one {einer Produktvariante} other {# Produktvarianten}} entfernt und gelöscht",
-    "facet-value-used": "Der gewählte FacetValue wurde {products, plural, =0 {} one {einem Produkt} other {# Produkten}}{both, select, both { und } single {}}{variants, plural, =0 {} one {einer Produktvariante} other {# Produktvarianten}} zugewiesen",
+    "facet-value-used": "Der FacetValue \"{ facetValueCode }\" wurde {products, plural, =0 {} one {einem Produkt} other {# Produkten}}{both, select, both { und } single {}}{variants, plural, =0 {} one {einer Produktvariante} other {# Produktvarianten}} zugewiesen",
     "payment-method-used-in-channels": "Die gewählte Zahlungsmethode wird von den folgenden Kanälen verwendet: { channelCodes }. Mit \"force: true\" wird sie von allen Kanälen entfernt.",
     "zone-used-in-channels": "Die ausgewählte Zone kann nicht gelöscht werden, da sie als Standard in den folgenden Kanälen verwendet wird: { channelCodes }",
     "zone-used-in-tax-rates": "Die ausgewählte Zone kann nicht gelöscht werden, da sie in den folgenden Steuersätzen verwendet wird: { taxRateNames }"
   }
-}
+}

+ 2 - 1
packages/core/src/i18n/messages/en.json

@@ -37,6 +37,7 @@
     "no-configurable-operation-def-with-code-found": "No { type } with the code '{ code }' could be found",
     "no-price-found-for-channel": "No price information was found for ProductVariant ID '{ variantId}' in the Channel '{ channel }'.",
     "no-search-plugin-configured": "No search plugin has been configured",
+    "order-could-not-be-determined-or-created": "No active Order could be determined nor created",
     "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",
     "pending-identifier-missing": "Could not find the pending email address to update",
     "permission-invalid": "The permission \"{ permission }\" may not be assigned",
@@ -120,7 +121,7 @@
     "facet-force-deleted": "The Facet was deleted and its FacetValues were removed from {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",
     "facet-used": "The Facet \"{ facetCode }\" includes FacetValues which are assigned to {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",
     "facet-value-force-deleted": "The selected FacetValue was removed from {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}} and deleted",
-    "facet-value-used": "The selected FacetValue is assigned to {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",
+    "facet-value-used": "The FacetValue \"{ facetValueCode }\" is assigned to {products, plural, =0 {} one {1 Product} other {# Products}}{both, select, both { , } single {}}{variants, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",
     "payment-method-used-in-channels": "The selected PaymentMethod is assigned to the following Channels: { channelCodes }. Set \"force: true\" to delete from all Channels.",
     "product-option-used": "Cannot delete the option \"{code}\" as it is being used by {count, plural, =0 {} one {1 ProductVariant} other {# ProductVariants}}",
     "zone-used-in-channels": "The selected Zone cannot be deleted as it used as a default in the following Channels: { channelCodes }",

+ 61 - 2
packages/core/src/service/helpers/active-order/active-order.service.ts

@@ -1,8 +1,9 @@
 import { Injectable } from '@nestjs/common';
 
 import { RequestContext } from '../../../api/common/request-context';
-import { InternalServerError } from '../../../common/error/errors';
-import { TransactionalConnection } from '../../../connection/transactional-connection';
+import { InternalServerError, UserInputError } from '../../../common/index';
+import { ConfigService } from '../../../config/index';
+import { TransactionalConnection } from '../../../connection/index';
 import { Order } from '../../../entity/order/order.entity';
 import { OrderService } from '../../services/order.service';
 import { SessionService } from '../../services/session.service';
@@ -19,6 +20,7 @@ export class ActiveOrderService {
         private sessionService: SessionService,
         private orderService: OrderService,
         private connection: TransactionalConnection,
+        private configService: ConfigService,
     ) {}
 
     /**
@@ -28,6 +30,8 @@ export class ActiveOrderService {
      *
      * Intended to be used at the Resolver layer for those resolvers that depend upon an active Order
      * being present.
+     *
+     * @deprecated From v1.9.0, use the `getActiveOrder` method which uses any configured ActiveOrderStrategies
      */
     async getOrderFromContext(ctx: RequestContext): Promise<Order | undefined>;
     async getOrderFromContext(ctx: RequestContext, createIfNotExists: true): Promise<Order>;
@@ -65,4 +69,59 @@ export class ActiveOrderService {
         }
         return order || undefined;
     }
+
+    /**
+     * @description
+     * Retrieves the active Order based on the configured {@link ActiveOrderStrategy}.
+     *
+     * @since 1.9.0
+     */
+    async getActiveOrder(
+        ctx: RequestContext,
+        input: { [strategyName: string]: any } | undefined,
+    ): Promise<Order | undefined>;
+    async getActiveOrder(
+        ctx: RequestContext,
+        input: { [strategyName: string]: any } | undefined,
+        createIfNotExists: true,
+    ): Promise<Order>;
+    async getActiveOrder(
+        ctx: RequestContext,
+        input: { [strategyName: string]: any } | undefined,
+        createIfNotExists = false,
+    ): Promise<Order | undefined> {
+        let order: any;
+        if (!order) {
+            const { activeOrderStrategy } = this.configService.orderOptions;
+            const strategyArray = Array.isArray(activeOrderStrategy)
+                ? activeOrderStrategy
+                : [activeOrderStrategy];
+            for (const strategy of strategyArray) {
+                const strategyInput = input?.[strategy.name] ?? {};
+                order = await strategy.determineActiveOrder(ctx, strategyInput);
+                if (order) {
+                    break;
+                }
+                if (createIfNotExists && typeof strategy.createActiveOrder === 'function') {
+                    order = await strategy.createActiveOrder(ctx, input);
+                }
+                if (order) {
+                    break;
+                }
+            }
+
+            if (!order && createIfNotExists) {
+                // No order has been found, and none could be created, which indicates that
+                // none of the configured strategies have a `createActiveOrder` method defined.
+                // In this case, we should throw an error because it is assumed that such a configuration
+                // indicates that an external order creation mechanism should be defined.
+                throw new UserInputError('error.order-could-not-be-determined-or-created');
+            }
+
+            if (order && ctx.session) {
+                await this.sessionService.setActiveOrder(ctx, ctx.session, order);
+            }
+        }
+        return order || undefined;
+    }
 }

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

@@ -36,17 +36,26 @@ export class ExternalAuthenticationService {
      * @description
      * Looks up a User based on their identifier from an external authentication
      * provider, ensuring this User is associated with a Customer account.
+     *
+     * By default, only customers in the currently-active Channel will be checked.
+     * By passing `false` as the `checkCurrentChannelOnly` argument, _all_ channels
+     * will be checked.
      */
     async findCustomerUser(
         ctx: RequestContext,
         strategy: string,
         externalIdentifier: string,
+        checkCurrentChannelOnly = true,
     ): Promise<User | undefined> {
         const user = await this.findUser(ctx, strategy, externalIdentifier);
 
         if (user) {
             // Ensure this User is associated with a Customer
-            const customer = await this.customerService.findOneByUserId(ctx, user.id);
+            const customer = await this.customerService.findOneByUserId(
+                ctx,
+                user.id,
+                checkCurrentChannelOnly,
+            );
             if (customer) {
                 return user;
             }

+ 29 - 42
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -24,7 +24,6 @@ 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 { CustomFieldConfig } from '../../../config/custom-field/custom-field-types';
@@ -36,7 +35,6 @@ import { OrderModification } from '../../../entity/order-modification/order-modi
 import { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
 import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
-import { Promotion } from '../../../entity/promotion/promotion.entity';
 import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
 import { Surcharge } from '../../../entity/surcharge/surcharge.entity';
 import { EventBus } from '../../../event-bus/event-bus';
@@ -213,7 +211,7 @@ export class OrderModifier {
             newOrderItems.forEach((item, i) => (item.id = identifiers[i].id));
             orderLine.items = await this.connection
                 .getRepository(ctx, OrderItem)
-                .find({ where: { line: orderLine } });
+                .find({ where: { line: orderLine }, order: { createdAt: 'ASC' } });
             if (!order.active && order.state !== 'Draft') {
                 await this.stockMovementService.createAllocationsForOrderLines(ctx, [
                     {
@@ -223,7 +221,7 @@ export class OrderModifier {
                 ]);
             }
         } else if (quantity < currentQuantity) {
-            if (order.active) {
+            if (order.active || order.state === 'Draft') {
                 // When an Order is still active, it is fine to just delete
                 // any OrderItems that are no longer needed
                 const keepItems = orderLine.items.slice(0, quantity);
@@ -465,9 +463,15 @@ export class OrderModifier {
         const updatedOrderLines = order.lines.filter(l => updatedOrderLineIds.includes(l.id));
         const promotions = await this.promotionService.getActivePromotionsInChannel(ctx);
         const activePromotionsPre = await this.promotionService.getActivePromotionsOnOrder(ctx, order.id);
-        await this.orderCalculator.applyPriceAdjustments(ctx, order, promotions, updatedOrderLines, {
-            recalculateShipping: input.options?.recalculateShipping,
-        });
+        const updatedOrderItems = await this.orderCalculator.applyPriceAdjustments(
+            ctx,
+            order,
+            promotions,
+            updatedOrderLines,
+            {
+                recalculateShipping: input.options?.recalculateShipping,
+            },
+        );
 
         const orderCustomFields = (input as any).customFields;
         if (orderCustomFields) {
@@ -491,11 +495,7 @@ export class OrderModifier {
             if (shippingDelta < 0) {
                 refundInput.shipping = shippingDelta * -1;
             }
-            refundInput.adjustment += await this.getAdjustmentFromNewlyAppliedPromotions(
-                ctx,
-                order,
-                activePromotionsPre,
-            );
+            refundInput.adjustment += this.calculateRefundAdjustment(delta, refundInput);
             const existingPayments = await this.getOrderPayments(ctx, order.id);
             const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
             if (payment) {
@@ -526,9 +526,10 @@ export class OrderModifier {
             await this.connection.getRepository(ctx, OrderItem).save(orderItems, { reload: false });
         } else {
             // Otherwise, just save those OrderItems that were specifically added/removed
+            // or updated when applying `OrderCalculator.applyPriceAdjustments()`
             await this.connection
                 .getRepository(ctx, OrderItem)
-                .save(modification.orderItems, { reload: false });
+                .save([...modification.orderItems, ...updatedOrderItems], { reload: false });
         }
         await this.connection.getRepository(ctx, ShippingLine).save(order.shippingLines, { reload: false });
         return { order, modification: createdModification };
@@ -546,35 +547,21 @@ export class OrderModifier {
         return noChanges;
     }
 
-    private async getAdjustmentFromNewlyAppliedPromotions(
-        ctx: RequestContext,
-        order: Order,
-        promotionsPre: Promotion[],
-    ) {
-        const newPromotionDiscounts = order.discounts
-            .filter(discount => {
-                const promotionId = AdjustmentSource.decodeSourceId(discount.adjustmentSource).id;
-                return !promotionsPre.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;
+    /**
+     * @description
+     * Because a Refund's amount is calculated based on the orderItems changed, plus shipping change,
+     * we need to make sure the amount gets adjusted to match any changes caused by other factors,
+     * i.e. promotions that were previously active but are no longer.
+     */
+    private calculateRefundAdjustment(
+        delta: number,
+        refundInput: RefundOrderInput & { orderItems: OrderItem[] },
+    ): number {
+        const existingAdjustment = refundInput.adjustment;
+        const itemAmount = summate(refundInput.orderItems, 'proratedUnitPriceWithTax');
+        const calculatedDelta = itemAmount + refundInput.shipping + existingAdjustment;
+        const absDelta = Math.abs(delta);
+        return absDelta !== calculatedDelta ? absDelta - calculatedDelta : 0;
     }
 
     private getOrderPayments(ctx: RequestContext, orderId: ID): Promise<Payment[]> {

+ 3 - 0
packages/core/src/service/initializer.service.ts

@@ -11,6 +11,7 @@ import { ChannelService } from './services/channel.service';
 import { GlobalSettingsService } from './services/global-settings.service';
 import { RoleService } from './services/role.service';
 import { ShippingMethodService } from './services/shipping-method.service';
+import { TaxRateService } from './services/tax-rate.service';
 import { ZoneService } from './services/zone.service';
 
 /**
@@ -27,6 +28,7 @@ export class InitializerService {
         private administratorService: AdministratorService,
         private shippingMethodService: ShippingMethodService,
         private globalSettingsService: GlobalSettingsService,
+        private taxRateService: TaxRateService,
         private eventBus: EventBus,
     ) {}
 
@@ -44,6 +46,7 @@ export class InitializerService {
         await this.roleService.initRoles();
         await this.administratorService.initAdministrators();
         await this.shippingMethodService.initShippingMethods();
+        await this.taxRateService.initTaxRates();
         this.eventBus.publish(new InitializerEvent());
     }
 

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

@@ -263,12 +263,13 @@ export class ChannelService {
 
     async delete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
         const channel = await this.connection.getEntityOrThrow(ctx, Channel, id);
+        const deletedChannel = new Channel(channel);
         await this.connection.getRepository(ctx, Session).delete({ activeChannelId: id });
         await this.connection.getRepository(ctx, Channel).delete(id);
         await this.connection.getRepository(ctx, ProductVariantPrice).delete({
             channelId: id,
         });
-        this.eventBus.publish(new ChannelEvent(ctx, channel, 'deleted', id));
+        this.eventBus.publish(new ChannelEvent(ctx, deletedChannel, 'deleted', id));
 
         return {
             result: DeletionResult.DELETED,

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

@@ -494,13 +494,15 @@ export class CollectionService implements OnModuleInit {
         const collection = await this.connection.getEntityOrThrow(ctx, Collection, id, {
             channelId: ctx.channelId,
         });
+        const deletedCollection = new Collection(collection);
         const descendants = await this.getDescendants(ctx, collection.id);
         for (const coll of [...descendants.reverse(), collection]) {
             const affectedVariantIds = await this.getCollectionProductVariantIds(coll);
+            const deletedColl = new Collection(coll);
             await this.connection.getRepository(ctx, Collection).remove(coll);
-            this.eventBus.publish(new CollectionModificationEvent(ctx, coll, affectedVariantIds));
+            this.eventBus.publish(new CollectionModificationEvent(ctx, deletedColl, affectedVariantIds));
         }
-        this.eventBus.publish(new CollectionEvent(ctx, collection, 'deleted', id));
+        this.eventBus.publish(new CollectionEvent(ctx, deletedCollection, 'deleted', id));
         return {
             result: DeletionResult.DELETED,
         };

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

@@ -130,8 +130,9 @@ export class CountryService {
                 message: ctx.translate('message.country-used-in-addresses', { count: addressesUsingCountry }),
             };
         } else {
+            const deletedCountry = new Country(country);
             await this.connection.getRepository(ctx, Country).remove(country);
-            this.eventBus.publish(new CountryEvent(ctx, country, 'deleted', id));
+            this.eventBus.publish(new CountryEvent(ctx, deletedCountry, 'deleted', id));
             return {
                 result: DeletionResult.DELETED,
                 message: '',

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

@@ -125,8 +125,9 @@ export class CustomerGroupService {
     async delete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
         const group = await this.connection.getEntityOrThrow(ctx, CustomerGroup, id);
         try {
+            const deletedGroup = new CustomerGroup(group);
             await this.connection.getRepository(ctx, CustomerGroup).remove(group);
-            this.eventBus.publish(new CustomerGroupEntityEvent(ctx, group, 'deleted', id));
+            this.eventBus.publish(new CustomerGroupEntityEvent(ctx, deletedGroup, 'deleted', id));
             return {
                 result: DeletionResult.DELETED,
             };

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

@@ -771,9 +771,10 @@ export class CustomerService {
                 address: addressToLine(address),
             },
         });
+        const deletedAddress = new Address(address);
         await this.connection.getRepository(ctx, Address).remove(address);
         address.customer = customer;
-        this.eventBus.publish(new CustomerAddressEvent(ctx, address, 'deleted', id));
+        this.eventBus.publish(new CustomerAddressEvent(ctx, deletedAddress, 'deleted', id));
         return true;
     }
 

+ 14 - 5
packages/core/src/service/services/facet-value.service.ts

@@ -14,7 +14,7 @@ import { Translated } from '../../common/types/locale-types';
 import { assertFound } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { TransactionalConnection } from '../../connection/transactional-connection';
-import { Product, ProductVariant } from '../../entity';
+import { Asset, Product, ProductVariant } from '../../entity';
 import { FacetValueTranslation } from '../../entity/facet-value/facet-value-translation.entity';
 import { FacetValue } from '../../entity/facet-value/facet-value.entity';
 import { Facet } from '../../entity/facet/facet.entity';
@@ -144,18 +144,27 @@ export class FacetValueService {
 
         const isInUse = !!(productCount || variantCount);
         const both = !!(productCount && variantCount) ? 'both' : 'single';
-        const i18nVars = { products: productCount, variants: variantCount, both };
         let message = '';
         let result: DeletionResult;
 
+        const facetValue = await this.connection.getEntityOrThrow(ctx, FacetValue, id);
+        const i18nVars = {
+            products: productCount,
+            variants: variantCount,
+            both,
+            facetValueCode: facetValue.code,
+        };
+        // Create a new facetValue so that the id is still available
+        // after deletion (the .remove() method sets it to undefined)
+        const deletedFacetValue = new FacetValue(facetValue);
+
         if (!isInUse) {
-            const facetValue = await this.connection.getEntityOrThrow(ctx, FacetValue, id);
             await this.connection.getRepository(ctx, FacetValue).remove(facetValue);
+            this.eventBus.publish(new FacetValueEvent(ctx, deletedFacetValue, 'deleted', id));
             result = DeletionResult.DELETED;
         } else if (force) {
-            const facetValue = await this.connection.getEntityOrThrow(ctx, FacetValue, id);
             await this.connection.getRepository(ctx, FacetValue).remove(facetValue);
-            this.eventBus.publish(new FacetValueEvent(ctx, facetValue, 'deleted', id));
+            this.eventBus.publish(new FacetValueEvent(ctx, deletedFacetValue, 'deleted', id));
             message = ctx.translate('message.facet-value-force-deleted', i18nVars);
             result = DeletionResult.DELETED;
         } else {

+ 3 - 1
packages/core/src/service/services/facet.service.ts

@@ -201,15 +201,17 @@ export class FacetService {
         const i18nVars = { products: productCount, variants: variantCount, both, facetCode: facet.code };
         let message = '';
         let result: DeletionResult;
+        const deletedFacet = new Facet(facet);
 
         if (!isInUse) {
             await this.connection.getRepository(ctx, Facet).remove(facet);
+            this.eventBus.publish(new FacetEvent(ctx, deletedFacet, 'deleted', id));
             result = DeletionResult.DELETED;
         } else if (force) {
             await this.connection.getRepository(ctx, Facet).remove(facet);
+            this.eventBus.publish(new FacetEvent(ctx, deletedFacet, 'deleted', id));
             message = ctx.translate('message.facet-force-deleted', i18nVars);
             result = DeletionResult.DELETED;
-            this.eventBus.publish(new FacetEvent(ctx, facet, 'deleted', id));
         } else {
             message = ctx.translate('message.facet-used', i18nVars);
             result = DeletionResult.NOT_DELETED;

+ 4 - 2
packages/core/src/service/services/history.service.ts

@@ -262,8 +262,9 @@ export class HistoryService {
 
     async deleteOrderHistoryEntry(ctx: RequestContext, id: ID): Promise<void> {
         const entry = await this.connection.getEntityOrThrow(ctx, OrderHistoryEntry, id);
+        const deletedEntry = new OrderHistoryEntry(entry);
         await this.connection.getRepository(ctx, OrderHistoryEntry).remove(entry);
-        this.eventBus.publish(new HistoryEntryEvent(ctx, entry, 'deleted', 'order', id));
+        this.eventBus.publish(new HistoryEntryEvent(ctx, deletedEntry, 'deleted', 'order', id));
     }
 
     async updateCustomerHistoryEntry<T extends keyof CustomerHistoryEntryData>(
@@ -288,8 +289,9 @@ export class HistoryService {
 
     async deleteCustomerHistoryEntry(ctx: RequestContext, id: ID): Promise<void> {
         const entry = await this.connection.getEntityOrThrow(ctx, CustomerHistoryEntry, id);
+        const deletedEntry = new CustomerHistoryEntry(entry);
         await this.connection.getRepository(ctx, CustomerHistoryEntry).remove(entry);
-        this.eventBus.publish(new HistoryEntryEvent(ctx, entry, 'deleted', 'customer', id));
+        this.eventBus.publish(new HistoryEntryEvent(ctx, deletedEntry, 'deleted', 'customer', id));
     }
 
     private async getAdministratorFromContext(ctx: RequestContext): Promise<Administrator | undefined> {

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

@@ -137,8 +137,11 @@ export class PaymentMethodService {
                 return { result, message };
             }
             try {
+                const deletedPaymentMethod = new PaymentMethod(paymentMethod);
                 await this.connection.getRepository(ctx, PaymentMethod).remove(paymentMethod);
-                this.eventBus.publish(new PaymentMethodEvent(ctx, paymentMethod, 'deleted', paymentMethodId));
+                this.eventBus.publish(
+                    new PaymentMethodEvent(ctx, deletedPaymentMethod, 'deleted', paymentMethodId),
+                );
                 return {
                     result: DeletionResult.DELETED,
                 };

+ 32 - 27
packages/core/src/service/services/payment.service.ts

@@ -1,25 +1,20 @@
 import { Injectable } from '@nestjs/common';
-import {
-    CancelPaymentResult,
-    ManualPaymentInput,
-    RefundOrderInput,
-    SettlePaymentResult,
-} from '@vendure/common/lib/generated-types';
+import { ManualPaymentInput, RefundOrderInput } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { summate } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../api/common/request-context';
-import { ErrorResultUnion } from '../../common/error/error-result';
 import { InternalServerError } from '../../common/error/errors';
 import {
     PaymentStateTransitionError,
     RefundStateTransitionError,
-    SettlePaymentError,
 } from '../../common/error/generated-graphql-admin-errors';
 import { IneligiblePaymentMethodError } from '../../common/error/generated-graphql-shop-errors';
 import { PaymentMetadata } from '../../common/types/common-types';
 import { idsAreEqual } from '../../common/utils';
+import { Logger, PaymentMethodHandler } from '../../config/index';
 import { TransactionalConnection } from '../../connection/transactional-connection';
+import { PaymentMethod } from '../../entity/index';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { Order } from '../../entity/order/order.entity';
 import { Payment } from '../../entity/payment/payment.entity';
@@ -277,10 +272,7 @@ export class PaymentService {
             return summate(nonFailedRefunds, 'total');
         }
 
-        const existingNonFailedRefunds =
-            orderWithRefunds.payments
-                ?.reduce((refunds, p) => [...refunds, ...p.refunds], [] as Refund[])
-                .filter(refund => refund.state !== 'Failed') ?? [];
+        const refundsCreated: Refund[] = [];
         const refundablePayments = orderWithRefunds.payments.filter(p => {
             return paymentRefundTotal(p) < p.amount;
         });
@@ -316,19 +308,32 @@ export class PaymentService {
                 state: 'Pending',
                 metadata: {},
             });
-            const { paymentMethod, handler } = await this.paymentMethodService.getMethodAndOperations(
-                ctx,
-                paymentToRefund.method,
-            );
-            const createRefundResult = await handler.createRefund(
-                ctx,
-                input,
-                total,
-                order,
-                paymentToRefund,
-                paymentMethod.handler.args,
-                paymentMethod,
-            );
+            let paymentMethod: PaymentMethod | undefined;
+            let handler: PaymentMethodHandler | undefined;
+            try {
+                const methodAndHandler = await this.paymentMethodService.getMethodAndOperations(
+                    ctx,
+                    paymentToRefund.method,
+                );
+                paymentMethod = methodAndHandler.paymentMethod;
+                handler = methodAndHandler.handler;
+            } catch (e) {
+                Logger.warn(
+                    `Could not find a corresponding PaymentMethodHandler when creating a refund for the Payment with method "${paymentToRefund.method}"`,
+                );
+            }
+            const createRefundResult =
+                paymentMethod && handler
+                    ? await handler.createRefund(
+                          ctx,
+                          input,
+                          total,
+                          order,
+                          paymentToRefund,
+                          paymentMethod.handler.args,
+                          paymentMethod,
+                      )
+                    : false;
             if (createRefundResult) {
                 refund.transactionId = createRefundResult.transactionId || '';
                 refund.metadata = createRefundResult.metadata || {};
@@ -353,9 +358,9 @@ export class PaymentService {
             if (primaryRefund == null) {
                 primaryRefund = refund;
             }
-            existingNonFailedRefunds.push(refund);
+            refundsCreated.push(refund);
             refundedPaymentIds.push(paymentToRefund.id);
-            refundOutstanding = refundTotal - summate(existingNonFailedRefunds, 'total');
+            refundOutstanding = refundTotal - summate(refundsCreated, 'total');
         } while (0 < refundOutstanding);
         // tslint:disable-next-line:no-non-null-assertion
         return primaryRefund!;

+ 2 - 1
packages/core/src/service/services/product-option-group.service.ts

@@ -133,6 +133,7 @@ export class ProductOptionGroupService {
         const optionGroup = await this.connection.getEntityOrThrow(ctx, ProductOptionGroup, id, {
             relations: ['options', 'product'],
         });
+        const deletedOptionGroup = new ProductOptionGroup(optionGroup);
         const inUseByActiveProducts = await this.isInUseByOtherProducts(ctx, optionGroup, productId);
         if (0 < inUseByActiveProducts) {
             return {
@@ -180,7 +181,7 @@ export class ProductOptionGroupService {
                 Logger.error(e.message, undefined, e.stack);
             }
         }
-        this.eventBus.publish(new ProductOptionGroupEvent(ctx, optionGroup, 'deleted', id));
+        this.eventBus.publish(new ProductOptionGroupEvent(ctx, deletedOptionGroup, 'deleted', id));
         return {
             result: DeletionResult.DELETED,
         };

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

@@ -106,6 +106,7 @@ export class ProductOptionService {
      */
     async delete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
         const productOption = await this.connection.getEntityOrThrow(ctx, ProductOption, id);
+        const deletedProductOption = new ProductOption(productOption);
         const inUseByActiveVariants = await this.isInUse(ctx, productOption, 'active');
         if (0 < inUseByActiveVariants) {
             return {
@@ -135,7 +136,7 @@ export class ProductOptionService {
                 Logger.error(e.message, undefined, e.stack);
             }
         }
-        this.eventBus.publish(new ProductOptionEvent(ctx, productOption, 'deleted', id));
+        this.eventBus.publish(new ProductOptionEvent(ctx, deletedProductOption, 'deleted', id));
         return {
             result: DeletionResult.DELETED,
         };

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

@@ -205,8 +205,9 @@ export class RoleService {
         if (role.code === SUPER_ADMIN_ROLE_CODE || role.code === CUSTOMER_ROLE_CODE) {
             throw new InternalServerError(`error.cannot-delete-role`, { roleCode: role.code });
         }
+        const deletedRole = new Role(role);
         await this.connection.getRepository(ctx, Role).remove(role);
-        this.eventBus.publish(new RoleEvent(ctx, role, 'deleted', id));
+        this.eventBus.publish(new RoleEvent(ctx, deletedRole, 'deleted', id));
         return {
             result: DeletionResult.DELETED,
         };

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

@@ -81,8 +81,9 @@ export class TaxCategoryService {
         }
 
         try {
+            const deletedTaxCategory = new TaxCategory(taxCategory);
             await this.connection.getRepository(ctx, TaxCategory).remove(taxCategory);
-            this.eventBus.publish(new TaxCategoryEvent(ctx, taxCategory, 'deleted', id));
+            this.eventBus.publish(new TaxCategoryEvent(ctx, deletedTaxCategory, 'deleted', id));
             return {
                 result: DeletionResult.DELETED,
             };

+ 32 - 8
packages/core/src/service/services/tax-rate.service.ts

@@ -1,4 +1,4 @@
-import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
+import { Injectable } from '@nestjs/common';
 import {
     CreateTaxRateInput,
     DeletionResponse,
@@ -9,10 +9,11 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
 import { RelationPaths } from '../../api/index';
-import { RequestContextCacheService } from '../../cache';
 import { EntityNotFoundError } from '../../common/error/errors';
+import { createSelfRefreshingCache, SelfRefreshingCache } from '../../common/index';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound } from '../../common/utils';
+import { ConfigService } from '../../config/index';
 import { TransactionalConnection } from '../../connection/transactional-connection';
 import { CustomerGroup } from '../../entity/customer-group/customer-group.entity';
 import { TaxCategory } from '../../entity/tax-category/tax-category.entity';
@@ -24,8 +25,6 @@ import { TaxRateModificationEvent } from '../../event-bus/events/tax-rate-modifi
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { patchEntity } from '../helpers/utils/patch-entity';
 
-const activeTaxRatesKey = 'active-tax-rates';
-
 /**
  * @description
  * Contains methods relating to {@link TaxRate} entities.
@@ -40,14 +39,23 @@ export class TaxRateService {
         name: 'No configured tax rate',
         id: '0',
     });
+    private activeTaxRates: SelfRefreshingCache<TaxRate[], [RequestContext]>;
 
     constructor(
         private connection: TransactionalConnection,
         private eventBus: EventBus,
         private listQueryBuilder: ListQueryBuilder,
-        private cacheService: RequestContextCacheService,
+        private configService: ConfigService,
     ) {}
 
+    /**
+     * When the app is bootstrapped, ensure the tax rate cache gets created
+     * @internal
+     */
+    async initTaxRates() {
+        await this.ensureCacheExists();
+    }
+
     findAll(
         ctx: RequestContext,
         options?: ListQueryOptions<TaxRate>,
@@ -128,9 +136,10 @@ export class TaxRateService {
 
     async delete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
         const taxRate = await this.connection.getEntityOrThrow(ctx, TaxRate, id);
+        const deletedTaxRate = new TaxRate(taxRate);
         try {
             await this.connection.getRepository(ctx, TaxRate).remove(taxRate);
-            this.eventBus.publish(new TaxRateEvent(ctx, taxRate, 'deleted', id));
+            this.eventBus.publish(new TaxRateEvent(ctx, deletedTaxRate, 'deleted', id));
             return {
                 result: DeletionResult.DELETED,
             };
@@ -153,11 +162,11 @@ export class TaxRateService {
     }
 
     private async getActiveTaxRates(ctx: RequestContext): Promise<TaxRate[]> {
-        return this.cacheService.get(ctx, activeTaxRatesKey, () => this.findActiveTaxRates(ctx));
+        return this.activeTaxRates.value(ctx);
     }
 
     private async updateActiveTaxRates(ctx: RequestContext) {
-        this.cacheService.set(ctx, activeTaxRatesKey, await this.findActiveTaxRates(ctx));
+        await this.activeTaxRates.refresh(ctx);
     }
 
     private async findActiveTaxRates(ctx: RequestContext): Promise<TaxRate[]> {
@@ -168,4 +177,19 @@ export class TaxRateService {
             },
         });
     }
+
+    /**
+     * Ensures taxRate cache exists. If not, this method creates one.
+     */
+    private async ensureCacheExists() {
+        if (this.activeTaxRates) {
+            return;
+        }
+
+        this.activeTaxRates = await createSelfRefreshingCache({
+            name: 'TaxRateService.activeTaxRates',
+            ttl: this.configService.entityOptions.taxRateCacheTtl,
+            refresh: { fn: ctx => this.findActiveTaxRates(ctx), defaultArgs: [RequestContext.empty()] },
+        });
+    }
 }

+ 2 - 2
packages/core/src/service/services/zone.service.ts

@@ -113,7 +113,7 @@ export class ZoneService {
 
     async delete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
         const zone = await this.connection.getEntityOrThrow(ctx, Zone, id);
-
+        const deletedZone = new Zone(zone);
         const channelsUsingZone = await this.connection
             .getRepository(ctx, Channel)
             .createQueryBuilder('channel')
@@ -146,7 +146,7 @@ export class ZoneService {
         } else {
             await this.connection.getRepository(ctx, Zone).remove(zone);
             await this.zones.refresh(ctx);
-            this.eventBus.publish(new ZoneEvent(ctx, zone, 'deleted', id));
+            this.eventBus.publish(new ZoneEvent(ctx, deletedZone, 'deleted', id));
             return {
                 result: DeletionResult.DELETED,
                 message: '',

+ 76 - 0
packages/dev-server/test-plugins/custom-active-order-plugin.ts

@@ -0,0 +1,76 @@
+import {
+    ActiveOrderStrategy,
+    idsAreEqual,
+    Injector,
+    Order,
+    OrderService,
+    RequestContext,
+    TransactionalConnection,
+    VendurePlugin,
+} from '@vendure/core';
+import { CustomOrderFields } from '@vendure/core/dist/entity/custom-entity-fields';
+import gql from 'graphql-tag';
+
+declare module '@vendure/core/dist/entity/custom-entity-fields' {
+    interface CustomOrderFields {
+        orderToken: string;
+    }
+}
+
+class TokenActiveOrderStrategy implements ActiveOrderStrategy {
+    readonly name = 'orderToken';
+
+    private connection: TransactionalConnection;
+    private orderService: OrderService;
+
+    init(injector: Injector) {
+        this.connection = injector.get(TransactionalConnection);
+        this.orderService = injector.get(OrderService);
+    }
+
+    defineInputType = () => gql`
+        input CustomActiveOrderInput {
+            orderToken: String
+        }
+    `;
+
+    async determineActiveOrder(ctx: RequestContext, input: { orderToken: string }) {
+        const qb = this.connection
+            .getRepository(ctx, Order)
+            .createQueryBuilder('order')
+            .leftJoinAndSelect('order.customer', 'customer')
+            .where('order.customFields.orderToken = :orderToken', { orderToken: input.orderToken });
+
+        const order = await qb.getOne();
+        if (!order) {
+            return;
+        }
+        return order;
+        // const orderUserId = order.customer && order.customer.user && order.customer.user.id;
+        // if (idsAreEqual(ctx.activeUserId, orderUserId)) {
+        //     return order;
+        // } else {
+        //     return;
+        // }
+    }
+
+    // async createActiveOrder(ctx: RequestContext) {
+    //     const order = await this.orderService.create(ctx, ctx.activeUserId);
+    //     order.customFields.orderToken = Math.random().toString(36).substr(5);
+    //     await this.connection.getRepository(ctx, Order).save(order);
+    //     return order;
+    // }
+}
+
+@VendurePlugin({
+    configuration: config => {
+        config.customFields.Order.push({
+            name: 'orderToken',
+            type: 'string',
+            internal: true,
+        });
+        config.orderOptions.activeOrderStrategy = new TokenActiveOrderStrategy();
+        return config;
+    },
+})
+export class CustomActiveOrderPlugin {}

+ 1 - 0
scripts/codegen/generate-graphql-types.ts

@@ -27,6 +27,7 @@ const specFileToIgnore = [
     'order-merge.e2e-spec',
     'entity-hydrator.e2e-spec',
     'relations-decorator.e2e-spec',
+    'active-order-strategy.e2e-spec',
 ];
 const E2E_ADMIN_QUERY_FILES = path.join(
     __dirname,

Неке датотеке нису приказане због велике количине промена