Browse Source

feat(core): Implement constraints on adding & fulfilling OrderItems

Relates to #319. This commit introduces the concept of `outOfStockThreshold`,
on both the global and ProductVariant level. This determines whether back-orders are allowed.
Michael Bromley 5 years ago
parent
commit
87d07f8a21
29 changed files with 1049 additions and 217 deletions
  1. 37 4
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 2 0
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 28 3
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 17 1
      packages/common/src/generated-shop-types.ts
  5. 28 3
      packages/common/src/generated-types.ts
  6. 3 36
      packages/core/e2e/global-settings.e2e-spec.ts
  7. 23 0
      packages/core/e2e/graphql/fragments.ts
  8. 147 64
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  9. 32 3
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  10. 14 0
      packages/core/e2e/graphql/shared-definitions.ts
  11. 6 0
      packages/core/e2e/graphql/shop-definitions.ts
  12. 426 36
      packages/core/e2e/stock-control.e2e-spec.ts
  13. 1 0
      packages/core/src/api/schema/admin-api/global-settings.api.graphql
  14. 22 2
      packages/core/src/api/schema/admin-api/order.api.graphql
  15. 7 1
      packages/core/src/api/schema/admin-api/product.api.graphql
  16. 24 3
      packages/core/src/api/schema/shop-api/shop.api.graphql
  17. 1 0
      packages/core/src/api/schema/type/global-settings.type.graphql
  18. 39 26
      packages/core/src/common/error/generated-graphql-admin-errors.ts
  19. 25 13
      packages/core/src/common/error/generated-graphql-shop-errors.ts
  20. 9 0
      packages/core/src/entity/global-settings/global-settings.entity.ts
  21. 16 0
      packages/core/src/entity/product-variant/product-variant.entity.ts
  22. 2 0
      packages/core/src/i18n/messages/en.json
  23. 3 3
      packages/core/src/service/helpers/utils/translate-entity.ts
  24. 63 14
      packages/core/src/service/services/order.service.ts
  25. 39 0
      packages/core/src/service/services/product-variant.service.ts
  26. 28 3
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  27. 0 0
      schema-admin.json
  28. 0 0
      schema-shop.json
  29. 7 2
      scripts/codegen/plugins/graphql-errors-plugin.ts

