Ver Fonte

fix(core): Prevent out of stock variants from being purchased

Fixes #1738
Michael Bromley há 3 anos atrás
pai
commit
eb3964c6a7

+ 101 - 2
packages/core/e2e/stock-control.e2e-spec.ts

@@ -4,7 +4,6 @@ import {
     manualFulfillmentHandler,
     mergeConfig,
     Order,
-    OrderPlacedStrategy,
     OrderState,
     RequestContext,
 } from '@vendure/core';
@@ -28,28 +27,36 @@ import {
     GlobalFlag,
     SettlePayment,
     StockMovementType,
-    TransitFulfillment,
     TransitionFulfillmentToState,
     UpdateGlobalSettings,
     UpdateProductVariantInput,
     UpdateProductVariants,
     UpdateStock,
+    UpdateStockMutation,
+    UpdateStockMutationVariables,
     VariantWithStockFragment,
 } from './graphql/generated-e2e-admin-types';
 import {
     AddItemToOrder,
+    AddItemToOrderMutation,
+    AddItemToOrderMutationVariables,
     AddPaymentToOrder,
     AdjustItemQuantity,
     ErrorCode,
     GetActiveOrder,
     GetProductStockLevel,
     GetShippingMethods,
+    GetShippingMethodsQuery,
     PaymentInput,
     SetShippingAddress,
+    SetShippingAddressMutation,
+    SetShippingAddressMutationVariables,
     SetShippingMethod,
     TestOrderFragmentFragment,
     TestOrderWithPaymentsFragment,
     TransitionToState,
+    TransitionToStateMutation,
+    TransitionToStateMutationVariables,
     UpdatedOrderFragment,
 } from './graphql/generated-e2e-shop-types';
 import {
@@ -1403,6 +1410,98 @@ describe('Stock control', () => {
             expect(variant2_2.stockOnHand).toBe(1);
         });
     });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/1738
+    describe('going out of stock after being added to order', () => {
+        const variantId = 'T_1';
+
+        beforeAll(async () => {
+            const { updateProductVariants } = await adminClient.query<
+                UpdateStockMutation,
+                UpdateStockMutationVariables
+            >(UPDATE_STOCK_ON_HAND, {
+                input: [
+                    {
+                        id: variantId,
+                        stockOnHand: 1,
+                        trackInventory: GlobalFlag.TRUE,
+                        useGlobalOutOfStockThreshold: false,
+                        outOfStockThreshold: 0,
+                    },
+                ] as UpdateProductVariantInput[],
+            });
+        });
+
+        it('prevents checkout if no saleable stock', async () => {
+            // First customer adds to order
+            await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+            const { addItemToOrder: add1 } = await shopClient.query<
+                AddItemToOrderMutation,
+                AddItemToOrderMutationVariables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: variantId,
+                quantity: 1,
+            });
+            orderGuard.assertSuccess(add1);
+
+            // Second customer adds to order
+            await shopClient.asUserWithCredentials('marques.sawayn@hotmail.com', 'test');
+            const { addItemToOrder: add2 } = await shopClient.query<
+                AddItemToOrderMutation,
+                AddItemToOrderMutationVariables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: variantId,
+                quantity: 1,
+            });
+            orderGuard.assertSuccess(add2);
+
+            // first customer can check out
+            await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+            await proceedToArrangingPayment(shopClient);
+            const result1 = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+            orderGuard.assertSuccess(result1);
+
+            const product1 = await getProductWithStockMovement('T_1');
+            const variant = product1?.variants.find(v => v.id === variantId);
+            expect(variant!.stockOnHand).toBe(1);
+            expect(variant!.stockAllocated).toBe(1);
+
+            // second customer CANNOT check out
+            await shopClient.asUserWithCredentials('marques.sawayn@hotmail.com', 'test');
+            await shopClient.query<SetShippingAddressMutation, SetShippingAddressMutationVariables>(
+                SET_SHIPPING_ADDRESS,
+                {
+                    input: {
+                        fullName: 'name',
+                        streetLine1: '12 the street',
+                        city: 'foo',
+                        postalCode: '123456',
+                        countryCode: 'US',
+                    },
+                },
+            );
+
+            const { eligibleShippingMethods } = await shopClient.query<GetShippingMethodsQuery>(
+                GET_ELIGIBLE_SHIPPING_METHODS,
+            );
+            const { setOrderShippingMethod } = await shopClient.query<
+                SetShippingMethod.Mutation,
+                SetShippingMethod.Variables
+            >(SET_SHIPPING_METHOD, {
+                id: eligibleShippingMethods[1].id,
+            });
+            orderGuard.assertSuccess(setOrderShippingMethod);
+            const { transitionOrderToState } = await shopClient.query<
+                TransitionToStateMutation,
+                TransitionToStateMutationVariables
+            >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
+            orderGuard.assertErrorResult(transitionOrderToState);
+
+            expect(transitionOrderToState!.transitionError).toBe(
+                'Cannot transition Order to the "ArrangingPayment" state due to insufficient stock of Laptop 13 inch 8GB',
+            );
+        });
+    });
 });
 
 const UPDATE_STOCK_ON_HAND = gql`

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

@@ -108,6 +108,7 @@
     "cannot-transition-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-no-additional-payments-needed": "Cannot transition Order to the \"ArrangingAdditionalPayment\" state as no additional payments are needed",
     "cannot-transition-to-shipping-when-order-is-empty": "Cannot transition Order to the \"ArrangingShipping\" state when it is empty",
+    "cannot-transition-to-payment-due-to-insufficient-stock": "Cannot transition Order to the \"ArrangingPayment\" state due to insufficient stock of { productVariantNames }",
     "cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
     "cannot-transition-to-payment-without-shipping-method": "Cannot transition Order to the \"ArrangingPayment\" state without a ShippingMethod",
     "cannot-transition-unless-all-cancelled": "Cannot transition Order to the \"Cancelled\" state unless all OrderItems are cancelled",

+ 17 - 0
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -19,6 +19,7 @@ import { ProductVariant } from '../../../entity/product-variant/product-variant.
 import { OrderPlacedEvent } from '../../../event-bus/events/order-placed-event';
 import { EventBus } from '../../../event-bus/index';
 import { HistoryService } from '../../services/history.service';
+import { ProductVariantService } from '../../services/product-variant.service';
 import { PromotionService } from '../../services/promotion.service';
 import { StockMovementService } from '../../services/stock-movement.service';
 import {
@@ -45,6 +46,7 @@ export class OrderStateMachine {
         private historyService: HistoryService,
         private promotionService: PromotionService,
         private eventBus: EventBus,
+        private productVariantService: ProductVariantService,
     ) {
         this.config = this.initConfig();
     }
@@ -132,6 +134,21 @@ export class OrderStateMachine {
             if (!data.order.shippingLines || data.order.shippingLines.length === 0) {
                 return `message.cannot-transition-to-payment-without-shipping-method`;
             }
+            const variantsWithInsufficientSaleableStock: ProductVariant[] = [];
+            for (const line of data.order.lines) {
+                const availableStock = await this.productVariantService.getSaleableStockLevel(
+                    data.ctx,
+                    line.productVariant,
+                );
+                if (line.quantity > availableStock) {
+                    variantsWithInsufficientSaleableStock.push(line.productVariant);
+                }
+            }
+            if (variantsWithInsufficientSaleableStock.length) {
+                return data.ctx.translate('message.cannot-transition-to-payment-due-to-insufficient-stock', {
+                    productVariantNames: variantsWithInsufficientSaleableStock.map(v => v.name).join(', '),
+                });
+            }
         }
         if (toState === 'PaymentAuthorized') {
             const hasAnAuthorizedPayment = !!data.order.payments.find(p => p.state === 'Authorized');