Просмотр исходного кода

Merge branch 'next' into transactions

Michael Bromley 5 лет назад
Родитель
Сommit
44a05434ce
86 измененных файлов с 2156 добавлено и 460 удалено
  1. 1 1
      docs/content/docs/plugins/plugin-examples.md
  2. 11 11
      docs/diagrams/order-state-diagram.puml
  3. 11 1
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  4. 2 2
      packages/admin-ui/src/lib/core/src/data/data.module.ts
  5. 1 1
      packages/admin-ui/src/lib/core/src/data/providers/order-data.service.ts
  6. 2 2
      packages/admin-ui/src/lib/core/src/data/providers/settings-data.service.ts
  7. 2 2
      packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.html
  8. 2 2
      packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.ts
  9. 2 2
      packages/admin-ui/src/lib/core/src/shared/pipes/order-state-i18n-token.pipe.ts
  10. 1 1
      packages/admin-ui/src/lib/order/src/components/line-fulfillment/line-fulfillment.component.html
  11. 6 10
      packages/admin-ui/src/lib/order/src/components/line-fulfillment/line-fulfillment.component.ts
  12. 2 5
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html
  13. 30 30
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.ts
  14. 5 5
      packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.html
  15. 9 9
      packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.ts
  16. 3 3
      packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html
  17. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  18. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  19. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  20. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  21. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  22. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  23. 3 3
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json
  24. 10 1
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  25. 3 1
      packages/common/src/generated-shop-types.ts
  26. 11 1
      packages/common/src/generated-types.ts
  27. 1 1
      packages/core/e2e/asset.e2e-spec.ts
  28. 487 0
      packages/core/e2e/customer-channel.e2e-spec.ts
  29. 1 1
      packages/core/e2e/customer-group.e2e-spec.ts
  30. 10 10
      packages/core/e2e/customer.e2e-spec.ts
  31. 242 0
      packages/core/e2e/fulfillment-process.e2e-spec.ts
  32. 317 45
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  33. 3 1
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  34. 51 0
      packages/core/e2e/graphql/shared-definitions.ts
  35. 1 1
      packages/core/e2e/order-channel.e2e-spec.ts
  36. 6 6
      packages/core/e2e/order-process.e2e-spec.ts
  37. 1 1
      packages/core/e2e/order-promotion.e2e-spec.ts
  38. 139 119
      packages/core/e2e/order.e2e-spec.ts
  39. 1 1
      packages/core/e2e/product.e2e-spec.ts
  40. 5 1
      packages/core/src/api/api-internal-modules.ts
  41. 44 1
      packages/core/src/api/middleware/auth-guard.ts
  42. 11 0
      packages/core/src/api/resolvers/admin/order.resolver.ts
  43. 13 3
      packages/core/src/api/resolvers/entity/fulfillment-entity.resolver.ts
  44. 3 0
      packages/core/src/api/schema/admin-api/fulfillment.api.graphql
  45. 1 0
      packages/core/src/api/schema/admin-api/order.api.graphql
  46. 2 1
      packages/core/src/api/schema/type/history-entry.type.graphql
  47. 1 0
      packages/core/src/api/schema/type/order.type.graphql
  48. 10 4
      packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts
  49. 1 3
      packages/core/src/config/default-config.ts
  50. 27 0
      packages/core/src/config/fulfillment/custom-fulfillment-process.ts
  51. 1 0
      packages/core/src/config/index.ts
  52. 2 1
      packages/core/src/config/promotion/conditions/contains-products-condition.ts
  53. 3 2
      packages/core/src/config/promotion/conditions/customer-group-condition.ts
  54. 2 1
      packages/core/src/config/promotion/conditions/has-facet-values-condition.ts
  55. 3 1
      packages/core/src/config/promotion/conditions/min-order-amount-condition.ts
  56. 4 2
      packages/core/src/config/promotion/promotion-condition.ts
  57. 1 0
      packages/core/src/config/session-cache/session-cache-strategy.ts
  58. 8 0
      packages/core/src/config/vendure-config.ts
  59. 12 7
      packages/core/src/entity/customer/customer.entity.ts
  60. 3 0
      packages/core/src/entity/fulfillment/fulfillment.entity.ts
  61. 3 2
      packages/core/src/entity/promotion/promotion.entity.ts
  62. 8 1
      packages/core/src/entity/session/session.entity.ts
  63. 22 0
      packages/core/src/event-bus/events/fulfillment-state-transition-event.ts
  64. 5 2
      packages/core/src/i18n/messages/en.json
  65. 5 3
      packages/core/src/i18n/messages/pt_BR.json
  66. 10 8
      packages/core/src/service/helpers/external-authentication/external-authentication.service.ts
  67. 138 0
      packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state-machine.ts
  68. 39 0
      packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state.ts
  69. 2 2
      packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts
  70. 14 10
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  71. 31 24
      packages/core/src/service/helpers/order-state-machine/order-state-machine.ts
  72. 14 6
      packages/core/src/service/helpers/order-state-machine/order-state.ts
  73. 35 12
      packages/core/src/service/helpers/utils/order-utils.ts
  74. 2 0
      packages/core/src/service/index.ts
  75. 4 0
      packages/core/src/service/service.module.ts
  76. 13 2
      packages/core/src/service/services/customer-group.service.ts
  77. 93 23
      packages/core/src/service/services/customer.service.ts
  78. 74 0
      packages/core/src/service/services/fulfillment.service.ts
  79. 7 1
      packages/core/src/service/services/history.service.ts
  80. 56 36
      packages/core/src/service/services/order.service.ts
  81. 15 0
      packages/core/src/service/services/session.service.ts
  82. 3 2
      packages/create/src/helpers.ts
  83. 1 1
      packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
  84. 10 1
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  85. 0 0
      schema-admin.json
  86. 0 0
      schema-shop.json

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

