Parcourir la source

feat(core): Implement FulfillmentHandlers

Relates to #529. This commit introduces a new FulfillmentHandler class which can be used to define
how Fulfillments are created.

BREAKING CHANGE: The Fulfillment and ShippingMethod entities have new fields relating to
FulfillmentHandlers. This will require a DB migration, though no custom data migration will be
needed for this particular change.

The `addFulfillmentToOrder` mutation input has changed: the `method` & `trackingCode` fields
have been replaced by a `handler` field which accepts a FulfillmentHandler code, and any
expected arguments defined by that handler.
Michael Bromley il y a 5 ans
Parent
commit
4e53d088f0
37 fichiers modifiés avec 993 ajouts et 119 suppressions
  1. 26 3
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  2. 1 0
      packages/common/src/generated-shop-types.ts
  3. 25 3
      packages/common/src/generated-types.ts
  4. 31 19
      packages/core/e2e/fulfillment-process.e2e-spec.ts
  5. 57 4
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  6. 1 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  7. 3 0
      packages/core/e2e/graphql/shared-definitions.ts
  8. 237 0
      packages/core/e2e/order-fulfillment.e2e-spec.ts
  9. 34 9
      packages/core/e2e/order.e2e-spec.ts
  10. 4 0
      packages/core/e2e/shipping-method-eligibility.e2e-spec.ts
  11. 2 0
      packages/core/e2e/shipping-method.e2e-spec.ts
  12. 43 13
      packages/core/e2e/stock-control.e2e-spec.ts
  13. 6 0
      packages/core/src/api/resolvers/admin/shipping-method.resolver.ts
  14. 17 2
      packages/core/src/api/schema/admin-api/order.api.graphql
  15. 3 0
      packages/core/src/api/schema/admin-api/shipping-method.api.graphql
  16. 1 0
      packages/core/src/api/schema/type/shipping-method.type.graphql
  17. 6 1
      packages/core/src/app.module.ts
  18. 55 19
      packages/core/src/common/configurable-operation.ts
  19. 22 1
      packages/core/src/common/error/generated-graphql-admin-errors.ts
  20. 1 1
      packages/core/src/config/config.service.ts
  21. 3 2
      packages/core/src/config/default-config.ts
  22. 158 0
      packages/core/src/config/fulfillment/fulfillment-handler.ts
  23. 30 0
      packages/core/src/config/fulfillment/manual-fulfillment-handler.ts
  24. 5 3
      packages/core/src/config/index.ts
  25. 10 6
      packages/core/src/config/vendure-config.ts
  26. 2 0
      packages/core/src/data-import/providers/populator/populator.ts
  27. 3 0
      packages/core/src/entity/fulfillment/fulfillment.entity.ts
  28. 3 0
      packages/core/src/entity/shipping-method/shipping-method.entity.ts
  29. 10 1
      packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state-machine.ts
  30. 5 2
      packages/core/src/service/helpers/shipping-configuration/shipping-configuration.ts
  31. 57 9
      packages/core/src/service/services/fulfillment.service.ts
  32. 21 17
      packages/core/src/service/services/order.service.ts
  33. 52 1
      packages/core/src/service/services/shipping-method.service.ts
  34. 33 0
      packages/dev-server/dev-config.ts
  35. 26 3
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  36. 0 0
      schema-admin.json
  37. 0 0
      schema-shop.json