+ 37 - 4
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1255,6 +1255,7 @@ export type Fulfillment = Node & {
 export type UpdateGlobalSettingsInput = {
   availableLanguages?: Maybe<Array<LanguageCode>>;
   trackInventory?: Maybe<Scalars['Boolean']>;
+  outOfStockThreshold?: Maybe<Scalars['Int']>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 
@@ -1426,6 +1427,19 @@ export type ItemsAlreadyFulfilledError = ErrorResult & {
   message: Scalars['String'];
 };
 
+/**
+ * Returned if attempting to create a Fulfillment when there is insufficient
+ * stockOnHand of a ProductVariant to satisfy the requested quantity.
+ */
+export type InsufficientStockOnHandError = ErrorResult & {
+  __typename?: 'InsufficientStockOnHandError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+  productVariantId: Scalars['ID'];
+  productVariantName: Scalars['String'];
+  stockOnHand: Scalars['Int'];
+};
+
 /** Returned if an operation has specified OrderLines from multiple Orders */
 export type MultipleOrderError = ErrorResult & {
   __typename?: 'MultipleOrderError';
@@ -1512,7 +1526,7 @@ export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type SettlePaymentResult = Payment | SettlePaymentError | PaymentStateTransitionError | OrderStateTransitionError;
 
-export type AddFulfillmentToOrderResult = Fulfillment | EmptyOrderLineSelectionError | ItemsAlreadyFulfilledError;
+export type AddFulfillmentToOrderResult = Fulfillment | EmptyOrderLineSelectionError | ItemsAlreadyFulfilledError | InsufficientStockOnHandError;
 
 export type CancelOrderResult = Order | EmptyOrderLineSelectionError | QuantityTooGreatError | MultipleOrderError | CancelActiveOrderError | OrderStateTransitionError;
 
@@ -1635,9 +1649,11 @@ export type Product = Node & {
 export type ProductVariant = Node & {
   __typename?: 'ProductVariant';
   enabled: Scalars['Boolean'];
+  trackInventory: GlobalFlag;
   stockOnHand: Scalars['Int'];
   stockAllocated: Scalars['Int'];
-  trackInventory: GlobalFlag;
+  outOfStockThreshold: Scalars['Int'];
+  useGlobalOutOfStockThreshold: Scalars['Boolean'];
   stockMovements: StockMovementList;
   id: Scalars['ID'];
   product: Product;
@@ -1723,6 +1739,8 @@ export type CreateProductVariantInput = {
   featuredAssetId?: Maybe<Scalars['ID']>;
   assetIds?: Maybe<Array<Scalars['ID']>>;
   stockOnHand?: Maybe<Scalars['Int']>;
+  outOfStockThreshold?: Maybe<Scalars['Int']>;
+  useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
   trackInventory?: Maybe<GlobalFlag>;
   customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1738,6 +1756,8 @@ export type UpdateProductVariantInput = {
   featuredAssetId?: Maybe<Scalars['ID']>;
   assetIds?: Maybe<Array<Scalars['ID']>>;
   stockOnHand?: Maybe<Scalars['Int']>;
+  outOfStockThreshold?: Maybe<Scalars['Int']>;
+  useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
   trackInventory?: Maybe<GlobalFlag>;
   customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1999,6 +2019,7 @@ 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',
+  INSUFFICIENT_STOCK_ON_HAND_ERROR = 'INSUFFICIENT_STOCK_ON_HAND_ERROR',
   MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
   CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
   PAYMENT_ORDER_MISMATCH_ERROR = 'PAYMENT_ORDER_MISMATCH_ERROR',
@@ -3177,6 +3198,7 @@ export type GlobalSettings = {
   updatedAt: Scalars['DateTime'];
   availableLanguages: Array<LanguageCode>;
   trackInventory: Scalars['Boolean'];
+  outOfStockThreshold: Scalars['Int'];
   serverConfig: ServerConfig;
   customFields?: Maybe<Scalars['JSON']>;
 };
@@ -4074,9 +4096,11 @@ export type TaxRateSortParameter = {
 
 export type ProductVariantFilterParameter = {
   enabled?: Maybe<BooleanOperators>;
+  trackInventory?: Maybe<StringOperators>;
   stockOnHand?: Maybe<NumberOperators>;
   stockAllocated?: Maybe<NumberOperators>;
-  trackInventory?: Maybe<StringOperators>;
+  outOfStockThreshold?: Maybe<NumberOperators>;
+  useGlobalOutOfStockThreshold?: Maybe<BooleanOperators>;
   createdAt?: Maybe<DateOperators>;
   updatedAt?: Maybe<DateOperators>;
   languageCode?: Maybe<StringOperators>;
@@ -4091,6 +4115,7 @@ export type ProductVariantFilterParameter = {
 export type ProductVariantSortParameter = {
   stockOnHand?: Maybe<SortOrder>;
   stockAllocated?: Maybe<SortOrder>;
+  outOfStockThreshold?: Maybe<SortOrder>;
   id?: Maybe<SortOrder>;
   productId?: Maybe<SortOrder>;
   createdAt?: Maybe<SortOrder>;
@@ -5102,6 +5127,9 @@ export type CreateFulfillmentMutation = { addFulfillmentToOrder: (
   ) | (
     { __typename?: 'ItemsAlreadyFulfilledError' }
     & ErrorResult_ItemsAlreadyFulfilledError_Fragment
+  ) | (
+    { __typename?: 'InsufficientStockOnHandError' }
+    & ErrorResult_InsufficientStockOnHandError_Fragment
   ) };
 
 export type CancelOrderMutationVariables = Exact<{
@@ -6786,6 +6814,11 @@ type ErrorResult_ItemsAlreadyFulfilledError_Fragment = (
   & Pick<ItemsAlreadyFulfilledError, 'errorCode' | 'message'>
 );
 
+type ErrorResult_InsufficientStockOnHandError_Fragment = (
+  { __typename?: 'InsufficientStockOnHandError' }
+  & Pick<InsufficientStockOnHandError, 'errorCode' | 'message'>
+);
+
 type ErrorResult_MultipleOrderError_Fragment = (
   { __typename?: 'MultipleOrderError' }
   & Pick<MultipleOrderError, 'errorCode' | 'message'>
@@ -6866,7 +6899,7 @@ type ErrorResult_EmailAddressConflictError_Fragment = (
   & Pick<EmailAddressConflictError, 'errorCode' | 'message'>
 );
 
-export type ErrorResultFragment = ErrorResult_MimeTypeError_Fragment | ErrorResult_LanguageNotAvailableError_Fragment | ErrorResult_ChannelDefaultLanguageError_Fragment | ErrorResult_SettlePaymentError_Fragment | ErrorResult_EmptyOrderLineSelectionError_Fragment | ErrorResult_ItemsAlreadyFulfilledError_Fragment | ErrorResult_MultipleOrderError_Fragment | ErrorResult_CancelActiveOrderError_Fragment | ErrorResult_PaymentOrderMismatchError_Fragment | ErrorResult_RefundOrderStateError_Fragment | ErrorResult_NothingToRefundError_Fragment | ErrorResult_AlreadyRefundedError_Fragment | ErrorResult_QuantityTooGreatError_Fragment | ErrorResult_RefundStateTransitionError_Fragment | ErrorResult_PaymentStateTransitionError_Fragment | ErrorResult_FulfillmentStateTransitionError_Fragment | ErrorResult_ProductOptionInUseError_Fragment | ErrorResult_MissingConditionsError_Fragment | ErrorResult_NativeAuthStrategyError_Fragment | ErrorResult_InvalidCredentialsError_Fragment | ErrorResult_OrderStateTransitionError_Fragment | ErrorResult_EmailAddressConflictError_Fragment;
+export type ErrorResultFragment = ErrorResult_MimeTypeError_Fragment | ErrorResult_LanguageNotAvailableError_Fragment | ErrorResult_ChannelDefaultLanguageError_Fragment | ErrorResult_SettlePaymentError_Fragment | ErrorResult_EmptyOrderLineSelectionError_Fragment | ErrorResult_ItemsAlreadyFulfilledError_Fragment | ErrorResult_InsufficientStockOnHandError_Fragment | ErrorResult_MultipleOrderError_Fragment | ErrorResult_CancelActiveOrderError_Fragment | ErrorResult_PaymentOrderMismatchError_Fragment | ErrorResult_RefundOrderStateError_Fragment | ErrorResult_NothingToRefundError_Fragment | ErrorResult_AlreadyRefundedError_Fragment | ErrorResult_QuantityTooGreatError_Fragment | ErrorResult_RefundStateTransitionError_Fragment | ErrorResult_PaymentStateTransitionError_Fragment | ErrorResult_FulfillmentStateTransitionError_Fragment | ErrorResult_ProductOptionInUseError_Fragment | ErrorResult_MissingConditionsError_Fragment | ErrorResult_NativeAuthStrategyError_Fragment | ErrorResult_InvalidCredentialsError_Fragment | ErrorResult_OrderStateTransitionError_Fragment | ErrorResult_EmailAddressConflictError_Fragment;
 
 export type ShippingMethodFragment = (
   { __typename?: 'ShippingMethod' }

+ 2 - 0
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -26,6 +26,7 @@ const result: PossibleTypesResultData = {
             'Fulfillment',
             'EmptyOrderLineSelectionError',
             'ItemsAlreadyFulfilledError',
+            'InsufficientStockOnHandError',
         ],
         CancelOrderResult: [
             'Order',
@@ -116,6 +117,7 @@ const result: PossibleTypesResultData = {
             'SettlePaymentError',
             'EmptyOrderLineSelectionError',
             'ItemsAlreadyFulfilledError',
+            'InsufficientStockOnHandError',
             'MultipleOrderError',
             'CancelActiveOrderError',
             'PaymentOrderMismatchError',

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

@@ -1083,6 +1083,7 @@ export type Fulfillment = Node & {
 export type UpdateGlobalSettingsInput = {
     availableLanguages?: Maybe<Array<LanguageCode>>;
     trackInventory?: Maybe<Scalars['Boolean']>;
+    outOfStockThreshold?: Maybe<Scalars['Int']>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
@@ -1245,6 +1246,18 @@ export type ItemsAlreadyFulfilledError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/**
+ * Returned if attempting to create a Fulfillment when there is insufficient
+ * stockOnHand of a ProductVariant to satisfy the requested quantity.
+ */
+export type InsufficientStockOnHandError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    productVariantId: Scalars['ID'];
+    productVariantName: Scalars['String'];
+    stockOnHand: Scalars['Int'];
+};
+
 /** Returned if an operation has specified OrderLines from multiple Orders */
 export type MultipleOrderError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1328,7 +1341,8 @@ export type SettlePaymentResult =
 export type AddFulfillmentToOrderResult =
     | Fulfillment
     | EmptyOrderLineSelectionError
-    | ItemsAlreadyFulfilledError;
+    | ItemsAlreadyFulfilledError
+    | InsufficientStockOnHandError;
 
 export type CancelOrderResult =
     | Order
@@ -1462,9 +1476,11 @@ export type Product = Node & {
 
 export type ProductVariant = Node & {
     enabled: Scalars['Boolean'];
+    trackInventory: GlobalFlag;
     stockOnHand: Scalars['Int'];
     stockAllocated: Scalars['Int'];
-    trackInventory: GlobalFlag;
+    outOfStockThreshold: Scalars['Int'];
+    useGlobalOutOfStockThreshold: Scalars['Boolean'];
     stockMovements: StockMovementList;
     id: Scalars['ID'];
     product: Product;
@@ -1549,6 +1565,8 @@ export type CreateProductVariantInput = {
     featuredAssetId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
     stockOnHand?: Maybe<Scalars['Int']>;
+    outOfStockThreshold?: Maybe<Scalars['Int']>;
+    useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
     trackInventory?: Maybe<GlobalFlag>;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1564,6 +1582,8 @@ export type UpdateProductVariantInput = {
     featuredAssetId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
     stockOnHand?: Maybe<Scalars['Int']>;
+    outOfStockThreshold?: Maybe<Scalars['Int']>;
+    useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
     trackInventory?: Maybe<GlobalFlag>;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1812,6 +1832,7 @@ 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',
+    INSUFFICIENT_STOCK_ON_HAND_ERROR = 'INSUFFICIENT_STOCK_ON_HAND_ERROR',
     MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
     CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
     PAYMENT_ORDER_MISMATCH_ERROR = 'PAYMENT_ORDER_MISMATCH_ERROR',
@@ -2959,6 +2980,7 @@ export type GlobalSettings = {
     updatedAt: Scalars['DateTime'];
     availableLanguages: Array<LanguageCode>;
     trackInventory: Scalars['Boolean'];
+    outOfStockThreshold: Scalars['Int'];
     serverConfig: ServerConfig;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -3817,9 +3839,11 @@ export type TaxRateSortParameter = {
 
 export type ProductVariantFilterParameter = {
     enabled?: Maybe<BooleanOperators>;
+    trackInventory?: Maybe<StringOperators>;
     stockOnHand?: Maybe<NumberOperators>;
     stockAllocated?: Maybe<NumberOperators>;
-    trackInventory?: Maybe<StringOperators>;
+    outOfStockThreshold?: Maybe<NumberOperators>;
+    useGlobalOutOfStockThreshold?: Maybe<BooleanOperators>;
     createdAt?: Maybe<DateOperators>;
     updatedAt?: Maybe<DateOperators>;
     languageCode?: Maybe<StringOperators>;
@@ -3834,6 +3858,7 @@ export type ProductVariantFilterParameter = {
 export type ProductVariantSortParameter = {
     stockOnHand?: Maybe<SortOrder>;
     stockAllocated?: Maybe<SortOrder>;
+    outOfStockThreshold?: Maybe<SortOrder>;
     id?: Maybe<SortOrder>;
     productId?: Maybe<SortOrder>;
     createdAt?: Maybe<SortOrder>;

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

@@ -390,6 +390,7 @@ export enum ErrorCode {
     ORDER_MODIFICATION_ERROR = 'ORDER_MODIFICATION_ERROR',
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
+    INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
     ORDER_PAYMENT_STATE_ERROR = 'ORDER_PAYMENT_STATE_ERROR',
     PAYMENT_FAILED_ERROR = 'PAYMENT_FAILED_ERROR',
     PAYMENT_DECLINED_ERROR = 'PAYMENT_DECLINED_ERROR',
@@ -1405,6 +1406,15 @@ export type NegativeQuantityError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Returned when attempting to add more items to the Order than are available */
+export type InsufficientStockError = ErrorResult & {
+    __typename?: 'InsufficientStockError';
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    quantityAvailable: Scalars['Int'];
+    order: Order;
+};
+
 /** Returned when attempting to add a Payment to an Order that is not in the `ArrangingPayment` state. */
 export type OrderPaymentStateError = ErrorResult & {
     __typename?: 'OrderPaymentStateError';
@@ -1544,7 +1554,12 @@ export type NotVerifiedError = ErrorResult & {
     message: Scalars['String'];
 };
 
-export type UpdateOrderItemsResult = Order | OrderModificationError | OrderLimitError | NegativeQuantityError;
+export type UpdateOrderItemsResult =
+    | Order
+    | OrderModificationError
+    | OrderLimitError
+    | NegativeQuantityError
+    | InsufficientStockError;
 
 export type RemoveOrderItemsResult = Order | OrderModificationError;
 
@@ -1886,6 +1901,7 @@ export type GlobalSettings = {
     updatedAt: Scalars['DateTime'];
     availableLanguages: Array<LanguageCode>;
     trackInventory: Scalars['Boolean'];
+    outOfStockThreshold: Scalars['Int'];
     serverConfig: ServerConfig;
     customFields?: Maybe<Scalars['JSON']>;
 };

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

@@ -1224,6 +1224,7 @@ export type Fulfillment = Node & {
 export type UpdateGlobalSettingsInput = {
   availableLanguages?: Maybe<Array<LanguageCode>>;
   trackInventory?: Maybe<Scalars['Boolean']>;
+  outOfStockThreshold?: Maybe<Scalars['Int']>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 
@@ -1395,6 +1396,19 @@ export type ItemsAlreadyFulfilledError = ErrorResult & {
   message: Scalars['String'];
 };
 
+/**
+ * Returned if attempting to create a Fulfillment when there is insufficient
+ * stockOnHand of a ProductVariant to satisfy the requested quantity.
+ */
+export type InsufficientStockOnHandError = ErrorResult & {
+  __typename?: 'InsufficientStockOnHandError';
+  errorCode: ErrorCode;
+  message: Scalars['String'];
+  productVariantId: Scalars['ID'];
+  productVariantName: Scalars['String'];
+  stockOnHand: Scalars['Int'];
+};
+
 /** Returned if an operation has specified OrderLines from multiple Orders */
 export type MultipleOrderError = ErrorResult & {
   __typename?: 'MultipleOrderError';
@@ -1481,7 +1495,7 @@ export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 export type SettlePaymentResult = Payment | SettlePaymentError | PaymentStateTransitionError | OrderStateTransitionError;
 
-export type AddFulfillmentToOrderResult = Fulfillment | EmptyOrderLineSelectionError | ItemsAlreadyFulfilledError;
+export type AddFulfillmentToOrderResult = Fulfillment | EmptyOrderLineSelectionError | ItemsAlreadyFulfilledError | InsufficientStockOnHandError;
 
 export type CancelOrderResult = Order | EmptyOrderLineSelectionError | QuantityTooGreatError | MultipleOrderError | CancelActiveOrderError | OrderStateTransitionError;
 
@@ -1604,9 +1618,11 @@ export type Product = Node & {
 export type ProductVariant = Node & {
   __typename?: 'ProductVariant';
   enabled: Scalars['Boolean'];
+  trackInventory: GlobalFlag;
   stockOnHand: Scalars['Int'];
   stockAllocated: Scalars['Int'];
-  trackInventory: GlobalFlag;
+  outOfStockThreshold: Scalars['Int'];
+  useGlobalOutOfStockThreshold: Scalars['Boolean'];
   stockMovements: StockMovementList;
   id: Scalars['ID'];
   product: Product;
@@ -1692,6 +1708,8 @@ export type CreateProductVariantInput = {
   featuredAssetId?: Maybe<Scalars['ID']>;
   assetIds?: Maybe<Array<Scalars['ID']>>;
   stockOnHand?: Maybe<Scalars['Int']>;
+  outOfStockThreshold?: Maybe<Scalars['Int']>;
+  useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
   trackInventory?: Maybe<GlobalFlag>;
   customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1707,6 +1725,8 @@ export type UpdateProductVariantInput = {
   featuredAssetId?: Maybe<Scalars['ID']>;
   assetIds?: Maybe<Array<Scalars['ID']>>;
   stockOnHand?: Maybe<Scalars['Int']>;
+  outOfStockThreshold?: Maybe<Scalars['Int']>;
+  useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
   trackInventory?: Maybe<GlobalFlag>;
   customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1968,6 +1988,7 @@ 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',
+  INSUFFICIENT_STOCK_ON_HAND_ERROR = 'INSUFFICIENT_STOCK_ON_HAND_ERROR',
   MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
   CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
   PAYMENT_ORDER_MISMATCH_ERROR = 'PAYMENT_ORDER_MISMATCH_ERROR',
@@ -3146,6 +3167,7 @@ export type GlobalSettings = {
   updatedAt: Scalars['DateTime'];
   availableLanguages: Array<LanguageCode>;
   trackInventory: Scalars['Boolean'];
+  outOfStockThreshold: Scalars['Int'];
   serverConfig: ServerConfig;
   customFields?: Maybe<Scalars['JSON']>;
 };
@@ -4043,9 +4065,11 @@ export type TaxRateSortParameter = {
 
 export type ProductVariantFilterParameter = {
   enabled?: Maybe<BooleanOperators>;
+  trackInventory?: Maybe<StringOperators>;
   stockOnHand?: Maybe<NumberOperators>;
   stockAllocated?: Maybe<NumberOperators>;
-  trackInventory?: Maybe<StringOperators>;
+  outOfStockThreshold?: Maybe<NumberOperators>;
+  useGlobalOutOfStockThreshold?: Maybe<BooleanOperators>;
   createdAt?: Maybe<DateOperators>;
   updatedAt?: Maybe<DateOperators>;
   languageCode?: Maybe<StringOperators>;
@@ -4060,6 +4084,7 @@ export type ProductVariantFilterParameter = {
 export type ProductVariantSortParameter = {
   stockOnHand?: Maybe<SortOrder>;
   stockAllocated?: Maybe<SortOrder>;
+  outOfStockThreshold?: Maybe<SortOrder>;
   id?: Maybe<SortOrder>;
   productId?: Maybe<SortOrder>;
   createdAt?: Maybe<SortOrder>;

+ 3 - 36
packages/core/e2e/global-settings.e2e-spec.ts

@@ -3,14 +3,16 @@ 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 { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
+import { GLOBAL_SETTINGS_FRAGMENT } from './graphql/fragments';
 import {
     GetGlobalSettings,
     GlobalSettingsFragment,
     LanguageCode,
     UpdateGlobalSettings,
 } from './graphql/generated-e2e-admin-types';
+import { UPDATE_GLOBAL_SETTINGS } from './graphql/shared-definitions';
 
 describe('GlobalSettings resolver', () => {
     const { server, adminClient } = createTestEnvironment({
@@ -104,28 +106,6 @@ describe('GlobalSettings resolver', () => {
     });
 });
 
-const GLOBAL_SETTINGS_FRAGMENT = gql`
-    fragment GlobalSettings on GlobalSettings {
-        id
-        availableLanguages
-        trackInventory
-        serverConfig {
-            orderProcess {
-                name
-                to
-            }
-            permittedAssetTypes
-            customFieldConfig {
-                Customer {
-                    ... on CustomField {
-                        name
-                    }
-                }
-            }
-        }
-    }
-`;
-
 const GET_GLOBAL_SETTINGS = gql`
     query GetGlobalSettings {
         globalSettings {
@@ -134,16 +114,3 @@ const GET_GLOBAL_SETTINGS = gql`
     }
     ${GLOBAL_SETTINGS_FRAGMENT}
 `;
-
-const UPDATE_GLOBAL_SETTINGS = gql`
-    mutation UpdateGlobalSettings($input: UpdateGlobalSettingsInput!) {
-        updateGlobalSettings(input: $input) {
-            ...GlobalSettings
-            ... on ErrorResult {
-                errorCode
-                message
-            }
-        }
-    }
-    ${GLOBAL_SETTINGS_FRAGMENT}
-`;

+ 23 - 0
packages/core/e2e/graphql/fragments.ts

@@ -512,3 +512,26 @@ export const CHANNEL_FRAGMENT = gql`
         pricesIncludeTax
     }
 `;
+
+export const GLOBAL_SETTINGS_FRAGMENT = gql`
+    fragment GlobalSettings on GlobalSettings {
+        id
+        availableLanguages
+        trackInventory
+        outOfStockThreshold
+        serverConfig {
+            orderProcess {
+                name
+                to
+            }
+            permittedAssetTypes
+            customFieldConfig {
+                Customer {
+                    ... on CustomField {
+                        name
+                    }
+                }
+            }
+        }
+    }
+`;

+ 147 - 64
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1083,6 +1083,7 @@ export type Fulfillment = Node & {
 export type UpdateGlobalSettingsInput = {
     availableLanguages?: Maybe<Array<LanguageCode>>;
     trackInventory?: Maybe<Scalars['Boolean']>;
+    outOfStockThreshold?: Maybe<Scalars['Int']>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
@@ -1245,6 +1246,18 @@ export type ItemsAlreadyFulfilledError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/**
+ * Returned if attempting to create a Fulfillment when there is insufficient
+ * stockOnHand of a ProductVariant to satisfy the requested quantity.
+ */
+export type InsufficientStockOnHandError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    productVariantId: Scalars['ID'];
+    productVariantName: Scalars['String'];
+    stockOnHand: Scalars['Int'];
+};
+
 /** Returned if an operation has specified OrderLines from multiple Orders */
 export type MultipleOrderError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1328,7 +1341,8 @@ export type SettlePaymentResult =
 export type AddFulfillmentToOrderResult =
     | Fulfillment
     | EmptyOrderLineSelectionError
-    | ItemsAlreadyFulfilledError;
+    | ItemsAlreadyFulfilledError
+    | InsufficientStockOnHandError;
 
 export type CancelOrderResult =
     | Order
@@ -1462,9 +1476,11 @@ export type Product = Node & {
 
 export type ProductVariant = Node & {
     enabled: Scalars['Boolean'];
+    trackInventory: GlobalFlag;
     stockOnHand: Scalars['Int'];
     stockAllocated: Scalars['Int'];
-    trackInventory: GlobalFlag;
+    outOfStockThreshold: Scalars['Int'];
+    useGlobalOutOfStockThreshold: Scalars['Boolean'];
     stockMovements: StockMovementList;
     id: Scalars['ID'];
     product: Product;
@@ -1549,6 +1565,8 @@ export type CreateProductVariantInput = {
     featuredAssetId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
     stockOnHand?: Maybe<Scalars['Int']>;
+    outOfStockThreshold?: Maybe<Scalars['Int']>;
+    useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
     trackInventory?: Maybe<GlobalFlag>;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1564,6 +1582,8 @@ export type UpdateProductVariantInput = {
     featuredAssetId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
     stockOnHand?: Maybe<Scalars['Int']>;
+    outOfStockThreshold?: Maybe<Scalars['Int']>;
+    useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
     trackInventory?: Maybe<GlobalFlag>;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1812,6 +1832,7 @@ 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',
+    INSUFFICIENT_STOCK_ON_HAND_ERROR = 'INSUFFICIENT_STOCK_ON_HAND_ERROR',
     MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
     CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
     PAYMENT_ORDER_MISMATCH_ERROR = 'PAYMENT_ORDER_MISMATCH_ERROR',
@@ -2959,6 +2980,7 @@ export type GlobalSettings = {
     updatedAt: Scalars['DateTime'];
     availableLanguages: Array<LanguageCode>;
     trackInventory: Scalars['Boolean'];
+    outOfStockThreshold: Scalars['Int'];
     serverConfig: ServerConfig;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -3817,9 +3839,11 @@ export type TaxRateSortParameter = {
 
 export type ProductVariantFilterParameter = {
     enabled?: Maybe<BooleanOperators>;
+    trackInventory?: Maybe<StringOperators>;
     stockOnHand?: Maybe<NumberOperators>;
     stockAllocated?: Maybe<NumberOperators>;
-    trackInventory?: Maybe<StringOperators>;
+    outOfStockThreshold?: Maybe<NumberOperators>;
+    useGlobalOutOfStockThreshold?: Maybe<BooleanOperators>;
     createdAt?: Maybe<DateOperators>;
     updatedAt?: Maybe<DateOperators>;
     languageCode?: Maybe<StringOperators>;
@@ -3834,6 +3858,7 @@ export type ProductVariantFilterParameter = {
 export type ProductVariantSortParameter = {
     stockOnHand?: Maybe<SortOrder>;
     stockAllocated?: Maybe<SortOrder>;
+    outOfStockThreshold?: Maybe<SortOrder>;
     id?: Maybe<SortOrder>;
     productId?: Maybe<SortOrder>;
     createdAt?: Maybe<SortOrder>;
@@ -4301,6 +4326,24 @@ export type IdTest9Query = { products: { items: Array<ProdFragmentFragment> } };
 
 export type ProdFragmentFragment = Pick<Product, 'id'> & { featuredAsset?: Maybe<Pick<Asset, 'id'>> };
 
+export type IdTest10QueryVariables = Exact<{ [key: string]: never }>;
+
+export type IdTest10Query = { products: { items: Array<ProdFragment1Fragment> } };
+
+export type ProdFragment1Fragment = ProdFragment2Fragment;
+
+export type ProdFragment2Fragment = Pick<Product, 'id'> & { featuredAsset?: Maybe<Pick<Asset, 'id'>> };
+
+export type IdTest11QueryVariables = Exact<{ [key: string]: never }>;
+
+export type IdTest11Query = { products: { items: Array<ProdFragment1_1Fragment> } };
+
+export type ProdFragment1_1Fragment = ProdFragment2_1Fragment;
+
+export type ProdFragment2_1Fragment = ProdFragment3_1Fragment;
+
+export type ProdFragment3_1Fragment = Pick<Product, 'id'> & { featuredAsset?: Maybe<Pick<Asset, 'id'>> };
+
 export type GetFacetWithValuesQueryVariables = Exact<{
     id: Scalars['ID'];
 }>;
@@ -4343,34 +4386,10 @@ export type UpdateFacetValuesMutationVariables = Exact<{
 
 export type UpdateFacetValuesMutation = { updateFacetValues: Array<FacetValueFragment> };
 
-export type GlobalSettingsFragment = Pick<GlobalSettings, 'id' | 'availableLanguages' | 'trackInventory'> & {
-    serverConfig: Pick<ServerConfig, 'permittedAssetTypes'> & {
-        orderProcess: Array<Pick<OrderProcessState, 'name' | 'to'>>;
-        customFieldConfig: {
-            Customer: Array<
-                | Pick<StringCustomFieldConfig, 'name'>
-                | Pick<LocaleStringCustomFieldConfig, 'name'>
-                | Pick<IntCustomFieldConfig, 'name'>
-                | Pick<FloatCustomFieldConfig, 'name'>
-                | Pick<BooleanCustomFieldConfig, 'name'>
-                | Pick<DateTimeCustomFieldConfig, 'name'>
-            >;
-        };
-    };
-};
-
 export type GetGlobalSettingsQueryVariables = Exact<{ [key: string]: never }>;
 
 export type GetGlobalSettingsQuery = { globalSettings: GlobalSettingsFragment };
 
-export type UpdateGlobalSettingsMutationVariables = Exact<{
-    input: UpdateGlobalSettingsInput;
-}>;
-
-export type UpdateGlobalSettingsMutation = {
-    updateGlobalSettings: GlobalSettingsFragment | Pick<ChannelDefaultLanguageError, 'errorCode' | 'message'>;
-};
-
 export type AdministratorFragment = Pick<Administrator, 'id' | 'firstName' | 'lastName' | 'emailAddress'> & {
     user: Pick<User, 'id' | 'identifier' | 'lastLogin'> & {
         roles: Array<Pick<Role, 'id' | 'code' | 'description' | 'permissions'>>;
@@ -4571,6 +4590,25 @@ export type ChannelFragment = Pick<
     'id' | 'code' | 'token' | 'currencyCode' | 'defaultLanguageCode' | 'pricesIncludeTax'
 > & { defaultShippingZone?: Maybe<Pick<Zone, 'id'>>; defaultTaxZone?: Maybe<Pick<Zone, 'id'>> };
 
+export type GlobalSettingsFragment = Pick<
+    GlobalSettings,
+    'id' | 'availableLanguages' | 'trackInventory' | 'outOfStockThreshold'
+> & {
+    serverConfig: Pick<ServerConfig, 'permittedAssetTypes'> & {
+        orderProcess: Array<Pick<OrderProcessState, 'name' | 'to'>>;
+        customFieldConfig: {
+            Customer: Array<
+                | Pick<StringCustomFieldConfig, 'name'>
+                | Pick<LocaleStringCustomFieldConfig, 'name'>
+                | Pick<IntCustomFieldConfig, 'name'>
+                | Pick<FloatCustomFieldConfig, 'name'>
+                | Pick<BooleanCustomFieldConfig, 'name'>
+                | Pick<DateTimeCustomFieldConfig, 'name'>
+            >;
+        };
+    };
+};
+
 export type CreateAdministratorMutationVariables = Exact<{
     input: CreateAdministratorInput;
 }>;
@@ -4875,7 +4913,8 @@ export type CreateFulfillmentMutation = {
     addFulfillmentToOrder:
         | FulfillmentFragment
         | Pick<EmptyOrderLineSelectionError, 'errorCode' | 'message'>
-        | Pick<ItemsAlreadyFulfilledError, 'errorCode' | 'message'>;
+        | Pick<ItemsAlreadyFulfilledError, 'errorCode' | 'message'>
+        | Pick<InsufficientStockOnHandError, 'errorCode' | 'message'>;
 };
 
 export type TransitFulfillmentMutationVariables = Exact<{
@@ -5064,6 +5103,14 @@ export type CanceledOrderFragment = Pick<Order, 'id'> & {
     lines: Array<Pick<OrderLine, 'quantity'> & { items: Array<Pick<OrderItem, 'id' | 'cancelled'>> }>;
 };
 
+export type UpdateGlobalSettingsMutationVariables = Exact<{
+    input: UpdateGlobalSettingsInput;
+}>;
+
+export type UpdateGlobalSettingsMutation = {
+    updateGlobalSettings: GlobalSettingsFragment | Pick<ChannelDefaultLanguageError, 'errorCode' | 'message'>;
+};
+
 export type UpdateOptionGroupMutationVariables = Exact<{
     input: UpdateProductOptionGroupInput;
 }>;
@@ -6137,6 +6184,42 @@ export namespace ProdFragment {
     export type FeaturedAsset = NonNullable<ProdFragmentFragment['featuredAsset']>;
 }
 
+export namespace IdTest10 {
+    export type Variables = IdTest10QueryVariables;
+    export type Query = IdTest10Query;
+    export type Products = NonNullable<IdTest10Query['products']>;
+    export type Items = NonNullable<NonNullable<NonNullable<IdTest10Query['products']>['items']>[number]>;
+}
+
+export namespace ProdFragment1 {
+    export type Fragment = ProdFragment1Fragment;
+}
+
+export namespace ProdFragment2 {
+    export type Fragment = ProdFragment2Fragment;
+    export type FeaturedAsset = NonNullable<ProdFragment2Fragment['featuredAsset']>;
+}
+
+export namespace IdTest11 {
+    export type Variables = IdTest11QueryVariables;
+    export type Query = IdTest11Query;
+    export type Products = NonNullable<IdTest11Query['products']>;
+    export type Items = NonNullable<NonNullable<NonNullable<IdTest11Query['products']>['items']>[number]>;
+}
+
+export namespace ProdFragment1_1 {
+    export type Fragment = ProdFragment1_1Fragment;
+}
+
+export namespace ProdFragment2_1 {
+    export type Fragment = ProdFragment2_1Fragment;
+}
+
+export namespace ProdFragment3_1 {
+    export type Fragment = ProdFragment3_1Fragment;
+    export type FeaturedAsset = NonNullable<ProdFragment3_1Fragment['featuredAsset']>;
+}
+
 export namespace GetFacetWithValues {
     export type Variables = GetFacetWithValuesQueryVariables;
     export type Query = GetFacetWithValuesQuery;
@@ -6189,48 +6272,12 @@ export namespace UpdateFacetValues {
     >;
 }
 
-export namespace GlobalSettings {
-    export type Fragment = GlobalSettingsFragment;
-    export type ServerConfig = NonNullable<GlobalSettingsFragment['serverConfig']>;
-    export type OrderProcess = NonNullable<
-        NonNullable<NonNullable<GlobalSettingsFragment['serverConfig']>['orderProcess']>[number]
-    >;
-    export type CustomFieldConfig = NonNullable<
-        NonNullable<GlobalSettingsFragment['serverConfig']>['customFieldConfig']
-    >;
-    export type Customer = NonNullable<
-        NonNullable<
-            NonNullable<NonNullable<GlobalSettingsFragment['serverConfig']>['customFieldConfig']>['Customer']
-        >[number]
-    >;
-    export type CustomFieldInlineFragment = DiscriminateUnion<
-        NonNullable<
-            NonNullable<
-                NonNullable<
-                    NonNullable<GlobalSettingsFragment['serverConfig']>['customFieldConfig']
-                >['Customer']
-            >[number]
-        >,
-        { __typename?: 'CustomField' }
-    >;
-}
-
 export namespace GetGlobalSettings {
     export type Variables = GetGlobalSettingsQueryVariables;
     export type Query = GetGlobalSettingsQuery;
     export type GlobalSettings = NonNullable<GetGlobalSettingsQuery['globalSettings']>;
 }
 
-export namespace UpdateGlobalSettings {
-    export type Variables = UpdateGlobalSettingsMutationVariables;
-    export type Mutation = UpdateGlobalSettingsMutation;
-    export type UpdateGlobalSettings = NonNullable<UpdateGlobalSettingsMutation['updateGlobalSettings']>;
-    export type ErrorResultInlineFragment = DiscriminateUnion<
-        NonNullable<UpdateGlobalSettingsMutation['updateGlobalSettings']>,
-        { __typename?: 'ErrorResult' }
-    >;
-}
-
 export namespace Administrator {
     export type Fragment = AdministratorFragment;
     export type User = NonNullable<AdministratorFragment['user']>;
@@ -6400,6 +6447,32 @@ export namespace Channel {
     export type DefaultTaxZone = NonNullable<ChannelFragment['defaultTaxZone']>;
 }
 
+export namespace GlobalSettings {
+    export type Fragment = GlobalSettingsFragment;
+    export type ServerConfig = NonNullable<GlobalSettingsFragment['serverConfig']>;
+    export type OrderProcess = NonNullable<
+        NonNullable<NonNullable<GlobalSettingsFragment['serverConfig']>['orderProcess']>[number]
+    >;
+    export type CustomFieldConfig = NonNullable<
+        NonNullable<GlobalSettingsFragment['serverConfig']>['customFieldConfig']
+    >;
+    export type Customer = NonNullable<
+        NonNullable<
+            NonNullable<NonNullable<GlobalSettingsFragment['serverConfig']>['customFieldConfig']>['Customer']
+        >[number]
+    >;
+    export type CustomFieldInlineFragment = DiscriminateUnion<
+        NonNullable<
+            NonNullable<
+                NonNullable<
+                    NonNullable<GlobalSettingsFragment['serverConfig']>['customFieldConfig']
+                >['Customer']
+            >[number]
+        >,
+        { __typename?: 'CustomField' }
+    >;
+}
+
 export namespace CreateAdministrator {
     export type Variables = CreateAdministratorMutationVariables;
     export type Mutation = CreateAdministratorMutation;
@@ -6866,6 +6939,16 @@ export namespace CanceledOrder {
     >;
 }
 
+export namespace UpdateGlobalSettings {
+    export type Variables = UpdateGlobalSettingsMutationVariables;
+    export type Mutation = UpdateGlobalSettingsMutation;
+    export type UpdateGlobalSettings = NonNullable<UpdateGlobalSettingsMutation['updateGlobalSettings']>;
+    export type ErrorResultInlineFragment = DiscriminateUnion<
+        NonNullable<UpdateGlobalSettingsMutation['updateGlobalSettings']>,
+        { __typename?: 'ErrorResult' }
+    >;
+}
+
 export namespace UpdateOptionGroup {
     export type Variables = UpdateOptionGroupMutationVariables;
     export type Mutation = UpdateOptionGroupMutation;

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

@@ -382,6 +382,7 @@ export enum ErrorCode {
     ORDER_MODIFICATION_ERROR = 'ORDER_MODIFICATION_ERROR',
     ORDER_LIMIT_ERROR = 'ORDER_LIMIT_ERROR',
     NEGATIVE_QUANTITY_ERROR = 'NEGATIVE_QUANTITY_ERROR',
+    INSUFFICIENT_STOCK_ERROR = 'INSUFFICIENT_STOCK_ERROR',
     ORDER_PAYMENT_STATE_ERROR = 'ORDER_PAYMENT_STATE_ERROR',
     PAYMENT_FAILED_ERROR = 'PAYMENT_FAILED_ERROR',
     PAYMENT_DECLINED_ERROR = 'PAYMENT_DECLINED_ERROR',
@@ -1381,6 +1382,14 @@ export type NegativeQuantityError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/** Returned when attempting to add more items to the Order than are available */
+export type InsufficientStockError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    quantityAvailable: Scalars['Int'];
+    order: Order;
+};
+
 /** Returned when attempting to add a Payment to an Order that is not in the `ArrangingPayment` state. */
 export type OrderPaymentStateError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1504,7 +1513,12 @@ export type NotVerifiedError = ErrorResult & {
     message: Scalars['String'];
 };
 
-export type UpdateOrderItemsResult = Order | OrderModificationError | OrderLimitError | NegativeQuantityError;
+export type UpdateOrderItemsResult =
+    | Order
+    | OrderModificationError
+    | OrderLimitError
+    | NegativeQuantityError
+    | InsufficientStockError;
 
 export type RemoveOrderItemsResult = Order | OrderModificationError;
 
@@ -1820,6 +1834,7 @@ export type GlobalSettings = {
     updatedAt: Scalars['DateTime'];
     availableLanguages: Array<LanguageCode>;
     trackInventory: Scalars['Boolean'];
+    outOfStockThreshold: Scalars['Int'];
     serverConfig: ServerConfig;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -2620,7 +2635,10 @@ export type AddItemToOrderMutation = {
         | UpdatedOrderFragment
         | Pick<OrderModificationError, 'errorCode' | 'message'>
         | Pick<OrderLimitError, 'errorCode' | 'message'>
-        | Pick<NegativeQuantityError, 'errorCode' | 'message'>;
+        | Pick<NegativeQuantityError, 'errorCode' | 'message'>
+        | (Pick<InsufficientStockError, 'errorCode' | 'message' | 'quantityAvailable'> & {
+              order: UpdatedOrderFragment;
+          });
 };
 
 export type SearchProductsShopQueryVariables = Exact<{
@@ -2791,7 +2809,8 @@ export type AdjustItemQuantityMutation = {
         | TestOrderFragmentFragment
         | Pick<OrderModificationError, 'errorCode' | 'message'>
         | Pick<OrderLimitError, 'errorCode' | 'message'>
-        | Pick<NegativeQuantityError, 'errorCode' | 'message'>;
+        | Pick<NegativeQuantityError, 'errorCode' | 'message'>
+        | Pick<InsufficientStockError, 'errorCode' | 'message'>;
 };
 
 export type RemoveItemFromOrderMutationVariables = Exact<{
@@ -3043,6 +3062,16 @@ export namespace AddItemToOrder {
         NonNullable<AddItemToOrderMutation['addItemToOrder']>,
         { __typename?: 'ErrorResult' }
     >;
+    export type InsufficientStockErrorInlineFragment = DiscriminateUnion<
+        NonNullable<AddItemToOrderMutation['addItemToOrder']>,
+        { __typename?: 'InsufficientStockError' }
+    >;
+    export type Order = NonNullable<
+        DiscriminateUnion<
+            NonNullable<AddItemToOrderMutation['addItemToOrder']>,
+            { __typename?: 'InsufficientStockError' }
+        >['order']
+    >;
 }
 
 export namespace SearchProductsShop {

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

@@ -10,6 +10,7 @@ import {
     CUSTOMER_FRAGMENT,
     FACET_WITH_VALUES_FRAGMENT,
     FULFILLMENT_FRAGMENT,
+    GLOBAL_SETTINGS_FRAGMENT,
     ORDER_FRAGMENT,
     ORDER_WITH_LINES_FRAGMENT,
     PRODUCT_VARIANT_FRAGMENT,
@@ -691,3 +692,16 @@ export const CANCEL_ORDER = gql`
         }
     }
 `;
+
+export const UPDATE_GLOBAL_SETTINGS = gql`
+    mutation UpdateGlobalSettings($input: UpdateGlobalSettingsInput!) {
+        updateGlobalSettings(input: $input) {
+            ...GlobalSettings
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+    ${GLOBAL_SETTINGS_FRAGMENT}
+`;

+ 6 - 0
packages/core/e2e/graphql/shop-definitions.ts

@@ -87,6 +87,12 @@ export const ADD_ITEM_TO_ORDER = gql`
                 errorCode
                 message
             }
+            ... on InsufficientStockError {
+                quantityAvailable
+                order {
+                    ...UpdatedOrder
+                }
+            }
         }
     }
     ${UPDATED_ORDER_FRAGMENT}

+ 426 - 36
packages/core/e2e/stock-control.e2e-spec.ts

@@ -13,27 +13,36 @@ import {
     CancelOrder,
     CreateAddressInput,
     CreateFulfillment,
+    ErrorCode as AdminErrorCode,
+    FulfillmentFragment,
     GetOrder,
     GetStockMovement,
     GlobalFlag,
     StockMovementType,
+    UpdateGlobalSettings,
     UpdateProductVariantInput,
+    UpdateProductVariants,
     UpdateStock,
     VariantWithStockFragment,
 } from './graphql/generated-e2e-admin-types';
 import {
     AddItemToOrder,
     AddPaymentToOrder,
+    ErrorCode,
     PaymentInput,
     SetShippingAddress,
     TestOrderFragmentFragment,
+    TestOrderWithPaymentsFragment,
     TransitionToState,
+    UpdatedOrderFragment,
 } from './graphql/generated-e2e-shop-types';
 import {
     CANCEL_ORDER,
     CREATE_FULFILLMENT,
     GET_ORDER,
     GET_STOCK_MOVEMENT,
+    UPDATE_GLOBAL_SETTINGS,
+    UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
 import {
     ADD_ITEM_TO_ORDER,
@@ -42,6 +51,7 @@ import {
     TRANSITION_TO_STATE,
 } from './graphql/shop-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
 
 describe('Stock control', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
@@ -52,15 +62,27 @@ describe('Stock control', () => {
         }),
     );
 
-    const orderGuard: ErrorResultGuard<TestOrderFragmentFragment> = createErrorResultGuard<
-        TestOrderFragmentFragment
-    >(input => !!input.lines);
+    const orderGuard: ErrorResultGuard<
+        TestOrderFragmentFragment | UpdatedOrderFragment
+    > = createErrorResultGuard<TestOrderFragmentFragment>(input => !!input.lines);
+
+    const fulfillmentGuard: ErrorResultGuard<FulfillmentFragment> = createErrorResultGuard<
+        FulfillmentFragment
+    >(input => !!input.state);
+
+    async function getProductWithStockMovement(productId: string) {
+        const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
+            GET_STOCK_MOVEMENT,
+            { id: productId },
+        );
+        return product;
+    }
 
     beforeAll(async () => {
         await server.init({
             initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-stock-control.csv'),
-            customerCount: 2,
+            customerCount: 3,
         });
         await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
@@ -162,10 +184,7 @@ describe('Stock control', () => {
         let orderId: string;
 
         beforeAll(async () => {
-            const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
-                GET_STOCK_MOVEMENT,
-                { id: 'T_2' },
-            );
+            const product = await getProductWithStockMovement('T_2');
             const [variant1, variant2, variant3] = product!.variants;
 
             await adminClient.query<UpdateStock.Mutation, UpdateStock.Variables>(UPDATE_STOCK_ON_HAND, {
@@ -218,7 +237,7 @@ describe('Stock control', () => {
         });
 
         it('creates an Allocation when order completed', async () => {
-            const { addPaymentToOrder } = await shopClient.query<
+            const { addPaymentToOrder: order } = await shopClient.query<
                 AddPaymentToOrder.Mutation,
                 AddPaymentToOrder.Variables
             >(ADD_PAYMENT, {
@@ -227,14 +246,11 @@ describe('Stock control', () => {
                     metadata: {},
                 } as PaymentInput,
             });
-            orderGuard.assertSuccess(addPaymentToOrder);
-            expect(addPaymentToOrder).not.toBeNull();
-            orderId = addPaymentToOrder.id;
+            orderGuard.assertSuccess(order);
+            expect(order).not.toBeNull();
+            orderId = order.id;
 
-            const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
-                GET_STOCK_MOVEMENT,
-                { id: 'T_2' },
-            );
+            const product = await getProductWithStockMovement('T_2');
             const [variant1, variant2, variant3] = product!.variants;
 
             expect(variant1.stockMovements.totalItems).toBe(2);
@@ -251,10 +267,7 @@ describe('Stock control', () => {
         });
 
         it('stockAllocated is updated according to trackInventory setting', async () => {
-            const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
-                GET_STOCK_MOVEMENT,
-                { id: 'T_2' },
-            );
+            const product = await getProductWithStockMovement('T_2');
             const [variant1, variant2, variant3] = product!.variants;
 
             // stockOnHand not changed yet
@@ -280,10 +293,7 @@ describe('Stock control', () => {
                 },
             });
 
-            const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
-                GET_STOCK_MOVEMENT,
-                { id: 'T_2' },
-            );
+            const product = await getProductWithStockMovement('T_2');
             const [_, variant2, __] = product!.variants;
 
             expect(variant2.stockMovements.totalItems).toBe(3);
@@ -309,10 +319,7 @@ describe('Stock control', () => {
                 },
             );
 
-            const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
-                GET_STOCK_MOVEMENT,
-                { id: 'T_2' },
-            );
+            const product = await getProductWithStockMovement('T_2');
             const [variant1, variant2, variant3] = product!.variants;
 
             expect(variant1.stockMovements.totalItems).toBe(3);
@@ -330,10 +337,7 @@ describe('Stock control', () => {
         });
 
         it('updates stockOnHand and stockAllocated when Sales are created', async () => {
-            const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
-                GET_STOCK_MOVEMENT,
-                { id: 'T_2' },
-            );
+            const product = await getProductWithStockMovement('T_2');
             const [variant1, variant2, variant3] = product!.variants;
 
             expect(variant1.stockOnHand).toBe(5); // untracked inventory
@@ -358,10 +362,7 @@ describe('Stock control', () => {
                 },
             });
 
-            const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
-                GET_STOCK_MOVEMENT,
-                { id: 'T_2' },
-            );
+            const product = await getProductWithStockMovement('T_2');
             const [variant1, variant2, variant3] = product!.variants;
 
             expect(variant1.stockMovements.totalItems).toBe(5);
@@ -379,6 +380,395 @@ describe('Stock control', () => {
             expect(variant3.stockMovements.items[6].type).toBe(StockMovementType.CANCELLATION);
         });
     });
+
+    describe('saleable stock level', () => {
+        let order: TestOrderWithPaymentsFragment;
+
+        beforeAll(async () => {
+            await adminClient.query<UpdateGlobalSettings.Mutation, UpdateGlobalSettings.Variables>(
+                UPDATE_GLOBAL_SETTINGS,
+                {
+                    input: {
+                        trackInventory: true,
+                        outOfStockThreshold: -5,
+                    },
+                },
+            );
+
+            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                UPDATE_PRODUCT_VARIANTS,
+                {
+                    input: [
+                        {
+                            id: 'T_1',
+                            stockOnHand: 3,
+                            outOfStockThreshold: 0,
+                            trackInventory: GlobalFlag.TRUE,
+                            useGlobalOutOfStockThreshold: false,
+                        },
+                        {
+                            id: 'T_2',
+                            stockOnHand: 3,
+                            outOfStockThreshold: 0,
+                            trackInventory: GlobalFlag.FALSE,
+                            useGlobalOutOfStockThreshold: false,
+                        },
+                        {
+                            id: 'T_3',
+                            stockOnHand: 3,
+                            outOfStockThreshold: 2,
+                            trackInventory: GlobalFlag.TRUE,
+                            useGlobalOutOfStockThreshold: false,
+                        },
+                        {
+                            id: 'T_4',
+                            stockOnHand: 3,
+                            outOfStockThreshold: 0,
+                            trackInventory: GlobalFlag.TRUE,
+                            useGlobalOutOfStockThreshold: true,
+                        },
+                    ],
+                },
+            );
+
+            await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
+        });
+
+        it('returns InsufficientStockError when tracking inventory', async () => {
+            const variantId = 'T_1';
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: variantId,
+                quantity: 5,
+            });
+
+            orderGuard.assertErrorResult(addItemToOrder);
+
+            expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
+            expect(addItemToOrder.message).toBe(
+                `Only 3 items were added to the order due to insufficient stock`,
+            );
+            expect((addItemToOrder as any).quantityAvailable).toBe(3);
+            // Still adds as many as available to the Order
+            expect((addItemToOrder as any).order.lines[0].productVariant.id).toBe(variantId);
+            expect((addItemToOrder as any).order.lines[0].quantity).toBe(3);
+
+            const product = await getProductWithStockMovement('T_1');
+            const variant = product!.variants[0];
+
+            expect(variant.id).toBe(variantId);
+            expect(variant.stockAllocated).toBe(0);
+            expect(variant.stockOnHand).toBe(3);
+        });
+
+        it('does not return error when not tracking inventory', async () => {
+            const variantId = 'T_2';
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: variantId,
+                quantity: 5,
+            });
+
+            orderGuard.assertSuccess(addItemToOrder);
+
+            expect(addItemToOrder.lines.length).toBe(2);
+            expect(addItemToOrder.lines[1].productVariant.id).toBe(variantId);
+            expect(addItemToOrder.lines[1].quantity).toBe(5);
+
+            const product = await getProductWithStockMovement('T_1');
+            const variant = product!.variants[1];
+
+            expect(variant.id).toBe(variantId);
+            expect(variant.stockAllocated).toBe(0);
+            expect(variant.stockOnHand).toBe(3);
+        });
+
+        it('returns InsufficientStockError for positive threshold', async () => {
+            const variantId = 'T_3';
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: variantId,
+                quantity: 2,
+            });
+
+            orderGuard.assertErrorResult(addItemToOrder);
+
+            expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
+            expect(addItemToOrder.message).toBe(
+                `Only 1 item was added to the order due to insufficient stock`,
+            );
+            expect((addItemToOrder as any).quantityAvailable).toBe(1);
+            // Still adds as many as available to the Order
+            expect((addItemToOrder as any).order.lines.length).toBe(3);
+            expect((addItemToOrder as any).order.lines[2].productVariant.id).toBe(variantId);
+            expect((addItemToOrder as any).order.lines[2].quantity).toBe(1);
+
+            const product = await getProductWithStockMovement('T_1');
+            const variant = product!.variants[2];
+
+            expect(variant.id).toBe(variantId);
+            expect(variant.stockAllocated).toBe(0);
+            expect(variant.stockOnHand).toBe(3);
+        });
+
+        it('negative threshold allows backorder', async () => {
+            const variantId = 'T_4';
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: variantId,
+                quantity: 8,
+            });
+
+            orderGuard.assertSuccess(addItemToOrder);
+
+            expect(addItemToOrder.lines.length).toBe(4);
+            expect(addItemToOrder.lines[3].productVariant.id).toBe(variantId);
+            expect(addItemToOrder.lines[3].quantity).toBe(8);
+
+            const product = await getProductWithStockMovement('T_1');
+            const variant = product!.variants[3];
+
+            expect(variant.id).toBe(variantId);
+            expect(variant.stockAllocated).toBe(0);
+            expect(variant.stockOnHand).toBe(3);
+        });
+
+        it('allocates stock', async () => {
+            await proceedToArrangingPayment(shopClient);
+            const result = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+            orderGuard.assertSuccess(result);
+            order = result;
+
+            const product = await getProductWithStockMovement('T_1');
+            const [variant1, variant2, variant3, variant4] = product!.variants;
+
+            expect(variant1.stockAllocated).toBe(3);
+            expect(variant1.stockOnHand).toBe(3);
+
+            expect(variant2.stockAllocated).toBe(0); // inventory not tracked
+            expect(variant2.stockOnHand).toBe(3);
+
+            expect(variant3.stockAllocated).toBe(1);
+            expect(variant3.stockOnHand).toBe(3);
+
+            expect(variant4.stockAllocated).toBe(8);
+            expect(variant4.stockOnHand).toBe(3);
+        });
+
+        it('addFulfillmentToOrder returns ErrorResult when insufficient stock on hand', async () => {
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    lines: order.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                    method: 'test method',
+                    trackingCode: 'ABC123',
+                },
+            });
+
+            fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
+
+            expect(addFulfillmentToOrder.errorCode).toBe(AdminErrorCode.INSUFFICIENT_STOCK_ON_HAND_ERROR);
+            expect(addFulfillmentToOrder.message).toBe(
+                `Cannot create a Fulfillment as 'Laptop 15 inch 16GB' has insufficient stockOnHand (3)`,
+            );
+        });
+
+        it('addFulfillmentToOrder succeeds when there is sufficient stockOnHand', async () => {
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    lines: order.lines
+                        .filter(l => l.productVariant.id === 'T_1')
+                        .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                    method: 'test method',
+                    trackingCode: 'ABC123',
+                },
+            });
+
+            fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
+
+            const product = await getProductWithStockMovement('T_1');
+            const variant = product!.variants[0];
+
+            expect(variant.stockOnHand).toBe(0);
+            expect(variant.stockAllocated).toBe(0);
+        });
+
+        it('addFulfillmentToOrder succeeds when inventory is not being tracked', async () => {
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    lines: order.lines
+                        .filter(l => l.productVariant.id === 'T_2')
+                        .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                    method: 'test method',
+                    trackingCode: 'ABC123',
+                },
+            });
+
+            fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
+
+            const product = await getProductWithStockMovement('T_1');
+            const variant = product!.variants[1];
+
+            expect(variant.stockOnHand).toBe(3);
+            expect(variant.stockAllocated).toBe(0);
+        });
+
+        it('addFulfillmentToOrder succeeds when making a partial Fulfillment with quantity equal to stockOnHand', async () => {
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    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',
+                },
+            });
+
+            fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
+
+            const product = await getProductWithStockMovement('T_1');
+            const variant = product!.variants[3];
+
+            expect(variant.stockOnHand).toBe(0);
+            expect(variant.stockAllocated).toBe(5);
+        });
+
+        it('fulfillment can be created after adjusting stockOnHand to be sufficient', async () => {
+            const { updateProductVariants } = await adminClient.query<
+                UpdateProductVariants.Mutation,
+                UpdateProductVariants.Variables
+            >(UPDATE_PRODUCT_VARIANTS, {
+                input: [
+                    {
+                        id: 'T_4',
+                        stockOnHand: 10,
+                    },
+                ],
+            });
+
+            expect(updateProductVariants[0]!.stockOnHand).toBe(10);
+
+            const { addFulfillmentToOrder } = await adminClient.query<
+                CreateFulfillment.Mutation,
+                CreateFulfillment.Variables
+            >(CREATE_FULFILLMENT, {
+                input: {
+                    lines: order.lines
+                        .filter(l => l.productVariant.id === 'T_4')
+                        .map(l => ({ orderLineId: l.id, quantity: 5 })),
+                    method: 'test method',
+                    trackingCode: 'ABC123',
+                },
+            });
+
+            fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
+
+            const product = await getProductWithStockMovement('T_1');
+            const variant = product!.variants[3];
+
+            expect(variant.stockOnHand).toBe(5);
+            expect(variant.stockAllocated).toBe(0);
+        });
+
+        describe('edge cases', () => {
+            const variantId = 'T_5';
+
+            beforeAll(async () => {
+                // First place an order which creates a backorder (excess of allocated units)
+                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                    UPDATE_PRODUCT_VARIANTS,
+                    {
+                        input: [
+                            {
+                                id: variantId,
+                                stockOnHand: 5,
+                                outOfStockThreshold: -20,
+                                trackInventory: GlobalFlag.TRUE,
+                                useGlobalOutOfStockThreshold: false,
+                            },
+                        ],
+                    },
+                );
+                await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
+                const { addItemToOrder: add1 } = await shopClient.query<
+                    AddItemToOrder.Mutation,
+                    AddItemToOrder.Variables
+                >(ADD_ITEM_TO_ORDER, {
+                    productVariantId: variantId,
+                    quantity: 25,
+                });
+                orderGuard.assertSuccess(add1);
+                await proceedToArrangingPayment(shopClient);
+                await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+            });
+
+            it('zero saleable stock', async () => {
+                await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+                // The saleable stock level is now 0 (25 allocated, 5 on hand, -20 threshold)
+                const { addItemToOrder } = await shopClient.query<
+                    AddItemToOrder.Mutation,
+                    AddItemToOrder.Variables
+                >(ADD_ITEM_TO_ORDER, {
+                    productVariantId: variantId,
+                    quantity: 1,
+                });
+                orderGuard.assertErrorResult(addItemToOrder);
+
+                expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
+                expect(addItemToOrder.message).toBe(
+                    `No items were added to the order due to insufficient stock`,
+                );
+            });
+
+            it('negative saleable stock', async () => {
+                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                    UPDATE_PRODUCT_VARIANTS,
+                    {
+                        input: [
+                            {
+                                id: variantId,
+                                outOfStockThreshold: -10,
+                            },
+                        ],
+                    },
+                );
+                // The saleable stock level is now -10 (25 allocated, 5 on hand, -10 threshold)
+                await shopClient.asUserWithCredentials('marques.sawayn@hotmail.com', 'test');
+                const { addItemToOrder } = await shopClient.query<
+                    AddItemToOrder.Mutation,
+                    AddItemToOrder.Variables
+                >(ADD_ITEM_TO_ORDER, {
+                    productVariantId: variantId,
+                    quantity: 1,
+                });
+                orderGuard.assertErrorResult(addItemToOrder);
+
+                expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
+                expect(addItemToOrder.message).toBe(
+                    `No items were added to the order due to insufficient stock`,
+                );
+            });
+        });
+    });
 });
 
 const UPDATE_STOCK_ON_HAND = gql`

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

@@ -9,6 +9,7 @@ type Mutation {
 input UpdateGlobalSettingsInput {
     availableLanguages: [LanguageCode!]
     trackInventory: Boolean
+    outOfStockThreshold: Int
 }
 
 """

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

@@ -92,6 +92,18 @@ type ItemsAlreadyFulfilledError implements ErrorResult {
     message: String!
 }
 
+"""
+Returned if attempting to create a Fulfillment when there is insufficient
+stockOnHand of a ProductVariant to satisfy the requested quantity.
+"""
+type InsufficientStockOnHandError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    productVariantId: ID!
+    productVariantName: String!
+    stockOnHand: Int!
+}
+
 "Returned if an operation has specified OrderLines from multiple Orders"
 type MultipleOrderError implements ErrorResult {
     errorCode: ErrorCode!
@@ -165,8 +177,16 @@ type FulfillmentStateTransitionError implements ErrorResult {
 }
 
 union TransitionOrderToStateResult = Order | OrderStateTransitionError
-union SettlePaymentResult = Payment | SettlePaymentError | PaymentStateTransitionError | OrderStateTransitionError
-union AddFulfillmentToOrderResult = Fulfillment | EmptyOrderLineSelectionError | ItemsAlreadyFulfilledError
+union SettlePaymentResult =
+      Payment
+    | SettlePaymentError
+    | PaymentStateTransitionError
+    | OrderStateTransitionError
+union AddFulfillmentToOrderResult =
+      Fulfillment
+    | EmptyOrderLineSelectionError
+    | ItemsAlreadyFulfilledError
+    | InsufficientStockOnHandError
 union CancelOrderResult =
       Order
     | EmptyOrderLineSelectionError

+ 7 - 1
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -45,9 +45,11 @@ type Product implements Node {
 
 type ProductVariant implements Node {
     enabled: Boolean!
+    trackInventory: GlobalFlag!
     stockOnHand: Int!
     stockAllocated: Int!
-    trackInventory: GlobalFlag!
+    outOfStockThreshold: Int!
+    useGlobalOutOfStockThreshold: Boolean!
     stockMovements(options: StockMovementListOptions): StockMovementList!
 }
 
@@ -113,6 +115,8 @@ input CreateProductVariantInput {
     featuredAssetId: ID
     assetIds: [ID!]
     stockOnHand: Int
+    outOfStockThreshold: Int
+    useGlobalOutOfStockThreshold: Boolean
     trackInventory: GlobalFlag
 }
 
@@ -127,6 +131,8 @@ input UpdateProductVariantInput {
     featuredAssetId: ID
     assetIds: [ID!]
     stockOnHand: Int
+    outOfStockThreshold: Int
+    useGlobalOutOfStockThreshold: Boolean
     trackInventory: GlobalFlag
 }
 

+ 24 - 3
packages/core/src/api/schema/shop-api/shop.api.graphql

@@ -197,6 +197,14 @@ type NegativeQuantityError implements ErrorResult {
     message: String!
 }
 
+"Returned when attempting to add more items to the Order than are available"
+type InsufficientStockError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    quantityAvailable: Int!
+    order: Order!
+}
+
 "Returned when attempting to add a Payment to an Order that is not in the `ArrangingPayment` state."
 type OrderPaymentStateError implements ErrorResult {
     errorCode: ErrorCode!
@@ -327,7 +335,12 @@ type NotVerifiedError implements ErrorResult {
     message: String!
 }
 
-union UpdateOrderItemsResult = Order | OrderModificationError | OrderLimitError | NegativeQuantityError
+union UpdateOrderItemsResult =
+      Order
+    | OrderModificationError
+    | OrderLimitError
+    | NegativeQuantityError
+    | InsufficientStockError
 union RemoveOrderItemsResult = Order | OrderModificationError
 union SetOrderShippingMethodResult = Order | OrderModificationError
 union ApplyCouponCodeResult = Order | CouponCodeExpiredError | CouponCodeInvalidError | CouponCodeLimitError
@@ -360,6 +373,14 @@ union UpdateCustomerEmailAddressResult =
     | IdentifierChangeTokenExpiredError
     | NativeAuthStrategyError
 union RequestPasswordResetResult = Success | NativeAuthStrategyError
-union ResetPasswordResult = CurrentUser | PasswordResetTokenInvalidError | PasswordResetTokenExpiredError | NativeAuthStrategyError
-union NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NotVerifiedError | NativeAuthStrategyError
+union ResetPasswordResult =
+      CurrentUser
+    | PasswordResetTokenInvalidError
+    | PasswordResetTokenExpiredError
+    | NativeAuthStrategyError
+union NativeAuthenticationResult =
+      CurrentUser
+    | InvalidCredentialsError
+    | NotVerifiedError
+    | NativeAuthStrategyError
 union AuthenticationResult = CurrentUser | InvalidCredentialsError | NotVerifiedError

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

@@ -4,6 +4,7 @@ type GlobalSettings {
     updatedAt: DateTime!
     availableLanguages: [LanguageCode!]!
     trackInventory: Boolean!
+    outOfStockThreshold: Int!
     serverConfig: ServerConfig!
 }
 

+ 39 - 26
packages/core/src/common/error/generated-graphql-admin-errors.ts

@@ -15,7 +15,7 @@ export type Scalars = {
 export class ErrorResult {
   readonly __typename: string;
   readonly errorCode: string;
-  message: Scalars['String'];
+message: Scalars['String'];
 }
 
 export class MimeTypeError extends ErrorResult {
@@ -23,8 +23,8 @@ export class MimeTypeError extends ErrorResult {
   readonly errorCode = 'MIME_TYPE_ERROR' as any;
   readonly message = 'MIME_TYPE_ERROR';
   constructor(
-    public   fileName: Scalars['String'],
-    public   mimeType: Scalars['String'],
+    public fileName: Scalars['String'],
+    public mimeType: Scalars['String'],
   ) {
     super();
   }
@@ -35,7 +35,7 @@ export class LanguageNotAvailableError extends ErrorResult {
   readonly errorCode = 'LANGUAGE_NOT_AVAILABLE_ERROR' as any;
   readonly message = 'LANGUAGE_NOT_AVAILABLE_ERROR';
   constructor(
-    public   languageCode: Scalars['String'],
+    public languageCode: Scalars['String'],
   ) {
     super();
   }
@@ -46,8 +46,8 @@ export class ChannelDefaultLanguageError extends ErrorResult {
   readonly errorCode = 'CHANNEL_DEFAULT_LANGUAGE_ERROR' as any;
   readonly message = 'CHANNEL_DEFAULT_LANGUAGE_ERROR';
   constructor(
-    public   language: Scalars['String'],
-    public   channelCode: Scalars['String'],
+    public language: Scalars['String'],
+    public channelCode: Scalars['String'],
   ) {
     super();
   }
@@ -58,7 +58,7 @@ export class SettlePaymentError extends ErrorResult {
   readonly errorCode = 'SETTLE_PAYMENT_ERROR' as any;
   readonly message = 'SETTLE_PAYMENT_ERROR';
   constructor(
-    public   paymentErrorMessage: Scalars['String'],
+    public paymentErrorMessage: Scalars['String'],
   ) {
     super();
   }
@@ -84,6 +84,19 @@ export class ItemsAlreadyFulfilledError extends ErrorResult {
   }
 }
 
+export class InsufficientStockOnHandError extends ErrorResult {
+  readonly __typename = 'InsufficientStockOnHandError';
+  readonly errorCode = 'INSUFFICIENT_STOCK_ON_HAND_ERROR' as any;
+  readonly message = 'INSUFFICIENT_STOCK_ON_HAND_ERROR';
+  constructor(
+    public productVariantId: Scalars['ID'],
+    public productVariantName: Scalars['String'],
+    public stockOnHand: Scalars['Int'],
+  ) {
+    super();
+  }
+}
+
 export class MultipleOrderError extends ErrorResult {
   readonly __typename = 'MultipleOrderError';
   readonly errorCode = 'MULTIPLE_ORDER_ERROR' as any;
@@ -99,7 +112,7 @@ export class CancelActiveOrderError extends ErrorResult {
   readonly errorCode = 'CANCEL_ACTIVE_ORDER_ERROR' as any;
   readonly message = 'CANCEL_ACTIVE_ORDER_ERROR';
   constructor(
-    public   orderState: Scalars['String'],
+    public orderState: Scalars['String'],
   ) {
     super();
   }
@@ -120,7 +133,7 @@ export class RefundOrderStateError extends ErrorResult {
   readonly errorCode = 'REFUND_ORDER_STATE_ERROR' as any;
   readonly message = 'REFUND_ORDER_STATE_ERROR';
   constructor(
-    public   orderState: Scalars['String'],
+    public orderState: Scalars['String'],
   ) {
     super();
   }
@@ -141,7 +154,7 @@ export class AlreadyRefundedError extends ErrorResult {
   readonly errorCode = 'ALREADY_REFUNDED_ERROR' as any;
   readonly message = 'ALREADY_REFUNDED_ERROR';
   constructor(
-    public   refundId: Scalars['ID'],
+    public refundId: Scalars['ID'],
   ) {
     super();
   }
@@ -162,9 +175,9 @@ export class RefundStateTransitionError extends ErrorResult {
   readonly errorCode = 'REFUND_STATE_TRANSITION_ERROR' as any;
   readonly message = 'REFUND_STATE_TRANSITION_ERROR';
   constructor(
-    public   transitionError: Scalars['String'],
-    public   fromState: Scalars['String'],
-    public   toState: Scalars['String'],
+    public transitionError: Scalars['String'],
+    public fromState: Scalars['String'],
+    public toState: Scalars['String'],
   ) {
     super();
   }
@@ -175,9 +188,9 @@ export class PaymentStateTransitionError extends ErrorResult {
   readonly errorCode = 'PAYMENT_STATE_TRANSITION_ERROR' as any;
   readonly message = 'PAYMENT_STATE_TRANSITION_ERROR';
   constructor(
-    public   transitionError: Scalars['String'],
-    public   fromState: Scalars['String'],
-    public   toState: Scalars['String'],
+    public transitionError: Scalars['String'],
+    public fromState: Scalars['String'],
+    public toState: Scalars['String'],
   ) {
     super();
   }
@@ -188,9 +201,9 @@ export class FulfillmentStateTransitionError extends ErrorResult {
   readonly errorCode = 'FULFILLMENT_STATE_TRANSITION_ERROR' as any;
   readonly message = 'FULFILLMENT_STATE_TRANSITION_ERROR';
   constructor(
-    public   transitionError: Scalars['String'],
-    public   fromState: Scalars['String'],
-    public   toState: Scalars['String'],
+    public transitionError: Scalars['String'],
+    public fromState: Scalars['String'],
+    public toState: Scalars['String'],
   ) {
     super();
   }
@@ -201,8 +214,8 @@ export class ProductOptionInUseError extends ErrorResult {
   readonly errorCode = 'PRODUCT_OPTION_IN_USE_ERROR' as any;
   readonly message = 'PRODUCT_OPTION_IN_USE_ERROR';
   constructor(
-    public   optionGroupCode: Scalars['String'],
-    public   productVariantCount: Scalars['Int'],
+    public optionGroupCode: Scalars['String'],
+    public productVariantCount: Scalars['Int'],
   ) {
     super();
   }
@@ -233,7 +246,7 @@ export class InvalidCredentialsError extends ErrorResult {
   readonly errorCode = 'INVALID_CREDENTIALS_ERROR' as any;
   readonly message = 'INVALID_CREDENTIALS_ERROR';
   constructor(
-    public   authenticationError: Scalars['String'],
+    public authenticationError: Scalars['String'],
   ) {
     super();
   }
@@ -244,9 +257,9 @@ export class OrderStateTransitionError extends ErrorResult {
   readonly errorCode = 'ORDER_STATE_TRANSITION_ERROR' as any;
   readonly message = 'ORDER_STATE_TRANSITION_ERROR';
   constructor(
-    public   transitionError: Scalars['String'],
-    public   fromState: Scalars['String'],
-    public   toState: Scalars['String'],
+    public transitionError: Scalars['String'],
+    public fromState: Scalars['String'],
+    public toState: Scalars['String'],
   ) {
     super();
   }
@@ -263,7 +276,7 @@ export class EmailAddressConflictError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set(['MimeTypeError', 'LanguageNotAvailableError', 'ChannelDefaultLanguageError', 'SettlePaymentError', 'EmptyOrderLineSelectionError', 'ItemsAlreadyFulfilledError', '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', '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);
 }

+ 25 - 13
packages/core/src/common/error/generated-graphql-shop-errors.ts

@@ -15,7 +15,7 @@ export type Scalars = {
 export class ErrorResult {
   readonly __typename: string;
   readonly errorCode: string;
-  message: Scalars['String'];
+message: Scalars['String'];
 }
 
 export class NativeAuthStrategyError extends ErrorResult {
@@ -33,7 +33,7 @@ export class InvalidCredentialsError extends ErrorResult {
   readonly errorCode = 'INVALID_CREDENTIALS_ERROR' as any;
   readonly message = 'INVALID_CREDENTIALS_ERROR';
   constructor(
-    public   authenticationError: Scalars['String'],
+    public authenticationError: Scalars['String'],
   ) {
     super();
   }
@@ -44,9 +44,9 @@ export class OrderStateTransitionError extends ErrorResult {
   readonly errorCode = 'ORDER_STATE_TRANSITION_ERROR' as any;
   readonly message = 'ORDER_STATE_TRANSITION_ERROR';
   constructor(
-    public   transitionError: Scalars['String'],
-    public   fromState: Scalars['String'],
-    public   toState: Scalars['String'],
+    public transitionError: Scalars['String'],
+    public fromState: Scalars['String'],
+    public toState: Scalars['String'],
   ) {
     super();
   }
@@ -77,7 +77,7 @@ export class OrderLimitError extends ErrorResult {
   readonly errorCode = 'ORDER_LIMIT_ERROR' as any;
   readonly message = 'ORDER_LIMIT_ERROR';
   constructor(
-    public   maxItems: Scalars['Int'],
+    public maxItems: Scalars['Int'],
   ) {
     super();
   }
@@ -93,6 +93,18 @@ export class NegativeQuantityError extends ErrorResult {
   }
 }
 
+export class InsufficientStockError extends ErrorResult {
+  readonly __typename = 'InsufficientStockError';
+  readonly errorCode = 'INSUFFICIENT_STOCK_ERROR' as any;
+  readonly message = 'INSUFFICIENT_STOCK_ERROR';
+  constructor(
+    public quantityAvailable: Scalars['Int'],
+    public order: any,
+  ) {
+    super();
+  }
+}
+
 export class OrderPaymentStateError extends ErrorResult {
   readonly __typename = 'OrderPaymentStateError';
   readonly errorCode = 'ORDER_PAYMENT_STATE_ERROR' as any;
@@ -108,7 +120,7 @@ export class PaymentFailedError extends ErrorResult {
   readonly errorCode = 'PAYMENT_FAILED_ERROR' as any;
   readonly message = 'PAYMENT_FAILED_ERROR';
   constructor(
-    public   paymentErrorMessage: Scalars['String'],
+    public paymentErrorMessage: Scalars['String'],
   ) {
     super();
   }
@@ -119,7 +131,7 @@ export class PaymentDeclinedError extends ErrorResult {
   readonly errorCode = 'PAYMENT_DECLINED_ERROR' as any;
   readonly message = 'PAYMENT_DECLINED_ERROR';
   constructor(
-    public   paymentErrorMessage: Scalars['String'],
+    public paymentErrorMessage: Scalars['String'],
   ) {
     super();
   }
@@ -130,7 +142,7 @@ export class CouponCodeInvalidError extends ErrorResult {
   readonly errorCode = 'COUPON_CODE_INVALID_ERROR' as any;
   readonly message = 'COUPON_CODE_INVALID_ERROR';
   constructor(
-    public   couponCode: Scalars['String'],
+    public couponCode: Scalars['String'],
   ) {
     super();
   }
@@ -141,7 +153,7 @@ export class CouponCodeExpiredError extends ErrorResult {
   readonly errorCode = 'COUPON_CODE_EXPIRED_ERROR' as any;
   readonly message = 'COUPON_CODE_EXPIRED_ERROR';
   constructor(
-    public   couponCode: Scalars['String'],
+    public couponCode: Scalars['String'],
   ) {
     super();
   }
@@ -152,8 +164,8 @@ export class CouponCodeLimitError extends ErrorResult {
   readonly errorCode = 'COUPON_CODE_LIMIT_ERROR' as any;
   readonly message = 'COUPON_CODE_LIMIT_ERROR';
   constructor(
-    public   couponCode: Scalars['String'],
-    public   limit: Scalars['Int'],
+    public couponCode: Scalars['String'],
+    public limit: Scalars['Int'],
   ) {
     super();
   }
@@ -260,7 +272,7 @@ export class NotVerifiedError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set(['NativeAuthStrategyError', 'InvalidCredentialsError', 'OrderStateTransitionError', 'EmailAddressConflictError', 'OrderModificationError', 'OrderLimitError', 'NegativeQuantityError', 'OrderPaymentStateError', 'PaymentFailedError', 'PaymentDeclinedError', 'CouponCodeInvalidError', 'CouponCodeExpiredError', 'CouponCodeLimitError', 'AlreadyLoggedInError', 'MissingPasswordError', 'PasswordAlreadySetError', 'VerificationTokenInvalidError', 'VerificationTokenExpiredError', 'IdentifierChangeTokenInvalidError', 'IdentifierChangeTokenExpiredError', 'PasswordResetTokenInvalidError', 'PasswordResetTokenExpiredError', 'NotVerifiedError']);
+const errorTypeNames = new Set(['NativeAuthStrategyError', 'InvalidCredentialsError', 'OrderStateTransitionError', 'EmailAddressConflictError', 'OrderModificationError', 'OrderLimitError', 'NegativeQuantityError', 'InsufficientStockError', 'OrderPaymentStateError', 'PaymentFailedError', 'PaymentDeclinedError', 'CouponCodeInvalidError', 'CouponCodeExpiredError', 'CouponCodeLimitError', 'AlreadyLoggedInError', 'MissingPasswordError', 'PasswordAlreadySetError', 'VerificationTokenInvalidError', 'VerificationTokenExpiredError', 'IdentifierChangeTokenInvalidError', 'IdentifierChangeTokenExpiredError', 'PasswordResetTokenInvalidError', 'PasswordResetTokenExpiredError', 'NotVerifiedError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }

+ 9 - 0
packages/core/src/entity/global-settings/global-settings.entity.ts

@@ -16,6 +16,7 @@ export class GlobalSettings extends VendureEntity implements HasCustomFields {
     availableLanguages: LanguageCode[];
 
     /**
+     * @description
      * Specifies the default value for inventory tracking for ProductVariants.
      * Can be overridden per ProductVariant, but this value determines the default
      * if not otherwise specified.
@@ -23,6 +24,14 @@ export class GlobalSettings extends VendureEntity implements HasCustomFields {
     @Column({ default: false })
     trackInventory: boolean;
 
+    /**
+     * @description
+     * Specifies the value of stockOnHand at which a given ProductVariant is considered
+     * out of stock.
+     */
+    @Column({ default: 0 })
+    outOfStockThreshold: number;
+
     @Column(type => CustomGlobalSettingsFields)
     customFields: CustomGlobalSettingsFields;
 }

+ 16 - 0
packages/core/src/entity/product-variant/product-variant.entity.ts

@@ -106,6 +106,22 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu
     @Column({ default: 0 })
     stockAllocated: number;
 
+    /**
+     * @description
+     * Specifies the value of stockOnHand at which the ProductVariant is considered
+     * out of stock.
+     */
+    @Column({ default: 0 })
+    outOfStockThreshold: number;
+
+    /**
+     * @description
+     * When true, the `outOfStockThreshold` value will be taken from the GlobalSettings and the
+     * value set on this ProductVariant will be ignored.
+     */
+    @Column({ default: true })
+    useGlobalOutOfStockThreshold: boolean;
+
     @Column({ type: 'varchar', default: GlobalFlag.INHERIT })
     trackInventory: GlobalFlag;
 

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

@@ -50,6 +50,8 @@
     "EMAIL_ADDRESS_CONFLICT_ERROR": "The email address is not available.",
     "EMPTY_ORDER_LINE_SELECTION_ERROR": "At least one OrderLine must be specified",
     "IDENTIFIER_CHANGE_TOKEN_INVALID_ERROR": "Identifier change token not recognized",
+    "INSUFFICIENT_STOCK_ERROR": "{quantityAvailable, plural, =0 {No items were} one {Only 1 item was} other {Only # items were}} added to the order due to insufficient stock",
+    "INSUFFICIENT_STOCK_ON_HAND_ERROR": "Cannot create a Fulfillment as '{productVariantName}' has insufficient stockOnHand ({stockOnHand})",
     "INVALID_CREDENTIALS_ERROR": "The provided credentials are invalid",
     "ITEMS_ALREADY_FULFILLED_ERROR": "One or more OrderItems are already part of a Fulfillment",
     "LANGUAGE_NOT_AVAILABLE_ERROR": "Language \"{languageCode}\" is not available. First enable it via GlobalSettings and try again",

+ 3 - 3
packages/core/src/service/helpers/utils/translate-entity.ts

@@ -41,9 +41,9 @@ export function translateEntity<T extends Translatable & VendureEntity>(
 ): Translated<T> {
     let translation: Translation<VendureEntity> | undefined;
     if (translatable.translations) {
-        translation = translatable.translations.find((t) => t.languageCode === languageCode);
+        translation = translatable.translations.find(t => t.languageCode === languageCode);
         if (!translation && languageCode !== DEFAULT_LANGUAGE_CODE) {
-            translation = translatable.translations.find((t) => t.languageCode === DEFAULT_LANGUAGE_CODE);
+            translation = translatable.translations.find(t => t.languageCode === DEFAULT_LANGUAGE_CODE);
         }
         if (!translation) {
             // If we cannot find any suitable translation, just return the first one to at least
@@ -152,7 +152,7 @@ export function translateTree<T extends TreeNode>(
 ): Translated<T> {
     const output = translateDeep(node, languageCode, translatableRelations);
     if (Array.isArray(output.children)) {
-        output.children = output.children.map((child) =>
+        output.children = output.children.map(child =>
             translateTree(child, languageCode, translatableRelations as any),
         );
     }

+ 63 - 14
packages/core/src/service/services/order.service.ts

@@ -29,19 +29,16 @@ import {
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { unique } from '@vendure/common/lib/unique';
+import { doc } from 'prettier';
 
 import { RequestContext } from '../../api/common/request-context';
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../common/error/error-result';
-import {
-    EntityNotFoundError,
-    IllegalOperationError,
-    InternalServerError,
-    UserInputError,
-} from '../../common/error/errors';
+import { EntityNotFoundError, UserInputError } from '../../common/error/errors';
 import {
     AlreadyRefundedError,
     CancelActiveOrderError,
     EmptyOrderLineSelectionError,
+    InsufficientStockOnHandError,
     ItemsAlreadyFulfilledError,
     MultipleOrderError,
     NothingToRefundError,
@@ -52,6 +49,7 @@ import {
     SettlePaymentError,
 } from '../../common/error/generated-graphql-admin-errors';
 import {
+    InsufficientStockError,
     NegativeQuantityError,
     OrderLimitError,
     OrderModificationError,
@@ -342,6 +340,8 @@ export class OrderService {
         quantity?: number | null,
         customFields?: { [key: string]: any },
     ): Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>> {
+        let correctedQuantity = quantity;
+        let quantityWasAdjustedDown = false;
         const { priceCalculationStrategy } = this.configService.orderOptions;
         const order =
             orderIdOrOrder instanceof Order
@@ -351,16 +351,28 @@ export class OrderService {
         if (customFields != null) {
             orderLine.customFields = customFields;
         }
-        if (quantity != null) {
+        if (correctedQuantity != null) {
             const currentQuantity = orderLine.quantity;
             const validationError =
                 this.assertAddingItemsState(order) ||
-                this.assertQuantityIsPositive(quantity) ||
-                this.assertNotOverOrderItemsLimit(order, quantity - currentQuantity);
+                this.assertQuantityIsPositive(correctedQuantity) ||
+                this.assertNotOverOrderItemsLimit(order, correctedQuantity - currentQuantity);
             if (validationError) {
                 return validationError;
             }
-            if (currentQuantity < quantity) {
+            const saleableStockLevel = await this.productVariantService.getSaleableStockLevel(
+                ctx,
+                orderLine.productVariant,
+            );
+            if (saleableStockLevel < correctedQuantity) {
+                correctedQuantity = Math.max(saleableStockLevel, 0);
+                quantityWasAdjustedDown = true;
+            }
+            if (correctedQuantity === 0) {
+                order.lines = order.lines.filter(l => !idsAreEqual(l.id, orderLineId));
+                await this.connection.getRepository(ctx, OrderLine).remove(orderLine);
+                return new InsufficientStockError(correctedQuantity, order);
+            } else if (currentQuantity < correctedQuantity) {
                 if (!orderLine.items) {
                     orderLine.items = [];
                 }
@@ -369,7 +381,7 @@ export class OrderService {
                     productVariant,
                     orderLine.customFields || {},
                 );
-                for (let i = currentQuantity; i < quantity; i++) {
+                for (let i = currentQuantity; i < correctedQuantity; i++) {
                     const orderItem = await this.connection.getRepository(ctx, OrderItem).save(
                         new OrderItem({
                             unitPrice: calculatedPrice.price,
@@ -382,12 +394,17 @@ export class OrderService {
                     );
                     orderLine.items.push(orderItem);
                 }
-            } else if (quantity < currentQuantity) {
-                orderLine.items = orderLine.items.slice(0, quantity);
+            } else if (correctedQuantity < currentQuantity) {
+                orderLine.items = orderLine.items.slice(0, correctedQuantity);
             }
         }
         await this.connection.getRepository(ctx, OrderLine).save(orderLine, { reload: false });
-        return this.applyPriceAdjustments(ctx, order, orderLine);
+        const updatedOrder = await this.applyPriceAdjustments(ctx, order, orderLine);
+        if (correctedQuantity && quantityWasAdjustedDown) {
+            return new InsufficientStockError(correctedQuantity, updatedOrder);
+        } else {
+            return updatedOrder;
+        }
     }
 
     async removeItemFromOrder(
@@ -680,6 +697,7 @@ export class OrderService {
         if (
             !input.lines ||
             input.lines.length === 0 ||
+            input.lines.length === 0 ||
             input.lines.reduce((total, line) => total + line.quantity, 0) === 0
         ) {
             return new EmptyOrderLineSelectionError();
@@ -692,6 +710,10 @@ export class OrderService {
         if (!ordersAndItems) {
             return new ItemsAlreadyFulfilledError();
         }
+        const stockCheckResult = await this.ensureSufficientStockForFulfillment(ctx, input);
+        if (isGraphQlErrorResult(stockCheckResult)) {
+            return stockCheckResult;
+        }
 
         const fulfillment = await this.fulfillmentService.create(ctx, {
             trackingCode: input.trackingCode,
@@ -714,6 +736,33 @@ export class OrderService {
         return fulfillment;
     }
 
+    private async ensureSufficientStockForFulfillment(
+        ctx: RequestContext,
+        input: FulfillOrderInput,
+    ): Promise<InsufficientStockOnHandError | undefined> {
+        const lines = await this.connection.getRepository(ctx, OrderLine).findByIds(
+            input.lines.map(l => l.orderLineId),
+            { relations: ['productVariant'] },
+        );
+
+        for (const line of lines) {
+            // tslint:disable-next-line:no-non-null-assertion
+            const lineInput = input.lines.find(l => idsAreEqual(l.orderLineId, line.id))!;
+            const fulfillableStockLevel = await this.productVariantService.getFulfillableStockLevel(
+                ctx,
+                line.productVariant,
+            );
+            if (fulfillableStockLevel < lineInput.quantity) {
+                const productVariant = translateDeep(line.productVariant, ctx.languageCode);
+                return new InsufficientStockOnHandError(
+                    productVariant.id as string,
+                    productVariant.name,
+                    productVariant.stockOnHand,
+                );
+            }
+        }
+    }
+
     async getOrderFulfillments(ctx: RequestContext, order: Order): Promise<Fulfillment[]> {
         let lines: OrderLine[];
         if (

+ 39 - 0
packages/core/src/service/services/product-variant.service.ts

@@ -3,6 +3,7 @@ import {
     CreateProductVariantInput,
     DeletionResponse,
     DeletionResult,
+    GlobalFlag,
     UpdateProductVariantInput,
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
@@ -189,6 +190,44 @@ export class ProductVariantService {
         return translateDeep(product, ctx.languageCode);
     }
 
+    /**
+     * @description
+     * Returns the number of saleable units of the ProductVariant, i.e. how many are available
+     * for purchase by Customers.
+     */
+    async getSaleableStockLevel(ctx: RequestContext, variant: ProductVariant): Promise<number> {
+        const { outOfStockThreshold, trackInventory } = await this.globalSettingsService.getSettings(ctx);
+        const inventoryNotTracked =
+            variant.trackInventory === GlobalFlag.FALSE ||
+            (variant.trackInventory === GlobalFlag.INHERIT && trackInventory === false);
+        if (inventoryNotTracked) {
+            return Number.MAX_SAFE_INTEGER;
+        }
+
+        const effectiveOutOfStockThreshold = variant.useGlobalOutOfStockThreshold
+            ? outOfStockThreshold
+            : variant.outOfStockThreshold;
+
+        return variant.stockOnHand - variant.stockAllocated - effectiveOutOfStockThreshold;
+    }
+
+    /**
+     * @description
+     * Returns the number of fulfillable units of the ProductVariant, equivalent to stockOnHand
+     * for those variants which are tracking inventory.
+     */
+    async getFulfillableStockLevel(ctx: RequestContext, variant: ProductVariant): Promise<number> {
+        const { outOfStockThreshold, trackInventory } = await this.globalSettingsService.getSettings(ctx);
+        const inventoryNotTracked =
+            variant.trackInventory === GlobalFlag.FALSE ||
+            (variant.trackInventory === GlobalFlag.INHERIT && trackInventory === false);
+        if (inventoryNotTracked) {
+            return Number.MAX_SAFE_INTEGER;
+        }
+
+        return variant.stockOnHand;
+    }
+
     async create(
         ctx: RequestContext,
         input: CreateProductVariantInput[],

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

@@ -1083,6 +1083,7 @@ export type Fulfillment = Node & {
 export type UpdateGlobalSettingsInput = {
     availableLanguages?: Maybe<Array<LanguageCode>>;
     trackInventory?: Maybe<Scalars['Boolean']>;
+    outOfStockThreshold?: Maybe<Scalars['Int']>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
@@ -1245,6 +1246,18 @@ export type ItemsAlreadyFulfilledError = ErrorResult & {
     message: Scalars['String'];
 };
 
+/**
+ * Returned if attempting to create a Fulfillment when there is insufficient
+ * stockOnHand of a ProductVariant to satisfy the requested quantity.
+ */
+export type InsufficientStockOnHandError = ErrorResult & {
+    errorCode: ErrorCode;
+    message: Scalars['String'];
+    productVariantId: Scalars['ID'];
+    productVariantName: Scalars['String'];
+    stockOnHand: Scalars['Int'];
+};
+
 /** Returned if an operation has specified OrderLines from multiple Orders */
 export type MultipleOrderError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1328,7 +1341,8 @@ export type SettlePaymentResult =
 export type AddFulfillmentToOrderResult =
     | Fulfillment
     | EmptyOrderLineSelectionError
-    | ItemsAlreadyFulfilledError;
+    | ItemsAlreadyFulfilledError
+    | InsufficientStockOnHandError;
 
 export type CancelOrderResult =
     | Order
@@ -1462,9 +1476,11 @@ export type Product = Node & {
 
 export type ProductVariant = Node & {
     enabled: Scalars['Boolean'];
+    trackInventory: GlobalFlag;
     stockOnHand: Scalars['Int'];
     stockAllocated: Scalars['Int'];
-    trackInventory: GlobalFlag;
+    outOfStockThreshold: Scalars['Int'];
+    useGlobalOutOfStockThreshold: Scalars['Boolean'];
     stockMovements: StockMovementList;
     id: Scalars['ID'];
     product: Product;
@@ -1549,6 +1565,8 @@ export type CreateProductVariantInput = {
     featuredAssetId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
     stockOnHand?: Maybe<Scalars['Int']>;
+    outOfStockThreshold?: Maybe<Scalars['Int']>;
+    useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
     trackInventory?: Maybe<GlobalFlag>;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1564,6 +1582,8 @@ export type UpdateProductVariantInput = {
     featuredAssetId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
     stockOnHand?: Maybe<Scalars['Int']>;
+    outOfStockThreshold?: Maybe<Scalars['Int']>;
+    useGlobalOutOfStockThreshold?: Maybe<Scalars['Boolean']>;
     trackInventory?: Maybe<GlobalFlag>;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1812,6 +1832,7 @@ 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',
+    INSUFFICIENT_STOCK_ON_HAND_ERROR = 'INSUFFICIENT_STOCK_ON_HAND_ERROR',
     MULTIPLE_ORDER_ERROR = 'MULTIPLE_ORDER_ERROR',
     CANCEL_ACTIVE_ORDER_ERROR = 'CANCEL_ACTIVE_ORDER_ERROR',
     PAYMENT_ORDER_MISMATCH_ERROR = 'PAYMENT_ORDER_MISMATCH_ERROR',
@@ -2959,6 +2980,7 @@ export type GlobalSettings = {
     updatedAt: Scalars['DateTime'];
     availableLanguages: Array<LanguageCode>;
     trackInventory: Scalars['Boolean'];
+    outOfStockThreshold: Scalars['Int'];
     serverConfig: ServerConfig;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -3817,9 +3839,11 @@ export type TaxRateSortParameter = {
 
 export type ProductVariantFilterParameter = {
     enabled?: Maybe<BooleanOperators>;
+    trackInventory?: Maybe<StringOperators>;
     stockOnHand?: Maybe<NumberOperators>;
     stockAllocated?: Maybe<NumberOperators>;
-    trackInventory?: Maybe<StringOperators>;
+    outOfStockThreshold?: Maybe<NumberOperators>;
+    useGlobalOutOfStockThreshold?: Maybe<BooleanOperators>;
     createdAt?: Maybe<DateOperators>;
     updatedAt?: Maybe<DateOperators>;
     languageCode?: Maybe<StringOperators>;
@@ -3834,6 +3858,7 @@ export type ProductVariantFilterParameter = {
 export type ProductVariantSortParameter = {
     stockOnHand?: Maybe<SortOrder>;
     stockAllocated?: Maybe<SortOrder>;
+    outOfStockThreshold?: Maybe<SortOrder>;
     id?: Maybe<SortOrder>;
     productId?: Maybe<SortOrder>;
     createdAt?: Maybe<SortOrder>;

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


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


+ 7 - 2
scripts/codegen/plugins/graphql-errors-plugin.ts

@@ -41,9 +41,10 @@ const errorsVisitor: Visitor<any> = {
             : '';
     },
     FieldDefinition(node: FieldDefinitionNode): string {
-        const scalarType = node.type.kind === 'ListType' ? node.type.type : node.type;
+        const type = ((node.type.kind === 'ListType' ? node.type.type : node.type) as unknown) as string;
+        const tsType = isScalar(type) ? `Scalars['${type}']` : 'any';
         const listPart = node.type.kind === 'ListType' ? `[]` : ``;
-        return `  ${node.name.value}: Scalars['${scalarType}']${listPart}`;
+        return `${node.name.value}: ${tsType}${listPart}`;
     },
     ScalarTypeDefinition: empty,
     InputObjectTypeDefinition: empty,
@@ -226,3 +227,7 @@ function isAdminApi(schema: GraphQLSchema): boolean {
 function camelToUpperSnakeCase(input: string): string {
     return input.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase();
 }
+
+function isScalar(type: string): boolean {
+    return ['ID', 'String', 'Boolean', 'Int', 'Float', 'JSON', 'DateTime', 'Upload'].includes(type);
+}

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