@@ -234,7 +234,7 @@ export class OrderAnalyticsPlugin implements OnVendureBootstrap {
    */
   onVendureBootstrap() {
     this.eventBus.ofType(OrderStateTransitionEvent).subscribe(event => {
-      if (event.toState === 'Fulfilled') {
+      if (event.toState === 'Delivered') {
         this.workerService.send(new ProcessOrderMessage({ orderId: event.order.id })).subscribe();
       }
     });

+ 11 - 11
docs/diagrams/order-state-diagram.puml

@@ -27,12 +27,12 @@ state AdminAPI {
         PaymentSettled: provider, i.e. the transaction is complete.
     }
 
-    state PartiallyFulfilled {
-        PartiallyFulfilled: One or more OrderItems have been dispatched to the Customer
+    state PartiallyDelivered {
+        PartiallyDelivered: One or more OrderItems have been dispatched to the Customer
     }
 
-    state Fulfilled #9d9 {
-        Fulfilled: All OrderItems have been dispatched to the Customer
+    state Delivered #9d9 {
+        Delivered: All OrderItems have been dispatched to the Customer
     }
 
 
@@ -41,7 +41,7 @@ state AdminAPI {
     }
     Cancelled --> [*]
 
-    Fulfilled --> [*]
+    Delivered --> [*]
 }
 
 
@@ -50,15 +50,15 @@ ArrangingPayment --> AddingItems: transitionOrderToState
 ArrangingPayment --> PaymentAuthorized: addPaymentToOrder
 ArrangingPayment -----> PaymentSettled: addPaymentToOrder
 PaymentAuthorized --> PaymentSettled: settlePayment
-PaymentSettled --> Fulfilled: fulfillOrder
-PaymentSettled --> PartiallyFulfilled: fulfillOrder
-PartiallyFulfilled --> PartiallyFulfilled: fulfillOrder
-PartiallyFulfilled --> Fulfilled: fulfillOrder
+PaymentSettled --> Delivered: fulfillOrder
+PaymentSettled --> PartiallyDelivered: fulfillOrder
+PartiallyDelivered --> PartiallyDelivered: fulfillOrder
+PartiallyDelivered --> Delivered: fulfillOrder
 
 PaymentAuthorized --> Cancelled: cancelOrder
 PaymentSettled --> Cancelled: cancelOrder
-PartiallyFulfilled --> Cancelled: cancelOrder
-Fulfilled --> Cancelled: cancelOrder
+PartiallyDelivered --> Cancelled: cancelOrder
+Delivered --> Cancelled: cancelOrder
 
 
 

+ 11 - 1
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1240,10 +1240,12 @@ export type FloatCustomFieldConfig = CustomField & {
 
 export type Fulfillment = Node & {
    __typename?: 'Fulfillment';
+  nextStates: Array<Scalars['String']>;
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
   orderItems: Array<OrderItem>;
+  state: Scalars['String'];
   method: Scalars['String'];
   trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1319,9 +1321,10 @@ export enum HistoryEntryType {
   CUSTOMER_NOTE = 'CUSTOMER_NOTE',
   ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
   ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-  ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+  ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
   ORDER_CANCELLATION = 'ORDER_CANCELLATION',
   ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+  ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
   ORDER_NOTE = 'ORDER_NOTE',
   ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
   ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED'
@@ -1906,6 +1909,7 @@ export type Mutation = {
   setUiLanguage?: Maybe<LanguageCode>;
   settlePayment: Payment;
   settleRefund: Refund;
+  transitionFulfillmentToState: Fulfillment;
   transitionOrderToState?: Maybe<Order>;
   /** Update an existing Administrator */
   updateAdministrator: Administrator;
@@ -2302,6 +2306,12 @@ export type MutationSettleRefundArgs = {
 };
 
 
+export type MutationTransitionFulfillmentToStateArgs = {
+  id: Scalars['ID'];
+  state: Scalars['String'];
+};
+
+
 export type MutationTransitionOrderToStateArgs = {
   id: Scalars['ID'];
   state: Scalars['String'];

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

@@ -1,6 +1,6 @@
-import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
+import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
 import { APP_INITIALIZER, Injector, NgModule } from '@angular/core';
-import { ApolloModule, APOLLO_OPTIONS } from 'apollo-angular';
+import { APOLLO_OPTIONS, ApolloModule } from 'apollo-angular';
 import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
 import { ApolloClientOptions } from 'apollo-client';
 import { ApolloLink } from 'apollo-link';

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

@@ -71,7 +71,7 @@ export class OrderDataService {
         });
     }
 
-    createFullfillment(input: FulfillOrderInput) {
+    createFulfillment(input: FulfillOrderInput) {
         return this.baseDataService.mutate<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
             CREATE_FULFILLMENT,
             {

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

@@ -74,10 +74,10 @@ import {
     GET_COUNTRY,
     GET_COUNTRY_LIST,
     GET_GLOBAL_SETTINGS,
-    GET_JOBS_BY_ID,
-    GET_JOBS_LIST,
     GET_JOB_INFO,
     GET_JOB_QUEUE_LIST,
+    GET_JOBS_BY_ID,
+    GET_JOBS_LIST,
     GET_PAYMENT_METHOD,
     GET_PAYMENT_METHOD_LIST,
     GET_TAX_CATEGORIES,

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.html

@@ -1,6 +1,6 @@
 <vdr-chip [ngClass]="state" [colorType]="chipColorType">
-    <clr-icon shape="success-standard" *ngIf="state === 'Fulfilled'" size="12"></clr-icon>
-    <clr-icon shape="success-standard" *ngIf="state === 'PartiallyFulfilled'" size="12"></clr-icon>
+    <clr-icon shape="success-standard" *ngIf="state === 'Delivered'" size="12"></clr-icon>
+    <clr-icon shape="success-standard" *ngIf="state === 'PartiallyDelivered'" size="12"></clr-icon>
     <clr-icon shape="ban" *ngIf="state === 'Cancelled'" size="12"></clr-icon>
     {{ state | orderStateI18nToken | translate }}
     <ng-content></ng-content>

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/components/order-state-label/order-state-label.component.ts

@@ -13,9 +13,9 @@ export class OrderStateLabelComponent {
         switch (this.state) {
             case 'PaymentAuthorized':
             case 'PaymentSettled':
-            case 'PartiallyFulfilled':
+            case 'PartiallyDelivered':
                 return 'warning';
-            case 'Fulfilled':
+            case 'Delivered':
                 return 'success';
             case 'Cancelled':
                 return 'error';

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/pipes/order-state-i18n-token.pipe.ts

@@ -10,8 +10,8 @@ export class OrderStateI18nTokenPipe implements PipeTransform {
         ArrangingPayment: _('order.state-arranging-payment'),
         PaymentAuthorized: _('order.state-payment-authorized'),
         PaymentSettled: _('order.state-payment-settled'),
-        PartiallyFulfilled: _('order.state-partially-fulfilled'),
-        Fulfilled: _('order.state-fulfilled'),
+        PartiallyDelivered: _('order.state-partially-delivered'),
+        Delivered: _('order.state-delivered'),
         Cancelled: _('order.state-cancelled'),
     };
     transform<T extends unknown>(value: T): T {

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

@@ -1,4 +1,4 @@
-<vdr-dropdown class="search-settings-menu" *ngIf="fulfilledCount || orderState === 'PartiallyFulfilled'">
+<vdr-dropdown class="search-settings-menu" *ngIf="fulfilledCount || orderState === 'PartiallyDelivered'">
     <button type="button" class="icon-button" vdrDropdownTrigger>
         <clr-icon *ngIf="fulfillmentStatus === 'full'" class="item-fulfilled" shape="check-circle"></clr-icon>
         <clr-icon

+ 6 - 10
packages/admin-ui/src/lib/order/src/components/line-fulfillment/line-fulfillment.component.ts

@@ -1,7 +1,6 @@
 import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
-import { unique } from '@vendure/common/lib/unique';
-
 import { OrderDetail } from '@vendure/admin-ui/core';
+import { unique } from '@vendure/common/lib/unique';
 
 export type FulfillmentStatus = 'full' | 'partial' | 'none';
 
@@ -20,7 +19,7 @@ export class LineFulfillmentComponent implements OnChanges {
 
     ngOnChanges(changes: SimpleChanges): void {
         if (this.line) {
-            this.fulfilledCount = this.getFulfilledCount(this.line);
+            this.fulfilledCount = this.getDeliveredCount(this.line);
             this.fulfillmentStatus = this.getFulfillmentStatus(this.fulfilledCount, this.line.items.length);
             this.fulfillments = this.getFulfillments(this.line);
         }
@@ -29,7 +28,7 @@ export class LineFulfillmentComponent implements OnChanges {
     /**
      * Returns the number of items in an OrderLine which are fulfilled.
      */
-    private getFulfilledCount(line: OrderDetail.Lines): number {
+    private getDeliveredCount(line: OrderDetail.Lines): number {
         return line.items.reduce((sum, item) => sum + (item.fulfillment ? 1 : 0), 0);
     }
 
@@ -57,12 +56,9 @@ export class LineFulfillmentComponent implements OnChanges {
                 }
             }
         }
-        const all = line.items.reduce(
-            (fulfillments, item) => {
-                return item.fulfillment ? [...fulfillments, item.fulfillment] : fulfillments;
-            },
-            [] as OrderDetail.Fulfillments[],
-        );
+        const all = line.items.reduce((fulfillments, item) => {
+            return item.fulfillment ? [...fulfillments, item.fulfillment] : fulfillments;
+        }, [] as OrderDetail.Fulfillments[]);
 
         return Object.entries(counts).map(([id, count]) => {
             return {

+ 2 - 5
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html

@@ -15,7 +15,7 @@
         <button
             class="btn btn-primary"
             (click)="fulfillOrder()"
-            [disabled]="order.state !== 'PaymentSettled' && order.state !== 'PartiallyFulfilled'"
+            [disabled]="order.state !== 'PaymentSettled' && order.state !== 'PartiallyDelivered'"
         >
             {{ 'order.fulfill-order' | translate }}
         </button>
@@ -273,10 +273,7 @@
                     </div>
                     <div class="card-block">
                         <div class="fulfillment-detail" *ngFor="let fulfillment of order.fulfillments">
-                            <vdr-fulfillment-detail
-                                [fulfillmentId]="fulfillment.id"
-                                [order]="order"
-                            ></vdr-fulfillment-detail>
+                            <vdr-fulfillment-detail [fulfillmentId]="fulfillment.id" [order]="order"></vdr-fulfillment-detail>
                         </div>
                     </div>
                 </div>

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

@@ -47,8 +47,8 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
         'ArrangingPayment',
         'PaymentAuthorized',
         'PaymentSettled',
-        'PartiallyFulfilled',
-        'Fulfilled',
+        'PartiallyDelivered',
+        'Delivered',
         'Cancelled',
     ];
     constructor(
@@ -85,15 +85,15 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                             createdAt: SortOrder.DESC,
                         },
                     })
-                    .mapStream((data) => data.order?.history.items);
+                    .mapStream(data => data.order?.history.items);
             }),
         );
         this.nextStates$ = this.entity$.pipe(
-            map((order) => {
+            map(order => {
                 const isInCustomState = !this.defaultStates.includes(order.state);
                 return isInCustomState
                     ? order.nextStates
-                    : order.nextStates.filter((s) => !this.defaultStates.includes(s));
+                    : order.nextStates.filter(s => !this.defaultStates.includes(s));
             }),
         );
     }
@@ -107,7 +107,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
     }
 
     getLinePromotions(line: OrderDetail.Lines) {
-        return line.adjustments.filter((a) => a.type === AdjustmentType.PROMOTION);
+        return line.adjustments.filter(a => a.type === AdjustmentType.PROMOTION);
     }
 
     getPromotionLink(promotion: OrderDetail.Adjustments): any[] {
@@ -119,7 +119,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
         this.entity$
             .pipe(
                 take(1),
-                switchMap((order) =>
+                switchMap(order =>
                     this.modalService.fromComponent(OrderProcessGraphDialogComponent, {
                         closable: true,
                         locals: {
@@ -132,7 +132,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
     }
 
     transitionToState(state: string) {
-        this.dataService.order.transitionToState(this.id, state).subscribe((val) => {
+        this.dataService.order.transitionToState(this.id, state).subscribe(val => {
             this.notificationService.success(_('order.transitioned-to-state-success'), { state });
             this.fetchHistory.next();
         });
@@ -154,7 +154,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
         promotionAdjustment: OrderDetail.Adjustments,
     ): string | undefined {
         const id = promotionAdjustment.adjustmentSource.split(':')[1];
-        const promotion = order.promotions.find((p) => p.id === id);
+        const promotion = order.promotions.find(p => p.id === id);
         if (promotion) {
             return promotion.couponCode || undefined;
         }
@@ -165,8 +165,8 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
             return [];
         }
         return Object.values(orderAddress)
-            .filter((val) => val !== 'OrderAddress')
-            .filter((line) => !!line);
+            .filter(val => val !== 'OrderAddress')
+            .filter(line => !!line);
     }
 
     settlePayment(payment: OrderDetail.Payments) {
@@ -187,7 +187,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
         this.entity$
             .pipe(
                 take(1),
-                switchMap((order) => {
+                switchMap(order => {
                     return this.modalService.fromComponent(FulfillOrderDialogComponent, {
                         size: 'xl',
                         locals: {
@@ -195,16 +195,16 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                         },
                     });
                 }),
-                switchMap((input) => {
+                switchMap(input => {
                     if (input) {
-                        return this.dataService.order.createFullfillment(input);
+                        return this.dataService.order.createFulfillment(input);
                     } else {
                         return of(undefined);
                     }
                 }),
-                switchMap((result) => this.refetchOrder(result)),
+                switchMap(result => this.refetchOrder(result)),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 if (result) {
                     this.notificationService.success(_('order.create-fulfillment-success'));
                 }
@@ -228,7 +228,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 },
             })
             .pipe(
-                switchMap((transactionId) => {
+                switchMap(transactionId => {
                     if (transactionId) {
                         return this.dataService.order.settleRefund(
                             {
@@ -243,7 +243,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 }),
                 // switchMap(result => this.refetchOrder(result)),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 if (result) {
                     this.notificationService.success(_('order.settle-refund-success'));
                 }
@@ -258,8 +258,8 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 note,
                 isPublic,
             })
-            .pipe(switchMap((result) => this.refetchOrder(result)))
-            .subscribe((result) => {
+            .pipe(switchMap(result => this.refetchOrder(result)))
+            .subscribe(result => {
                 this.notificationService.success(_('common.notify-create-success'), {
                     entity: 'Note',
                 });
@@ -277,7 +277,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 },
             })
             .pipe(
-                switchMap((result) => {
+                switchMap(result => {
                     if (result) {
                         return this.dataService.order.updateOrderNote({
                             noteId: entry.id,
@@ -289,7 +289,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                     }
                 }),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 this.fetchHistory.next();
                 this.notificationService.success(_('common.notify-update-success'), {
                     entity: 'Note',
@@ -307,7 +307,7 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                     { type: 'danger', label: _('common.delete'), returnValue: true },
                 ],
             })
-            .pipe(switchMap((res) => (res ? this.dataService.order.deleteOrderNote(entry.id) : EMPTY)))
+            .pipe(switchMap(res => (res ? this.dataService.order.deleteOrderNote(entry.id) : EMPTY)))
             .subscribe(() => {
                 this.fetchHistory.next();
                 this.notificationService.success(_('common.notify-delete-success'), {
@@ -325,16 +325,16 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 },
             })
             .pipe(
-                switchMap((input) => {
+                switchMap(input => {
                     if (input) {
                         return this.dataService.order.cancelOrder(input);
                     } else {
                         return of(undefined);
                     }
                 }),
-                switchMap((result) => this.refetchOrder(result)),
+                switchMap(result => this.refetchOrder(result)),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 if (result) {
                     this.notificationService.success(_('order.cancelled-order-success'));
                 }
@@ -350,10 +350,10 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                 },
             })
             .pipe(
-                switchMap((input) => {
+                switchMap(input => {
                     if (input) {
                         return this.dataService.order.refundOrder(omit(input, ['cancel'])).pipe(
-                            switchMap((result) => {
+                            switchMap(result => {
                                 if (input.cancel.length) {
                                     return this.dataService.order.cancelOrder({
                                         orderId: this.id,
@@ -369,9 +369,9 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderDetail.Fragme
                         return of(undefined);
                     }
                 }),
-                switchMap((result) => this.refetchOrder(result)),
+                switchMap(result => this.refetchOrder(result)),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 if (result) {
                     this.notificationService.success(_('order.refund-order-success'));
                 }

+ 5 - 5
packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.html

@@ -30,13 +30,13 @@
     >
         <ng-container [ngSwitch]="entry.type">
             <ng-container *ngSwitchCase="type.ORDER_STATE_TRANSITION">
-                <div class="title" *ngIf="entry.data.to === 'Fulfilled'">
+                <div class="title" *ngIf="entry.data.to === 'Delivered'">
                     {{ 'order.history-order-fulfilled' | translate }}
                 </div>
                 <div class="title" *ngIf="entry.data.to === 'Cancelled'">
                     {{ 'order.history-order-cancelled' | translate }}
                 </div>
-                <ng-template [ngIf]="entry.data.to !== 'Cancelled' && entry.data.to !== 'Fulfilled'">
+                <ng-template [ngIf]="entry.data.to !== 'Cancelled' && entry.data.to !== 'Delivered'">
                     {{
                         'order.history-order-transition'
                             | translate: { from: entry.data.from, to: entry.data.to }
@@ -81,12 +81,12 @@
                     </vdr-labeled-data>
                 </vdr-history-entry-detail>
             </ng-container>
-            <ng-container *ngSwitchCase="type.ORDER_FULLFILLMENT">
+            <ng-container *ngSwitchCase="type.ORDER_FULFILLMENT">
                 <div class="title">
                     {{ 'order.history-fulfillment-created' | translate }}
                 </div>
-                {{ 'order.tracking-code' | translate }}: {{ getFullfillment(entry)?.trackingCode }}
-                <vdr-history-entry-detail *ngIf="getFullfillment(entry) as fulfillment">
+                {{ 'order.tracking-code' | translate }}: {{ getFulfillment(entry)?.trackingCode }}
+                <vdr-history-entry-detail *ngIf="getFulfillment(entry) as fulfillment">
                     <vdr-fulfillment-detail
                         [fulfillmentId]="fulfillment.id"
                         [order]="order"

+ 9 - 9
packages/admin-ui/src/lib/order/src/components/order-history/order-history.component.ts

@@ -26,7 +26,7 @@ export class OrderHistoryComponent {
 
     getDisplayType(entry: GetOrderHistory.Items): TimelineDisplayType {
         if (entry.type === HistoryEntryType.ORDER_STATE_TRANSITION) {
-            if (entry.data.to === 'Fulfilled') {
+            if (entry.data.to === 'Delivered') {
                 return 'success';
             }
             if (entry.data.to === 'Cancelled') {
@@ -49,7 +49,7 @@ export class OrderHistoryComponent {
 
     getTimelineIcon(entry: GetOrderHistory.Items) {
         if (entry.type === HistoryEntryType.ORDER_STATE_TRANSITION) {
-            if (entry.data.to === 'Fulfilled') {
+            if (entry.data.to === 'Delivered') {
                 return ['success-standard', 'is-solid'];
             }
             if (entry.data.to === 'Cancelled') {
@@ -62,7 +62,7 @@ export class OrderHistoryComponent {
         if (entry.type === HistoryEntryType.ORDER_NOTE) {
             return 'note';
         }
-        if (entry.type === HistoryEntryType.ORDER_FULLFILLMENT) {
+        if (entry.type === HistoryEntryType.ORDER_FULFILLMENT) {
             return 'truck';
         }
     }
@@ -71,14 +71,14 @@ export class OrderHistoryComponent {
         switch (entry.type) {
             case HistoryEntryType.ORDER_STATE_TRANSITION: {
                 return (
-                    entry.data.to === 'Fulfilled' ||
+                    entry.data.to === 'Delivered' ||
                     entry.data.to === 'Cancelled' ||
                     entry.data.to === 'Settled'
                 );
             }
             case HistoryEntryType.ORDER_PAYMENT_TRANSITION:
                 return entry.data.to === 'Settled';
-            case HistoryEntryType.ORDER_FULLFILLMENT:
+            case HistoryEntryType.ORDER_FULFILLMENT:
             case HistoryEntryType.ORDER_NOTE:
                 return true;
             default:
@@ -86,15 +86,15 @@ export class OrderHistoryComponent {
         }
     }
 
-    getFullfillment(entry: GetOrderHistory.Items): OrderDetail.Fulfillments | undefined {
-        if (entry.type === HistoryEntryType.ORDER_FULLFILLMENT && this.order.fulfillments) {
-            return this.order.fulfillments.find((f) => f.id === entry.data.fulfillmentId);
+    getFulfillment(entry: GetOrderHistory.Items): OrderDetail.Fulfillments | undefined {
+        if (entry.type === HistoryEntryType.ORDER_FULFILLMENT && this.order.fulfillments) {
+            return this.order.fulfillments.find(f => f.id === entry.data.fulfillmentId);
         }
     }
 
     getPayment(entry: GetOrderHistory.Items): OrderDetail.Payments | undefined {
         if (entry.type === HistoryEntryType.ORDER_PAYMENT_TRANSITION && this.order.payments) {
-            return this.order.payments.find((p) => p.id === entry.data.paymentId);
+            return this.order.payments.find(p => p.id === entry.data.paymentId);
         }
     }
 

+ 3 - 3
packages/admin-ui/src/lib/order/src/components/order-list/order-list.component.html

@@ -7,10 +7,10 @@
                 <option value="ArrangingPayment">{{ 'order.state-arranging-payment' | translate }}</option>
                 <option value="PaymentAuthorized">{{ 'order.state-payment-authorized' | translate }}</option>
                 <option value="PaymentSettled">{{ 'order.state-payment-settled' | translate }}</option>
-                <option value="PartiallyFulfilled">
-                    {{ 'order.state-partially-fulfilled' | translate }}
+                <option value="PartiallyDelivered">
+                    {{ 'order.state-partially-delivered' | translate }}
                 </option>
-                <option value="Fulfilled">{{ 'order.state-fulfilled' | translate }}</option>
+                <option value="Delivered">{{ 'order.state-delivered' | translate }}</option>
                 <option value="Cancelled">{{ 'order.state-cancelled' | translate }}</option>
             </select>
             <input

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

@@ -597,8 +597,8 @@
     "state-all-orders": "Alle Bestellungen",
     "state-arranging-payment": "Zahlung einrichten",
     "state-cancelled": "Storniert",
-    "state-fulfilled": "Ausgeführt",
-    "state-partially-fulfilled": "Teilweise ausgeführt",
+    "state-delivered": "Ausgeführt",
+    "state-partially-delivered": "Teilweise ausgeführt",
     "state-payment-authorized": "Zahlung autorisiert",
     "state-payment-settled": "Bezahlt",
     "sub-total": "Zwischensumme",
@@ -691,4 +691,4 @@
     "job-result": "Job-Ergebnis",
     "job-state": "Job-Status"
   }
-}
+}

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

@@ -597,8 +597,8 @@
     "state-all-orders": "All orders",
     "state-arranging-payment": "Arranging payment",
     "state-cancelled": "Cancelled",
-    "state-fulfilled": "Fulfilled",
-    "state-partially-fulfilled": "Partially fulfilled",
+    "state-delivered": "Delivered",
+    "state-partially-delivered": "Partially fulfilled",
     "state-payment-authorized": "Payment authorized",
     "state-payment-settled": "Payment settled",
     "sub-total": "Sub total",
@@ -691,4 +691,4 @@
     "job-result": "Job result",
     "job-state": "Job state"
   }
-}
+}

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

@@ -597,8 +597,8 @@
     "state-all-orders": "",
     "state-arranging-payment": "",
     "state-cancelled": "",
-    "state-fulfilled": "",
-    "state-partially-fulfilled": "",
+    "state-delivered": "",
+    "state-partially-delivered": "",
     "state-payment-authorized": "",
     "state-payment-settled": "",
     "sub-total": "Sub total",
@@ -691,4 +691,4 @@
     "job-result": "Resultado",
     "job-state": "Estado"
   }
-}
+}

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

@@ -597,8 +597,8 @@
     "state-all-orders": "Wszystkie zamówienia",
     "state-arranging-payment": "Oczekiwanie na płatność",
     "state-cancelled": "Anulowano",
-    "state-fulfilled": "Zrealizowano",
-    "state-partially-fulfilled": "Częściowo zrealizowano",
+    "state-delivered": "Zrealizowano",
+    "state-partially-delivered": "Częściowo zrealizowano",
     "state-payment-authorized": "Płatność zaakceptowana",
     "state-payment-settled": "Płatność rozliczona",
     "sub-total": "Sub total",
@@ -691,4 +691,4 @@
     "job-result": "Rezultat zlecenia",
     "job-state": "Status zlecenia"
   }
-}
+}

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

@@ -597,8 +597,8 @@
     "state-all-orders": "Todos os pedidos",
     "state-arranging-payment": "Organização de pagamento",
     "state-cancelled": "Cancelado",
-    "state-fulfilled": "Realizado",
-    "state-partially-fulfilled": "Parcialmente realizado",
+    "state-delivered": "Realizado",
+    "state-partially-delivered": "Parcialmente realizado",
     "state-payment-authorized": "Pagamento autorizado",
     "state-payment-settled": "Pagamento liquidado",
     "sub-total": "Subtotal",
@@ -691,4 +691,4 @@
     "job-result": "Resultado do trabalho",
     "job-state": "Estado do trabalho"
   }
-}
+}

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

@@ -597,8 +597,8 @@
     "state-all-orders": "所有订单",
     "state-arranging-payment": "正在付款",
     "state-cancelled": "已取消",
-    "state-fulfilled": "已完成",
-    "state-partially-fulfilled": "部分配货",
+    "state-delivered": "已完成",
+    "state-partially-delivered": "部分配货",
     "state-payment-authorized": "已授权支付",
     "state-payment-settled": "已结算",
     "sub-total": "小计金额",
@@ -691,4 +691,4 @@
     "job-result": "",
     "job-state": ""
   }
-}
+}

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

@@ -597,8 +597,8 @@
     "state-all-orders": "所有訂單",
     "state-arranging-payment": "正在付款",
     "state-cancelled": "已取消",
-    "state-fulfilled": "已完成",
-    "state-partially-fulfilled": "部分配貨",
+    "state-delivered": "已完成",
+    "state-partially-delivered": "部分配貨",
     "state-payment-authorized": "已授權支付",
     "state-payment-settled": "已結算",
     "sub-total": "小計金額",
@@ -691,4 +691,4 @@
     "job-result": "",
     "job-state": ""
   }
-}
+}

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

@@ -1234,10 +1234,12 @@ export type FloatCustomFieldConfig = CustomField & {
 
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
+    nextStates: Array<Scalars['String']>;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     orderItems: Array<OrderItem>;
+    state: Scalars['String'];
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1313,9 +1315,10 @@ export enum HistoryEntryType {
     CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-    ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+    ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
     ORDER_CANCELLATION = 'ORDER_CANCELLATION',
     ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+    ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
     ORDER_NOTE = 'ORDER_NOTE',
     ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
     ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED',
@@ -1869,6 +1872,7 @@ export type Mutation = {
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
     transitionOrderToState?: Maybe<Order>;
+    transitionFulfillmentToState: Fulfillment;
     setOrderCustomFields?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
@@ -2162,6 +2166,11 @@ export type MutationTransitionOrderToStateArgs = {
     state: Scalars['String'];
 };
 
+export type MutationTransitionFulfillmentToStateArgs = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
 export type MutationSetOrderCustomFieldsArgs = {
     input: UpdateOrderInput;
 };

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

@@ -884,6 +884,7 @@ export type Fulfillment = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     orderItems: Array<OrderItem>;
+    state: Scalars['String'];
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -953,9 +954,10 @@ export enum HistoryEntryType {
     CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-    ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+    ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
     ORDER_CANCELLATION = 'ORDER_CANCELLATION',
     ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+    ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
     ORDER_NOTE = 'ORDER_NOTE',
     ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
     ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED',

+ 11 - 1
packages/common/src/generated-types.ts

@@ -1232,10 +1232,12 @@ export type FloatCustomFieldConfig = CustomField & {
 
 export type Fulfillment = Node & {
    __typename?: 'Fulfillment';
+  nextStates: Array<Scalars['String']>;
   id: Scalars['ID'];
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
   orderItems: Array<OrderItem>;
+  state: Scalars['String'];
   method: Scalars['String'];
   trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1311,9 +1313,10 @@ export enum HistoryEntryType {
   CUSTOMER_NOTE = 'CUSTOMER_NOTE',
   ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
   ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-  ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+  ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
   ORDER_CANCELLATION = 'ORDER_CANCELLATION',
   ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+  ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
   ORDER_NOTE = 'ORDER_NOTE',
   ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
   ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED'
@@ -1868,6 +1871,7 @@ export type Mutation = {
   updateOrderNote: HistoryEntry;
   deleteOrderNote: DeletionResponse;
   transitionOrderToState?: Maybe<Order>;
+  transitionFulfillmentToState: Fulfillment;
   setOrderCustomFields?: Maybe<Order>;
   /** Update an existing PaymentMethod */
   updatePaymentMethod: PaymentMethod;
@@ -2214,6 +2218,12 @@ export type MutationTransitionOrderToStateArgs = {
 };
 
 
+export type MutationTransitionFulfillmentToStateArgs = {
+  id: Scalars['ID'];
+  state: Scalars['String'];
+};
+
+
 export type MutationSetOrderCustomFieldsArgs = {
   input: UpdateOrderInput;
 };

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

@@ -6,7 +6,7 @@ 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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { ASSET_FRAGMENT } from './graphql/fragments';
 import {

+ 487 - 0
packages/core/e2e/customer-channel.e2e-spec.ts

@@ -0,0 +1,487 @@
+/* tslint:disable:no-non-null-assertion */
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { CUSTOMER_FRAGMENT } from './graphql/fragments';
+import {
+    AddCustomersToGroup,
+    CreateAddress,
+    CreateChannel,
+    CreateCustomer,
+    CreateCustomerGroup,
+    CurrencyCode,
+    DeleteCustomer,
+    DeleteCustomerAddress,
+    GetCustomerGroup,
+    GetCustomerList,
+    LanguageCode,
+    Me,
+    RemoveCustomersFromGroup,
+    UpdateAddress,
+    UpdateCustomer,
+} from './graphql/generated-e2e-admin-types';
+import { Register } from './graphql/generated-e2e-shop-types';
+import {
+    CREATE_CHANNEL,
+    CREATE_CUSTOMER_GROUP,
+    CUSTOMER_GROUP_FRAGMENT,
+    GET_CUSTOMER_LIST,
+    ME,
+    REMOVE_CUSTOMERS_FROM_GROUP,
+} from './graphql/shared-definitions';
+import { DELETE_ADDRESS, REGISTER_ACCOUNT } from './graphql/shop-definitions';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+
+describe('ChannelAware Customers', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
+    const SECOND_CHANNEL_TOKEN = 'second_channel_token';
+    let firstCustomer: GetCustomerList.Items;
+    let secondCustomer: GetCustomerList.Items;
+    let thirdCustomer: GetCustomerList.Items;
+    const numberOfCustomers = 3;
+    let customerGroupId: string;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: numberOfCustomers,
+        });
+        await adminClient.asSuperAdmin();
+
+        const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+            GET_CUSTOMER_LIST,
+            {
+                options: { take: numberOfCustomers },
+            },
+        );
+        firstCustomer = customers.items[0];
+        secondCustomer = customers.items[1];
+        thirdCustomer = customers.items[2];
+
+        await adminClient.query<CreateChannel.Mutation, CreateChannel.Variables>(CREATE_CHANNEL, {
+            input: {
+                code: 'second-channel',
+                token: SECOND_CHANNEL_TOKEN,
+                defaultLanguageCode: LanguageCode.en,
+                currencyCode: CurrencyCode.GBP,
+                pricesIncludeTax: true,
+                defaultShippingZoneId: 'T_1',
+                defaultTaxZoneId: 'T_1',
+            },
+        });
+
+        const { createCustomerGroup } = await adminClient.query<
+            CreateCustomerGroup.Mutation,
+            CreateCustomerGroup.Variables
+        >(CREATE_CUSTOMER_GROUP, {
+            input: {
+                name: 'TestGroup',
+            },
+        });
+        customerGroupId = createCustomerGroup.id;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('Address manipulation', () => {
+        it(
+            'throws when updating address from customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<UpdateAddress.Mutation, UpdateAddress.Variables>(UPDATE_ADDRESS, {
+                    input: {
+                        id: 'T_1',
+                        streetLine1: 'Dummy street',
+                    },
+                });
+            }, `No Address with the id '1' could be found`),
+        );
+
+        it(
+            'throws when creating address for customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<CreateAddress.Mutation, CreateAddress.Variables>(CREATE_ADDRESS, {
+                    id: firstCustomer.id,
+                    input: {
+                        streetLine1: 'Dummy street',
+                        countryCode: 'BE',
+                    },
+                });
+            }, `No Customer with the id '1' could be found`),
+        );
+
+        it(
+            'throws when deleting address from customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<DeleteCustomerAddress.Mutation, DeleteCustomerAddress.Variables>(
+                    DELETE_ADDRESS,
+                    {
+                        id: 'T_1',
+                    },
+                );
+            }, `No Address with the id '1' could be found`),
+        );
+    });
+
+    describe('Customer manipulation', () => {
+        it(
+            'throws when deleting customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<DeleteCustomer.Mutation, DeleteCustomer.Variables>(DELETE_CUSTOMER, {
+                    id: firstCustomer.id,
+                });
+            }, `No Customer with the id '1' could be found`),
+        );
+
+        it(
+            'throws when updating customer from other channel',
+            assertThrowsWithMessage(async () => {
+                adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+                await adminClient.query<UpdateCustomer.Mutation, UpdateCustomer.Variables>(UPDATE_CUSTOMER, {
+                    input: {
+                        id: firstCustomer.id,
+                        firstName: 'John',
+                        lastName: 'Doe',
+                    },
+                });
+            }, `No Customer with the id '1' could be found`),
+        );
+
+        it('creates customers on current and default channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await adminClient.query<CreateCustomer.Mutation, CreateCustomer.Variables>(CREATE_CUSTOMER, {
+                input: {
+                    firstName: 'John',
+                    lastName: 'Doe',
+                    emailAddress: 'john.doe@test.com',
+                },
+            });
+            const customersSecondChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const customersDefaultChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+
+            expect(customersSecondChannel.customers.totalItems).toBe(1);
+            expect(customersDefaultChannel.customers.totalItems).toBe(numberOfCustomers + 1);
+        });
+
+        it('only shows customers from current channel', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+            );
+            expect(customers.totalItems).toBe(1);
+        });
+
+        it('shows all customers on default channel', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+            );
+            expect(customers.totalItems).toBe(numberOfCustomers + 1);
+        });
+
+        it('brings customer to current channel when creating with existing emailAddress', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            let customersDefaultChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            let customersSecondChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            expect(customersDefaultChannel.customers.items.map(customer => customer.emailAddress)).toContain(
+                firstCustomer.emailAddress,
+            );
+            expect(
+                customersSecondChannel.customers.items.map(customer => customer.emailAddress),
+            ).not.toContain(firstCustomer.emailAddress);
+
+            await adminClient.query<CreateCustomer.Mutation, CreateCustomer.Variables>(CREATE_CUSTOMER, {
+                input: {
+                    firstName: firstCustomer.firstName + '_new',
+                    lastName: firstCustomer.lastName + '_new',
+                    emailAddress: firstCustomer.emailAddress,
+                },
+            });
+
+            customersSecondChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            customersDefaultChannel = await adminClient.query<
+                GetCustomerList.Query,
+                GetCustomerList.Variables
+            >(GET_CUSTOMER_LIST);
+            const firstCustomerOnNewChannel = customersSecondChannel.customers.items.find(
+                customer => customer.emailAddress === firstCustomer.emailAddress,
+            );
+            const firstCustomerOnDefaultChannel = customersDefaultChannel.customers.items.find(
+                customer => customer.emailAddress === firstCustomer.emailAddress,
+            );
+
+            expect(firstCustomerOnNewChannel).not.toBeNull();
+            expect(firstCustomerOnNewChannel?.emailAddress).toBe(firstCustomer.emailAddress);
+            expect(firstCustomerOnNewChannel?.firstName).toBe(firstCustomer.firstName + '_new');
+            expect(firstCustomerOnNewChannel?.lastName).toBe(firstCustomer.lastName + '_new');
+
+            expect(firstCustomerOnDefaultChannel).not.toBeNull();
+            expect(firstCustomerOnDefaultChannel?.emailAddress).toBe(firstCustomer.emailAddress);
+            expect(firstCustomerOnDefaultChannel?.firstName).toBe(firstCustomer.firstName + '_new');
+            expect(firstCustomerOnDefaultChannel?.lastName).toBe(firstCustomer.lastName + '_new');
+        });
+    });
+
+    describe('Shop API', () => {
+        it('assigns authenticated customers to the channels they visit', async () => {
+            shopClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await shopClient.asUserWithCredentials(secondCustomer.emailAddress, 'test');
+            await shopClient.query<Me.Query>(ME);
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+            );
+            expect(customers.totalItems).toBe(3);
+            expect(customers.items.map(customer => customer.emailAddress)).toContain(
+                secondCustomer.emailAddress,
+            );
+        });
+
+        it('assigns newly registered customers to channel', async () => {
+            shopClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await shopClient.asAnonymousUser();
+            await shopClient.query<Register.Mutation, Register.Variables>(REGISTER_ACCOUNT, {
+                input: {
+                    emailAddress: 'john.doe.2@test.com',
+                },
+            });
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { customers } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+                GET_CUSTOMER_LIST,
+            );
+            expect(customers.totalItems).toBe(4);
+            expect(customers.items.map(customer => customer.emailAddress)).toContain('john.doe.2@test.com');
+        });
+    });
+
+    describe('Customergroup manipulation', () => {
+        it('does not add a customer from another channel to customerGroup', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await adminClient.query<AddCustomersToGroup.Mutation, AddCustomersToGroup.Variables>(
+                ADD_CUSTOMERS_TO_GROUP,
+                {
+                    groupId: customerGroupId,
+                    customerIds: [thirdCustomer.id],
+                },
+            );
+
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { customerGroup } = await adminClient.query<
+                GetCustomerGroup.Query,
+                GetCustomerGroup.Variables
+            >(GET_CUSTOMER_GROUP, {
+                id: customerGroupId,
+            });
+            expect(customerGroup!.customers.totalItems).toBe(0);
+        });
+
+        it('only shows customers from current channel in customerGroup', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            await adminClient.query<AddCustomersToGroup.Mutation, AddCustomersToGroup.Variables>(
+                ADD_CUSTOMERS_TO_GROUP,
+                {
+                    groupId: customerGroupId,
+                    customerIds: [secondCustomer.id, thirdCustomer.id],
+                },
+            );
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { customerGroup } = await adminClient.query<
+                GetCustomerGroup.Query,
+                GetCustomerGroup.Variables
+            >(GET_CUSTOMER_GROUP, {
+                id: customerGroupId,
+            });
+            expect(customerGroup!.customers.totalItems).toBe(1);
+            expect(customerGroup!.customers.items.map(customer => customer.id)).toContain(secondCustomer.id);
+        });
+
+        it('throws when deleting customer from other channel from customerGroup', async () => {
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            await adminClient.query<RemoveCustomersFromGroup.Mutation, RemoveCustomersFromGroup.Variables>(
+                REMOVE_CUSTOMERS_FROM_GROUP,
+                {
+                    groupId: customerGroupId,
+                    customerIds: [thirdCustomer.id],
+                },
+            );
+        });
+    });
+});
+
+export const CREATE_ADDRESS = gql`
+    mutation CreateAddress($id: ID!, $input: CreateAddressInput!) {
+        createCustomerAddress(customerId: $id, input: $input) {
+            id
+            fullName
+            company
+            streetLine1
+            streetLine2
+            city
+            province
+            postalCode
+            country {
+                code
+                name
+            }
+            phoneNumber
+            defaultShippingAddress
+            defaultBillingAddress
+        }
+    }
+`;
+
+export const UPDATE_ADDRESS = gql`
+    mutation UpdateAddress($input: UpdateAddressInput!) {
+        updateCustomerAddress(input: $input) {
+            id
+            defaultShippingAddress
+            defaultBillingAddress
+            country {
+                code
+                name
+            }
+        }
+    }
+`;
+
+export const CREATE_CUSTOMER = gql`
+    mutation CreateCustomer($input: CreateCustomerInput!, $password: String) {
+        createCustomer(input: $input, password: $password) {
+            ...Customer
+        }
+    }
+    ${CUSTOMER_FRAGMENT}
+`;
+
+export const UPDATE_CUSTOMER = gql`
+    mutation UpdateCustomer($input: UpdateCustomerInput!) {
+        updateCustomer(input: $input) {
+            ...Customer
+        }
+    }
+    ${CUSTOMER_FRAGMENT}
+`;
+
+export const DELETE_CUSTOMER = gql`
+    mutation DeleteCustomer($id: ID!) {
+        deleteCustomer(id: $id) {
+            result
+        }
+    }
+`;
+
+export const UPDATE_CUSTOMER_NOTE = gql`
+    mutation UpdateCustomerNote($input: UpdateCustomerNoteInput!) {
+        updateCustomerNote(input: $input) {
+            id
+            data
+            isPublic
+        }
+    }
+`;
+
+export const DELETE_CUSTOMER_NOTE = gql`
+    mutation DeleteCustomerNote($id: ID!) {
+        deleteCustomerNote(id: $id) {
+            result
+            message
+        }
+    }
+`;
+
+export const UPDATE_CUSTOMER_GROUP = gql`
+    mutation UpdateCustomerGroup($input: UpdateCustomerGroupInput!) {
+        updateCustomerGroup(input: $input) {
+            ...CustomerGroup
+        }
+    }
+    ${CUSTOMER_GROUP_FRAGMENT}
+`;
+
+export const DELETE_CUSTOMER_GROUP = gql`
+    mutation DeleteCustomerGroup($id: ID!) {
+        deleteCustomerGroup(id: $id) {
+            result
+            message
+        }
+    }
+`;
+
+export const GET_CUSTOMER_GROUPS = gql`
+    query GetCustomerGroups($options: CustomerGroupListOptions) {
+        customerGroups(options: $options) {
+            items {
+                id
+                name
+            }
+            totalItems
+        }
+    }
+`;
+
+export const GET_CUSTOMER_GROUP = gql`
+    query GetCustomerGroup($id: ID!, $options: CustomerListOptions) {
+        customerGroup(id: $id) {
+            id
+            name
+            customers(options: $options) {
+                items {
+                    id
+                }
+                totalItems
+            }
+        }
+    }
+`;
+
+export const ADD_CUSTOMERS_TO_GROUP = gql`
+    mutation AddCustomersToGroup($groupId: ID!, $customerIds: [ID!]!) {
+        addCustomersToGroup(customerGroupId: $groupId, customerIds: $customerIds) {
+            ...CustomerGroup
+        }
+    }
+    ${CUSTOMER_GROUP_FRAGMENT}
+`;
+
+export const GET_CUSTOMER_WITH_GROUPS = gql`
+    query GetCustomerWithGroups($id: ID!) {
+        customer(id: $id) {
+            id
+            groups {
+                id
+                name
+            }
+        }
+    }
+`;

+ 1 - 1
packages/core/e2e/customer-group.e2e-spec.ts

@@ -4,7 +4,7 @@ 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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import {
     AddCustomersToGroup,

+ 10 - 10
packages/core/e2e/customer.e2e-spec.ts

@@ -52,7 +52,7 @@ let sendEmailFn: jest.Mock;
 class TestEmailPlugin implements OnModuleInit {
     constructor(private eventBus: EventBus) {}
     onModuleInit() {
-        this.eventBus.ofType(AccountRegistrationEvent).subscribe((event) => {
+        this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
             sendEmailFn(event);
         });
     }
@@ -170,7 +170,7 @@ describe('Customer resolver', () => {
             });
 
             expect(result.customer!.addresses!.length).toBe(2);
-            firstCustomerAddressIds = result.customer!.addresses!.map((a) => a.id).sort();
+            firstCustomerAddressIds = result.customer!.addresses!.map(a => a.id).sort();
         });
 
         it('updateCustomerAddress updates the country', async () => {
@@ -209,7 +209,7 @@ describe('Customer resolver', () => {
                 id: firstCustomer.id,
             });
             const otherAddress = result2.customer!.addresses!.filter(
-                (a) => a.id !== firstCustomerAddressIds[1],
+                a => a.id !== firstCustomerAddressIds[1],
             )[0]!;
             expect(otherAddress.defaultShippingAddress).toBe(false);
             expect(otherAddress.defaultBillingAddress).toBe(false);
@@ -233,7 +233,7 @@ describe('Customer resolver', () => {
                 id: firstCustomer.id,
             });
             const otherAddress2 = result4.customer!.addresses!.filter(
-                (a) => a.id !== firstCustomerAddressIds[0],
+                a => a.id !== firstCustomerAddressIds[0],
             )[0]!;
             expect(otherAddress2.defaultShippingAddress).toBe(false);
             expect(otherAddress2.defaultBillingAddress).toBe(false);
@@ -336,10 +336,10 @@ describe('Customer resolver', () => {
             );
             expect(customer!.addresses!.length).toBe(2);
             const defaultAddress = customer!.addresses!.filter(
-                (a) => a.defaultBillingAddress && a.defaultShippingAddress,
+                a => a.defaultBillingAddress && a.defaultShippingAddress,
             );
             const otherAddress = customer!.addresses!.filter(
-                (a) => !a.defaultBillingAddress && !a.defaultShippingAddress,
+                a => !a.defaultBillingAddress && !a.defaultShippingAddress,
             );
             expect(defaultAddress.length).toBe(1);
             expect(otherAddress.length).toBe(1);
@@ -448,7 +448,7 @@ describe('Customer resolver', () => {
                 GET_CUSTOMER_LIST,
             );
 
-            expect(result.customers.items.map((c) => c.id).includes(thirdCustomer.id)).toBe(false);
+            expect(result.customers.items.map(c => c.id).includes(thirdCustomer.id)).toBe(false);
         });
 
         it(
@@ -593,7 +593,7 @@ const GET_CUSTOMER_WITH_USER = gql`
     }
 `;
 
-const CREATE_ADDRESS = gql`
+export const CREATE_ADDRESS = gql`
     mutation CreateAddress($id: ID!, $input: CreateAddressInput!) {
         createCustomerAddress(customerId: $id, input: $input) {
             id
@@ -615,7 +615,7 @@ const CREATE_ADDRESS = gql`
     }
 `;
 
-const UPDATE_ADDRESS = gql`
+export const UPDATE_ADDRESS = gql`
     mutation UpdateAddress($input: UpdateAddressInput!) {
         updateCustomerAddress(input: $input) {
             id
@@ -660,7 +660,7 @@ export const UPDATE_CUSTOMER = gql`
     ${CUSTOMER_FRAGMENT}
 `;
 
-const DELETE_CUSTOMER = gql`
+export const DELETE_CUSTOMER = gql`
     mutation DeleteCustomer($id: ID!) {
         deleteCustomer(id: $id) {
             result

+ 242 - 0
packages/core/e2e/fulfillment-process.e2e-spec.ts

@@ -0,0 +1,242 @@
+/* tslint:disable:no-non-null-assertion */
+import { CustomFulfillmentProcess, FulfillmentState, mergeConfig } 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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
+import {
+    CreateFulfillment,
+    GetCustomerList,
+    GetOrderFulfillments,
+    TransitFulfillment,
+} from './graphql/generated-e2e-admin-types';
+import { AddItemToOrder } from './graphql/generated-e2e-shop-types';
+import {
+    CREATE_FULFILLMENT,
+    GET_CUSTOMER_LIST,
+    GET_ORDER_FULFILLMENTS,
+    TRANSIT_FULFILLMENT,
+} from './graphql/shared-definitions';
+import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
+import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
+
+const initSpy = jest.fn();
+const transitionStartSpy = jest.fn();
+const transitionEndSpy = jest.fn();
+const transitionEndSpy2 = jest.fn();
+const transitionErrorSpy = jest.fn();
+
+describe('Fulfillment process', () => {
+    const VALIDATION_ERROR_MESSAGE = 'Fulfillment must have a tracking code';
+    const customOrderProcess: CustomFulfillmentProcess<'AwaitingPickup'> = {
+        init(injector) {
+            initSpy(injector.getConnection().name);
+        },
+        transitions: {
+            Pending: {
+                to: ['AwaitingPickup'],
+                mergeStrategy: 'replace',
+            },
+            AwaitingPickup: {
+                to: ['Shipped'],
+            },
+        },
+        onTransitionStart(fromState, toState, data) {
+            transitionStartSpy(fromState, toState, data);
+            if (fromState === 'AwaitingPickup' && toState === 'Shipped') {
+                if (!data.fulfillment.trackingCode) {
+                    return VALIDATION_ERROR_MESSAGE;
+                }
+            }
+        },
+        onTransitionEnd(fromState, toState, data) {
+            transitionEndSpy(fromState, toState, data);
+        },
+        onTransitionError(fromState, toState, message) {
+            transitionErrorSpy(fromState, toState, message);
+        },
+    };
+
+    const customOrderProcess2: CustomFulfillmentProcess<'AwaitingPickup'> = {
+        transitions: {
+            AwaitingPickup: {
+                to: ['Cancelled'],
+            },
+        },
+        onTransitionEnd(fromState, toState, data) {
+            transitionEndSpy2(fromState, toState, data);
+        },
+    };
+
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            shippingOptions: {
+                ...testConfig.shippingOptions,
+                customFulfillmentProcess: [customOrderProcess as any, customOrderProcess2 as any],
+            },
+            paymentOptions: {
+                paymentMethodHandlers: [testSuccessfulPaymentMethod],
+            },
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+
+        // Create a couple of orders to be queried
+        const result = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
+            GET_CUSTOMER_LIST,
+            {
+                options: {
+                    take: 3,
+                },
+            },
+        );
+        const customers = result.customers.items;
+
+        /**
+         * Creates a Orders to test Fulfillment Process
+         */
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        // Add Items
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_1',
+            quantity: 1,
+        });
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_2',
+            quantity: 1,
+        });
+        // Transit to payment
+        await proceedToArrangingPayment(shopClient);
+        await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+
+        // Add a fulfillment without tracking code
+        await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(CREATE_FULFILLMENT, {
+            input: {
+                lines: [{ orderLineId: 'T_1', quantity: 1 }],
+                method: 'Test1',
+            },
+        });
+
+        // Add a fulfillment with tracking code
+        await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(CREATE_FULFILLMENT, {
+            input: {
+                lines: [{ orderLineId: 'T_2', quantity: 1 }],
+                method: 'Test1',
+                trackingCode: '222',
+            },
+        });
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('CustomFulfillmentProcess', () => {
+        it('replaced transition target', async () => {
+            const { order } = await adminClient.query<
+                GetOrderFulfillments.Query,
+                GetOrderFulfillments.Variables
+            >(GET_ORDER_FULFILLMENTS, {
+                id: 'T_1',
+            });
+            const [fulfillment] = order?.fulfillments || [];
+            expect(fulfillment.nextStates).toEqual(['AwaitingPickup']);
+        });
+
+        it('custom onTransitionStart handler returning error message', async () => {
+            // First transit to AwaitingPickup
+            await adminClient.query<TransitFulfillment.Mutation, TransitFulfillment.Variables>(
+                TRANSIT_FULFILLMENT,
+                {
+                    id: 'T_1',
+                    state: 'AwaitingPickup',
+                },
+            );
+
+            transitionStartSpy.mockClear();
+            transitionErrorSpy.mockClear();
+            transitionEndSpy.mockClear();
+
+            try {
+                await adminClient.query<TransitFulfillment.Mutation, TransitFulfillment.Variables>(
+                    TRANSIT_FULFILLMENT,
+                    {
+                        id: 'T_1',
+                        state: 'Shipped',
+                    },
+                );
+                fail('Should have thrown');
+            } catch (e) {
+                expect(e.message).toContain(VALIDATION_ERROR_MESSAGE);
+            }
+
+            expect(transitionStartSpy).toHaveBeenCalledTimes(1);
+            expect(transitionErrorSpy).toHaveBeenCalledTimes(1);
+            expect(transitionEndSpy).not.toHaveBeenCalled();
+            expect(transitionErrorSpy.mock.calls[0]).toEqual([
+                'AwaitingPickup',
+                'Shipped',
+                VALIDATION_ERROR_MESSAGE,
+            ]);
+        });
+
+        it('custom onTransitionStart handler allows transition', async () => {
+            transitionEndSpy.mockClear();
+
+            // First transit to AwaitingPickup
+            await adminClient.query<TransitFulfillment.Mutation, TransitFulfillment.Variables>(
+                TRANSIT_FULFILLMENT,
+                {
+                    id: 'T_2',
+                    state: 'AwaitingPickup',
+                },
+            );
+
+            transitionEndSpy.mockClear();
+
+            const { transitionFulfillmentToState } = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_2',
+                state: 'Shipped',
+            });
+
+            expect(transitionEndSpy).toHaveBeenCalledTimes(1);
+            expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['AwaitingPickup', 'Shipped']);
+            expect(transitionFulfillmentToState?.state).toBe('Shipped');
+        });
+
+        it('composes multiple CustomFulfillmentProcesses', async () => {
+            const { order } = await adminClient.query<
+                GetOrderFulfillments.Query,
+                GetOrderFulfillments.Variables
+            >(GET_ORDER_FULFILLMENTS, {
+                id: 'T_1',
+            });
+            const [fulfillment] = order?.fulfillments || [];
+            expect(fulfillment.nextStates).toEqual(['Shipped', 'Cancelled']);
+        });
+    });
+});
+
+export const ADMIN_TRANSITION_TO_STATE = gql`
+    mutation AdminTransition($id: ID!, $state: String!) {
+        transitionOrderToState(id: $id, state: $state) {
+            id
+            state
+            nextStates
+        }
+    }
+`;

+ 317 - 45
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1234,10 +1234,12 @@ export type FloatCustomFieldConfig = CustomField & {
 
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
+    nextStates: Array<Scalars['String']>;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     orderItems: Array<OrderItem>;
+    state: Scalars['String'];
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1313,9 +1315,10 @@ export enum HistoryEntryType {
     CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-    ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+    ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
     ORDER_CANCELLATION = 'ORDER_CANCELLATION',
     ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+    ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
     ORDER_NOTE = 'ORDER_NOTE',
     ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
     ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED',
@@ -1869,6 +1872,7 @@ export type Mutation = {
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
     transitionOrderToState?: Maybe<Order>;
+    transitionFulfillmentToState: Fulfillment;
     setOrderCustomFields?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
@@ -2162,6 +2166,11 @@ export type MutationTransitionOrderToStateArgs = {
     state: Scalars['String'];
 };
 
+export type MutationTransitionFulfillmentToStateArgs = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
 export type MutationSetOrderCustomFieldsArgs = {
     input: UpdateOrderInput;
 };
@@ -4020,6 +4029,142 @@ export type CreateCountryMutation = { __typename?: 'Mutation' } & {
     createCountry: { __typename?: 'Country' } & CountryFragment;
 };
 
+export type CreateAddressMutationVariables = {
+    id: Scalars['ID'];
+    input: CreateAddressInput;
+};
+
+export type CreateAddressMutation = { __typename?: 'Mutation' } & {
+    createCustomerAddress: { __typename?: 'Address' } & Pick<
+        Address,
+        | 'id'
+        | 'fullName'
+        | 'company'
+        | 'streetLine1'
+        | 'streetLine2'
+        | 'city'
+        | 'province'
+        | 'postalCode'
+        | 'phoneNumber'
+        | 'defaultShippingAddress'
+        | 'defaultBillingAddress'
+    > & { country: { __typename?: 'Country' } & Pick<Country, 'code' | 'name'> };
+};
+
+export type UpdateAddressMutationVariables = {
+    input: UpdateAddressInput;
+};
+
+export type UpdateAddressMutation = { __typename?: 'Mutation' } & {
+    updateCustomerAddress: { __typename?: 'Address' } & Pick<
+        Address,
+        'id' | 'defaultShippingAddress' | 'defaultBillingAddress'
+    > & { country: { __typename?: 'Country' } & Pick<Country, 'code' | 'name'> };
+};
+
+export type CreateCustomerMutationVariables = {
+    input: CreateCustomerInput;
+    password?: Maybe<Scalars['String']>;
+};
+
+export type CreateCustomerMutation = { __typename?: 'Mutation' } & {
+    createCustomer: { __typename?: 'Customer' } & CustomerFragment;
+};
+
+export type UpdateCustomerMutationVariables = {
+    input: UpdateCustomerInput;
+};
+
+export type UpdateCustomerMutation = { __typename?: 'Mutation' } & {
+    updateCustomer: { __typename?: 'Customer' } & CustomerFragment;
+};
+
+export type DeleteCustomerMutationVariables = {
+    id: Scalars['ID'];
+};
+
+export type DeleteCustomerMutation = { __typename?: 'Mutation' } & {
+    deleteCustomer: { __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result'>;
+};
+
+export type UpdateCustomerNoteMutationVariables = {
+    input: UpdateCustomerNoteInput;
+};
+
+export type UpdateCustomerNoteMutation = { __typename?: 'Mutation' } & {
+    updateCustomerNote: { __typename?: 'HistoryEntry' } & Pick<HistoryEntry, 'id' | 'data' | 'isPublic'>;
+};
+
+export type DeleteCustomerNoteMutationVariables = {
+    id: Scalars['ID'];
+};
+
+export type DeleteCustomerNoteMutation = { __typename?: 'Mutation' } & {
+    deleteCustomerNote: { __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result' | 'message'>;
+};
+
+export type UpdateCustomerGroupMutationVariables = {
+    input: UpdateCustomerGroupInput;
+};
+
+export type UpdateCustomerGroupMutation = { __typename?: 'Mutation' } & {
+    updateCustomerGroup: { __typename?: 'CustomerGroup' } & CustomerGroupFragment;
+};
+
+export type DeleteCustomerGroupMutationVariables = {
+    id: Scalars['ID'];
+};
+
+export type DeleteCustomerGroupMutation = { __typename?: 'Mutation' } & {
+    deleteCustomerGroup: { __typename?: 'DeletionResponse' } & Pick<DeletionResponse, 'result' | 'message'>;
+};
+
+export type GetCustomerGroupsQueryVariables = {
+    options?: Maybe<CustomerGroupListOptions>;
+};
+
+export type GetCustomerGroupsQuery = { __typename?: 'Query' } & {
+    customerGroups: { __typename?: 'CustomerGroupList' } & Pick<CustomerGroupList, 'totalItems'> & {
+            items: Array<{ __typename?: 'CustomerGroup' } & Pick<CustomerGroup, 'id' | 'name'>>;
+        };
+};
+
+export type GetCustomerGroupQueryVariables = {
+    id: Scalars['ID'];
+    options?: Maybe<CustomerListOptions>;
+};
+
+export type GetCustomerGroupQuery = { __typename?: 'Query' } & {
+    customerGroup?: Maybe<
+        { __typename?: 'CustomerGroup' } & Pick<CustomerGroup, 'id' | 'name'> & {
+                customers: { __typename?: 'CustomerList' } & Pick<CustomerList, 'totalItems'> & {
+                        items: Array<{ __typename?: 'Customer' } & Pick<Customer, 'id'>>;
+                    };
+            }
+    >;
+};
+
+export type AddCustomersToGroupMutationVariables = {
+    groupId: Scalars['ID'];
+    customerIds: Array<Scalars['ID']>;
+};
+
+export type AddCustomersToGroupMutation = { __typename?: 'Mutation' } & {
+    addCustomersToGroup: { __typename?: 'CustomerGroup' } & CustomerGroupFragment;
+};
+
+export type GetCustomerWithGroupsQueryVariables = {
+    id: Scalars['ID'];
+};
+
+export type GetCustomerWithGroupsQuery = { __typename?: 'Query' } & {
+    customer?: Maybe<
+        { __typename?: 'Customer' } & Pick<Customer, 'id'> & {
+                groups: Array<{ __typename?: 'CustomerGroup' } & Pick<CustomerGroup, 'id' | 'name'>>;
+            }
+    >;
+};
+
 export type UpdateCustomerGroupMutationVariables = {
     input: UpdateCustomerGroupInput;
 };
@@ -4438,6 +4583,15 @@ export type UpdateFacetValuesMutation = { __typename?: 'Mutation' } & {
     updateFacetValues: Array<{ __typename?: 'FacetValue' } & FacetValueFragment>;
 };
 
+export type AdminTransitionMutationVariables = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
+export type AdminTransitionMutation = { __typename?: 'Mutation' } & {
+    transitionOrderToState?: Maybe<{ __typename?: 'Order' } & Pick<Order, 'id' | 'state' | 'nextStates'>>;
+};
+
 export type AdministratorFragment = { __typename?: 'Administrator' } & Pick<
     Administrator,
     'id' | 'firstName' | 'lastName' | 'emailAddress'
@@ -5079,6 +5233,45 @@ export type RemoveCustomersFromGroupMutation = { __typename?: 'Mutation' } & {
     removeCustomersFromGroup: { __typename?: 'CustomerGroup' } & CustomerGroupFragment;
 };
 
+export type CreateFulfillmentMutationVariables = {
+    input: FulfillOrderInput;
+};
+
+export type CreateFulfillmentMutation = { __typename?: 'Mutation' } & {
+    fulfillOrder: { __typename?: 'Fulfillment' } & Pick<
+        Fulfillment,
+        'id' | 'method' | 'state' | 'trackingCode'
+    > & { orderItems: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>> };
+};
+
+export type TransitFulfillmentMutationVariables = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
+export type TransitFulfillmentMutation = { __typename?: 'Mutation' } & {
+    transitionFulfillmentToState: { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'state'>;
+};
+
+export type GetOrderFulfillmentsQueryVariables = {
+    id: Scalars['ID'];
+};
+
+export type GetOrderFulfillmentsQuery = { __typename?: 'Query' } & {
+    order?: Maybe<
+        { __typename?: 'Order' } & Pick<Order, 'id' | 'state'> & {
+                fulfillments?: Maybe<
+                    Array<
+                        { __typename?: 'Fulfillment' } & Pick<
+                            Fulfillment,
+                            'id' | 'state' | 'nextStates' | 'method'
+                        >
+                    >
+                >;
+            }
+    >;
+};
+
 export type UpdateOptionGroupMutationVariables = {
     input: UpdateProductOptionGroupInput;
 };
@@ -5141,38 +5334,19 @@ export type SettlePaymentMutation = { __typename?: 'Mutation' } & {
     settlePayment: { __typename?: 'Payment' } & Pick<Payment, 'id' | 'state' | 'metadata'>;
 };
 
-export type CreateFulfillmentMutationVariables = {
-    input: FulfillOrderInput;
-};
-
-export type CreateFulfillmentMutation = { __typename?: 'Mutation' } & {
-    fulfillOrder: { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'method' | 'trackingCode'> & {
-            orderItems: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>>;
-        };
-};
-
-export type GetOrderFulfillmentsQueryVariables = {
-    id: Scalars['ID'];
-};
-
-export type GetOrderFulfillmentsQuery = { __typename?: 'Query' } & {
-    order?: Maybe<
-        { __typename?: 'Order' } & Pick<Order, 'id'> & {
-                fulfillments?: Maybe<
-                    Array<{ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'method'>>
-                >;
-            }
-    >;
-};
-
 export type GetOrderListFulfillmentsQueryVariables = {};
 
 export type GetOrderListFulfillmentsQuery = { __typename?: 'Query' } & {
     orders: { __typename?: 'OrderList' } & {
         items: Array<
-            { __typename?: 'Order' } & Pick<Order, 'id'> & {
+            { __typename?: 'Order' } & Pick<Order, 'id' | 'state'> & {
                     fulfillments?: Maybe<
-                        Array<{ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'method'>>
+                        Array<
+                            { __typename?: 'Fulfillment' } & Pick<
+                                Fulfillment,
+                                'id' | 'state' | 'nextStates' | 'method'
+                            >
+                        >
                     >;
                 }
         >;
@@ -5185,10 +5359,10 @@ export type GetOrderFulfillmentItemsQueryVariables = {
 
 export type GetOrderFulfillmentItemsQuery = { __typename?: 'Query' } & {
     order?: Maybe<
-        { __typename?: 'Order' } & Pick<Order, 'id'> & {
+        { __typename?: 'Order' } & Pick<Order, 'id' | 'state'> & {
                 fulfillments?: Maybe<
                     Array<
-                        { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id'> & {
+                        { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'state'> & {
                                 orderItems: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>>;
                             }
                     >
@@ -6083,6 +6257,92 @@ export namespace CreateCountry {
     export type CreateCountry = CountryFragment;
 }
 
+export namespace CreateAddress {
+    export type Variables = CreateAddressMutationVariables;
+    export type Mutation = CreateAddressMutation;
+    export type CreateCustomerAddress = CreateAddressMutation['createCustomerAddress'];
+    export type Country = CreateAddressMutation['createCustomerAddress']['country'];
+}
+
+export namespace UpdateAddress {
+    export type Variables = UpdateAddressMutationVariables;
+    export type Mutation = UpdateAddressMutation;
+    export type UpdateCustomerAddress = UpdateAddressMutation['updateCustomerAddress'];
+    export type Country = UpdateAddressMutation['updateCustomerAddress']['country'];
+}
+
+export namespace CreateCustomer {
+    export type Variables = CreateCustomerMutationVariables;
+    export type Mutation = CreateCustomerMutation;
+    export type CreateCustomer = CustomerFragment;
+}
+
+export namespace UpdateCustomer {
+    export type Variables = UpdateCustomerMutationVariables;
+    export type Mutation = UpdateCustomerMutation;
+    export type UpdateCustomer = CustomerFragment;
+}
+
+export namespace DeleteCustomer {
+    export type Variables = DeleteCustomerMutationVariables;
+    export type Mutation = DeleteCustomerMutation;
+    export type DeleteCustomer = DeleteCustomerMutation['deleteCustomer'];
+}
+
+export namespace UpdateCustomerNote {
+    export type Variables = UpdateCustomerNoteMutationVariables;
+    export type Mutation = UpdateCustomerNoteMutation;
+    export type UpdateCustomerNote = UpdateCustomerNoteMutation['updateCustomerNote'];
+}
+
+export namespace DeleteCustomerNote {
+    export type Variables = DeleteCustomerNoteMutationVariables;
+    export type Mutation = DeleteCustomerNoteMutation;
+    export type DeleteCustomerNote = DeleteCustomerNoteMutation['deleteCustomerNote'];
+}
+
+export namespace UpdateCustomerGroup {
+    export type Variables = UpdateCustomerGroupMutationVariables;
+    export type Mutation = UpdateCustomerGroupMutation;
+    export type UpdateCustomerGroup = CustomerGroupFragment;
+}
+
+export namespace DeleteCustomerGroup {
+    export type Variables = DeleteCustomerGroupMutationVariables;
+    export type Mutation = DeleteCustomerGroupMutation;
+    export type DeleteCustomerGroup = DeleteCustomerGroupMutation['deleteCustomerGroup'];
+}
+
+export namespace GetCustomerGroups {
+    export type Variables = GetCustomerGroupsQueryVariables;
+    export type Query = GetCustomerGroupsQuery;
+    export type CustomerGroups = GetCustomerGroupsQuery['customerGroups'];
+    export type Items = NonNullable<GetCustomerGroupsQuery['customerGroups']['items'][0]>;
+}
+
+export namespace GetCustomerGroup {
+    export type Variables = GetCustomerGroupQueryVariables;
+    export type Query = GetCustomerGroupQuery;
+    export type CustomerGroup = NonNullable<GetCustomerGroupQuery['customerGroup']>;
+    export type Customers = NonNullable<GetCustomerGroupQuery['customerGroup']>['customers'];
+    export type Items = NonNullable<
+        NonNullable<GetCustomerGroupQuery['customerGroup']>['customers']['items'][0]
+    >;
+}
+
+export namespace AddCustomersToGroup {
+    export type Variables = AddCustomersToGroupMutationVariables;
+    export type Mutation = AddCustomersToGroupMutation;
+    export type AddCustomersToGroup = CustomerGroupFragment;
+}
+
+export namespace GetCustomerWithGroups {
+    export type Variables = GetCustomerWithGroupsQueryVariables;
+    export type Query = GetCustomerWithGroupsQuery;
+    export type Customer = NonNullable<GetCustomerWithGroupsQuery['customer']>;
+    export type Groups = NonNullable<NonNullable<GetCustomerWithGroupsQuery['customer']>['groups'][0]>;
+}
+
 export namespace UpdateCustomerGroup {
     export type Variables = UpdateCustomerGroupMutationVariables;
     export type Mutation = UpdateCustomerGroupMutation;
@@ -6370,6 +6630,12 @@ export namespace UpdateFacetValues {
     export type UpdateFacetValues = FacetValueFragment;
 }
 
+export namespace AdminTransition {
+    export type Variables = AdminTransitionMutationVariables;
+    export type Mutation = AdminTransitionMutation;
+    export type TransitionOrderToState = NonNullable<AdminTransitionMutation['transitionOrderToState']>;
+}
+
 export namespace Administrator {
     export type Fragment = AdministratorFragment;
     export type User = AdministratorFragment['user'];
@@ -6768,6 +7034,28 @@ export namespace RemoveCustomersFromGroup {
     export type RemoveCustomersFromGroup = CustomerGroupFragment;
 }
 
+export namespace CreateFulfillment {
+    export type Variables = CreateFulfillmentMutationVariables;
+    export type Mutation = CreateFulfillmentMutation;
+    export type FulfillOrder = CreateFulfillmentMutation['fulfillOrder'];
+    export type OrderItems = NonNullable<CreateFulfillmentMutation['fulfillOrder']['orderItems'][0]>;
+}
+
+export namespace TransitFulfillment {
+    export type Variables = TransitFulfillmentMutationVariables;
+    export type Mutation = TransitFulfillmentMutation;
+    export type TransitionFulfillmentToState = TransitFulfillmentMutation['transitionFulfillmentToState'];
+}
+
+export namespace GetOrderFulfillments {
+    export type Variables = GetOrderFulfillmentsQueryVariables;
+    export type Query = GetOrderFulfillmentsQuery;
+    export type Order = NonNullable<GetOrderFulfillmentsQuery['order']>;
+    export type Fulfillments = NonNullable<
+        NonNullable<NonNullable<GetOrderFulfillmentsQuery['order']>['fulfillments']>[0]
+    >;
+}
+
 export namespace UpdateOptionGroup {
     export type Variables = UpdateOptionGroupMutationVariables;
     export type Mutation = UpdateOptionGroupMutation;
@@ -6814,22 +7102,6 @@ export namespace SettlePayment {
     export type SettlePayment = SettlePaymentMutation['settlePayment'];
 }
 
-export namespace CreateFulfillment {
-    export type Variables = CreateFulfillmentMutationVariables;
-    export type Mutation = CreateFulfillmentMutation;
-    export type FulfillOrder = CreateFulfillmentMutation['fulfillOrder'];
-    export type OrderItems = NonNullable<CreateFulfillmentMutation['fulfillOrder']['orderItems'][0]>;
-}
-
-export namespace GetOrderFulfillments {
-    export type Variables = GetOrderFulfillmentsQueryVariables;
-    export type Query = GetOrderFulfillmentsQuery;
-    export type Order = NonNullable<GetOrderFulfillmentsQuery['order']>;
-    export type Fulfillments = NonNullable<
-        NonNullable<NonNullable<GetOrderFulfillmentsQuery['order']>['fulfillments']>[0]
-    >;
-}
-
 export namespace GetOrderListFulfillments {
     export type Variables = GetOrderListFulfillmentsQueryVariables;
     export type Query = GetOrderListFulfillmentsQuery;

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

@@ -884,6 +884,7 @@ export type Fulfillment = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     orderItems: Array<OrderItem>;
+    state: Scalars['String'];
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -953,9 +954,10 @@ export enum HistoryEntryType {
     CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-    ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+    ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
     ORDER_CANCELLATION = 'ORDER_CANCELLATION',
     ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+    ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
     ORDER_NOTE = 'ORDER_NOTE',
     ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
     ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED',

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

@@ -8,6 +8,7 @@ import {
     CURRENT_USER_FRAGMENT,
     CUSTOMER_FRAGMENT,
     FACET_WITH_VALUES_FRAGMENT,
+    ORDER_FRAGMENT,
     ORDER_WITH_LINES_FRAGMENT,
     PRODUCT_VARIANT_FRAGMENT,
     PRODUCT_WITH_VARIANTS_FRAGMENT,
@@ -436,3 +437,53 @@ export const REMOVE_CUSTOMERS_FROM_GROUP = gql`
     }
     ${CUSTOMER_GROUP_FRAGMENT}
 `;
+
+export const CREATE_FULFILLMENT = gql`
+    mutation CreateFulfillment($input: FulfillOrderInput!) {
+        fulfillOrder(input: $input) {
+            id
+            method
+            state
+            trackingCode
+            orderItems {
+                id
+            }
+        }
+    }
+`;
+
+export const TRANSIT_FULFILLMENT = gql`
+    mutation TransitFulfillment($id: ID!, $state: String!) {
+        transitionFulfillmentToState(id: $id, state: $state) {
+            id
+            state
+        }
+    }
+`;
+
+export const GET_ORDER_FULFILLMENTS = gql`
+    query GetOrderFulfillments($id: ID!) {
+        order(id: $id) {
+            id
+            state
+            fulfillments {
+                id
+                state
+                nextStates
+                method
+            }
+        }
+    }
+`;
+
+export const GET_ORDERS_LIST = gql`
+    query GetOrderList($options: OrderListOptions) {
+        orders(options: $options) {
+            items {
+                ...Order
+            }
+            totalItems
+        }
+    }
+    ${ORDER_FRAGMENT}
+`;

+ 1 - 1
packages/core/e2e/order-channel.e2e-spec.ts

@@ -21,10 +21,10 @@ import {
     CREATE_CHANNEL,
     GET_CUSTOMER_LIST,
     GET_ORDER,
+    GET_ORDERS_LIST,
     GET_PRODUCT_WITH_VARIANTS,
 } from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-definitions';
-import { GET_ORDERS_LIST } from './order.e2e-spec';
 
 describe('Channelaware orders', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(testConfig);

+ 6 - 6
packages/core/e2e/order-process.e2e-spec.ts

@@ -357,19 +357,19 @@ describe('Order process', () => {
             expect(result.order?.state).toBe('PaymentSettled');
         });
 
-        it('cannot manually transition to PartiallyFulfilled', async () => {
+        it('cannot manually transition to PartiallyDelivered', async () => {
             try {
                 const { transitionOrderToState } = await adminClient.query<
                     AdminTransition.Mutation,
                     AdminTransition.Variables
                 >(ADMIN_TRANSITION_TO_STATE, {
                     id: order.id,
-                    state: 'PartiallyFulfilled',
+                    state: 'PartiallyDelivered',
                 });
                 fail('Should have thrown');
             } catch (e) {
                 expect(e.message).toContain(
-                    'Cannot transition Order to the "PartiallyFulfilled" state unless some OrderItems are fulfilled',
+                    'Cannot transition Order to the "PartiallyDelivered" state unless some OrderItems are delivered',
                 );
             }
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
@@ -378,19 +378,19 @@ describe('Order process', () => {
             expect(result.order?.state).toBe('PaymentSettled');
         });
 
-        it('cannot manually transition to PartiallyFulfilled', async () => {
+        it('cannot manually transition to PartiallyDelivered', async () => {
             try {
                 const { transitionOrderToState } = await adminClient.query<
                     AdminTransition.Mutation,
                     AdminTransition.Variables
                 >(ADMIN_TRANSITION_TO_STATE, {
                     id: order.id,
-                    state: 'Fulfilled',
+                    state: 'Delivered',
                 });
                 fail('Should have thrown');
             } catch (e) {
                 expect(e.message).toContain(
-                    'Cannot transition Order to the "Fulfilled" state unless all OrderItems are fulfilled',
+                    'Cannot transition Order to the "Delivered" state unless all OrderItems are delivered',
                 );
             }
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {

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

@@ -15,7 +15,7 @@ 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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import {

+ 139 - 119
packages/core/e2e/order.e2e-spec.ts

@@ -13,7 +13,6 @@ import {
     singleStageRefundablePaymentMethod,
     twoStagePaymentMethod,
 } from './fixtures/test-payment-methods';
-import { ORDER_FRAGMENT } from './graphql/fragments';
 import {
     AddNoteToOrder,
     CancelOrder,
@@ -29,21 +28,25 @@ import {
     GetProductWithVariants,
     GetStockMovement,
     HistoryEntryType,
-    OrderItemFragment,
     RefundOrder,
     SettlePayment,
     SettleRefund,
     SortOrder,
     StockMovementType,
+    TransitFulfillment,
     UpdateOrderNote,
     UpdateProductVariants,
 } from './graphql/generated-e2e-admin-types';
 import { AddItemToOrder, DeletionResult, GetActiveOrder } from './graphql/generated-e2e-shop-types';
 import {
+    CREATE_FULFILLMENT,
     GET_CUSTOMER_LIST,
     GET_ORDER,
+    GET_ORDER_FULFILLMENTS,
+    GET_ORDERS_LIST,
     GET_PRODUCT_WITH_VARIANTS,
     GET_STOCK_MOVEMENT,
+    TRANSIT_FULFILLMENT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-definitions';
@@ -295,7 +298,7 @@ describe('Orders resolver', () => {
             }, 'Nothing to fulfill'),
         );
 
-        it('creates a partial fulfillment', async () => {
+        it('creates the first fulfillment', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
@@ -307,60 +310,60 @@ describe('Orders resolver', () => {
                 CreateFulfillment.Variables
             >(CREATE_FULFILLMENT, {
                 input: {
-                    lines: lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
+                    lines: [{ orderLineId: lines[0].id, quantity: lines[0].quantity }],
                     method: 'Test1',
                     trackingCode: '111',
                 },
             });
 
+            expect(fulfillOrder!.id).toBe('T_1');
             expect(fulfillOrder!.method).toBe('Test1');
             expect(fulfillOrder!.trackingCode).toBe('111');
-
-            const line0ItemIds = lines[0].items.map(i => i.id);
-            const line1ItemIds = lines[1].items.map(i => i.id);
-            expect(fulfillOrder!.orderItems.length).toBe(2);
-            expect(line0ItemIds).toContain(fulfillOrder!.orderItems[0].id);
-            expect(line1ItemIds).toContain(fulfillOrder!.orderItems[1].id);
+            expect(fulfillOrder!.state).toBe('Pending');
+            expect(fulfillOrder!.orderItems).toEqual([{ id: lines[0].items[0].id }]);
 
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
 
-            expect(result.order!.state).toBe('PartiallyFulfilled');
-
             expect(result.order!.lines[0].items[0].fulfillment!.id).toBe(fulfillOrder!.id);
             expect(
                 result.order!.lines[1].items.filter(
                     i => i.fulfillment && i.fulfillment.id === fulfillOrder.id,
                 ).length,
-            ).toBe(1);
-            expect(result.order!.lines[1].items.filter(i => i.fulfillment == null).length).toBe(2);
+            ).toBe(0);
+            expect(result.order!.lines[1].items.filter(i => i.fulfillment == null).length).toBe(3);
         });
 
-        it('creates a second partial fulfillment', async () => {
+        it('creates the second fulfillment', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
-            expect(order!.state).toBe('PartiallyFulfilled');
-            const lines = order!.lines;
+
+            const unfulfilledItems =
+                order?.lines.filter(l => {
+                    const items = l.items.filter(i => i.fulfillment === null);
+                    return items.length > 0 ? true : false;
+                }) || [];
 
             const { fulfillOrder } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
             >(CREATE_FULFILLMENT, {
                 input: {
-                    lines: [{ orderLineId: lines[1].id, quantity: 1 }],
+                    lines: unfulfilledItems.map(l => ({
+                        orderLineId: l.id,
+                        quantity: l.items.length,
+                    })),
                     method: 'Test2',
                     trackingCode: '222',
                 },
             });
 
-            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
-                id: 'T_2',
-            });
-            expect(result.order!.state).toBe('PartiallyFulfilled');
-            expect(result.order!.lines[1].items.filter(i => i.fulfillment != null).length).toBe(2);
-            expect(result.order!.lines[1].items.filter(i => i.fulfillment == null).length).toBe(1);
+            expect(fulfillOrder!.id).toBe('T_2');
+            expect(fulfillOrder!.method).toBe('Test2');
+            expect(fulfillOrder!.trackingCode).toBe('222');
+            expect(fulfillOrder!.state).toBe('Pending');
         });
 
         it(
@@ -369,7 +372,6 @@ describe('Orders resolver', () => {
                 const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                     id: 'T_2',
                 });
-                expect(order!.state).toBe('PartiallyFulfilled');
                 await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
                     CREATE_FULFILLMENT,
                     {
@@ -386,43 +388,69 @@ describe('Orders resolver', () => {
                 );
             }, 'One or more OrderItems have already been fulfilled'),
         );
+        it('transits the first fulfillment from created to Shipped and automatically change the order state to PartiallyShipped', async () => {
+            const fulfillment = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_1',
+                state: 'Shipped',
+            });
+            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_1');
+            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Shipped');
 
-        it('completes fulfillment', async () => {
             const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
-            expect(order!.state).toBe('PartiallyFulfilled');
-
-            const orderItems = order!.lines.reduce(
-                (items, line) => [...items, ...line.items],
-                [] as OrderItemFragment[],
-            );
-            const unfulfilledItem = order!.lines[1].items.find(i => i.fulfillment == null)!;
+            expect(order?.state).toBe('PartiallyShipped');
+        });
+        it('transits the second fulfillment from created to Shipped and automatically change the order state to Shipped', async () => {
+            const fulfillment = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_2',
+                state: 'Shipped',
+            });
+            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_2');
+            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Shipped');
 
-            const { fulfillOrder } = await adminClient.query<
-                CreateFulfillment.Mutation,
-                CreateFulfillment.Variables
-            >(CREATE_FULFILLMENT, {
-                input: {
-                    lines: [
-                        {
-                            orderLineId: order!.lines[1].id,
-                            quantity: 1,
-                        },
-                    ],
-                    method: 'Test3',
-                    trackingCode: '333',
-                },
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: 'T_2',
+            });
+            expect(order?.state).toBe('Shipped');
+        });
+        it('transits the first fulfillment from Shipped to Delivered and change the order state to PartiallyDelivered', async () => {
+            const fulfillment = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_1',
+                state: 'Delivered',
             });
+            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_1');
+            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Delivered');
 
-            expect(fulfillOrder!.method).toBe('Test3');
-            expect(fulfillOrder!.trackingCode).toBe('333');
-            expect(fulfillOrder!.orderItems).toEqual([{ id: unfulfilledItem.id }]);
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: 'T_2',
+            });
+            expect(order?.state).toBe('PartiallyDelivered');
+        });
+        it('transits the second fulfillment from Shipped to Delivered and change the order state to Delivered', async () => {
+            const fulfillment = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_2',
+                state: 'Delivered',
+            });
+            expect(fulfillment.transitionFulfillmentToState?.id).toBe('T_2');
+            expect(fulfillment.transitionFulfillmentToState?.state).toBe('Delivered');
 
-            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
             });
-            expect(result.order!.state).toBe('Fulfilled');
+            expect(order?.state).toBe('Delivered');
         });
 
         it('order history contains expected entries', async () => {
@@ -437,49 +465,81 @@ describe('Orders resolver', () => {
             );
             expect(order!.history.items.map(pick(['type', 'data']))).toEqual([
                 {
-                    type: HistoryEntryType.ORDER_FULLFILLMENT,
                     data: {
                         fulfillmentId: 'T_1',
                     },
+                    type: HistoryEntryType.ORDER_FULFILLMENT,
+                },
+                {
+                    data: {
+                        fulfillmentId: 'T_2',
+                    },
+                    type: HistoryEntryType.ORDER_FULFILLMENT,
+                },
+                {
+                    data: {
+                        from: 'Pending',
+                        fulfillmentId: 'T_1',
+                        to: 'Shipped',
+                    },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
                 },
                 {
-                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                     data: {
                         from: 'PaymentSettled',
-                        to: 'PartiallyFulfilled',
+                        to: 'PartiallyShipped',
                     },
+                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                 },
-
                 {
-                    type: HistoryEntryType.ORDER_FULLFILLMENT,
                     data: {
+                        from: 'Pending',
                         fulfillmentId: 'T_2',
+                        to: 'Shipped',
                     },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
                 },
                 {
-                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                     data: {
-                        from: 'PartiallyFulfilled',
-                        to: 'PartiallyFulfilled',
+                        from: 'PartiallyShipped',
+                        to: 'Shipped',
                     },
+                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                 },
                 {
-                    type: HistoryEntryType.ORDER_FULLFILLMENT,
                     data: {
-                        fulfillmentId: 'T_3',
+                        from: 'Shipped',
+                        fulfillmentId: 'T_1',
+                        to: 'Delivered',
                     },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
                 },
                 {
+                    data: {
+                        from: 'Shipped',
+                        to: 'PartiallyDelivered',
+                    },
                     type: HistoryEntryType.ORDER_STATE_TRANSITION,
+                },
+                {
+                    data: {
+                        from: 'Shipped',
+                        fulfillmentId: 'T_2',
+                        to: 'Delivered',
+                    },
+                    type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
+                },
+                {
                     data: {
-                        from: 'PartiallyFulfilled',
-                        to: 'Fulfilled',
+                        from: 'PartiallyDelivered',
+                        to: 'Delivered',
                     },
+                    type: HistoryEntryType.ORDER_STATE_TRANSITION,
                 },
             ]);
         });
 
-        it('order.fullfillments resolver for single order', async () => {
+        it('order.fulfillments resolver for single order', async () => {
             const { order } = await adminClient.query<
                 GetOrderFulfillments.Query,
                 GetOrderFulfillments.Variables
@@ -487,41 +547,33 @@ describe('Orders resolver', () => {
                 id: 'T_2',
             });
 
-            const sortedFulfillments = order!.fulfillments?.sort((a, b) => (a.id < b.id ? -1 : 1));
-            expect(sortedFulfillments).toEqual([
-                { id: 'T_1', method: 'Test1' },
-                { id: 'T_2', method: 'Test2' },
-                { id: 'T_3', method: 'Test3' },
+            expect(order!.fulfillments).toEqual([
+                { id: 'T_1', method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
+                { id: 'T_2', method: 'Test2', state: 'Delivered', nextStates: ['Cancelled'] },
             ]);
         });
 
-        it('order.fullfillments resolver for order list', async () => {
+        it('order.fulfillments resolver for order list', async () => {
             const { orders } = await adminClient.query<GetOrderListFulfillments.Query>(
                 GET_ORDER_LIST_FULFILLMENTS,
             );
 
             expect(orders.items[0].fulfillments).toEqual([]);
             expect(orders.items[1].fulfillments).toEqual([
-                { id: 'T_1', method: 'Test1' },
-                { id: 'T_2', method: 'Test2' },
-                { id: 'T_3', method: 'Test3' },
+                { id: 'T_1', method: 'Test1', state: 'Delivered', nextStates: ['Cancelled'] },
+                { id: 'T_2', method: 'Test2', state: 'Delivered', nextStates: ['Cancelled'] },
             ]);
         });
 
-        it('order.fullfillments.orderItems resolver', async () => {
+        it('order.fulfillments.orderItems resolver', async () => {
             const { order } = await adminClient.query<
                 GetOrderFulfillmentItems.Query,
                 GetOrderFulfillmentItems.Variables
             >(GET_ORDER_FULFILLMENT_ITEMS, {
                 id: 'T_2',
             });
-
-            const getFulfillment = (id: string): GetOrderFulfillmentItems.Fulfillments => {
-                return order?.fulfillments?.find(f => f.id === id)!;
-            };
-            expect(getFulfillment('T_1').orderItems).toEqual([{ id: 'T_3' }, { id: 'T_4' }]);
-            expect(getFulfillment('T_2').orderItems).toEqual([{ id: 'T_5' }]);
-            expect(getFulfillment('T_3').orderItems).toEqual([{ id: 'T_6' }]);
+            expect(order!.fulfillments![0].orderItems).toEqual([{ id: 'T_3' }]);
+            expect(order!.fulfillments![1].orderItems).toEqual([{ id: 'T_4' }, { id: 'T_5' }, { id: 'T_6' }]);
         });
     });
 
@@ -1308,18 +1360,6 @@ async function createTestOrder(
     return { product, productVariantId, orderId };
 }
 
-export const GET_ORDERS_LIST = gql`
-    query GetOrderList($options: OrderListOptions) {
-        orders(options: $options) {
-            items {
-                ...Order
-            }
-            totalItems
-        }
-    }
-    ${ORDER_FRAGMENT}
-`;
-
 export const SETTLE_PAYMENT = gql`
     mutation SettlePayment($id: ID!) {
         settlePayment(id: $id) {
@@ -1330,38 +1370,16 @@ export const SETTLE_PAYMENT = gql`
     }
 `;
 
-export const CREATE_FULFILLMENT = gql`
-    mutation CreateFulfillment($input: FulfillOrderInput!) {
-        fulfillOrder(input: $input) {
-            id
-            method
-            trackingCode
-            orderItems {
-                id
-            }
-        }
-    }
-`;
-
-export const GET_ORDER_FULFILLMENTS = gql`
-    query GetOrderFulfillments($id: ID!) {
-        order(id: $id) {
-            id
-            fulfillments {
-                id
-                method
-            }
-        }
-    }
-`;
-
 export const GET_ORDER_LIST_FULFILLMENTS = gql`
     query GetOrderListFulfillments {
         orders {
             items {
                 id
+                state
                 fulfillments {
                     id
+                    state
+                    nextStates
                     method
                 }
             }
@@ -1373,8 +1391,10 @@ export const GET_ORDER_FULFILLMENT_ITEMS = gql`
     query GetOrderFulfillmentItems($id: ID!) {
         order(id: $id) {
             id
+            state
             fulfillments {
                 id
+                state
                 orderItems {
                     id
                 }

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

@@ -6,7 +6,7 @@ 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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import {
     AddOptionGroupToProduct,

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

@@ -39,7 +39,10 @@ import {
 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 { FulfillmentEntityResolver } from './resolvers/entity/fulfillment-entity.resolver';
+import {
+    FulfillmentAdminEntityResolver,
+    FulfillmentEntityResolver,
+} from './resolvers/entity/fulfillment-entity.resolver';
 import { OrderAdminEntityResolver, OrderEntityResolver } from './resolvers/entity/order-entity.resolver';
 import { OrderLineEntityResolver } from './resolvers/entity/order-line-entity.resolver';
 import { PaymentEntityResolver } from './resolvers/entity/payment-entity.resolver';
@@ -120,6 +123,7 @@ export const entityResolvers = [
 export const adminEntityResolvers = [
     CustomerAdminEntityResolver,
     OrderAdminEntityResolver,
+    FulfillmentAdminEntityResolver,
     ProductVariantAdminEntityResolver,
     ProductAdminEntityResolver,
 ];

+ 44 - 1
packages/core/src/api/middleware/auth-guard.ts

@@ -7,9 +7,13 @@ import { REQUEST_CONTEXT_KEY } from '../../common/constants';
 import { ForbiddenError } from '../../common/error/errors';
 import { ConfigService } from '../../config/config.service';
 import { CachedSession } from '../../config/session-cache/session-cache-strategy';
+import { Customer } from '../../entity/customer/customer.entity';
+import { ChannelService } from '../../service/services/channel.service';
+import { CustomerService } from '../../service/services/customer.service';
 import { SessionService } from '../../service/services/session.service';
 import { extractSessionToken } from '../common/extract-session-token';
 import { parseContext } from '../common/parse-context';
+import { RequestContext } from '../common/request-context';
 import { RequestContextService } from '../common/request-context.service';
 import { setSessionToken } from '../common/set-session-token';
 import { PERMISSIONS_METADATA_KEY } from '../decorators/allow.decorator';
@@ -27,6 +31,8 @@ export class AuthGuard implements CanActivate {
         private configService: ConfigService,
         private requestContextService: RequestContextService,
         private sessionService: SessionService,
+        private customerService: CustomerService,
+        private channelService: ChannelService,
     ) {}
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -36,7 +42,12 @@ export class AuthGuard implements CanActivate {
         const isPublic = !!permissions && permissions.includes(Permission.Public);
         const hasOwnerPermission = !!permissions && permissions.includes(Permission.Owner);
         const session = await this.getSession(req, res, hasOwnerPermission);
-        const requestContext = await this.requestContextService.fromRequest(req, info, permissions, session);
+        let requestContext = await this.requestContextService.fromRequest(req, info, permissions, session);
+
+        const requestContextShouldBeReinitialized = await this.setActiveChannel(requestContext, session);
+        if (requestContextShouldBeReinitialized) {
+            requestContext = await this.requestContextService.fromRequest(req, info, permissions, session);
+        }
         (req as any)[REQUEST_CONTEXT_KEY] = requestContext;
 
         if (authDisabled || !permissions || isPublic) {
@@ -51,6 +62,38 @@ export class AuthGuard implements CanActivate {
         }
     }
 
+    private async setActiveChannel(
+        requestContext: RequestContext,
+        session?: CachedSession,
+    ): Promise<boolean> {
+        if (!session) {
+            return false;
+        }
+        // In case the session does not have an activeChannelId or the activeChannelId
+        // does not correspond to the current channel, the activeChannelId on the session is set
+        const activeChannelShouldBeSet =
+            !session.activeChannelId || session.activeChannelId !== requestContext.channelId;
+        if (activeChannelShouldBeSet) {
+            await this.sessionService.setActiveChannel(session, requestContext.channel);
+            if (requestContext.activeUserId) {
+                const customer = await this.customerService.findOneByUserId(
+                    requestContext,
+                    requestContext.activeUserId,
+                    false,
+                );
+                // To avoid assigning the customer to the active channel on every request,
+                // it is only done on the first request and whenever the channel changes
+                if (customer) {
+                    await this.channelService.assignToChannels(requestContext, Customer, customer.id, [
+                        requestContext.channelId,
+                    ]);
+                }
+            }
+            return true;
+        }
+        return false;
+    }
+
     private async getSession(
         req: Request,
         res: Response,

+ 11 - 0
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -8,6 +8,7 @@ import {
     MutationSetOrderCustomFieldsArgs,
     MutationSettlePaymentArgs,
     MutationSettleRefundArgs,
+    MutationTransitionFulfillmentToStateArgs,
     MutationTransitionOrderToStateArgs,
     MutationUpdateOrderNoteArgs,
     Permission,
@@ -17,6 +18,7 @@ import {
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
 import { Order } from '../../../entity/order/order.entity';
+import { FulfillmentState } from '../../../service/helpers/fulfillment-state-machine/fulfillment-state';
 import { OrderState } from '../../../service/helpers/order-state-machine/order-state';
 import { OrderService } from '../../../service/services/order.service';
 import { ShippingMethodService } from '../../../service/services/shipping-method.service';
@@ -113,4 +115,13 @@ export class OrderResolver {
     ) {
         return this.orderService.transitionToState(ctx, args.id, args.state as OrderState);
     }
+
+    @Mutation()
+    @Allow(Permission.UpdateOrder)
+    async transitionFulfillmentToState(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationTransitionFulfillmentToStateArgs,
+    ) {
+        return this.orderService.transitionFulfillmentToState(ctx, args.id, args.state as FulfillmentState);
+    }
 }

+ 13 - 3
packages/core/src/api/resolvers/entity/fulfillment-entity.resolver.ts

@@ -1,16 +1,26 @@
 import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
 import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
-import { OrderService } from '../../../service/services/order.service';
+import { FulfillmentService } from '../../../service/services/fulfillment.service';
 import { RequestContext } from '../../common/request-context';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Fulfillment')
 export class FulfillmentEntityResolver {
-    constructor(private orderService: OrderService) {}
+    constructor(private fulfillmentService: FulfillmentService) {}
 
     @ResolveField()
     async orderItems(@Ctx() ctx: RequestContext, @Parent() fulfillment: Fulfillment) {
-        return this.orderService.getFulfillmentOrderItems(ctx, fulfillment.id);
+        return this.fulfillmentService.getOrderItemsByFulfillmentId(ctx, fulfillment.id);
+    }
+}
+
+@Resolver('Fulfillment')
+export class FulfillmentAdminEntityResolver {
+    constructor(private fulfillmentService: FulfillmentService) {}
+
+    @ResolveField()
+    async nextStates(@Parent() fulfillment: Fulfillment) {
+        return this.fulfillmentService.getNextStates(fulfillment);
     }
 }

+ 3 - 0
packages/core/src/api/schema/admin-api/fulfillment.api.graphql

@@ -0,0 +1,3 @@
+type Fulfillment {
+    nextStates: [String!]!
+}

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

@@ -13,6 +13,7 @@ type Mutation {
     updateOrderNote(input: UpdateOrderNoteInput!): HistoryEntry!
     deleteOrderNote(id: ID!): DeletionResponse!
     transitionOrderToState(id: ID!, state: String!): Order
+    transitionFulfillmentToState(id: ID!, state: String!): Fulfillment!
     setOrderCustomFields(input: UpdateOrderInput!): Order
 }
 

+ 2 - 1
packages/core/src/api/schema/type/history-entry.type.graphql

@@ -25,9 +25,10 @@ enum HistoryEntryType {
     CUSTOMER_NOTE
     ORDER_STATE_TRANSITION
     ORDER_PAYMENT_TRANSITION
-    ORDER_FULLFILLMENT
+    ORDER_FULFILLMENT
     ORDER_CANCELLATION
     ORDER_REFUND_TRANSITION
+    ORDER_FULFILLMENT_TRANSITION
     ORDER_NOTE
     ORDER_COUPON_APPLIED
     ORDER_COUPON_REMOVED

+ 1 - 0
packages/core/src/api/schema/type/order.type.graphql

@@ -120,6 +120,7 @@ type Fulfillment implements Node {
     createdAt: DateTime!
     updatedAt: DateTime!
     orderItems: [OrderItem!]!
+    state: String!
     method: String!
     trackingCode: String
 }

+ 10 - 4
packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts

@@ -27,12 +27,18 @@ describe('FSM validateTransitionDefinition()', () => {
                 to: ['PaymentSettled', 'Cancelled'],
             },
             PaymentSettled: {
-                to: ['PartiallyFulfilled', 'Fulfilled', 'Cancelled'],
+                to: ['PartiallyDelivered', 'Delivered', 'PartiallyShipped', 'Shipped', 'Cancelled'],
             },
-            PartiallyFulfilled: {
-                to: ['Fulfilled', 'PartiallyFulfilled', 'Cancelled'],
+            PartiallyShipped: {
+                to: ['Shipped', 'PartiallyDelivered', 'Cancelled'],
             },
-            Fulfilled: {
+            Shipped: {
+                to: ['PartiallyDelivered', 'Delivered', 'Cancelled'],
+            },
+            PartiallyDelivered: {
+                to: ['Delivered', 'Cancelled'],
+            },
+            Delivered: {
                 to: ['Cancelled'],
             },
             Cancelled: {

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

@@ -60,9 +60,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         tokenMethod: 'cookie',
         sessionSecret: '',
         cookieOptions: {
-            secret: Math.random()
-                .toString(36)
-                .substr(3),
+            secret: Math.random().toString(36).substr(3),
             httpOnly: true,
         },
         authTokenHeaderKey: DEFAULT_AUTH_TOKEN_HEADER_KEY,

+ 27 - 0
packages/core/src/config/fulfillment/custom-fulfillment-process.ts

@@ -0,0 +1,27 @@
+import {
+    OnTransitionEndFn,
+    OnTransitionErrorFn,
+    OnTransitionStartFn,
+    Transitions,
+} from '../../common/finite-state-machine/types';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import {
+    FulfillmentState,
+    FulfillmentTransitionData,
+} from '../../service/helpers/fulfillment-state-machine/fulfillment-state';
+
+/**
+ * @description
+ * Used to define extensions to or modifications of the default fulfillment process.
+ *
+ * For detailed description of the interface members, see the {@link StateMachineConfig} docs.
+ *
+ * @docsCategory fulfillments
+ */
+export interface CustomFulfillmentProcess<State extends string> extends InjectableStrategy {
+    transitions?: Transitions<State, State | FulfillmentState> &
+        Partial<Transitions<FulfillmentState | State>>;
+    onTransitionStart?: OnTransitionStartFn<State | FulfillmentState, FulfillmentTransitionData>;
+    onTransitionEnd?: OnTransitionEndFn<State | FulfillmentState, FulfillmentTransitionData>;
+    onTransitionError?: OnTransitionErrorFn<State | FulfillmentState>;
+}

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

@@ -30,4 +30,5 @@ export * from './shipping-method/default-shipping-calculator';
 export * from './shipping-method/default-shipping-eligibility-checker';
 export * from './shipping-method/shipping-calculator';
 export * from './shipping-method/shipping-eligibility-checker';
+export * from './fulfillment/custom-fulfillment-process';
 export * from './vendure-config';

+ 2 - 1
packages/core/src/config/promotion/conditions/contains-products-condition.ts

@@ -1,6 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
+import { RequestContext } from '../../../api/common/request-context';
 import { idsAreEqual } from '../../../common/utils';
 import { OrderLine } from '../../../entity/order-line/order-line.entity';
 import { Order } from '../../../entity/order/order.entity';
@@ -20,7 +21,7 @@ export const containsProducts = new PromotionCondition({
             label: [{ languageCode: LanguageCode.en, value: 'Product variants' }],
         },
     },
-    async check(order: Order, args) {
+    async check(ctx: RequestContext, order: Order, args) {
         const ids = args.productVariantIds;
         let matches = 0;
         for (const line of order.lines) {

+ 3 - 2
packages/core/src/config/promotion/conditions/customer-group-condition.ts

@@ -1,6 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
+import { RequestContext } from '../../../api/common/request-context';
 import { TtlCache } from '../../../common/ttl-cache';
 import { idsAreEqual } from '../../../common/utils';
 import { Order } from '../../../entity/order/order.entity';
@@ -26,14 +27,14 @@ export const customerGroup = new PromotionCondition({
         const { CustomerService } = await import('../../../service/services/customer.service');
         customerService = injector.get(CustomerService);
     },
-    async check(order: Order, args) {
+    async check(ctx: RequestContext, order: Order, args) {
         if (!order.customer) {
             return false;
         }
         const customerId = order.customer.id;
         let groupIds = cache.get(customerId);
         if (!groupIds) {
-            const groups = await customerService.getCustomerGroups(undefined, customerId);
+            const groups = await customerService.getCustomerGroups(ctx, customerId);
             groupIds = groups.map(g => g.id);
             cache.set(customerId, groupIds);
         }

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

@@ -1,5 +1,6 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
+import { RequestContext } from '../../../api/common/request-context';
 import { Order } from '../../../entity/order/order.entity';
 import { PromotionCondition } from '../promotion-condition';
 import { FacetValueChecker } from '../utils/facet-value-checker';
@@ -19,7 +20,7 @@ export const hasFacetValues = new PromotionCondition({
         facetValueChecker = new FacetValueChecker(injector.getConnection());
     },
     // tslint:disable-next-line:no-shadowed-variable
-    async check(order: Order, args) {
+    async check(ctx: RequestContext, order: Order, args) {
         let matches = 0;
         for (const line of order.lines) {
             if (await facetValueChecker.hasFacetValues(line, args.facets)) {

+ 3 - 1
packages/core/src/config/promotion/conditions/min-order-amount-condition.ts

@@ -1,5 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 
+import { RequestContext } from '../../../api/common/request-context';
+import { Order } from '../../../entity/order/order.entity';
 import { PromotionCondition } from '../promotion-condition';
 
 export const minimumOrderAmount = new PromotionCondition({
@@ -12,7 +14,7 @@ export const minimumOrderAmount = new PromotionCondition({
         },
         taxInclusive: { type: 'boolean' },
     },
-    check(order, args) {
+    check(ctx: RequestContext, order: Order, args) {
         if (args.taxInclusive) {
             return order.subTotal >= args.amount;
         } else {

+ 4 - 2
packages/core/src/config/promotion/promotion-condition.ts

@@ -1,6 +1,7 @@
 import { ConfigArg } from '@vendure/common/lib/generated-types';
 import { ConfigArgType, ID } from '@vendure/common/lib/shared-types';
 
+import { RequestContext } from '../../api/common/request-context';
 import {
     ConfigArgs,
     ConfigArgValues,
@@ -18,6 +19,7 @@ import { Order } from '../../entity/order/order.entity';
  * @docsPage promotion-condition
  */
 export type CheckPromotionConditionFn<T extends ConfigArgs> = (
+    ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
 ) => boolean | Promise<boolean>;
@@ -61,7 +63,7 @@ export class PromotionCondition<T extends ConfigArgs = ConfigArgs> extends Confi
         this.priorityValue = config.priorityValue || 0;
     }
 
-    async check(order: Order, args: ConfigArg[]): Promise<boolean> {
-        return this.checkFn(order, this.argsArrayToHash(args));
+    async check(ctx: RequestContext, order: Order, args: ConfigArg[]): Promise<boolean> {
+        return this.checkFn(ctx, order, this.argsArrayToHash(args));
     }
 }

+ 1 - 0
packages/core/src/config/session-cache/session-cache-strategy.ts

@@ -40,6 +40,7 @@ export type CachedSession = {
     activeOrderId?: ID;
     authenticationStrategy?: string;
     user?: CachedSessionUser;
+    activeChannelId?: ID;
 };
 
 /**

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

@@ -19,6 +19,7 @@ import { AuthenticationStrategy } from './auth/authentication-strategy';
 import { CollectionFilter } from './collection/collection-filter';
 import { CustomFields } from './custom-field/custom-field-types';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
+import { CustomFulfillmentProcess } from './fulfillment/custom-fulfillment-process';
 import { JobQueueStrategy } from './job-queue/job-queue-strategy';
 import { VendureLogger } from './logger/vendure-logger';
 import { CustomOrderProcess } from './order/custom-order-process';
@@ -511,6 +512,13 @@ export interface ShippingOptions {
      * An array of available ShippingCalculators for use in configuring ShippingMethods
      */
     shippingCalculators?: Array<ShippingCalculator<any>>;
+
+    /**
+     * @description
+     * Allows the definition of custom states and transition logic for the fulfillment process state machine.
+     * Takes an array of objects implementing the {@link CustomFulfillmentProcess} interface.
+     */
+    customFulfillmentProcess?: Array<CustomFulfillmentProcess<any>>;
 }
 
 /**

+ 12 - 7
packages/core/src/entity/customer/customer.entity.ts

@@ -1,10 +1,11 @@
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinColumn, JoinTable, ManyToMany, OneToMany, OneToOne } from 'typeorm';
 
-import { SoftDeletable } from '../../common/types/common-types';
+import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { Address } from '../address/address.entity';
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { CustomCustomerFields } from '../custom-entity-fields';
 import { CustomerGroup } from '../customer-group/customer-group.entity';
 import { Order } from '../order/order.entity';
@@ -19,7 +20,7 @@ import { User } from '../user/user.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Customer extends VendureEntity implements HasCustomFields, SoftDeletable {
+export class Customer extends VendureEntity implements ChannelAware, HasCustomFields, SoftDeletable {
     constructor(input?: DeepPartial<Customer>) {
         super(input);
     }
@@ -40,20 +41,24 @@ export class Customer extends VendureEntity implements HasCustomFields, SoftDele
     @Column()
     emailAddress: string;
 
-    @ManyToMany((type) => CustomerGroup, (group) => group.customers)
+    @ManyToMany(type => CustomerGroup, group => group.customers)
     @JoinTable()
     groups: CustomerGroup[];
 
-    @OneToMany((type) => Address, (address) => address.customer)
+    @OneToMany(type => Address, address => address.customer)
     addresses: Address[];
 
-    @OneToMany((type) => Order, (order) => order.customer)
+    @OneToMany(type => Order, order => order.customer)
     orders: Order[];
 
-    @OneToOne((type) => User, { eager: true })
+    @OneToOne(type => User, { eager: true })
     @JoinColumn()
     user?: User;
 
-    @Column((type) => CustomCustomerFields)
+    @Column(type => CustomCustomerFields)
     customFields: CustomCustomerFields;
+
+    @ManyToMany(type => Channel)
+    @JoinTable()
+    channels: Channel[];
 }

+ 3 - 0
packages/core/src/entity/fulfillment/fulfillment.entity.ts

@@ -1,6 +1,7 @@
 import { Column, Entity, OneToMany } from 'typeorm';
 
 import { DeepPartial } from '../../../../common/lib/shared-types';
+import { FulfillmentState } from '../../service/helpers/fulfillment-state-machine/fulfillment-state';
 import { VendureEntity } from '../base/base.entity';
 import { OrderItem } from '../order-item/order-item.entity';
 
@@ -17,6 +18,8 @@ export class Fulfillment extends VendureEntity {
         super(input);
     }
 
+    @Column('varchar') state: FulfillmentState;
+
     @Column({ default: '' })
     trackingCode: string;
 

+ 3 - 2
packages/core/src/entity/promotion/promotion.entity.ts

@@ -2,6 +2,7 @@ import { Adjustment, AdjustmentType, ConfigurableOperation } from '@vendure/comm
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
+import { RequestContext } from '../../api/common/request-context';
 import { AdjustmentSource } from '../../common/types/adjustment-source';
 import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { getConfig } from '../../config/config-helpers';
@@ -127,7 +128,7 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
         }
     }
 
-    async test(order: Order): Promise<boolean> {
+    async test(ctx: RequestContext, order: Order): Promise<boolean> {
         if (this.endsAt && this.endsAt < new Date()) {
             return false;
         }
@@ -139,7 +140,7 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
         }
         for (const condition of this.conditions) {
             const promotionCondition = this.allConditions[condition.code];
-            if (!promotionCondition || !(await promotionCondition.check(order, condition.args))) {
+            if (!promotionCondition || !(await promotionCondition.check(ctx, order, condition.args))) {
                 return false;
             }
         }

+ 8 - 1
packages/core/src/entity/session/session.entity.ts

@@ -2,6 +2,7 @@ import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, Index, ManyToOne, TableInheritance } from 'typeorm';
 
 import { VendureEntity } from '../base/base.entity';
+import { Channel } from '../channel/channel.entity';
 import { Customer } from '../customer/customer.entity';
 import { EntityId } from '../entity-id.decorator';
 import { Order } from '../order/order.entity';
@@ -28,6 +29,12 @@ export abstract class Session extends VendureEntity {
     @EntityId({ nullable: true })
     activeOrderId?: ID;
 
-    @ManyToOne((type) => Order)
+    @ManyToOne(type => Order)
     activeOrder: Order | null;
+
+    @EntityId({ nullable: true })
+    activeChannelId?: ID;
+
+    @ManyToOne(type => Channel)
+    activeChannel: Channel | null;
 }

+ 22 - 0
packages/core/src/event-bus/events/fulfillment-state-transition-event.ts

@@ -0,0 +1,22 @@
+import { RequestContext } from '../../api/common/request-context';
+import { Fulfillment } from '../../entity/fulfillment/fulfillment.entity';
+import { FulfillmentState } from '../../service/helpers/fulfillment-state-machine/fulfillment-state';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired whenever an {@link Fulfillment} transitions from one {@link FulfillmentState} to another.
+ *
+ * @docsCategory events
+ * @docsPage Event Types
+ */
+export class FulfillmentStateTransitionEvent extends VendureEvent {
+    constructor(
+        public fromState: FulfillmentState,
+        public toState: FulfillmentState,
+        public ctx: RequestContext,
+        public fulfillment: Fulfillment,
+    ) {
+        super();
+    }
+}

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

@@ -15,11 +15,14 @@
     "cannot-transition-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-payment-from-to": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-refund-from-to": "Cannot transition Refund from \"{ fromState }\" to \"{ toState }\"",
+    "cannot-transition-fulfillment-from-to": "Cannot transition Fulfillment from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-to-shipping-when-order-is-empty": "Cannot transition Order to the \"ArrangingShipping\" state when it is empty",
     "cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
     "cannot-transition-unless-all-cancelled": "Cannot transition Order to the \"Cancelled\" state unless all OrderItems are cancelled",
-    "cannot-transition-unless-all-order-items-fulfilled": "Cannot transition Order to the \"Fulfilled\" state unless all OrderItems are fulfilled",
-    "cannot-transition-unless-some-order-items-fulfilled": "Cannot transition Order to the \"PartiallyFulfilled\" state unless some OrderItems are fulfilled",
+    "cannot-transition-unless-all-order-items-delivered": "Cannot transition Order to the \"Delivered\" state unless all OrderItems are delivered",
+    "cannot-transition-unless-some-order-items-delivered": "Cannot transition Order to the \"PartiallyDelivered\" state unless some OrderItems are delivered",
+    "cannot-transition-unless-some-order-items-shipped": "Cannot transition Order to the \"PartiallyShipped\" state unless some OrderItems are shipped",
+    "cannot-transition-unless-all-order-items-shipped": "Cannot transition Order to the \"Shipped\" state unless all OrderItems are shipped",
     "cannot-transition-without-authorized-payments": "Cannot transition Order to the \"PaymentAuthorized\" state when the total is not covered by authorized Payments",
     "cannot-transition-without-settled-payments": "Cannot transition Order to the \"PaymentSettled\" state when the total is not covered by settled Payments",
     "cannot-use-registered-email-address-for-guest-order":  "Cannot use a registered email address for a guest order. Please log in first",

+ 5 - 3
packages/core/src/i18n/messages/pt_BR.json

@@ -17,11 +17,13 @@
     "cannot-transition-refund-from-to": "Não é possível fazer a transição do reembolso de \"{fromState}\" para \"{toState}\"",
     "cannot-transition-to-shipping-when-order-is-empty": "Não é possível fazer a transição do pedido para o estado \"ArrangingShipping\" quando estiver vazio",
     "cannot-transition-to-payment-without-customer": "Não é possível fazer a transição do pedido para o estado \"ArrangingPayment\" sem detalhes do cliente",
-    "cannot-transition-unless-all-cancelled": "Não é possível fazer a transição do pedido para o estado \"Cancelled\", a menos que todos os itens de ordem sejam cancelados",
-    "cannot-transition-unless-all-order-items-fulfilled": "Não é possível fazer a transição do pedido para o estado \"Fulfilled\", a menos que todos os itens de ordem sejam cumpridos",
-    "cannot-transition-unless-some-order-items-fulfilled": "Não é possível fazer a transição do pedido para o estado \"PartiallyFulfilled\", a menos que alguns itens de ordem sejam atendidos",
+    "cannot-transition-unless-all-cancelled": "Não é possível fazer a transição do pedido para o estado \"Cancelled\", a menos que todos os itens do pedido sejam cancelados",
+    "cannot-transition-unless-all-order-items-delivered": "Não é possível fazer a transição do pedido para o estado \"Delivered\", a menos que todos os itens do pedido sejam entregues",
+    "cannot-transition-unless-some-order-items-delivered": "Não é possível fazer a transição do pedido para o estado \"PartiallyDelivered\", a menos que alguns itens do pedido sejam entregues",
     "cannot-transition-without-authorized-payments": "Não é possível fazer a transição do pedido para o estado \"PaymentAuthorized\" quando o total não estiver coberto por pagamentos autorizados",
     "cannot-transition-without-settled-payments": "Não é possível fazer a transição do pedido para o estado \"PaymentSettled\" quando o total não estiver coberto por pagamentos liquidados",
+    "cannot-transition-unless-some-order-items-shipped": "Não é possível fazer a transição do pedido para o estado \"PartiallyShipped\", a menos que alguns itens do pedido sejam enviados",
+    "cannot-transition-unless-all-order-items-shipped": "Não é possível fazer a transição do pedido para o estado \"Shipped\", a menos que todos os itens do pedido sejam enviados",
     "cannot-use-registered-email-address-for-guest-order": "Não é possível usar um endereço de e-mail registrado para um pedido de hóspede. Faça login primeiro",
     "channel-not-found": "Nenhum canal com o token \"{token}\" existe",
     "collection-id-or-slug-must-be-provided": "O ID da coleção ou a lesma devem ser fornecidos",

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

@@ -9,6 +9,7 @@ import { Customer } from '../../../entity/customer/customer.entity';
 import { Role } from '../../../entity/role/role.entity';
 import { User } from '../../../entity/user/user.entity';
 import { AdministratorService } from '../../services/administrator.service';
+import { ChannelService } from '../../services/channel.service';
 import { CustomerService } from '../../services/customer.service';
 import { HistoryService } from '../../services/history.service';
 import { RoleService } from '../../services/role.service';
@@ -29,6 +30,7 @@ export class ExternalAuthenticationService {
         private historyService: HistoryService,
         private customerService: CustomerService,
         private administratorService: AdministratorService,
+        private channelService: ChannelService,
     ) {}
 
     /**
@@ -107,14 +109,14 @@ export class ExternalAuthenticationService {
         newUser.authenticationMethods = [authMethod];
         const savedUser = await this.connection.getRepository(ctx, User).save(newUser);
 
-        const customer = await this.connection.getRepository(ctx, Customer).save(
-            new Customer({
-                emailAddress: config.emailAddress,
-                firstName: config.firstName,
-                lastName: config.lastName,
-                user: savedUser,
-            }),
-        );
+        const customer = new Customer({
+            emailAddress: config.emailAddress,
+            firstName: config.firstName,
+            lastName: config.lastName,
+            user: savedUser,
+        });
+        this.channelService.assignToCurrentChannel(customer, ctx);
+        await this.connection.getRepository(ctx, Customer).save(customer);
 
         await this.historyService.createHistoryEntryForCustomer({
             customerId: customer.id,

+ 138 - 0
packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state-machine.ts

@@ -0,0 +1,138 @@
+import { Injectable } from '@nestjs/common';
+import { HistoryEntryType } from '@vendure/common/lib/generated-types';
+
+import { RequestContext } from '../../../api/common/request-context';
+import { IllegalOperationError } from '../../../common/error/errors';
+import { FSM } from '../../../common/finite-state-machine/finite-state-machine';
+import { mergeTransitionDefinitions } from '../../../common/finite-state-machine/merge-transition-definitions';
+import { StateMachineConfig, Transitions } from '../../../common/finite-state-machine/types';
+import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
+import { awaitPromiseOrObservable } from '../../../common/utils';
+import { ConfigService } from '../../../config/config.service';
+import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
+import { Order } from '../../../entity/order/order.entity';
+import { HistoryService } from '../../services/history.service';
+
+import {
+    FulfillmentState,
+    fulfillmentStateTransitions,
+    FulfillmentTransitionData,
+} from './fulfillment-state';
+
+@Injectable()
+export class FulfillmentStateMachine {
+    readonly config: StateMachineConfig<FulfillmentState, FulfillmentTransitionData>;
+    private readonly initialState: FulfillmentState = 'Pending';
+
+    constructor(private configService: ConfigService, private historyService: HistoryService) {
+        this.config = this.initConfig();
+    }
+
+    getInitialState(): FulfillmentState {
+        return this.initialState;
+    }
+
+    canTransition(currentState: FulfillmentState, newState: FulfillmentState): boolean {
+        return new FSM(this.config, currentState).canTransitionTo(newState);
+    }
+
+    getNextStates(fulfillment: Fulfillment): ReadonlyArray<FulfillmentState> {
+        const fsm = new FSM(this.config, fulfillment.state);
+        return fsm.getNextStates();
+    }
+
+    async transition(
+        ctx: RequestContext,
+        fulfillment: Fulfillment,
+        orders: Order[],
+        state: FulfillmentState,
+    ) {
+        const fsm = new FSM(this.config, fulfillment.state);
+        await fsm.transitionTo(state, { ctx, orders, fulfillment });
+        fulfillment.state = fsm.currentState;
+    }
+
+    /**
+     * Specific business logic to be executed on Fulfillment state transitions.
+     */
+    private async onTransitionStart(
+        fromState: FulfillmentState,
+        toState: FulfillmentState,
+        data: FulfillmentTransitionData,
+    ) {
+        /**/
+    }
+
+    /**
+     * Specific business logic to be executed after Fulfillment state transition completes.
+     */
+    private async onTransitionEnd(
+        fromState: FulfillmentState,
+        toState: FulfillmentState,
+        data: FulfillmentTransitionData,
+    ) {
+        const historyEntryPromises = data.orders.map(order =>
+            this.historyService.createHistoryEntryForOrder({
+                orderId: order.id,
+                type: HistoryEntryType.ORDER_FULFILLMENT_TRANSITION,
+                ctx: data.ctx,
+                data: {
+                    fulfillmentId: data.fulfillment.id,
+                    from: fromState,
+                    to: toState,
+                },
+            }),
+        );
+        await Promise.all(historyEntryPromises);
+    }
+
+    private initConfig(): StateMachineConfig<FulfillmentState, FulfillmentTransitionData> {
+        const customProcesses = this.configService.shippingOptions.customFulfillmentProcess ?? [];
+
+        const allTransitions = customProcesses.reduce(
+            (transitions, process) =>
+                mergeTransitionDefinitions(transitions, process.transitions as Transitions<any>),
+            fulfillmentStateTransitions,
+        );
+
+        const validationResult = validateTransitionDefinition(allTransitions, 'Pending');
+
+        return {
+            transitions: allTransitions,
+            onTransitionStart: async (fromState, toState, data) => {
+                for (const process of customProcesses) {
+                    if (typeof process.onTransitionStart === 'function') {
+                        const result = await awaitPromiseOrObservable(
+                            process.onTransitionStart(fromState, toState, data),
+                        );
+                        if (result === false || typeof result === 'string') {
+                            return result;
+                        }
+                    }
+                }
+                return this.onTransitionStart(fromState, toState, data);
+            },
+            onTransitionEnd: async (fromState, toState, data) => {
+                for (const process of customProcesses) {
+                    if (typeof process.onTransitionEnd === 'function') {
+                        await awaitPromiseOrObservable(process.onTransitionEnd(fromState, toState, data));
+                    }
+                }
+                await this.onTransitionEnd(fromState, toState, data);
+            },
+            onError: async (fromState, toState, message) => {
+                for (const process of customProcesses) {
+                    if (typeof process.onTransitionError === 'function') {
+                        await awaitPromiseOrObservable(
+                            process.onTransitionError(fromState, toState, message),
+                        );
+                    }
+                }
+                throw new IllegalOperationError(message || 'error.cannot-transition-fulfillment-from-to', {
+                    fromState,
+                    toState,
+                });
+            },
+        };
+    }
+}

+ 39 - 0
packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state.ts

@@ -0,0 +1,39 @@
+import { RequestContext } from '../../../api/common/request-context';
+import { Transitions } from '../../../common/finite-state-machine/types';
+import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
+import { Order } from '../../../entity/order/order.entity';
+
+/**
+ * @description
+ * These are the default states of the fulfillment process.
+ *
+ * @docsCategory fulfillment
+ */
+export type FulfillmentState = 'Pending' | 'Shipped' | 'Delivered' | 'Cancelled';
+
+export const fulfillmentStateTransitions: Transitions<FulfillmentState> = {
+    Pending: {
+        to: ['Shipped', 'Delivered', 'Cancelled'],
+    },
+    Shipped: {
+        to: ['Delivered', 'Cancelled'],
+    },
+    Delivered: {
+        to: ['Cancelled'],
+    },
+    Cancelled: {
+        to: [],
+    },
+};
+
+/**
+ * @description
+ * The data which is passed to the state transition handlers of the FulfillmentStateMachine.
+ *
+ * @docsCategory fulfillment
+ */
+export interface FulfillmentTransitionData {
+    ctx: RequestContext;
+    orders: Order[];
+    fulfillment: Fulfillment;
+}

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

@@ -146,7 +146,7 @@ describe('OrderCalculator', () => {
             args: { minimum: { type: 'int' } },
             code: 'order_total_condition',
             description: [{ languageCode: LanguageCode.en, value: '' }],
-            check(order, args) {
+            check(ctx, order, args) {
                 return args.minimum <= order.total;
             },
         });
@@ -359,7 +359,7 @@ describe('OrderCalculator', () => {
                         value: 'Passes if any order line has at least the minimum quantity',
                     },
                 ],
-                check(_order, args) {
+                check(ctx, _order, args) {
                     for (const line of _order.lines) {
                         if (args.minimum <= line.quantity) {
                             return true;

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

@@ -71,7 +71,7 @@ export class OrderCalculator {
 
             // Then test and apply promotions
             const totalBeforePromotions = order.total;
-            const itemsModifiedByPromotions = await this.applyPromotions(order, promotions);
+            const itemsModifiedByPromotions = await this.applyPromotions(ctx, order, promotions);
             itemsModifiedByPromotions.forEach(item => updatedOrderItems.add(item));
 
             if (order.total !== totalBeforePromotions || itemsModifiedByPromotions.length) {
@@ -148,9 +148,13 @@ export class OrderCalculator {
      * Applies any eligible promotions to each OrderItem in the order. Returns an array of
      * any OrderItems which had their Adjustments modified.
      */
-    private async applyPromotions(order: Order, promotions: Promotion[]): Promise<OrderItem[]> {
-        const updatedItems = await this.applyOrderItemPromotions(order, promotions);
-        await this.applyOrderPromotions(order, promotions);
+    private async applyPromotions(
+        ctx: RequestContext,
+        order: Order,
+        promotions: Promotion[],
+    ): Promise<OrderItem[]> {
+        const updatedItems = await this.applyOrderItemPromotions(ctx, order, promotions);
+        await this.applyOrderPromotions(ctx, order, promotions);
         return updatedItems;
     }
 
@@ -159,7 +163,7 @@ export class OrderCalculator {
      * of applying the promotions, and also due to added complexity in the name of performance
      * optimization. Therefore it is heavily annotated so that the purpose of each step is clear.
      */
-    private async applyOrderItemPromotions(order: Order, promotions: Promotion[]) {
+    private async applyOrderItemPromotions(ctx: RequestContext, order: Order, promotions: Promotion[]) {
         // The naive implementation updates *every* OrderItem after this function is run.
         // However, on a very large order with hundreds or thousands of OrderItems, this results in
         // very poor performance. E.g. updating a single quantity of an OrderLine results in saving
@@ -171,7 +175,7 @@ export class OrderCalculator {
         for (const line of order.lines) {
             // Must be re-calculated for each line, since the previous lines may have triggered promotions
             // which affected the order price.
-            const applicablePromotions = await filterAsync(promotions, p => p.test(order));
+            const applicablePromotions = await filterAsync(promotions, p => p.test(ctx, order));
 
             const lineHasExistingPromotions =
                 line.items[0].pendingAdjustments &&
@@ -193,7 +197,7 @@ export class OrderCalculator {
                 // We need to test the promotion *again*, even though we've tested them for the line.
                 // This is because the previous Promotions may have adjusted the Order in such a way
                 // as to render later promotions no longer applicable.
-                if (await promotion.test(order)) {
+                if (await promotion.test(ctx, order)) {
                     for (const item of line.items) {
                         const adjustment = await promotion.apply({
                             orderItem: item,
@@ -250,14 +254,14 @@ export class OrderCalculator {
         return hasPromotionsThatAreNoLongerApplicable;
     }
 
-    private async applyOrderPromotions(order: Order, promotions: Promotion[]) {
+    private async applyOrderPromotions(ctx: RequestContext, order: Order, promotions: Promotion[]) {
         order.clearAdjustments(AdjustmentType.PROMOTION);
-        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(order));
+        const applicableOrderPromotions = await filterAsync(promotions, p => p.test(ctx, order));
         if (applicableOrderPromotions.length) {
             for (const promotion of applicableOrderPromotions) {
                 // re-test the promotion on each iteration, since the order total
                 // may be modified by a previously-applied promotion
-                if (await promotion.test(order)) {
+                if (await promotion.test(ctx, order)) {
                     const adjustment = await promotion.apply({ order });
                     if (adjustment) {
                         order.pendingAdjustments = order.pendingAdjustments.concat(adjustment);

+ 31 - 24
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { HistoryEntryType } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { IllegalOperationError } from '../../../common/error/errors';
@@ -16,8 +17,10 @@ import { StockMovementService } from '../../services/stock-movement.service';
 import { TransactionalConnection } from '../../transaction/transactional-connection';
 import {
     orderItemsAreAllCancelled,
-    orderItemsAreFulfilled,
-    orderItemsArePartiallyFulfilled,
+    orderItemsAreDelivered,
+    orderItemsArePartiallyDelivered,
+    orderItemsArePartiallyShipped,
+    orderItemsAreShipped,
     orderTotalIsCovered,
 } from '../utils/order-utils';
 
@@ -57,6 +60,12 @@ export class OrderStateMachine {
         order.state = fsm.currentState;
     }
 
+    private async findOrderWithFulfillments(ctx: RequestContext, id: ID): Promise<Order> {
+        return await this.connection.getEntityOrThrow(ctx, Order, id, {
+            relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
+        });
+    }
+
     /**
      * Specific business logic to be executed on Order state transitions.
      */
@@ -80,30 +89,28 @@ export class OrderStateMachine {
                 return `error.cannot-transition-unless-all-cancelled`;
             }
         }
-        if (toState === 'PartiallyFulfilled') {
-            const orderWithFulfillments = await this.connection.getEntityOrThrow(
-                data.ctx,
-                Order,
-                data.order.id,
-                {
-                    relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
-                },
-            );
-            if (!orderItemsArePartiallyFulfilled(orderWithFulfillments)) {
-                return `error.cannot-transition-unless-some-order-items-fulfilled`;
+        if (toState === 'PartiallyShipped') {
+            const orderWithFulfillments = await this.findOrderWithFulfillments(data.ctx, data.order.id);
+            if (!orderItemsArePartiallyShipped(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-some-order-items-shipped`;
+            }
+        }
+        if (toState === 'Shipped') {
+            const orderWithFulfillments = await this.findOrderWithFulfillments(data.ctx, data.order.id);
+            if (!orderItemsAreShipped(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-all-order-items-shipped`;
+            }
+        }
+        if (toState === 'PartiallyDelivered') {
+            const orderWithFulfillments = await this.findOrderWithFulfillments(data.ctx, data.order.id);
+            if (!orderItemsArePartiallyDelivered(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-some-order-items-delivered`;
             }
         }
-        if (toState === 'Fulfilled') {
-            const orderWithFulfillments = await this.connection.getEntityOrThrow(
-                data.ctx,
-                Order,
-                data.order.id,
-                {
-                    relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
-                },
-            );
-            if (!orderItemsAreFulfilled(orderWithFulfillments)) {
-                return `error.cannot-transition-unless-all-order-items-fulfilled`;
+        if (toState === 'Delivered') {
+            const orderWithFulfillments = await this.findOrderWithFulfillments(data.ctx, data.order.id);
+            if (!orderItemsAreDelivered(orderWithFulfillments)) {
+                return `error.cannot-transition-unless-all-order-items-delivered`;
             }
         }
     }

+ 14 - 6
packages/core/src/service/helpers/order-state-machine/order-state.ts

@@ -14,8 +14,10 @@ export type OrderState =
     | 'ArrangingPayment'
     | 'PaymentAuthorized'
     | 'PaymentSettled'
-    | 'PartiallyFulfilled'
-    | 'Fulfilled'
+    | 'PartiallyShipped'
+    | 'Shipped'
+    | 'PartiallyDelivered'
+    | 'Delivered'
     | 'Cancelled';
 
 export const orderStateTransitions: Transitions<OrderState> = {
@@ -29,12 +31,18 @@ export const orderStateTransitions: Transitions<OrderState> = {
         to: ['PaymentSettled', 'Cancelled'],
     },
     PaymentSettled: {
-        to: ['PartiallyFulfilled', 'Fulfilled', 'Cancelled'],
+        to: ['PartiallyDelivered', 'Delivered', 'PartiallyShipped', 'Shipped', 'Cancelled'],
     },
-    PartiallyFulfilled: {
-        to: ['Fulfilled', 'PartiallyFulfilled', 'Cancelled'],
+    PartiallyShipped: {
+        to: ['Shipped', 'PartiallyDelivered', 'Cancelled'],
     },
-    Fulfilled: {
+    Shipped: {
+        to: ['PartiallyDelivered', 'Delivered', 'Cancelled'],
+    },
+    PartiallyDelivered: {
+        to: ['Delivered', 'Cancelled'],
+    },
+    Delivered: {
         to: ['Cancelled'],
     },
     Cancelled: {

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

@@ -7,38 +7,61 @@ import { PaymentState } from '../payment-state-machine/payment-state';
  */
 export function orderTotalIsCovered(order: Order, state: PaymentState): boolean {
     return (
-        order.payments.filter((p) => p.state === state).reduce((sum, p) => sum + p.amount, 0) === order.total
+        order.payments.filter(p => p.state === state).reduce((sum, p) => sum + p.amount, 0) === order.total
     );
 }
 
 /**
- * Returns true if all (non-cancelled) OrderItems are fulfilled.
+ * Returns true if all (non-cancelled) OrderItems are delivered.
  */
-export function orderItemsAreFulfilled(order: Order) {
+export function orderItemsAreDelivered(order: Order) {
     return getOrderItems(order)
-        .filter((orderItem) => !orderItem.cancelled)
-        .every(isFulfilled);
+        .filter(orderItem => !orderItem.cancelled)
+        .every(isDelivered);
 }
 
 /**
- * Returns true if at least one, but not all (non-cancelled) OrderItems are fulfilled.
+ * Returns true if at least one, but not all (non-cancelled) OrderItems are delivered.
  */
-export function orderItemsArePartiallyFulfilled(order: Order) {
-    const nonCancelledItems = getOrderItems(order).filter((orderItem) => !orderItem.cancelled);
-    return nonCancelledItems.some(isFulfilled) && !nonCancelledItems.every(isFulfilled);
+export function orderItemsArePartiallyDelivered(order: Order) {
+    const nonCancelledItems = getNonCancelledItems(order);
+    return nonCancelledItems.some(isDelivered) && !nonCancelledItems.every(isDelivered);
+}
+
+/**
+ * Returns true if at least one, but not all (non-cancelled) OrderItems are shipped.
+ */
+export function orderItemsArePartiallyShipped(order: Order) {
+    const nonCancelledItems = getNonCancelledItems(order);
+    return nonCancelledItems.some(isShipped) && !nonCancelledItems.every(isShipped);
+}
+
+/**
+ * Returns true if all (non-cancelled) OrderItems are shipped.
+ */
+export function orderItemsAreShipped(order: Order) {
+    return getOrderItems(order)
+        .filter(orderItem => !orderItem.cancelled)
+        .every(isShipped);
 }
 
 /**
  * Returns true if all OrderItems in the order are cancelled
  */
 export function orderItemsAreAllCancelled(order: Order) {
-    return getOrderItems(order).every((orderItem) => orderItem.cancelled);
+    return getOrderItems(order).every(orderItem => orderItem.cancelled);
 }
 
 function getOrderItems(order: Order): OrderItem[] {
     return order.lines.reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[]);
 }
+function getNonCancelledItems(order: Order): OrderItem[] {
+    return getOrderItems(order).filter(orderItem => !orderItem.cancelled);
+}
 
-function isFulfilled(orderItem: OrderItem) {
-    return !!orderItem.fulfillment;
+function isDelivered(orderItem: OrderItem) {
+    return orderItem.fulfillment && orderItem.fulfillment.state === 'Delivered';
+}
+function isShipped(orderItem: OrderItem) {
+    return orderItem.fulfillment && orderItem.fulfillment.state === 'Shipped';
 }

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

@@ -6,6 +6,7 @@ export * from './helpers/list-query-builder/list-query-builder';
 export * from './helpers/external-authentication/external-authentication.service';
 export * from './helpers/order-calculator/order-calculator';
 export * from './helpers/order-state-machine/order-state';
+export * from './helpers/fulfillment-state-machine/fulfillment-state';
 export * from './helpers/payment-state-machine/payment-state';
 export * from './services/administrator.service';
 export * from './services/asset.service';
@@ -16,6 +17,7 @@ export * from './services/customer.service';
 export * from './services/customer-group.service';
 export * from './services/facet.service';
 export * from './services/facet-value.service';
+export * from './services/fulfillment.service';
 export * from './services/global-settings.service';
 export * from './services/order.service';
 export * from './services/payment-method.service';

+ 4 - 0
packages/core/src/service/service.module.ts

@@ -12,6 +12,7 @@ import { WorkerServiceModule } from '../worker/worker-service.module';
 import { CollectionController } from './controllers/collection.controller';
 import { TaxRateController } from './controllers/tax-rate.controller';
 import { ExternalAuthenticationService } from './helpers/external-authentication/external-authentication.service';
+import { FulfillmentStateMachine } from './helpers/fulfillment-state-machine/fulfillment-state-machine';
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from './helpers/order-calculator/order-calculator';
 import { OrderMerger } from './helpers/order-merger/order-merger';
@@ -36,6 +37,7 @@ import { CustomerGroupService } from './services/customer-group.service';
 import { CustomerService } from './services/customer.service';
 import { FacetValueService } from './services/facet-value.service';
 import { FacetService } from './services/facet.service';
+import { FulfillmentService } from './services/fulfillment.service';
 import { GlobalSettingsService } from './services/global-settings.service';
 import { HistoryService } from './services/history.service';
 import { OrderTestingService } from './services/order-testing.service';
@@ -68,6 +70,7 @@ const services = [
     CustomerService,
     FacetService,
     FacetValueService,
+    FulfillmentService,
     GlobalSettingsService,
     HistoryService,
     OrderService,
@@ -95,6 +98,7 @@ const helpers = [
     TaxCalculator,
     OrderCalculator,
     OrderStateMachine,
+    FulfillmentStateMachine,
     OrderMerger,
     PaymentStateMachine,
     ListQueryBuilder,

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

@@ -50,7 +50,9 @@ export class CustomerGroupService {
         return this.listQueryBuilder
             .build(Customer, options, { ctx })
             .leftJoin('customer.groups', 'group')
+            .leftJoin('customer.channels', 'channel')
             .andWhere('group.id = :groupId', { groupId: customerGroupId })
+            .andWhere('channel.id =:channelId', { channelId: ctx.channelId })
             .getManyAndCount()
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
@@ -147,9 +149,18 @@ export class CustomerGroupService {
         return assertFound(this.findOne(ctx, group.id));
     }
 
-    private getCustomersFromIds(ctx: RequestContext, ids: ID[]): Promise<Customer[]> {
+    private getCustomersFromIds(ctx: RequestContext, ids: ID[]): Promise<Customer[]> | Customer[] {
+        if (ids.length === 0) {
+            return new Array<Customer>();
+        } // TypeORM throws error when list is empty
         return this.connection
             .getRepository(ctx, Customer)
-            .findByIds(ids, { where: { deletedAt: null }, relations: ['groups'] });
+            .createQueryBuilder('customer')
+            .leftJoin('customer.channels', 'channel')
+            .leftJoinAndSelect('customer.groups', 'group')
+            .where('customer.id IN (:...customerIds)', { customerIds: ids })
+            .andWhere('channel.id = :channelId', { channelId: ctx.channelId })
+            .andWhere('customer.deletedAt is null')
+            .getMany();
     }
 }

+ 93 - 23
packages/core/src/service/services/customer.service.ts

@@ -26,6 +26,7 @@ import { NATIVE_AUTH_STRATEGY_NAME } from '../../config/auth/native-authenticati
 import { ConfigService } from '../../config/config.service';
 import { Address } from '../../entity/address/address.entity';
 import { NativeAuthenticationMethod } from '../../entity/authentication-method/native-authentication-method.entity';
+import { Channel } from '../../entity/channel/channel.entity';
 import { CustomerGroup } from '../../entity/customer-group/customer-group.entity';
 import { Customer } from '../../entity/customer/customer.entity';
 import { HistoryEntry } from '../../entity/history-entry/history-entry.entity';
@@ -41,6 +42,7 @@ import { patchEntity } from '../helpers/utils/patch-entity';
 import { translateDeep } from '../helpers/utils/translate-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
+import { ChannelService } from './channel.service';
 import { CountryService } from './country.service';
 import { HistoryService } from './history.service';
 import { UserService } from './user.service';
@@ -55,6 +57,7 @@ export class CustomerService {
         private listQueryBuilder: ListQueryBuilder,
         private eventBus: EventBus,
         private historyService: HistoryService,
+        private channelService: ChannelService,
     ) {}
 
     findAll(
@@ -62,22 +65,34 @@ export class CustomerService {
         options: ListQueryOptions<Customer> | undefined,
     ): Promise<PaginatedList<Customer>> {
         return this.listQueryBuilder
-            .build(Customer, options, { where: { deletedAt: null }, ctx })
+            .build(Customer, options, {
+                relations: ['channels'],
+                channelId: ctx.channelId,
+                where: { deletedAt: null },
+                ctx,
+            })
             .getManyAndCount()
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
 
     findOne(ctx: RequestContext, id: ID): Promise<Customer | undefined> {
-        return this.connection.getRepository(ctx, Customer).findOne(id, { where: { deletedAt: null } });
+        return this.connection.findOneInChannel(ctx, Customer, id, ctx.channelId, {
+            where: { deletedAt: null },
+        });
     }
 
-    findOneByUserId(ctx: RequestContext, userId: ID): Promise<Customer | undefined> {
-        return this.connection.getRepository(ctx, Customer).findOne({
-            where: {
-                user: { id: userId },
-                deletedAt: null,
-            },
-        });
+    findOneByUserId(ctx: RequestContext, userId: ID, filterOnChannel = true): Promise<Customer | undefined> {
+        let query = this.connection
+            .getRepository(ctx, Customer)
+            .createQueryBuilder('customer')
+            .leftJoin('customer.channels', 'channel')
+            .leftJoinAndSelect('customer.user', 'user')
+            .where('user.id = :userId', { userId })
+            .andWhere('customer.deletedAt is null');
+        if (filterOnChannel) {
+            query = query.andWhere('channel.id = :channelId', { channelId: ctx.channelId });
+        }
+        return query.getOne();
     }
 
     findAddressesByCustomerId(ctx: RequestContext, customerId: ID): Promise<Address[]> {
@@ -96,10 +111,19 @@ export class CustomerService {
             });
     }
 
-    async getCustomerGroups(ctx: RequestContext | undefined, customerId: ID): Promise<CustomerGroup[]> {
-        const customerWithGroups = await this.connection
-            .getRepository(ctx, Customer)
-            .findOne(customerId, { relations: ['groups'] });
+    async getCustomerGroups(ctx: RequestContext, customerId: ID): Promise<CustomerGroup[]> {
+        const customerWithGroups = await this.connection.findOneInChannel(
+            ctx,
+            Customer,
+            customerId,
+            ctx?.channelId,
+            {
+                relations: ['groups'],
+                where: {
+                    deletedAt: null,
+                },
+            },
+        );
         if (customerWithGroups) {
             return customerWithGroups.groups;
         } else {
@@ -111,7 +135,21 @@ export class CustomerService {
         input.emailAddress = normalizeEmailAddress(input.emailAddress);
         const customer = new Customer(input);
 
+        const existingCustomerInChannel = await this.connection
+            .getRepository(ctx, Customer)
+            .createQueryBuilder('customer')
+            .leftJoin('customer.channels', 'channel')
+            .where('channel.id = :channelId', { channelId: ctx.channelId })
+            .andWhere('customer.emailAddress = :emailAddress', { emailAddress: input.emailAddress })
+            .andWhere('customer.deletedAt is null')
+            .getOne();
+
+        if (existingCustomerInChannel) {
+            throw new UserInputError(`error.email-address-must-be-unique`);
+        }
+
         const existingCustomer = await this.connection.getRepository(ctx, Customer).findOne({
+            relations: ['channels'],
             where: {
                 emailAddress: input.emailAddress,
                 deletedAt: null,
@@ -124,7 +162,13 @@ export class CustomerService {
             },
         });
 
-        if (existingCustomer || existingUser) {
+        if (existingCustomer && existingUser) {
+            // Customer already exists, bring to this Channel
+            const updatedCustomer = patchEntity(existingCustomer, input);
+            updatedCustomer.channels.push(ctx.channel);
+            return this.connection.getRepository(Customer).save(updatedCustomer);
+        } else if (existingCustomer || existingUser) {
+            // Not sure when this situation would occur
             throw new UserInputError(`error.email-address-must-be-unique`);
         }
         customer.user = await this.userService.createCustomerUser(ctx, input.emailAddress, password);
@@ -137,7 +181,8 @@ export class CustomerService {
         } else {
             this.eventBus.publish(new AccountRegistrationEvent(ctx, customer.user));
         }
-        const createdCustomer = await await this.connection.getRepository(ctx, Customer).save(customer);
+        this.channelService.assignToCurrentChannel(customer, ctx);
+        const createdCustomer = await this.connection.getRepository(ctx, Customer).save(customer);
 
         await this.historyService.createHistoryEntryForCustomer({
             ctx,
@@ -381,9 +426,11 @@ export class CustomerService {
     }
 
     async update(ctx: RequestContext, input: UpdateCustomerInput): Promise<Customer> {
-        const customer = await this.connection.getEntityOrThrow(ctx, Customer, input.id);
+        const customer = await this.connection.getEntityOrThrow(ctx, Customer, input.id, {
+            channelId: ctx.channelId,
+        });
         const updatedCustomer = patchEntity(customer, input);
-        await this.connection.getRepository(ctx, Customer).save(customer, { reload: false });
+        await this.connection.getRepository(ctx, Customer).save(updatedCustomer, { reload: false });
         await this.historyService.createHistoryEntryForCustomer({
             customerId: customer.id,
             ctx,
@@ -406,6 +453,7 @@ export class CustomerService {
         input.emailAddress = normalizeEmailAddress(input.emailAddress);
         let customer: Customer;
         const existing = await this.connection.getRepository(ctx, Customer).findOne({
+            relations: ['channels'],
             where: {
                 emailAddress: input.emailAddress,
                 deletedAt: null,
@@ -417,21 +465,21 @@ export class CustomerService {
                 throw new IllegalOperationError('error.cannot-use-registered-email-address-for-guest-order');
             }
             customer = patchEntity(existing, input);
+            customer.channels.push(await this.connection.getEntityOrThrow(ctx, Channel, ctx.channelId));
         } else {
             customer = new Customer(input);
+            this.channelService.assignToCurrentChannel(customer, ctx);
         }
         return this.connection.getRepository(ctx, Customer).save(customer);
     }
 
     async createAddress(ctx: RequestContext, customerId: ID, input: CreateAddressInput): Promise<Address> {
-        const customer = await this.connection.getRepository(ctx, Customer).findOne(customerId, {
+        const customer = await this.connection.getEntityOrThrow(ctx, Customer, customerId, {
             where: { deletedAt: null },
             relations: ['addresses'],
+            channelId: ctx.channelId,
         });
 
-        if (!customer) {
-            throw new EntityNotFoundError('Customer', customerId);
-        }
         const country = await this.countryService.findOneByCode(ctx, input.countryCode);
         const address = new Address({
             ...input,
@@ -454,6 +502,15 @@ export class CustomerService {
         const address = await this.connection.getEntityOrThrow(ctx, Address, input.id, {
             relations: ['customer', 'country'],
         });
+        const customer = await this.connection.findOneInChannel(
+            ctx,
+            Customer,
+            address.customer.id,
+            ctx.channelId,
+        );
+        if (!customer) {
+            throw new EntityNotFoundError('Address', input.id);
+        }
         if (input.countryCode && input.countryCode !== address.country.code) {
             address.country = await this.countryService.findOneByCode(ctx, input.countryCode);
         } else {
@@ -479,6 +536,15 @@ export class CustomerService {
         const address = await this.connection.getEntityOrThrow(ctx, Address, id, {
             relations: ['customer', 'country'],
         });
+        const customer = await this.connection.findOneInChannel(
+            ctx,
+            Customer,
+            address.customer.id,
+            ctx.channelId,
+        );
+        if (!customer) {
+            throw new EntityNotFoundError('Address', id);
+        }
         address.country = translateDeep(address.country, ctx.languageCode);
         await this.reassignDefaultsForDeletedAddress(ctx, address);
         await this.historyService.createHistoryEntryForCustomer({
@@ -494,7 +560,9 @@ export class CustomerService {
     }
 
     async softDelete(ctx: RequestContext, customerId: ID): Promise<DeletionResponse> {
-        const customer = await this.connection.getEntityOrThrow(ctx, Customer, customerId);
+        const customer = await this.connection.getEntityOrThrow(ctx, Customer, customerId, {
+            channelId: ctx.channelId,
+        });
         await this.connection
             .getRepository(ctx, Customer)
             .update({ id: customerId }, { deletedAt: new Date() });
@@ -506,7 +574,9 @@ export class CustomerService {
     }
 
     async addNoteToCustomer(ctx: RequestContext, input: AddNoteToCustomerInput): Promise<Customer> {
-        const customer = await this.connection.getEntityOrThrow(ctx, Customer, input.id);
+        const customer = await this.connection.getEntityOrThrow(ctx, Customer, input.id, {
+            channelId: ctx.channelId,
+        });
         await this.historyService.createHistoryEntryForCustomer(
             {
                 ctx,

+ 74 - 0
packages/core/src/service/services/fulfillment.service.ts

@@ -0,0 +1,74 @@
+import { Injectable } from '@nestjs/common';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
+
+import { RequestContext } from '../../api/common/request-context';
+import { Fulfillment } from '../../entity/fulfillment/fulfillment.entity';
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { Order } from '../../entity/order/order.entity';
+import { EventBus } from '../../event-bus/event-bus';
+import { FulfillmentStateTransitionEvent } from '../../event-bus/events/fulfillment-state-transition-event';
+import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillment-state';
+import { FulfillmentStateMachine } from '../helpers/fulfillment-state-machine/fulfillment-state-machine';
+import { TransactionalConnection } from '../transaction/transactional-connection';
+
+@Injectable()
+export class FulfillmentService {
+    constructor(
+        private connection: TransactionalConnection,
+        private fulfillmentStateMachine: FulfillmentStateMachine,
+        private eventBus: EventBus,
+    ) {}
+
+    async create(ctx: RequestContext, input: DeepPartial<Fulfillment>): Promise<Fulfillment> {
+        const newFulfillment = new Fulfillment({
+            ...input,
+            state: this.fulfillmentStateMachine.getInitialState(),
+        });
+        return this.connection.getRepository(ctx, Fulfillment).save(newFulfillment);
+    }
+
+    async findOneOrThrow(
+        ctx: RequestContext,
+        id: ID,
+        relations: string[] = ['orderItems'],
+    ): Promise<Fulfillment> {
+        return await this.connection.getEntityOrThrow(ctx, Fulfillment, id, {
+            relations,
+        });
+    }
+
+    async getOrderItemsByFulfillmentId(ctx: RequestContext, id: ID): Promise<OrderItem[]> {
+        const fulfillment = await this.findOneOrThrow(ctx, id);
+        return fulfillment.orderItems;
+    }
+
+    async transitionToState(
+        ctx: RequestContext,
+        fulfillmentId: ID,
+        state: FulfillmentState,
+    ): Promise<{
+        fulfillment: Fulfillment;
+        orders: Order[];
+        fromState: FulfillmentState;
+        toState: FulfillmentState;
+    }> {
+        const fulfillment = await this.findOneOrThrow(ctx, fulfillmentId, [
+            'orderItems',
+            'orderItems.line',
+            'orderItems.line.order',
+        ]);
+        // Find orders based on order items filtering by id, removing duplicated orders
+        const ordersInOrderItems = fulfillment.orderItems.map(oi => oi.line.order);
+        const orders = ordersInOrderItems.filter((v, i, a) => a.findIndex(t => t.id === v.id) === i);
+        const fromState = fulfillment.state;
+        await this.fulfillmentStateMachine.transition(ctx, fulfillment, orders, state);
+        await this.connection.getRepository(Fulfillment).save(fulfillment, { reload: false });
+        this.eventBus.publish(new FulfillmentStateTransitionEvent(fromState, state, ctx, fulfillment));
+
+        return { fulfillment, orders, fromState, toState: state };
+    }
+
+    getNextStates(fulfillment: Fulfillment): ReadonlyArray<FulfillmentState> {
+        return this.fulfillmentStateMachine.getNextStates(fulfillment);
+    }
+}

+ 7 - 1
packages/core/src/service/services/history.service.ts

@@ -12,6 +12,7 @@ import { Administrator } from '../../entity/administrator/administrator.entity';
 import { CustomerHistoryEntry } from '../../entity/history-entry/customer-history-entry.entity';
 import { HistoryEntry } from '../../entity/history-entry/history-entry.entity';
 import { OrderHistoryEntry } from '../../entity/history-entry/order-history-entry.entity';
+import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillment-state';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { PaymentState } from '../helpers/payment-state-machine/payment-state';
@@ -72,7 +73,12 @@ export type OrderHistoryEntryData = {
         from: PaymentState;
         to: PaymentState;
     };
-    [HistoryEntryType.ORDER_FULLFILLMENT]: {
+    [HistoryEntryType.ORDER_FULFILLMENT_TRANSITION]: {
+        fulfillmentId: ID;
+        from: FulfillmentState;
+        to: FulfillmentState;
+    };
+    [HistoryEntryType.ORDER_FULFILLMENT]: {
         fulfillmentId: ID;
     };
     [HistoryEntryType.ORDER_CANCELLATION]: {

+ 56 - 36
packages/core/src/service/services/order.service.ts

@@ -45,6 +45,7 @@ import { EventBus } from '../../event-bus/event-bus';
 import { OrderStateTransitionEvent } from '../../event-bus/events/order-state-transition-event';
 import { PaymentStateTransitionEvent } from '../../event-bus/events/payment-state-transition-event';
 import { RefundStateTransitionEvent } from '../../event-bus/events/refund-state-transition-event';
+import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillment-state';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from '../helpers/order-calculator/order-calculator';
 import { OrderMerger } from '../helpers/order-merger/order-merger';
@@ -55,7 +56,8 @@ import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state
 import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator';
 import {
     orderItemsAreAllCancelled,
-    orderItemsAreFulfilled,
+    orderItemsAreDelivered,
+    orderItemsAreShipped,
     orderTotalIsCovered,
 } from '../helpers/utils/order-utils';
 import { patchEntity } from '../helpers/utils/patch-entity';
@@ -65,6 +67,7 @@ import { TransactionalConnection } from '../transaction/transactional-connection
 import { ChannelService } from './channel.service';
 import { CountryService } from './country.service';
 import { CustomerService } from './customer.service';
+import { FulfillmentService } from './fulfillment.service';
 import { HistoryService } from './history.service';
 import { PaymentMethodService } from './payment-method.service';
 import { ProductVariantService } from './product-variant.service';
@@ -85,6 +88,7 @@ export class OrderService {
         private orderMerger: OrderMerger,
         private paymentStateMachine: PaymentStateMachine,
         private paymentMethodService: PaymentMethodService,
+        private fulfillmentService: FulfillmentService,
         private listQueryBuilder: ListQueryBuilder,
         private stockMovementService: StockMovementService,
         private refundStateMachine: RefundStateMachine,
@@ -461,6 +465,44 @@ export class OrderService {
         this.eventBus.publish(new OrderStateTransitionEvent(fromState, state, ctx, order));
         return order;
     }
+    async transitionFulfillmentToState(
+        ctx: RequestContext,
+        fulfillmentId: ID,
+        state: FulfillmentState,
+    ): Promise<Fulfillment> {
+        const { fulfillment, fromState, toState, orders } = await this.fulfillmentService.transitionToState(
+            ctx,
+            fulfillmentId,
+            state,
+        );
+        await Promise.all(
+            orders.map(order => this.handleFulfillmentStateTransitByOrder(ctx, order.id, fromState, toState)),
+        );
+        return fulfillment;
+    }
+    private async handleFulfillmentStateTransitByOrder(
+        ctx: RequestContext,
+        orderId: ID,
+        fromState: FulfillmentState,
+        toState: FulfillmentState,
+    ): Promise<void> {
+        if (fromState === 'Pending' && toState === 'Shipped') {
+            const orderWithFulfillment = await this.getOrderWithFulfillments(ctx, orderId);
+            if (orderItemsAreShipped(orderWithFulfillment)) {
+                await this.transitionToState(ctx, orderId, 'Shipped');
+            } else {
+                await this.transitionToState(ctx, orderId, 'PartiallyShipped');
+            }
+        }
+        if (fromState === 'Shipped' && toState === 'Delivered') {
+            const orderWithFulfillment = await this.getOrderWithFulfillments(ctx, orderId);
+            if (orderItemsAreDelivered(orderWithFulfillment)) {
+                await this.transitionToState(ctx, orderId, 'Delivered');
+            } else {
+                await this.transitionToState(ctx, orderId, 'PartiallyDelivered');
+            }
+        }
+    }
 
     async addPaymentToOrder(ctx: RequestContext, orderId: ID, input: PaymentInput): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
@@ -535,45 +577,25 @@ export class OrderService {
         );
 
         for (const order of orders) {
-            if (order.state !== 'PaymentSettled' && order.state !== 'PartiallyFulfilled') {
+            if (order.state !== 'PaymentSettled' && order.state !== 'PartiallyDelivered') {
                 throw new IllegalOperationError('error.create-fulfillment-orders-must-be-settled');
             }
         }
-
-        const fulfillment = await this.connection.getRepository(ctx, Fulfillment).save(
-            new Fulfillment({
-                trackingCode: input.trackingCode,
-                method: input.method,
-                orderItems: items,
-            }),
-        );
+        const fulfillment = await this.fulfillmentService.create(ctx, {
+            trackingCode: input.trackingCode,
+            method: input.method,
+            orderItems: items,
+        });
 
         for (const order of orders) {
             await this.historyService.createHistoryEntryForOrder({
                 ctx,
                 orderId: order.id,
-                type: HistoryEntryType.ORDER_FULLFILLMENT,
+                type: HistoryEntryType.ORDER_FULFILLMENT,
                 data: {
                     fulfillmentId: fulfillment.id,
                 },
             });
-            const orderWithFulfillments = await this.connection.findOneInChannel(
-                ctx,
-                Order,
-                order.id,
-                ctx.channelId,
-                {
-                    relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
-                },
-            );
-            if (!orderWithFulfillments) {
-                throw new InternalServerError('error.could-not-find-order');
-            }
-            if (orderItemsAreFulfilled(orderWithFulfillments)) {
-                await this.transitionToState(ctx, order.id, 'Fulfilled');
-            } else {
-                await this.transitionToState(ctx, order.id, 'PartiallyFulfilled');
-            }
         }
         return fulfillment;
     }
@@ -598,14 +620,6 @@ export class OrderService {
         const items = lines.reduce((acc, l) => [...acc, ...l.items], [] as OrderItem[]);
         return unique(items.map(i => i.fulfillment).filter(notNullOrUndefined), 'id');
     }
-
-    async getFulfillmentOrderItems(ctx: RequestContext, id: ID): Promise<OrderItem[]> {
-        const fulfillment = await this.connection.getEntityOrThrow(ctx, Fulfillment, id, {
-            relations: ['orderItems'],
-        });
-        return fulfillment.orderItems;
-    }
-
     async cancelOrder(ctx: RequestContext, input: CancelOrderInput): Promise<Order> {
         let allOrderItemsCancelled = false;
         if (input.lines != null) {
@@ -927,6 +941,12 @@ export class OrderService {
         return order;
     }
 
+    private async getOrderWithFulfillments(ctx: RequestContext, orderId: ID): Promise<Order> {
+        return await this.connection.getEntityOrThrow(ctx, Order, orderId, {
+            relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
+        });
+    }
+
     private async getOrdersAndItemsFromLines(
         ctx: RequestContext,
         orderLinesInput: OrderLineInput[],

+ 15 - 0
packages/core/src/service/services/session.service.ts

@@ -131,6 +131,7 @@ export class SessionService implements EntitySubscriberInterface {
             token: session.token,
             expires: session.expires,
             activeOrderId: session.activeOrderId,
+            activeChannelId: session.activeChannelId,
         };
         if (this.isAuthenticatedSession(session)) {
             serializedSession.authenticationStrategy = session.authenticationStrategy;
@@ -199,6 +200,20 @@ export class SessionService implements EntitySubscriberInterface {
         return serializedSession;
     }
 
+    async setActiveChannel(serializedSession: CachedSession, channel: Channel): Promise<CachedSession> {
+        const session = await this.connection
+            .getRepository(Session)
+            .findOne(serializedSession.id, { relations: ['user', 'user.roles', 'user.roles.channels'] });
+        if (session) {
+            session.activeChannel = channel;
+            await this.connection.getRepository(Session).save(session, { reload: false });
+            const updatedSerializedSession = this.serializeSession(session);
+            await this.sessionCacheStrategy.set(updatedSerializedSession);
+            return updatedSerializedSession;
+        }
+        return serializedSession;
+    }
+
     /**
      * Deletes all existing sessions for the given user.
      */

+ 3 - 2
packages/create/src/helpers.ts

@@ -344,8 +344,9 @@ async function checkPostgresDbExists(options: any, root: string): Promise<true>
 function throwConnectionError(err: any) {
     throw new Error(
         `Could not connect to the database. ` +
-            `Please check the connection settings in your Vendure config.\n[${err.message ||
-                err.toString()}]`,
+            `Please check the connection settings in your Vendure config.\n[${
+                err.message || err.toString()
+            }]`,
     );
 }
 

+ 1 - 1
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -15,7 +15,7 @@ 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 { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 import {
     AssignProductsToChannel,
     CreateChannel,

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

@@ -1234,10 +1234,12 @@ export type FloatCustomFieldConfig = CustomField & {
 
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
+    nextStates: Array<Scalars['String']>;
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     orderItems: Array<OrderItem>;
+    state: Scalars['String'];
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1313,9 +1315,10 @@ export enum HistoryEntryType {
     CUSTOMER_NOTE = 'CUSTOMER_NOTE',
     ORDER_STATE_TRANSITION = 'ORDER_STATE_TRANSITION',
     ORDER_PAYMENT_TRANSITION = 'ORDER_PAYMENT_TRANSITION',
-    ORDER_FULLFILLMENT = 'ORDER_FULLFILLMENT',
+    ORDER_FULFILLMENT = 'ORDER_FULFILLMENT',
     ORDER_CANCELLATION = 'ORDER_CANCELLATION',
     ORDER_REFUND_TRANSITION = 'ORDER_REFUND_TRANSITION',
+    ORDER_FULFILLMENT_TRANSITION = 'ORDER_FULFILLMENT_TRANSITION',
     ORDER_NOTE = 'ORDER_NOTE',
     ORDER_COUPON_APPLIED = 'ORDER_COUPON_APPLIED',
     ORDER_COUPON_REMOVED = 'ORDER_COUPON_REMOVED',
@@ -1869,6 +1872,7 @@ export type Mutation = {
     updateOrderNote: HistoryEntry;
     deleteOrderNote: DeletionResponse;
     transitionOrderToState?: Maybe<Order>;
+    transitionFulfillmentToState: Fulfillment;
     setOrderCustomFields?: Maybe<Order>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
@@ -2162,6 +2166,11 @@ export type MutationTransitionOrderToStateArgs = {
     state: Scalars['String'];
 };
 
+export type MutationTransitionFulfillmentToStateArgs = {
+    id: Scalars['ID'];
+    state: Scalars['String'];
+};
+
 export type MutationSetOrderCustomFieldsArgs = {
     input: UpdateOrderInput;
 };

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-admin.json


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-shop.json


Некоторые файлы не были показаны из-за большого количества измененных файлов