+ 26 - 3
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -67,6 +67,7 @@ export type Query = {
     shippingMethod?: Maybe<ShippingMethod>;
     shippingEligibilityCheckers: Array<ConfigurableOperationDefinition>;
     shippingCalculators: Array<ConfigurableOperationDefinition>;
+    fulfillmentHandlers: Array<ConfigurableOperationDefinition>;
     testShippingMethod: TestShippingMethodResult;
     testEligibleShippingMethods: Array<ShippingMethodQuote>;
     taxCategories: Array<TaxCategory>;
@@ -1218,8 +1219,7 @@ export type UpdateOrderInput = {
 
 export type FulfillOrderInput = {
     lines: Array<OrderLineInput>;
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
+    handler: ConfigurableOperationInput;
 };
 
 export type CancelOrderInput = {
@@ -1279,6 +1279,19 @@ export type ItemsAlreadyFulfilledError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Returned if the specified FulfillmentHandler code is not valid */
+export type InvalidFulfillmentHandlerError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned if an error is thrown in a FulfillmentHandler's createFulfillment method */
+export type CreateFulfillmentError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    fulfillmentHandlerError: Scalars['String'];
+};
+
 /**
  * Returned if attempting to create a Fulfillment when there is insufficient
  * stockOnHand of a ProductVariant to satisfy the requested quantity.
@@ -1375,7 +1388,10 @@ export type AddFulfillmentToOrderResult =
     | Fulfillment
     | EmptyOrderLineSelectionError
     | ItemsAlreadyFulfilledError
-    | InsufficientStockOnHandError;
+    | InsufficientStockOnHandError
+    | InvalidFulfillmentHandlerError
+    | FulfillmentStateTransitionError
+    | CreateFulfillmentError;
 
 export type CancelOrderResult =
     | Order
@@ -1711,6 +1727,7 @@ export type ShippingMethodTranslationInput = {
 
 export type CreateShippingMethodInput = {
     code: Scalars['String'];
+    fulfillmentHandler: Scalars['String'];
     checker: ConfigurableOperationInput;
     calculator: ConfigurableOperationInput;
     translations: Array<ShippingMethodTranslationInput>;
@@ -1720,6 +1737,7 @@ export type CreateShippingMethodInput = {
 export type UpdateShippingMethodInput = {
     id: Scalars['ID'];
     code?: Maybe<Scalars['String']>;
+    fulfillmentHandler?: Maybe<Scalars['String']>;
     checker?: Maybe<ConfigurableOperationInput>;
     calculator?: Maybe<ConfigurableOperationInput>;
     translations: Array<ShippingMethodTranslationInput>;
@@ -1950,6 +1968,8 @@ export enum ErrorCode {
     SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
     EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
     ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
+    INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
+    CREATE_FULFILLMENT_ERROR = 'CREATE_FULFILLMENT_ERROR',
     INSUFFICIENT_STOCK_ON_HAND_ERROR = 'INSUFFICIENT_STOCK_ON_HAND_ERROR',
     MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
     CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
@@ -3392,6 +3412,7 @@ export type ShippingMethod = Node & {
     code: Scalars['String'];
     name: Scalars['String'];
     description: Scalars['String'];
+    fulfillmentHandlerCode: Scalars['String'];
     checker: ConfigurableOperation;
     calculator: ConfigurableOperation;
     translations: Array<ShippingMethodTranslation>;
@@ -3934,6 +3955,7 @@ export type ShippingMethodFilterParameter = {
     code?: Maybe<StringOperators>;
     name?: Maybe<StringOperators>;
     description?: Maybe<StringOperators>;
+    fulfillmentHandlerCode?: Maybe<StringOperators>;
 };
 
 export type ShippingMethodSortParameter = {
@@ -3943,6 +3965,7 @@ export type ShippingMethodSortParameter = {
     code?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
     description?: Maybe<SortOrder>;
+    fulfillmentHandlerCode?: Maybe<SortOrder>;
 };
 
 export type TaxRateFilterParameter = {

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

@@ -2428,6 +2428,7 @@ export type ShippingMethod = Node & {
     code: Scalars['String'];
     name: Scalars['String'];
     description: Scalars['String'];
+    fulfillmentHandlerCode: Scalars['String'];
     checker: ConfigurableOperation;
     calculator: ConfigurableOperation;
     translations: Array<ShippingMethodTranslation>;

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

@@ -68,6 +68,7 @@ export type Query = {
   shippingMethod?: Maybe<ShippingMethod>;
   shippingEligibilityCheckers: Array<ConfigurableOperationDefinition>;
   shippingCalculators: Array<ConfigurableOperationDefinition>;
+  fulfillmentHandlers: Array<ConfigurableOperationDefinition>;
   testShippingMethod: TestShippingMethodResult;
   testEligibleShippingMethods: Array<ShippingMethodQuote>;
   taxCategories: Array<TaxCategory>;
@@ -1368,8 +1369,7 @@ export type UpdateOrderInput = {
 
 export type FulfillOrderInput = {
   lines: Array<OrderLineInput>;
-  method: Scalars['String'];
-  trackingCode?: Maybe<Scalars['String']>;
+  handler: ConfigurableOperationInput;
 };
 
 export type CancelOrderInput = {
@@ -1432,6 +1432,21 @@ export type ItemsAlreadyFulfilledError = ErrorResult & {
   message: Scalars['String'];
 };
 
+/** Returned if the specified FulfillmentHandler code is not valid */
+export type InvalidFulfillmentHandlerError = ErrorResult & {
+  __typename?: 'InvalidFulfillmentHandlerError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+};
+
+/** Returned if an error is thrown in a FulfillmentHandler's createFulfillment method */
+export type CreateFulfillmentError = ErrorResult & {
+  __typename?: 'CreateFulfillmentError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+  fulfillmentHandlerError: Scalars['String'];
+};
+
 /**
  * Returned if attempting to create a Fulfillment when there is insufficient
  * stockOnHand of a ProductVariant to satisfy the requested quantity.
@@ -1531,7 +1546,7 @@ export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type SettlePaymentResult = Payment | SettlePaymentError | PaymentStateTransitionError | OrderStateTransitionError;
 
-export type AddFulfillmentToOrderResult = Fulfillment | EmptyOrderLineSelectionError | ItemsAlreadyFulfilledError | InsufficientStockOnHandError;
+export type AddFulfillmentToOrderResult = Fulfillment | EmptyOrderLineSelectionError | ItemsAlreadyFulfilledError | InsufficientStockOnHandError | InvalidFulfillmentHandlerError | FulfillmentStateTransitionError | CreateFulfillmentError;
 
 export type CancelOrderResult = Order | EmptyOrderLineSelectionError | QuantityTooGreatError | MultipleOrderError | CancelActiveOrderError | OrderStateTransitionError;
 
@@ -1859,6 +1874,7 @@ export type ShippingMethodTranslationInput = {
 
 export type CreateShippingMethodInput = {
   code: Scalars['String'];
+  fulfillmentHandler: Scalars['String'];
   checker: ConfigurableOperationInput;
   calculator: ConfigurableOperationInput;
   translations: Array<ShippingMethodTranslationInput>;
@@ -1868,6 +1884,7 @@ export type CreateShippingMethodInput = {
 export type UpdateShippingMethodInput = {
   id: Scalars['ID'];
   code?: Maybe<Scalars['String']>;
+  fulfillmentHandler?: Maybe<Scalars['String']>;
   checker?: Maybe<ConfigurableOperationInput>;
   calculator?: Maybe<ConfigurableOperationInput>;
   translations: Array<ShippingMethodTranslationInput>;
@@ -2109,6 +2126,8 @@ export enum ErrorCode {
   SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
   EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
   ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
+  INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
+  CREATE_FULFILLMENT_ERROR = 'CREATE_FULFILLMENT_ERROR',
   INSUFFICIENT_STOCK_ON_HAND_ERROR = 'INSUFFICIENT_STOCK_ON_HAND_ERROR',
   MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
   CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
@@ -3615,6 +3634,7 @@ export type ShippingMethod = Node & {
   code: Scalars['String'];
   name: Scalars['String'];
   description: Scalars['String'];
+  fulfillmentHandlerCode: Scalars['String'];
   checker: ConfigurableOperation;
   calculator: ConfigurableOperation;
   translations: Array<ShippingMethodTranslation>;
@@ -4166,6 +4186,7 @@ export type ShippingMethodFilterParameter = {
   code?: Maybe<StringOperators>;
   name?: Maybe<StringOperators>;
   description?: Maybe<StringOperators>;
+  fulfillmentHandlerCode?: Maybe<StringOperators>;
 };
 
 export type ShippingMethodSortParameter = {
@@ -4175,6 +4196,7 @@ export type ShippingMethodSortParameter = {
   code?: Maybe<SortOrder>;
   name?: Maybe<SortOrder>;
   description?: Maybe<SortOrder>;
+  fulfillmentHandlerCode?: Maybe<SortOrder>;
 };
 
 export type TaxRateFilterParameter = {

+ 31 - 19
packages/core/e2e/fulfillment-process.e2e-spec.ts

@@ -1,15 +1,16 @@
 /* tslint:disable:no-non-null-assertion */
-import { CustomFulfillmentProcess, FulfillmentState, mergeConfig } from '@vendure/core';
-import { createTestEnvironment } from '@vendure/testing';
-import gql from 'graphql-tag';
+import { CustomFulfillmentProcess, manualFulfillmentHandler, mergeConfig } from '@vendure/core';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import {
     CreateFulfillment,
+    ErrorCode,
+    FulfillmentFragment,
     GetCustomerList,
     GetOrderFulfillments,
     TransitFulfillment,
@@ -31,6 +32,9 @@ const transitionEndSpy2 = jest.fn();
 const transitionErrorSpy = jest.fn();
 
 describe('Fulfillment process', () => {
+    const fulfillmentGuard: ErrorResultGuard<FulfillmentFragment> = createErrorResultGuard(
+        input => !!input.id,
+    );
     const VALIDATION_ERROR_MESSAGE = 'Fulfillment must have a tracking code';
     const customOrderProcess: CustomFulfillmentProcess<'AwaitingPickup'> = {
         init(injector) {
@@ -124,7 +128,10 @@ describe('Fulfillment process', () => {
         await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(CREATE_FULFILLMENT, {
             input: {
                 lines: [{ orderLineId: 'T_1', quantity: 1 }],
-                method: 'Test1',
+                handler: {
+                    code: manualFulfillmentHandler.code,
+                    arguments: [{ name: 'method', value: 'Test1' }],
+                },
             },
         });
 
@@ -132,8 +139,13 @@ describe('Fulfillment process', () => {
         await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(CREATE_FULFILLMENT, {
             input: {
                 lines: [{ orderLineId: 'T_2', quantity: 1 }],
-                method: 'Test1',
-                trackingCode: '222',
+                handler: {
+                    code: manualFulfillmentHandler.code,
+                    arguments: [
+                        { name: 'method', value: 'Test1' },
+                        { name: 'trackingCode', value: '222' },
+                    ],
+                },
             },
         });
     }, TEST_SETUP_TIMEOUT_MS);
@@ -168,18 +180,17 @@ describe('Fulfillment process', () => {
             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);
-            }
+            const { transitionFulfillmentToState } = await adminClient.query<
+                TransitFulfillment.Mutation,
+                TransitFulfillment.Variables
+            >(TRANSIT_FULFILLMENT, {
+                id: 'T_1',
+                state: 'Shipped',
+            });
+
+            fulfillmentGuard.assertErrorResult(transitionFulfillmentToState);
+            expect(transitionFulfillmentToState.errorCode).toBe(ErrorCode.FULFILLMENT_STATE_TRANSITION_ERROR);
+            expect(transitionFulfillmentToState.transitionError).toBe(VALIDATION_ERROR_MESSAGE);
 
             expect(transitionStartSpy).toHaveBeenCalledTimes(1);
             expect(transitionErrorSpy).toHaveBeenCalledTimes(1);
@@ -212,6 +223,7 @@ describe('Fulfillment process', () => {
                 id: 'T_2',
                 state: 'Shipped',
             });
+            fulfillmentGuard.assertSuccess(transitionFulfillmentToState);
 
             expect(transitionEndSpy).toHaveBeenCalledTimes(1);
             expect(transitionEndSpy.mock.calls[0].slice(0, 2)).toEqual(['AwaitingPickup', 'Shipped']);

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

@@ -67,6 +67,7 @@ export type Query = {
     shippingMethod?: Maybe<ShippingMethod>;
     shippingEligibilityCheckers: Array<ConfigurableOperationDefinition>;
     shippingCalculators: Array<ConfigurableOperationDefinition>;
+    fulfillmentHandlers: Array<ConfigurableOperationDefinition>;
     testShippingMethod: TestShippingMethodResult;
     testEligibleShippingMethods: Array<ShippingMethodQuote>;
     taxCategories: Array<TaxCategory>;
@@ -1218,8 +1219,7 @@ export type UpdateOrderInput = {
 
 export type FulfillOrderInput = {
     lines: Array<OrderLineInput>;
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
+    handler: ConfigurableOperationInput;
 };
 
 export type CancelOrderInput = {
@@ -1279,6 +1279,19 @@ export type ItemsAlreadyFulfilledError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Returned if the specified FulfillmentHandler code is not valid */
+export type InvalidFulfillmentHandlerError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned if an error is thrown in a FulfillmentHandler's createFulfillment method */
+export type CreateFulfillmentError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    fulfillmentHandlerError: Scalars['String'];
+};
+
 /**
  * Returned if attempting to create a Fulfillment when there is insufficient
  * stockOnHand of a ProductVariant to satisfy the requested quantity.
@@ -1375,7 +1388,10 @@ export type AddFulfillmentToOrderResult =
     | Fulfillment
     | EmptyOrderLineSelectionError
     | ItemsAlreadyFulfilledError
-    | InsufficientStockOnHandError;
+    | InsufficientStockOnHandError
+    | InvalidFulfillmentHandlerError
+    | FulfillmentStateTransitionError
+    | CreateFulfillmentError;
 
 export type CancelOrderResult =
     | Order
@@ -1711,6 +1727,7 @@ export type ShippingMethodTranslationInput = {
 
 export type CreateShippingMethodInput = {
     code: Scalars['String'];
+    fulfillmentHandler: Scalars['String'];
     checker: ConfigurableOperationInput;
     calculator: ConfigurableOperationInput;
     translations: Array<ShippingMethodTranslationInput>;
@@ -1720,6 +1737,7 @@ export type CreateShippingMethodInput = {
 export type UpdateShippingMethodInput = {
     id: Scalars['ID'];
     code?: Maybe<Scalars['String']>;
+    fulfillmentHandler?: Maybe<Scalars['String']>;
     checker?: Maybe<ConfigurableOperationInput>;
     calculator?: Maybe<ConfigurableOperationInput>;
     translations: Array<ShippingMethodTranslationInput>;
@@ -1950,6 +1968,8 @@ export enum ErrorCode {
     SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
     EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
     ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
+    INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
+    CREATE_FULFILLMENT_ERROR = 'CREATE_FULFILLMENT_ERROR',
     INSUFFICIENT_STOCK_ON_HAND_ERROR = 'INSUFFICIENT_STOCK_ON_HAND_ERROR',
     MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
     CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
@@ -3392,6 +3412,7 @@ export type ShippingMethod = Node & {
     code: Scalars['String'];
     name: Scalars['String'];
     description: Scalars['String'];
+    fulfillmentHandlerCode: Scalars['String'];
     checker: ConfigurableOperation;
     calculator: ConfigurableOperation;
     translations: Array<ShippingMethodTranslation>;
@@ -3934,6 +3955,7 @@ export type ShippingMethodFilterParameter = {
     code?: Maybe<StringOperators>;
     name?: Maybe<StringOperators>;
     description?: Maybe<StringOperators>;
+    fulfillmentHandlerCode?: Maybe<StringOperators>;
 };
 
 export type ShippingMethodSortParameter = {
@@ -3943,6 +3965,7 @@ export type ShippingMethodSortParameter = {
     code?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
     description?: Maybe<SortOrder>;
+    fulfillmentHandlerCode?: Maybe<SortOrder>;
 };
 
 export type TaxRateFilterParameter = {
@@ -5083,7 +5106,10 @@ export type CreateFulfillmentMutation = {
         | FulfillmentFragment
         | Pick<EmptyOrderLineSelectionError, 'errorCode' | 'message'>
         | Pick<ItemsAlreadyFulfilledError, 'errorCode' | 'message'>
-        | Pick<InsufficientStockOnHandError, 'errorCode' | 'message'>;
+        | Pick<InsufficientStockOnHandError, 'errorCode' | 'message'>
+        | Pick<InvalidFulfillmentHandlerError, 'errorCode' | 'message'>
+        | Pick<FulfillmentStateTransitionError, 'errorCode' | 'message'>
+        | Pick<CreateFulfillmentError, 'errorCode' | 'message' | 'fulfillmentHandlerError'>;
 };
 
 export type TransitFulfillmentMutationVariables = Exact<{
@@ -5341,6 +5367,16 @@ export type UpdateOptionGroupMutationVariables = Exact<{
 
 export type UpdateOptionGroupMutation = { updateProductOptionGroup: Pick<ProductOptionGroup, 'id'> };
 
+export type GetFulfillmentHandlersQueryVariables = Exact<{ [key: string]: never }>;
+
+export type GetFulfillmentHandlersQuery = {
+    fulfillmentHandlers: Array<
+        Pick<ConfigurableOperationDefinition, 'code' | 'description'> & {
+            args: Array<Pick<ConfigArgDefinition, 'name' | 'type' | 'description' | 'label' | 'ui'>>;
+        }
+    >;
+};
+
 export type DeletePromotionAdHoc1MutationVariables = Exact<{ [key: string]: never }>;
 
 export type DeletePromotionAdHoc1Mutation = { deletePromotion: Pick<DeletionResponse, 'result'> };
@@ -6990,6 +7026,10 @@ export namespace CreateFulfillment {
         NonNullable<CreateFulfillmentMutation['addFulfillmentToOrder']>,
         { __typename?: 'ErrorResult' }
     >;
+    export type CreateFulfillmentErrorInlineFragment = DiscriminateUnion<
+        NonNullable<CreateFulfillmentMutation['addFulfillmentToOrder']>,
+        { __typename?: 'CreateFulfillmentError' }
+    >;
 }
 
 export namespace TransitFulfillment {
@@ -7239,6 +7279,19 @@ export namespace UpdateOptionGroup {
     export type UpdateProductOptionGroup = NonNullable<UpdateOptionGroupMutation['updateProductOptionGroup']>;
 }
 
+export namespace GetFulfillmentHandlers {
+    export type Variables = GetFulfillmentHandlersQueryVariables;
+    export type Query = GetFulfillmentHandlersQuery;
+    export type FulfillmentHandlers = NonNullable<
+        NonNullable<GetFulfillmentHandlersQuery['fulfillmentHandlers']>[number]
+    >;
+    export type Args = NonNullable<
+        NonNullable<
+            NonNullable<NonNullable<GetFulfillmentHandlersQuery['fulfillmentHandlers']>[number]>['args']
+        >[number]
+    >;
+}
+
 export namespace DeletePromotionAdHoc1 {
     export type Variables = DeletePromotionAdHoc1MutationVariables;
     export type Mutation = DeletePromotionAdHoc1Mutation;

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

@@ -2322,6 +2322,7 @@ export type ShippingMethod = Node & {
     code: Scalars['String'];
     name: Scalars['String'];
     description: Scalars['String'];
+    fulfillmentHandlerCode: Scalars['String'];
     checker: ConfigurableOperation;
     calculator: ConfigurableOperation;
     translations: Array<ShippingMethodTranslation>;

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

@@ -461,6 +461,9 @@ export const CREATE_FULFILLMENT = gql`
                 errorCode
                 message
             }
+            ... on CreateFulfillmentError {
+                fulfillmentHandlerError
+            }
         }
     }
     ${FULFILLMENT_FRAGMENT}

+ 237 - 0
packages/core/e2e/order-fulfillment.e2e-spec.ts

@@ -0,0 +1,237 @@
+import { mergeConfig } from '@vendure/common/lib/merge-config';
+import {
+    defaultShippingCalculator,
+    defaultShippingEligibilityChecker,
+    FulfillmentHandler,
+    LanguageCode,
+    manualFulfillmentHandler,
+} from '@vendure/core';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
+import {
+    CreateFulfillment,
+    CreateFulfillmentError,
+    CreateShippingMethod,
+    ErrorCode,
+    FulfillmentFragment,
+    GetFulfillmentHandlers,
+    TransitFulfillment,
+} from './graphql/generated-e2e-admin-types';
+import { AddItemToOrder, TestOrderWithPaymentsFragment } from './graphql/generated-e2e-shop-types';
+import {
+    CREATE_FULFILLMENT,
+    CREATE_SHIPPING_METHOD,
+    TRANSIT_FULFILLMENT,
+} from './graphql/shared-definitions';
+import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
+import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
+
+const badTrackingCode = 'bad-code';
+const transitionErrorMessage = 'Some error message';
+const transitionSpy = jest.fn();
+const testFulfillmentHandler = new FulfillmentHandler({
+    code: 'test-fulfillment-handler',
+    description: [{ languageCode: LanguageCode.en, value: 'Test fulfillment handler' }],
+    args: {
+        trackingCode: {
+            type: 'string',
+        },
+    },
+    createFulfillment: (ctx, orders, items, args) => {
+        if (args.trackingCode === badTrackingCode) {
+            throw new Error('The code was bad!');
+        }
+        return {
+            trackingCode: args.trackingCode,
+        };
+    },
+    onFulfillmentTransition: (fromState, toState, { fulfillment }) => {
+        transitionSpy(fromState, toState);
+        if (toState === 'Shipped') {
+            return transitionErrorMessage;
+        }
+    },
+});
+
+describe('Order fulfillments', () => {
+    const orderGuard: ErrorResultGuard<TestOrderWithPaymentsFragment> = createErrorResultGuard<
+        TestOrderWithPaymentsFragment
+    >(input => !!input.lines);
+    const fulfillmentGuard: ErrorResultGuard<FulfillmentFragment> = createErrorResultGuard<
+        FulfillmentFragment
+    >(input => !!input.id);
+
+    let order: TestOrderWithPaymentsFragment;
+    let f1Id: string;
+
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            paymentOptions: {
+                paymentMethodHandlers: [testSuccessfulPaymentMethod],
+            },
+            shippingOptions: {
+                fulfillmentHandlers: [manualFulfillmentHandler, testFulfillmentHandler],
+            },
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 2,
+        });
+        await adminClient.asSuperAdmin();
+        await adminClient.query<CreateShippingMethod.Mutation, CreateShippingMethod.Variables>(
+            CREATE_SHIPPING_METHOD,
+            {
+                input: {
+                    code: 'test-method',
+                    fulfillmentHandler: manualFulfillmentHandler.code,
+                    checker: {
+                        code: defaultShippingEligibilityChecker.code,
+                        arguments: [
+                            {
+                                name: 'orderMinimum',
+                                value: '0',
+                            },
+                        ],
+                    },
+                    calculator: {
+                        code: defaultShippingCalculator.code,
+                        arguments: [
+                            {
+                                name: 'rate',
+                                value: '500',
+                            },
+                            {
+                                name: 'taxRate',
+                                value: '0',
+                            },
+                        ],
+                    },
+                    translations: [{ languageCode: LanguageCode.en, name: 'test method', description: '' }],
+                },
+            },
+        );
+        await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+        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,
+        });
+        await proceedToArrangingPayment(shopClient);
+        const result = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+        orderGuard.assertSuccess(result);
+        order = result;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('fulfillmentHandlers query', async () => {
+        const { fulfillmentHandlers } = await adminClient.query<GetFulfillmentHandlers.Query>(
+            GET_FULFILLMENT_HANDLERS,
+        );
+
+        expect(fulfillmentHandlers.map(h => h.code)).toEqual([
+            'manual-fulfillment',
+            'test-fulfillment-handler',
+        ]);
+    });
+
+    it('creates fulfillment based on args', async () => {
+        const { addFulfillmentToOrder } = await adminClient.query<
+            CreateFulfillment.Mutation,
+            CreateFulfillment.Variables
+        >(CREATE_FULFILLMENT, {
+            input: {
+                lines: order.lines.slice(0, 1).map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                handler: {
+                    code: testFulfillmentHandler.code,
+                    arguments: [
+                        {
+                            name: 'trackingCode',
+                            value: 'abc123',
+                        },
+                    ],
+                },
+            },
+        });
+        fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
+
+        expect(addFulfillmentToOrder.trackingCode).toBe('abc123');
+        f1Id = addFulfillmentToOrder.id;
+    });
+
+    it('onFulfillmentTransition is called', async () => {
+        expect(transitionSpy).toHaveBeenCalledTimes(1);
+        expect(transitionSpy).toHaveBeenCalledWith('Created', 'Pending');
+    });
+
+    it('onFulfillmentTransition can prevent state transition', async () => {
+        const { transitionFulfillmentToState } = await adminClient.query<
+            TransitFulfillment.Mutation,
+            TransitFulfillment.Variables
+        >(TRANSIT_FULFILLMENT, {
+            id: f1Id,
+            state: 'Shipped',
+        });
+
+        fulfillmentGuard.assertErrorResult(transitionFulfillmentToState);
+        expect(transitionFulfillmentToState.errorCode).toBe(ErrorCode.FULFILLMENT_STATE_TRANSITION_ERROR);
+        expect(transitionFulfillmentToState.transitionError).toBe(transitionErrorMessage);
+    });
+
+    it('throwing from createFulfillment returns CreateFulfillmentError result', async () => {
+        const { addFulfillmentToOrder } = await adminClient.query<
+            CreateFulfillment.Mutation,
+            CreateFulfillment.Variables
+        >(CREATE_FULFILLMENT, {
+            input: {
+                lines: order.lines.slice(1, 2).map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                handler: {
+                    code: testFulfillmentHandler.code,
+                    arguments: [
+                        {
+                            name: 'trackingCode',
+                            value: badTrackingCode,
+                        },
+                    ],
+                },
+            },
+        });
+        fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
+
+        expect(addFulfillmentToOrder.errorCode).toBe(ErrorCode.CREATE_FULFILLMENT_ERROR);
+        expect((addFulfillmentToOrder as CreateFulfillmentError).fulfillmentHandlerError).toBe(
+            'The code was bad!',
+        );
+    });
+});
+
+const GET_FULFILLMENT_HANDLERS = gql`
+    query GetFulfillmentHandlers {
+        fulfillmentHandlers {
+            code
+            description
+            args {
+                name
+                type
+                description
+                label
+                ui
+            }
+        }
+    }
+`;

+ 34 - 9
packages/core/e2e/order.e2e-spec.ts

@@ -1,5 +1,6 @@
 /* tslint:disable:no-non-null-assertion */
 import { pick } from '@vendure/common/lib/pick';
+import { manualFulfillmentHandler } from '@vendure/core';
 import {
     createErrorResultGuard,
     createTestEnvironment,
@@ -353,7 +354,10 @@ describe('Orders resolver', () => {
             >(CREATE_FULFILLMENT, {
                 input: {
                     lines: [],
-                    method: 'Test',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [{ name: 'method', value: 'Test' }],
+                    },
                 },
             });
             fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
@@ -373,7 +377,10 @@ describe('Orders resolver', () => {
             >(CREATE_FULFILLMENT, {
                 input: {
                     lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
-                    method: 'Test',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [{ name: 'method', value: 'Test' }],
+                    },
                 },
             });
             fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
@@ -395,8 +402,13 @@ describe('Orders resolver', () => {
             >(CREATE_FULFILLMENT, {
                 input: {
                     lines: [{ orderLineId: lines[0].id, quantity: lines[0].quantity }],
-                    method: 'Test1',
-                    trackingCode: '111',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [
+                            { name: 'method', value: 'Test1' },
+                            { name: 'trackingCode', value: '111' },
+                        ],
+                    },
                 },
             });
             fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
@@ -430,8 +442,13 @@ describe('Orders resolver', () => {
             >(CREATE_FULFILLMENT, {
                 input: {
                     lines,
-                    method: 'Test2',
-                    trackingCode: '222',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [
+                            { name: 'method', value: 'Test2' },
+                            { name: 'trackingCode', value: '222' },
+                        ],
+                    },
                 },
             });
             fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
@@ -479,8 +496,13 @@ describe('Orders resolver', () => {
             >(CREATE_FULFILLMENT, {
                 input: {
                     lines,
-                    method: 'Test3',
-                    trackingCode: '333',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [
+                            { name: 'method', value: 'Test3' },
+                            { name: 'trackingCode', value: '333' },
+                        ],
+                    },
                 },
             });
             fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
@@ -501,13 +523,16 @@ describe('Orders resolver', () => {
                 CreateFulfillment.Variables
             >(CREATE_FULFILLMENT, {
                 input: {
-                    method: 'Test',
                     lines: [
                         {
                             orderLineId: order!.lines[0].id,
                             quantity: 1,
                         },
                     ],
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [{ name: 'method', value: 'Test' }],
+                    },
                 },
             });
             fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);

+ 4 - 0
packages/core/e2e/shipping-method-eligibility.e2e-spec.ts

@@ -1,6 +1,7 @@
 import {
     defaultShippingCalculator,
     defaultShippingEligibilityChecker,
+    manualFulfillmentHandler,
     ShippingCalculator,
     ShippingEligibilityChecker,
 } from '@vendure/core';
@@ -120,6 +121,7 @@ describe('ShippingMethod resolver', () => {
         >(CREATE_SHIPPING_METHOD, {
             input: {
                 code: 'single-line',
+                fulfillmentHandler: manualFulfillmentHandler.code,
                 checker: {
                     code: checker1.code,
                     arguments: [],
@@ -141,6 +143,7 @@ describe('ShippingMethod resolver', () => {
         >(CREATE_SHIPPING_METHOD, {
             input: {
                 code: 'multi-line',
+                fulfillmentHandler: manualFulfillmentHandler.code,
                 checker: {
                     code: checker2.code,
                     arguments: [],
@@ -162,6 +165,7 @@ describe('ShippingMethod resolver', () => {
         >(CREATE_SHIPPING_METHOD, {
             input: {
                 code: 'optimized',
+                fulfillmentHandler: manualFulfillmentHandler.code,
                 checker: {
                     code: checker3.code,
                     arguments: [],

+ 2 - 0
packages/core/e2e/shipping-method.e2e-spec.ts

@@ -10,6 +10,7 @@ import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { manualFulfillmentHandler } from '../src/config/fulfillment/manual-fulfillment-handler';
 
 import { SHIPPING_METHOD_FRAGMENT } from './graphql/fragments';
 import {
@@ -154,6 +155,7 @@ describe('ShippingMethod resolver', () => {
         >(CREATE_SHIPPING_METHOD, {
             input: {
                 code: 'new-method',
+                fulfillmentHandler: manualFulfillmentHandler.code,
                 checker: {
                     code: defaultShippingEligibilityChecker.code,
                     arguments: [

+ 43 - 13
packages/core/e2e/stock-control.e2e-spec.ts

@@ -1,5 +1,5 @@
 /* tslint:disable:no-non-null-assertion */
-import { mergeConfig, OrderState } from '@vendure/core';
+import { manualFulfillmentHandler, mergeConfig, OrderState } from '@vendure/core';
 import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
@@ -324,8 +324,13 @@ describe('Stock control', () => {
                 {
                     input: {
                         lines: order?.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [],
-                        method: 'test method',
-                        trackingCode: 'ABC123',
+                        handler: {
+                            code: manualFulfillmentHandler.code,
+                            arguments: [
+                                { name: 'method', value: 'test method' },
+                                { name: 'trackingCode', value: 'ABC123' },
+                            ],
+                        },
                     },
                 },
             );
@@ -602,8 +607,13 @@ describe('Stock control', () => {
             >(CREATE_FULFILLMENT, {
                 input: {
                     lines: order.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
-                    method: 'test method',
-                    trackingCode: 'ABC123',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [
+                            { name: 'method', value: 'test method' },
+                            { name: 'trackingCode', value: 'ABC123' },
+                        ],
+                    },
                 },
             });
 
@@ -624,8 +634,13 @@ describe('Stock control', () => {
                     lines: order.lines
                         .filter(l => l.productVariant.id === 'T_1')
                         .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
-                    method: 'test method',
-                    trackingCode: 'ABC123',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [
+                            { name: 'method', value: 'test method' },
+                            { name: 'trackingCode', value: 'ABC123' },
+                        ],
+                    },
                 },
             });
 
@@ -647,8 +662,13 @@ describe('Stock control', () => {
                     lines: order.lines
                         .filter(l => l.productVariant.id === 'T_2')
                         .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
-                    method: 'test method',
-                    trackingCode: 'ABC123',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [
+                            { name: 'method', value: 'test method' },
+                            { name: 'trackingCode', value: 'ABC123' },
+                        ],
+                    },
                 },
             });
 
@@ -670,8 +690,13 @@ describe('Stock control', () => {
                     lines: order.lines
                         .filter(l => l.productVariant.id === 'T_4')
                         .map(l => ({ orderLineId: l.id, quantity: 3 })), // we know there are only 3 on hand
-                    method: 'test method',
-                    trackingCode: 'ABC123',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [
+                            { name: 'method', value: 'test method' },
+                            { name: 'trackingCode', value: 'ABC123' },
+                        ],
+                    },
                 },
             });
 
@@ -707,8 +732,13 @@ describe('Stock control', () => {
                     lines: order.lines
                         .filter(l => l.productVariant.id === 'T_4')
                         .map(l => ({ orderLineId: l.id, quantity: 5 })),
-                    method: 'test method',
-                    trackingCode: 'ABC123',
+                    handler: {
+                        code: manualFulfillmentHandler.code,
+                        arguments: [
+                            { name: 'method', value: 'test method' },
+                            { name: 'trackingCode', value: 'ABC123' },
+                        ],
+                    },
                 },
             });
 

+ 6 - 0
packages/core/src/api/resolvers/admin/shipping-method.resolver.ts

@@ -58,6 +58,12 @@ export class ShippingMethodResolver {
         return this.shippingMethodService.getShippingCalculators(ctx);
     }
 
+    @Query()
+    @Allow(Permission.ReadSettings)
+    fulfillmentHandlers(@Ctx() ctx: RequestContext): ConfigurableOperationDefinition[] {
+        return this.shippingMethodService.getFulfillmentHandlers(ctx);
+    }
+
     @Transaction()
     @Mutation()
     @Allow(Permission.CreateSettings)

+ 17 - 2
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -31,8 +31,7 @@ input UpdateOrderInput {
 
 input FulfillOrderInput {
     lines: [OrderLineInput!]!
-    method: String!
-    trackingCode: String
+    handler: ConfigurableOperationInput!
 }
 
 input CancelOrderInput {
@@ -92,6 +91,19 @@ type ItemsAlreadyFulfilledError implements ErrorResult {
     message: String!
 }
 
+"Returned if the specified FulfillmentHandler code is not valid"
+type InvalidFulfillmentHandlerError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+}
+
+"Returned if an error is thrown in a FulfillmentHandler's createFulfillment method"
+type CreateFulfillmentError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    fulfillmentHandlerError: String!
+}
+
 """
 Returned if attempting to create a Fulfillment when there is insufficient
 stockOnHand of a ProductVariant to satisfy the requested quantity.
@@ -187,6 +199,9 @@ union AddFulfillmentToOrderResult =
     | EmptyOrderLineSelectionError
     | ItemsAlreadyFulfilledError
     | InsufficientStockOnHandError
+    | InvalidFulfillmentHandlerError
+    | FulfillmentStateTransitionError
+    | CreateFulfillmentError
 union CancelOrderResult =
       Order
     | EmptyOrderLineSelectionError

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

@@ -3,6 +3,7 @@ type Query {
     shippingMethod(id: ID!): ShippingMethod
     shippingEligibilityCheckers: [ConfigurableOperationDefinition!]!
     shippingCalculators: [ConfigurableOperationDefinition!]!
+    fulfillmentHandlers: [ConfigurableOperationDefinition!]!
     testShippingMethod(input: TestShippingMethodInput!): TestShippingMethodResult!
     testEligibleShippingMethods(input: TestEligibleShippingMethodsInput!): [ShippingMethodQuote!]!
 }
@@ -28,6 +29,7 @@ input ShippingMethodTranslationInput {
 
 input CreateShippingMethodInput {
     code: String!
+    fulfillmentHandler: String!
     checker: ConfigurableOperationInput!
     calculator: ConfigurableOperationInput!
     translations: [ShippingMethodTranslationInput!]!
@@ -36,6 +38,7 @@ input CreateShippingMethodInput {
 input UpdateShippingMethodInput {
     id: ID!
     code: String
+    fulfillmentHandler: String
     checker: ConfigurableOperationInput
     calculator: ConfigurableOperationInput
     translations: [ShippingMethodTranslationInput!]!

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

@@ -5,6 +5,7 @@ type ShippingMethod implements Node {
     code: String!
     name: String!
     description: String!
+    fulfillmentHandlerCode: String!
     checker: ConfigurableOperation!
     calculator: ConfigurableOperation!
     translations: [ShippingMethodTranslation!]!

+ 6 - 1
packages/core/src/app.module.ts

@@ -153,7 +153,11 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat
         const { paymentMethodHandlers } = this.configService.paymentOptions;
         const { collectionFilters } = this.configService.catalogOptions;
         const { promotionActions, promotionConditions } = this.configService.promotionOptions;
-        const { shippingCalculators, shippingEligibilityCheckers } = this.configService.shippingOptions;
+        const {
+            shippingCalculators,
+            shippingEligibilityCheckers,
+            fulfillmentHandlers,
+        } = this.configService.shippingOptions;
         return [
             ...paymentMethodHandlers,
             ...collectionFilters,
@@ -161,6 +165,7 @@ export class AppModule implements NestModule, OnApplicationBootstrap, OnApplicat
             ...(promotionConditions || []),
             ...(shippingCalculators || []),
             ...(shippingEligibilityCheckers || []),
+            ...(fulfillmentHandlers || []),
         ];
     }
 }

+ 55 - 19
packages/core/src/common/configurable-operation.ts

@@ -153,27 +153,63 @@ export type ConfigArgs = {
  * in business logic.
  */
 export type ConfigArgValues<T extends ConfigArgs> = {
-    [K in keyof T]: T[K] extends ConfigArgListDef<'int' | 'float'>
-        ? number[]
-        : T[K] extends ConfigArgDef<'int' | 'float'>
-        ? number
-        : T[K] extends ConfigArgListDef<'datetime'>
-        ? Date[]
-        : T[K] extends ConfigArgDef<'datetime'>
-        ? Date
-        : T[K] extends ConfigArgListDef<'boolean'>
-        ? boolean[]
-        : T[K] extends ConfigArgDef<'boolean'>
-        ? boolean
-        : T[K] extends ConfigArgListDef<'ID'>
-        ? ID[]
-        : T[K] extends ConfigArgDef<'ID'>
-        ? ID
-        : T[K] extends ConfigArgListDef<'string'>
-        ? string[]
-        : string;
+    [K in keyof T]: ConfigArgDefToType<T[K]>;
 };
 
+/**
+ * Converts a ConfigArgDef to a TS type, e.g:
+ *
+ * ConfigArgListDef<'datetime'> -> Date[]
+ * ConfigArgDef<'boolean'> -> boolean
+ */
+export type ConfigArgDefToType<D extends ConfigArgDef<ConfigArgType>> = D extends ConfigArgListDef<
+    'int' | 'float'
+>
+    ? number[]
+    : D extends ConfigArgDef<'int' | 'float'>
+    ? number
+    : D extends ConfigArgListDef<'datetime'>
+    ? Date[]
+    : D extends ConfigArgDef<'datetime'>
+    ? Date
+    : D extends ConfigArgListDef<'boolean'>
+    ? boolean[]
+    : D extends ConfigArgDef<'boolean'>
+    ? boolean
+    : D extends ConfigArgListDef<'ID'>
+    ? ID[]
+    : D extends ConfigArgDef<'ID'>
+    ? ID
+    : D extends ConfigArgListDef<'string'>
+    ? string[]
+    : string;
+
+/**
+ * Converts a TS type to a ConfigArgDef, e.g:
+ *
+ * Date[] -> ConfigArgListDef<'datetime'>
+ * boolean -> ConfigArgDef<'boolean'>
+ */
+export type TypeToConfigArgDef<T extends ConfigArgDefToType<any>> = T extends number
+    ? ConfigArgDef<'int' | 'float'>
+    : T extends number[]
+    ? ConfigArgListDef<'int' | 'float'>
+    : T extends Date[]
+    ? ConfigArgListDef<'datetime'>
+    : T extends Date
+    ? ConfigArgDef<'datetime'>
+    : T extends boolean[]
+    ? ConfigArgListDef<'boolean'>
+    : T extends boolean
+    ? ConfigArgDef<'boolean'>
+    : T extends string[]
+    ? ConfigArgListDef<'string'>
+    : T extends string
+    ? ConfigArgDef<'string'>
+    : T extends ID[]
+    ? ConfigArgListDef<'ID'>
+    : ConfigArgDef<'ID'>;
+
 /**
  * @description
  * Common configuration options used when creating a new instance of a

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

@@ -84,6 +84,27 @@ export class ItemsAlreadyFulfilledError extends ErrorResult {
   }
 }
 
+export class InvalidFulfillmentHandlerError extends ErrorResult {
+  readonly __typename = 'InvalidFulfillmentHandlerError';
+  readonly errorCode = 'INVALID_FULFILLMENT_HANDLER_ERROR' as any;
+  readonly message = 'INVALID_FULFILLMENT_HANDLER_ERROR';
+  constructor(
+  ) {
+    super();
+  }
+}
+
+export class CreateFulfillmentError extends ErrorResult {
+  readonly __typename = 'CreateFulfillmentError';
+  readonly errorCode = 'CREATE_FULFILLMENT_ERROR' as any;
+  readonly message = 'CREATE_FULFILLMENT_ERROR';
+  constructor(
+    public fulfillmentHandlerError: Scalars['String'],
+  ) {
+    super();
+  }
+}
+
 export class InsufficientStockOnHandError extends ErrorResult {
   readonly __typename = 'InsufficientStockOnHandError';
   readonly errorCode = 'INSUFFICIENT_STOCK_ON_HAND_ERROR' as any;
@@ -276,7 +297,7 @@ export class EmailAddressConflictError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set(['MimeTypeError', 'LanguageNotAvailableError', 'ChannelDefaultLanguageError', 'SettlePaymentError', 'EmptyOrderLineSelectionError', 'ItemsAlreadyFulfilledError', 'InsufficientStockOnHandError', 'MultipleOrderError', 'CancelActiveOrderError', 'PaymentOrderMismatchError', 'RefundOrderStateError', 'NothingToRefundError', 'AlreadyRefundedError', 'QuantityTooGreatError', 'RefundStateTransitionError', 'PaymentStateTransitionError', 'FulfillmentStateTransitionError', 'ProductOptionInUseError', 'MissingConditionsError', 'NativeAuthStrategyError', 'InvalidCredentialsError', 'OrderStateTransitionError', 'EmailAddressConflictError']);
+const errorTypeNames = new Set(['MimeTypeError', 'LanguageNotAvailableError', 'ChannelDefaultLanguageError', 'SettlePaymentError', 'EmptyOrderLineSelectionError', 'ItemsAlreadyFulfilledError', 'InvalidFulfillmentHandlerError', 'CreateFulfillmentError', 'InsufficientStockOnHandError', 'MultipleOrderError', 'CancelActiveOrderError', 'PaymentOrderMismatchError', 'RefundOrderStateError', 'NothingToRefundError', 'AlreadyRefundedError', 'QuantityTooGreatError', 'RefundStateTransitionError', 'PaymentStateTransitionError', 'FulfillmentStateTransitionError', 'ProductOptionInUseError', 'MissingConditionsError', 'NativeAuthStrategyError', 'InvalidCredentialsError', 'OrderStateTransitionError', 'EmailAddressConflictError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }

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

@@ -75,7 +75,7 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.promotionOptions;
     }
 
-    get shippingOptions(): ShippingOptions {
+    get shippingOptions(): Required<ShippingOptions> {
         return this.activeConfig.shippingOptions;
     }
 

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

@@ -6,7 +6,6 @@ import {
     SUPER_ADMIN_USER_PASSWORD,
 } from '@vendure/common/lib/shared-constants';
 
-import { generatePublicId } from '../common/generate-public-id';
 import { InMemoryJobQueueStrategy } from '../job-queue/in-memory-job-queue-strategy';
 
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
@@ -15,8 +14,8 @@ import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storag
 import { NativeAuthenticationStrategy } from './auth/native-authentication-strategy';
 import { defaultCollectionFilters } from './collection/default-collection-filters';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
+import { manualFulfillmentHandler } from './fulfillment/manual-fulfillment-handler';
 import { DefaultLogger } from './logger/default-logger';
-import { TypeOrmLogger } from './logger/typeorm-logger';
 import { DefaultPriceCalculationStrategy } from './order/default-price-calculation-strategy';
 import { DefaultStockAllocationStrategy } from './order/default-stock-allocation-strategy';
 import { MergeOrdersStrategy } from './order/merge-orders-strategy';
@@ -101,6 +100,8 @@ export const defaultConfig: RuntimeVendureConfig = {
     shippingOptions: {
         shippingEligibilityCheckers: [defaultShippingEligibilityChecker],
         shippingCalculators: [defaultShippingCalculator],
+        customFulfillmentProcess: [],
+        fulfillmentHandlers: [manualFulfillmentHandler],
     },
     orderOptions: {
         orderItemsLimit: 999,

+ 158 - 0
packages/core/src/config/fulfillment/fulfillment-handler.ts

@@ -0,0 +1,158 @@
+import { ConfigArg } from '@vendure/common/lib/generated-types';
+
+import { RequestContext } from '../../api/common/request-context';
+import {
+    ConfigArgs,
+    ConfigArgValues,
+    ConfigurableOperationDef,
+    ConfigurableOperationDefOptions,
+} from '../../common/configurable-operation';
+import { OnTransitionStartFn } from '../../common/finite-state-machine/types';
+import { Fulfillment } from '../../entity/fulfillment/fulfillment.entity';
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { Order } from '../../entity/order/order.entity';
+import {
+    FulfillmentState,
+    FulfillmentTransitionData,
+} from '../../service/helpers/fulfillment-state-machine/fulfillment-state';
+import { CalculateShippingFnResult } from '../shipping-method/shipping-calculator';
+
+export type CreateFulfillmentResult = Partial<Pick<Fulfillment, 'trackingCode' | 'method' | 'customFields'>>;
+
+/**
+ * @description
+ * The function called when creating a new Fulfillment
+ *
+ * @docsCategory fulfillment
+ * @docsPage FulfillmentHandler
+ */
+export type CreateFulfillmentFn<T extends ConfigArgs> = (
+    ctx: RequestContext,
+    orders: Order[],
+    orderItems: OrderItem[],
+    args: ConfigArgValues<T>,
+) => CreateFulfillmentResult | Promise<CreateFulfillmentResult>;
+
+/**
+ * @description
+ * The configuration object used to instantiate a {@link FulfillmentHandler}.
+ *
+ * @docsCategory fulfillment
+ * @docsPage FulfillmentHandler
+ */
+export interface FulfillmentHandlerConfig<T extends ConfigArgs> extends ConfigurableOperationDefOptions<T> {
+    createFulfillment: CreateFulfillmentFn<T>;
+    onFulfillmentTransition?: OnTransitionStartFn<FulfillmentState, FulfillmentTransitionData>;
+}
+
+/**
+ * @description
+ * A FulfillmentHandler is used when creating a new {@link Fulfillment}. When the `addFulfillmentToOrder` mutation
+ * is executed, the specified handler will be used and it's `createFulfillment` method is called. This method
+ * may perform async tasks such as calling a 3rd-party shipping API to register a new shipment and receive
+ * a tracking code. This data can then be returned and will be incorporated into the created Fulfillment.
+ *
+ * If the `args` property is defined, this means that arguments passed to the `addFulfillmentToOrder` mutation
+ * will be passed through to the `createFulfillment` method as the last argument.
+ *
+ * @example
+ * ```TypeScript
+ * let shipomatic;
+ *
+ * export const shipomaticFulfillmentHandler = new FulfillmentHandler({
+ *   code: 'ship-o-matic',
+ *   description: [{
+ *     languageCode: LanguageCode.en,
+ *     value: 'Generate tracking codes via the Ship-o-matic API'
+ *   }],
+ *
+ *   args: {
+ *     preferredService: {
+ *       type: 'string',
+ *       config: {
+ *         options: [
+ *           { value: 'first_class' },
+ *           { value: 'priority'},
+ *           { value: 'standard' },
+ *         ],
+ *       },
+ *     }
+ *   },
+ *
+ *   init: () => {
+ *     // some imaginary shipping service
+ *     shipomatic = new ShipomaticClient(API_KEY);
+ *   },
+ *
+ *   createFulfillment: async (ctx, orders, orderItems, args) => {
+ *      const shipment = getShipmentFromOrders(orders, orderItems);
+ *      try {
+ *        const transaction = await shipomatic.transaction.create({
+ *          shipment,
+ *          service_level: args.preferredService,
+ *          label_file_type: 'png',
+ *        })
+ *        return {
+ *          method: args.method,
+ *          trackingCode: args.trackingCode,
+ *        };
+ *      } catch (e) {
+ *        // Errors thrown from within this function will
+ *        // result in a CreateFulfillmentError being returned
+ *        throw e;
+ *      }
+ *   },
+ *
+ *   onFulfillmentTransition: async (fromState, toState, { fulfillment }) => {
+ *     if (toState === 'Cancelled') {
+ *       await shipomatic.transaction.cancel({
+ *         tracking_code: fulfillment.trackingCode,
+ *       });
+ *     }
+ *   }
+ * });
+ * ```
+ *
+ * @docsCategory fulfillment
+ * @docsPage FulfillmentHandler
+ * @docsWeight 0
+ */
+export class FulfillmentHandler<T extends ConfigArgs = ConfigArgs> extends ConfigurableOperationDef<T> {
+    private readonly createFulfillmentFn: CreateFulfillmentFn<T>;
+    private readonly onFulfillmentTransitionFn:
+        | OnTransitionStartFn<FulfillmentState, FulfillmentTransitionData>
+        | undefined;
+
+    constructor(config: FulfillmentHandlerConfig<T>) {
+        super(config);
+        this.createFulfillmentFn = config.createFulfillment;
+        if (config.onFulfillmentTransition) {
+            this.onFulfillmentTransitionFn = config.onFulfillmentTransition;
+        }
+    }
+
+    /**
+     * @internal
+     */
+    createFulfillment(
+        ctx: RequestContext,
+        orders: Order[],
+        orderItems: OrderItem[],
+        args: ConfigArg[],
+    ): Partial<Fulfillment> | Promise<Partial<Fulfillment>> {
+        return this.createFulfillmentFn(ctx, orders, orderItems, this.argsArrayToHash(args));
+    }
+
+    /**
+     * @internal
+     */
+    onFulfillmentTransition(
+        fromState: FulfillmentState,
+        toState: FulfillmentState,
+        data: FulfillmentTransitionData,
+    ) {
+        if (typeof this.onFulfillmentTransitionFn === 'function') {
+            return this.onFulfillmentTransitionFn(fromState, toState, data);
+        }
+    }
+}

+ 30 - 0
packages/core/src/config/fulfillment/manual-fulfillment-handler.ts

@@ -0,0 +1,30 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+
+import { FulfillmentHandler } from './fulfillment-handler';
+
+export const manualFulfillmentHandler = new FulfillmentHandler({
+    code: 'manual-fulfillment',
+    description: [{ languageCode: LanguageCode.en, value: 'Manually enter fulfillment details' }],
+    args: {
+        method: {
+            type: 'string',
+            config: {
+                options: [
+                    { value: 'next_day' },
+                    { value: 'first_class' },
+                    { value: 'priority' },
+                    { value: 'standard' },
+                ],
+            },
+        },
+        trackingCode: {
+            type: 'string',
+        },
+    },
+    createFulfillment: (ctx, orders, orderItems, args) => {
+        return {
+            method: args.method,
+            trackingCode: args.trackingCode,
+        };
+    },
+});

+ 5 - 3
packages/core/src/config/index.ts

@@ -13,6 +13,9 @@ export * from './default-config';
 export * from './entity-id-strategy/auto-increment-id-strategy';
 export * from './entity-id-strategy/entity-id-strategy';
 export * from './entity-id-strategy/uuid-id-strategy';
+export * from './fulfillment/custom-fulfillment-process';
+export * from './fulfillment/fulfillment-handler';
+export * from './fulfillment/manual-fulfillment-handler';
 export * from './logger/default-logger';
 export * from './logger/noop-logger';
 export * from './logger/vendure-logger';
@@ -25,12 +28,11 @@ export * from './order/stock-allocation-strategy';
 export * from './payment-method/example-payment-method-handler';
 export * from './payment-method/payment-method-handler';
 export * from './promotion';
-export * from './session-cache/session-cache-strategy';
-export * from './session-cache/noop-session-cache-strategy';
 export * from './session-cache/in-memory-session-cache-strategy';
+export * from './session-cache/noop-session-cache-strategy';
+export * from './session-cache/session-cache-strategy';
 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';

+ 10 - 6
packages/core/src/config/vendure-config.ts

@@ -4,14 +4,9 @@ import { ClientOptions, Transport } from '@nestjs/microservices';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { PluginDefinition } from 'apollo-server-core';
 import { RequestHandler } from 'express';
-import { Observable } from 'rxjs';
 import { ConnectionOptions } from 'typeorm';
 
-import { RequestContext } from '../api/common/request-context';
-import { Transitions } from '../common/finite-state-machine/types';
 import { PermissionDefinition } from '../common/permission-definition';
-import { Order } from '../entity/order/order.entity';
-import { OrderState } from '../service/helpers/order-state-machine/order-state';
 
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
@@ -21,6 +16,7 @@ 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 { FulfillmentHandler } from './fulfillment/fulfillment-handler';
 import { JobQueueStrategy } from './job-queue/job-queue-strategy';
 import { VendureLogger } from './logger/vendure-logger';
 import { CustomOrderProcess } from './order/custom-order-process';
@@ -539,6 +535,12 @@ export interface ShippingOptions {
      * Takes an array of objects implementing the {@link CustomFulfillmentProcess} interface.
      */
     customFulfillmentProcess?: Array<CustomFulfillmentProcess<any>>;
+
+    /**
+     * @description
+     * An array of available FulfillmentHandlers.
+     */
+    fulfillmentHandlers?: Array<FulfillmentHandler<any>>;
 }
 
 /**
@@ -832,9 +834,11 @@ export interface RuntimeVendureConfig extends Required<VendureConfig> {
     authOptions: Required<AuthOptions>;
     customFields: Required<CustomFields>;
     importExportOptions: Required<ImportExportOptions>;
+    jobQueueOptions: Required<JobQueueOptions>;
     orderOptions: Required<OrderOptions>;
+    promotionOptions: Required<PromotionOptions>;
+    shippingOptions: Required<ShippingOptions>;
     workerOptions: Required<WorkerOptions>;
-    jobQueueOptions: Required<JobQueueOptions>;
 }
 
 type DeepPartialSimple<T> = {

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

@@ -5,6 +5,7 @@ import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { defaultShippingCalculator, defaultShippingEligibilityChecker } from '../../../config';
+import { manualFulfillmentHandler } from '../../../config/fulfillment/manual-fulfillment-handler';
 import { Channel, Collection, FacetValue, TaxCategory } from '../../../entity';
 import { CollectionService, FacetValueService, ShippingMethodService } from '../../../service';
 import { ChannelService } from '../../../service/services/channel.service';
@@ -197,6 +198,7 @@ export class Populator {
     ) {
         for (const method of shippingMethods) {
             await this.shippingMethodService.create(ctx, {
+                fulfillmentHandler: manualFulfillmentHandler.code,
                 checker: {
                     code: defaultShippingEligibilityChecker.code,
                     arguments: [{ name: 'orderMinimum', value: '0' }],

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

@@ -28,6 +28,9 @@ export class Fulfillment extends VendureEntity implements HasCustomFields {
     @Column()
     method: string;
 
+    @Column()
+    handlerCode: string;
+
     @ManyToMany(type => OrderItem, orderItem => orderItem.fulfillments)
     orderItems: OrderItem[];
 

+ 3 - 0
packages/core/src/entity/shipping-method/shipping-method.entity.ts

@@ -57,6 +57,9 @@ export class ShippingMethod
 
     @Column('simple-json') calculator: ConfigurableOperation;
 
+    @Column()
+    fulfillmentHandlerCode: string;
+
     @ManyToMany(type => Channel)
     @JoinTable()
     channels: Channel[];

+ 10 - 1
packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state-machine.ts

@@ -60,7 +60,16 @@ export class FulfillmentStateMachine {
         toState: FulfillmentState,
         data: FulfillmentTransitionData,
     ) {
-        /**/
+        const { fulfillmentHandlers } = this.configService.shippingOptions;
+        const fulfillmentHandler = fulfillmentHandlers.find(h => h.code === data.fulfillment.handlerCode);
+        if (fulfillmentHandler) {
+            const result = await awaitPromiseOrObservable(
+                fulfillmentHandler.onFulfillmentTransition(fromState, toState, data),
+            );
+            if (result === false || typeof result === 'string') {
+                return result;
+            }
+        }
     }
 
     /**

+ 5 - 2
packages/core/src/service/helpers/shipping-configuration/shipping-configuration.ts

@@ -4,22 +4,25 @@ import { ConfigurableOperationInput } from '@vendure/common/lib/generated-types'
 import { ConfigurableOperation } from '../../../../../common/lib/generated-types';
 import { UserInputError } from '../../../common/error/errors';
 import { ConfigService } from '../../../config/config.service';
+import { FulfillmentHandler } from '../../../config/fulfillment/fulfillment-handler';
 import { ShippingCalculator } from '../../../config/shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from '../../../config/shipping-method/shipping-eligibility-checker';
 
 /**
- * This helper class provides methods relating to ShippingMethod configurable operations (eligibility checkers
- * and calculators).
+ * This helper class provides methods relating to ShippingMethod configurable operations (eligibility checkers, calculators
+ * and fulfillment handlers).
  */
 @Injectable()
 export class ShippingConfiguration {
     readonly shippingEligibilityCheckers: ShippingEligibilityChecker[];
     readonly shippingCalculators: ShippingCalculator[];
+    readonly fulfillmentHandlers: FulfillmentHandler[];
 
     constructor(private configService: ConfigService) {
         this.shippingEligibilityCheckers =
             this.configService.shippingOptions.shippingEligibilityCheckers || [];
         this.shippingCalculators = this.configService.shippingOptions.shippingCalculators || [];
+        this.fulfillmentHandlers = this.configService.shippingOptions.fulfillmentHandlers || [];
     }
 
     parseCheckerInput(input: ConfigurableOperationInput): ConfigurableOperation {

+ 57 - 9
packages/core/src/service/services/fulfillment.service.ts

@@ -1,7 +1,16 @@
 import { Injectable } from '@nestjs/common';
+import { ConfigurableOperationInput } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
+import { isObject } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../api/common/request-context';
+import {
+    CreateFulfillmentError,
+    FulfillmentStateTransitionError,
+    InvalidFulfillmentHandlerError,
+} from '../../common/error/generated-graphql-admin-errors';
+import { OrderStateTransitionError } from '../../common/error/generated-graphql-shop-errors';
+import { ConfigService } from '../../config/config.service';
 import { Fulfillment } from '../../entity/fulfillment/fulfillment.entity';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { Order } from '../../entity/order/order.entity';
@@ -17,12 +26,43 @@ export class FulfillmentService {
         private connection: TransactionalConnection,
         private fulfillmentStateMachine: FulfillmentStateMachine,
         private eventBus: EventBus,
+        private configService: ConfigService,
     ) {}
 
-    async create(ctx: RequestContext, input: DeepPartial<Fulfillment>): Promise<Fulfillment> {
+    async create(
+        ctx: RequestContext,
+        orders: Order[],
+        items: OrderItem[],
+        handler: ConfigurableOperationInput,
+    ): Promise<Fulfillment | InvalidFulfillmentHandlerError | CreateFulfillmentError> {
+        const fulfillmentHandler = this.configService.shippingOptions.fulfillmentHandlers.find(
+            h => h.code === handler.code,
+        );
+        if (!fulfillmentHandler) {
+            return new InvalidFulfillmentHandlerError();
+        }
+        let fulfillmentPartial;
+        try {
+            fulfillmentPartial = await fulfillmentHandler.createFulfillment(
+                ctx,
+                orders,
+                items,
+                handler.arguments,
+            );
+        } catch (e: unknown) {
+            let message = 'No error message';
+            if (isObject(e)) {
+                message = (e as any).message || e.toString();
+            }
+            return new CreateFulfillmentError(message);
+        }
         const newFulfillment = new Fulfillment({
-            ...input,
+            method: '',
+            trackingCode: '',
+            ...fulfillmentPartial,
+            orderItems: items,
             state: this.fulfillmentStateMachine.getInitialState(),
+            handlerCode: fulfillmentHandler.code,
         });
         return this.connection.getRepository(ctx, Fulfillment).save(newFulfillment);
     }
@@ -46,12 +86,15 @@ export class FulfillmentService {
         ctx: RequestContext,
         fulfillmentId: ID,
         state: FulfillmentState,
-    ): Promise<{
-        fulfillment: Fulfillment;
-        orders: Order[];
-        fromState: FulfillmentState;
-        toState: FulfillmentState;
-    }> {
+    ): Promise<
+        | {
+              fulfillment: Fulfillment;
+              orders: Order[];
+              fromState: FulfillmentState;
+              toState: FulfillmentState;
+          }
+        | FulfillmentStateTransitionError
+    > {
         const fulfillment = await this.findOneOrThrow(ctx, fulfillmentId, [
             'orderItems',
             'orderItems.line',
@@ -61,7 +104,12 @@ export class FulfillmentService {
         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);
+        try {
+            await this.fulfillmentStateMachine.transition(ctx, fulfillment, orders, state);
+        } catch (e) {
+            const transitionError = ctx.translate(e.message, { fromState, toState: state });
+            return new FulfillmentStateTransitionError(transitionError, fromState, state);
+        }
         await this.connection.getRepository(ctx, Fulfillment).save(fulfillment, { reload: false });
         this.eventBus.publish(new FulfillmentStateTransitionEvent(fromState, state, ctx, fulfillment));
 

+ 21 - 17
packages/core/src/service/services/order.service.ts

@@ -38,6 +38,7 @@ import {
     AlreadyRefundedError,
     CancelActiveOrderError,
     EmptyOrderLineSelectionError,
+    FulfillmentStateTransitionError,
     InsufficientStockOnHandError,
     ItemsAlreadyFulfilledError,
     MultipleOrderError,
@@ -575,12 +576,12 @@ export class OrderService {
         ctx: RequestContext,
         fulfillmentId: ID,
         state: FulfillmentState,
-    ): Promise<Fulfillment> {
-        const { fulfillment, fromState, toState, orders } = await this.fulfillmentService.transitionToState(
-            ctx,
-            fulfillmentId,
-            state,
-        );
+    ): Promise<Fulfillment | FulfillmentStateTransitionError> {
+        const result = await this.fulfillmentService.transitionToState(ctx, fulfillmentId, state);
+        if (isGraphQlErrorResult(result)) {
+            return result;
+        }
+        const { fulfillment, fromState, toState, orders } = result;
         await Promise.all(
             orders.map(order => this.handleFulfillmentStateTransitByOrder(ctx, order, fromState, toState)),
         );
@@ -726,11 +727,15 @@ export class OrderService {
             return stockCheckResult;
         }
 
-        const fulfillment = await this.fulfillmentService.create(ctx, {
-            trackingCode: input.trackingCode,
-            method: input.method,
-            orderItems: ordersAndItems.items,
-        });
+        const fulfillment = await this.fulfillmentService.create(
+            ctx,
+            ordersAndItems.orders,
+            ordersAndItems.items,
+            input.handler,
+        );
+        if (isGraphQlErrorResult(fulfillment)) {
+            return fulfillment;
+        }
 
         await this.stockMovementService.createSalesForOrder(ctx, ordersAndItems.items);
 
@@ -744,12 +749,11 @@ export class OrderService {
                 },
             });
         }
-        const { fulfillment: pendingFulfillment } = await this.fulfillmentService.transitionToState(
-            ctx,
-            fulfillment.id,
-            'Pending',
-        );
-        return pendingFulfillment;
+        const result = await this.fulfillmentService.transitionToState(ctx, fulfillment.id, 'Pending');
+        if (isGraphQlErrorResult(result)) {
+            return result;
+        }
+        return result.fulfillment;
     }
 
     private async ensureSufficientStockForFulfillment(

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

@@ -14,6 +14,7 @@ import { EntityNotFoundError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
+import { Logger } from '../../config/logger/vendure-logger';
 import { Channel } from '../../entity/channel/channel.entity';
 import { ShippingMethodTranslation } from '../../entity/shipping-method/shipping-method-translation.entity';
 import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity';
@@ -40,7 +41,12 @@ export class ShippingMethodService {
     ) {}
 
     async initShippingMethods() {
-        await this.updateActiveShippingMethods(RequestContext.empty());
+        if (this.configService.shippingOptions.fulfillmentHandlers.length === 0) {
+            throw new Error(
+                `No FulfillmentHandlers were found. Please ensure the VendureConfig.shippingOptions.fulfillmentHandlers array contains at least one FulfillmentHandler.`,
+            );
+        }
+        await this.verifyShippingMethods();
     }
 
     findAll(
@@ -82,6 +88,10 @@ export class ShippingMethodService {
             entityType: ShippingMethod,
             translationType: ShippingMethodTranslation,
             beforeSave: method => {
+                method.fulfillmentHandlerCode = this.ensureValidFulfillmentHandlerCode(
+                    method.code,
+                    input.fulfillmentHandler,
+                );
                 method.checker = this.shippingConfiguration.parseCheckerInput(input.checker);
                 method.calculator = this.shippingConfiguration.parseCalculatorInput(input.calculator);
             },
@@ -113,6 +123,12 @@ export class ShippingMethodService {
                 input.calculator,
             );
         }
+        if (input.fulfillmentHandler) {
+            updatedShippingMethod.fulfillmentHandlerCode = this.ensureValidFulfillmentHandlerCode(
+                updatedShippingMethod.code,
+                input.fulfillmentHandler,
+            );
+        }
         await this.connection
             .getRepository(ctx, ShippingMethod)
             .save(updatedShippingMethod, { reload: false });
@@ -141,10 +157,29 @@ export class ShippingMethodService {
         return this.shippingConfiguration.shippingCalculators.map(x => x.toGraphQlType(ctx));
     }
 
+    getFulfillmentHandlers(ctx: RequestContext): ConfigurableOperationDefinition[] {
+        return this.shippingConfiguration.fulfillmentHandlers.map(x => x.toGraphQlType(ctx));
+    }
+
     getActiveShippingMethods(channel: Channel): ShippingMethod[] {
         return this.activeShippingMethods.filter(sm => sm.channels.find(c => c.id === channel.id));
     }
 
+    /**
+     * Ensures that all ShippingMethods have a valid fulfillmentHandlerCode
+     */
+    private async verifyShippingMethods() {
+        await this.updateActiveShippingMethods(RequestContext.empty());
+        for (const method of this.activeShippingMethods) {
+            const handlerCode = method.fulfillmentHandlerCode;
+            const verifiedHandlerCode = this.ensureValidFulfillmentHandlerCode(method.code, handlerCode);
+            if (handlerCode !== verifiedHandlerCode) {
+                method.fulfillmentHandlerCode = verifiedHandlerCode;
+                await this.connection.getRepository(ShippingMethod).save(method);
+            }
+        }
+    }
+
     private async updateActiveShippingMethods(ctx: RequestContext) {
         const activeShippingMethods = await this.connection.getRepository(ctx, ShippingMethod).find({
             relations: ['channels'],
@@ -152,4 +187,20 @@ export class ShippingMethodService {
         });
         this.activeShippingMethods = activeShippingMethods.map(m => translateDeep(m, ctx.languageCode));
     }
+
+    private ensureValidFulfillmentHandlerCode(
+        shippingMethodCode: string,
+        fulfillmentHandlerCode: string,
+    ): string {
+        const { fulfillmentHandlers } = this.configService.shippingOptions;
+        let handler = fulfillmentHandlers.find(h => h.code === fulfillmentHandlerCode);
+        if (!handler) {
+            handler = fulfillmentHandlers[0];
+            Logger.error(
+                `The ShippingMethod "${shippingMethodCode}" references an invalid FulfillmentHandler.\n` +
+                    `The FulfillmentHandler with code "${fulfillmentHandlerCode}" was not found. Using "${handler.code}" instead.`,
+            );
+        }
+        return handler.code;
+    }
 }

+ 33 - 0
packages/dev-server/dev-config.ts

@@ -7,7 +7,11 @@ import {
     DefaultLogger,
     DefaultSearchPlugin,
     examplePaymentHandler,
+    FulfillmentHandler,
+    LanguageCode,
+    Logger,
     LogLevel,
+    manualFulfillmentHandler,
     PermissionDefinition,
     VendureConfig,
 } from '@vendure/core';
@@ -16,6 +20,32 @@ import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
 import path from 'path';
 import { ConnectionOptions } from 'typeorm';
 
+const customFulfillmentHandler = new FulfillmentHandler({
+    code: 'ship-o-matic',
+    description: [
+        {
+            languageCode: LanguageCode.en,
+            value: 'Generate tracking codes via the Ship-o-matic API',
+        },
+    ],
+    args: {
+        preferredService: {
+            type: 'string',
+            config: {
+                options: [{ value: 'first_class' }, { value: 'priority' }, { value: 'standard' }],
+            },
+        },
+    },
+    createFulfillment: async (ctx, orders, orderItems, args) => {
+        return {
+            trackingCode: 'SHIP-' + Math.random().toString(36).substr(3),
+        };
+    },
+    onFulfillmentTransition: async (fromState, toState, { fulfillment }) => {
+        Logger.info(`Transitioned Fulfillment ${fulfillment.trackingCode} to state ${toState}`);
+    },
+});
+
 /**
  * Config settings used during development
  */
@@ -58,6 +88,9 @@ export const devConfig: VendureConfig = {
     importExportOptions: {
         importAssetsDir: path.join(__dirname, 'import-assets'),
     },
+    shippingOptions: {
+        fulfillmentHandlers: [manualFulfillmentHandler, customFulfillmentHandler],
+    },
     plugins: [
         AssetServerPlugin.init({
             route: 'assets',

+ 26 - 3
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -67,6 +67,7 @@ export type Query = {
     shippingMethod?: Maybe<ShippingMethod>;
     shippingEligibilityCheckers: Array<ConfigurableOperationDefinition>;
     shippingCalculators: Array<ConfigurableOperationDefinition>;
+    fulfillmentHandlers: Array<ConfigurableOperationDefinition>;
     testShippingMethod: TestShippingMethodResult;
     testEligibleShippingMethods: Array<ShippingMethodQuote>;
     taxCategories: Array<TaxCategory>;
@@ -1218,8 +1219,7 @@ export type UpdateOrderInput = {
 
 export type FulfillOrderInput = {
     lines: Array<OrderLineInput>;
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
+    handler: ConfigurableOperationInput;
 };
 
 export type CancelOrderInput = {
@@ -1279,6 +1279,19 @@ export type ItemsAlreadyFulfilledError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Returned if the specified FulfillmentHandler code is not valid */
+export type InvalidFulfillmentHandlerError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+};
+
+/** Returned if an error is thrown in a FulfillmentHandler's createFulfillment method */
+export type CreateFulfillmentError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    fulfillmentHandlerError: Scalars['String'];
+};
+
 /**
  * Returned if attempting to create a Fulfillment when there is insufficient
  * stockOnHand of a ProductVariant to satisfy the requested quantity.
@@ -1375,7 +1388,10 @@ export type AddFulfillmentToOrderResult =
     | Fulfillment
     | EmptyOrderLineSelectionError
     | ItemsAlreadyFulfilledError
-    | InsufficientStockOnHandError;
+    | InsufficientStockOnHandError
+    | InvalidFulfillmentHandlerError
+    | FulfillmentStateTransitionError
+    | CreateFulfillmentError;
 
 export type CancelOrderResult =
     | Order
@@ -1711,6 +1727,7 @@ export type ShippingMethodTranslationInput = {
 
 export type CreateShippingMethodInput = {
     code: Scalars['String'];
+    fulfillmentHandler: Scalars['String'];
     checker: ConfigurableOperationInput;
     calculator: ConfigurableOperationInput;
     translations: Array<ShippingMethodTranslationInput>;
@@ -1720,6 +1737,7 @@ export type CreateShippingMethodInput = {
 export type UpdateShippingMethodInput = {
     id: Scalars['ID'];
     code?: Maybe<Scalars['String']>;
+    fulfillmentHandler?: Maybe<Scalars['String']>;
     checker?: Maybe<ConfigurableOperationInput>;
     calculator?: Maybe<ConfigurableOperationInput>;
     translations: Array<ShippingMethodTranslationInput>;
@@ -1950,6 +1968,8 @@ export enum ErrorCode {
     SETTLE_PAYMENT_ERROR = 'SETTLE_PAYMENT_ERROR',
     EMPTY_ORDER_LINE_SELECTION_ERROR = 'EMPTY_ORDER_LINE_SELECTION_ERROR',
     ITEMS_ALREADY_FULFILLED_ERROR = 'ITEMS_ALREADY_FULFILLED_ERROR',
+    INVALID_FULFILLMENT_HANDLER_ERROR = 'INVALID_FULFILLMENT_HANDLER_ERROR',
+    CREATE_FULFILLMENT_ERROR = 'CREATE_FULFILLMENT_ERROR',
     INSUFFICIENT_STOCK_ON_HAND_ERROR = 'INSUFFICIENT_STOCK_ON_HAND_ERROR',
     MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
     CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
@@ -3392,6 +3412,7 @@ export type ShippingMethod = Node & {
     code: Scalars['String'];
     name: Scalars['String'];
     description: Scalars['String'];
+    fulfillmentHandlerCode: Scalars['String'];
     checker: ConfigurableOperation;
     calculator: ConfigurableOperation;
     translations: Array<ShippingMethodTranslation>;
@@ -3934,6 +3955,7 @@ export type ShippingMethodFilterParameter = {
     code?: Maybe<StringOperators>;
     name?: Maybe<StringOperators>;
     description?: Maybe<StringOperators>;
+    fulfillmentHandlerCode?: Maybe<StringOperators>;
 };
 
 export type ShippingMethodSortParameter = {
@@ -3943,6 +3965,7 @@ export type ShippingMethodSortParameter = {
     code?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
     description?: Maybe<SortOrder>;
+    fulfillmentHandlerCode?: Maybe<SortOrder>;
 };
 
 export type TaxRateFilterParameter = {

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
schema-admin.json


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
schema-shop.json


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff