1
0
Эх сурвалжийг харах

Merge branch 'minor' into major

Michael Bromley 3 жил өмнө
parent
commit
f134627aca

+ 15 - 0
CHANGELOG.md

@@ -1,3 +1,18 @@
+## <small>1.9.1 (2022-12-08)</small>
+
+
+#### Fixes
+
+* **admin-ui** Attribution via utm parameters for Unsplash production approval (#1917) ([d3c76d3](https://github.com/vendure-ecommerce/vendure/commit/d3c76d3)), closes [#1917](https://github.com/vendure-ecommerce/vendure/issues/1917)
+* **admin-ui** Fix bug in setting variant assets ([ed988d3](https://github.com/vendure-ecommerce/vendure/commit/ed988d3))
+* **admin-ui** Update variant pagination controls when filtering ([0a4f330](https://github.com/vendure-ecommerce/vendure/commit/0a4f330))
+* **core** Call lifecycle hooks on ChangedPriceHandlingStrategy ([75a695e](https://github.com/vendure-ecommerce/vendure/commit/75a695e)), closes [#1916](https://github.com/vendure-ecommerce/vendure/issues/1916)
+* **core** Correctly decode list query ID operators ([9f9b0e6](https://github.com/vendure-ecommerce/vendure/commit/9f9b0e6)), closes [#1922](https://github.com/vendure-ecommerce/vendure/issues/1922)
+* **core** Fix postgres error when filtering list with empty set ([10c05cf](https://github.com/vendure-ecommerce/vendure/commit/10c05cf))
+* **core** ListQueryOptions type detects nullable fields on entity ([d6e5696](https://github.com/vendure-ecommerce/vendure/commit/d6e5696)), closes [#1834](https://github.com/vendure-ecommerce/vendure/issues/1834)
+* **core** Order calculator supports multiple shipping lines ([06e2be2](https://github.com/vendure-ecommerce/vendure/commit/06e2be2)), closes [#1897](https://github.com/vendure-ecommerce/vendure/issues/1897)
+* **core** Prevent out of stock variants from being purchased ([eb3964c](https://github.com/vendure-ecommerce/vendure/commit/eb3964c)), closes [#1738](https://github.com/vendure-ecommerce/vendure/issues/1738)
+
 ## 1.9.0 (2022-12-01)
 
 

+ 17 - 21
packages/admin-ui/src/lib/catalog/src/components/product-detail/product-detail.component.ts

@@ -9,7 +9,6 @@ import {
     createUpdatedTranslatable,
     CustomFieldConfig,
     DataService,
-    FacetValueFragment,
     findTranslation,
     getChannelCodeFromUserStatus,
     GetProductWithVariantsQuery,
@@ -37,7 +36,6 @@ import { BehaviorSubject, combineLatest, concat, EMPTY, from, merge, Observable
 import {
     debounceTime,
     distinctUntilChanged,
-    filter,
     map,
     mergeMap,
     shareReplay,
@@ -49,7 +47,6 @@ import {
     take,
     takeUntil,
     tap,
-    withLatestFrom,
 } from 'rxjs/operators';
 
 import { ProductDetailService } from '../../providers/product-detail/product-detail.service';
@@ -134,24 +131,17 @@ export class ProductDetailComponent
     ngOnInit() {
         this.init();
         this.product$ = this.entity$;
-        this.totalItems$ = this.product$.pipe(map(product => product.variantList.totalItems));
-        this.paginationConfig$ = combineLatest(this.totalItems$, this.itemsPerPage$, this.currentPage$).pipe(
-            map(([totalItems, itemsPerPage, currentPage]) => ({
-                totalItems,
-                itemsPerPage,
-                currentPage,
-            })),
-        );
-        const variants$ = this.product$.pipe(map(product => product.variantList.items));
         const filterTerm$ = this.filterInput.valueChanges.pipe(
             startWith(''),
             debounceTime(200),
             shareReplay(),
+            tap(() => this.currentPage$.next(1)),
         );
         const initialVariants$ = this.product$.pipe(map(p => p.variantList.items));
-        const updatedVariants$ = combineLatest(filterTerm$, this.currentPage$, this.itemsPerPage$).pipe(
+        const variantsList$ = combineLatest(filterTerm$, this.currentPage$, this.itemsPerPage$).pipe(
             skipUntil(initialVariants$),
             skip(1),
+            debounceTime(100),
             switchMap(([term, currentPage, itemsPerPage]) => {
                 return this.dataService.product
                     .getProductVariants(
@@ -165,10 +155,11 @@ export class ProductDetailComponent
                         },
                         this.id,
                     )
-                    .mapStream(({ productVariants }) => productVariants.items);
+                    .mapStream(({ productVariants }) => productVariants);
             }),
             shareReplay({ bufferSize: 1, refCount: true }),
         );
+        const updatedVariants$ = variantsList$.pipe(map(result => result.items));
         this.variants$ = merge(initialVariants$, updatedVariants$).pipe(
             tap(variants => {
                 for (const variant of variants) {
@@ -176,6 +167,17 @@ export class ProductDetailComponent
                 }
             }),
         );
+        this.totalItems$ = merge(
+            this.product$.pipe(map(product => product.variantList.totalItems)),
+            variantsList$.pipe(map(result => result.totalItems)),
+        );
+        this.paginationConfig$ = combineLatest(this.totalItems$, this.itemsPerPage$, this.currentPage$).pipe(
+            map(([totalItems, itemsPerPage, currentPage]) => ({
+                totalItems,
+                itemsPerPage,
+                currentPage,
+            })),
+        );
         this.taxCategories$ = this.productDetailService.getTaxCategories().pipe(takeUntil(this.destroy$));
         this.activeTab$ = this.route.paramMap.pipe(map(qpm => qpm.get('tab') as any));
 
@@ -304,13 +306,7 @@ export class ProductDetailComponent
             .subscribe();
     }
 
-    removeVariantFromChannel({
-        channelId,
-        variant,
-    }: {
-        channelId: string;
-        variant: ProductVariantFragment;
-    }) {
+    removeVariantFromChannel({ channelId, variant }: { channelId: string; variant: ProductVariantFragment }) {
         from(getChannelCodeFromUserStatus(this.dataService, channelId))
             .pipe(
                 switchMap(({ channelCode }) => {

+ 1 - 1
packages/admin-ui/src/lib/catalog/src/components/product-variants-list/product-variants-list.component.ts

@@ -155,7 +155,7 @@ export class ProductVariantsListComponent implements OnInit, OnDestroy {
             variantId,
             ...event,
         });
-        const index = this.variants.findIndex(v => v.id === variantId);
+        const index = this.formArray.controls.findIndex(c => c.value.id === variantId);
         this.formArray.at(index).markAsDirty();
     }
 

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

@@ -61,10 +61,10 @@ export class LoginComponent {
         const user: any = (res as any).user;
         const location: any = (res as any).location;
 
-        this.imageUrl = res.urls.regular;
+        this.imageUrl = res.urls.regular + '?utm_source=Vendure+Login+Image&utm_medium=referral';
         this.imageCreator = user.name;
         this.imageLocation = location.name;
-        this.imageCreatorUrl = user.links.html;
+        this.imageCreatorUrl = user.links.html + '?utm_source=Vendure+Login+Image&utm_medium=referral';
         this.imageUnsplashUrl = res.links.html;
     }
 

+ 4 - 4
packages/core/e2e/list-query-builder.e2e-spec.ts

@@ -355,7 +355,7 @@ describe('ListQueryBuilder', () => {
                 options: {
                     filter: {
                         ownerId: {
-                            eq: '13',
+                            eq: 'T_13',
                         },
                     },
                 },
@@ -369,7 +369,7 @@ describe('ListQueryBuilder', () => {
                 options: {
                     filter: {
                         ownerId: {
-                            notEq: '13',
+                            notEq: 'T_13',
                         },
                     },
                 },
@@ -383,7 +383,7 @@ describe('ListQueryBuilder', () => {
                 options: {
                     filter: {
                         ownerId: {
-                            in: ['10', '15'],
+                            in: ['T_10', 'T_15'],
                         },
                     },
                 },
@@ -411,7 +411,7 @@ describe('ListQueryBuilder', () => {
                 options: {
                     filter: {
                         ownerId: {
-                            notIn: ['10', '15'],
+                            notIn: ['T_10', 'T_15'],
                         },
                     },
                 },

+ 152 - 61
packages/core/e2e/stock-control.e2e-spec.ts

@@ -4,7 +4,6 @@ import {
     manualFulfillmentHandler,
     mergeConfig,
     Order,
-    OrderPlacedStrategy,
     OrderState,
     RequestContext,
 } from '@vendure/core';
@@ -997,51 +996,51 @@ describe('Stock control', () => {
         describe('adjusting stockOnHand with negative outOfStockThreshold', () => {
             const variant1Id = 'T_1';
             beforeAll(async () => {
-                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
-                    UPDATE_PRODUCT_VARIANTS,
-                    {
-                        input: [
-                            {
-                                id: variant1Id,
-                                stockOnHand: 0,
-                                outOfStockThreshold: -20,
-                                trackInventory: GlobalFlag.TRUE,
-                                useGlobalOutOfStockThreshold: false,
-                            },
-                        ],
-                    },
-                );
+                await adminClient.query<
+                    Codegen.UpdateProductVariantsMutation,
+                    Codegen.UpdateProductVariantsMutationVariables
+                >(UPDATE_PRODUCT_VARIANTS, {
+                    input: [
+                        {
+                            id: variant1Id,
+                            stockOnHand: 0,
+                            outOfStockThreshold: -20,
+                            trackInventory: GlobalFlag.TRUE,
+                            useGlobalOutOfStockThreshold: false,
+                        },
+                    ],
+                });
             });
 
             it(
                 'attempting to set stockOnHand below outOfStockThreshold throws',
                 assertThrowsWithMessage(async () => {
-                    const result = await adminClient.query<UpdateStock.Mutation, UpdateStock.Variables>(
-                        UPDATE_STOCK_ON_HAND,
-                        {
-                            input: [
-                                {
-                                    id: variant1Id,
-                                    stockOnHand: -21,
-                                },
-                            ] as UpdateProductVariantInput[],
-                        },
-                    );
-                }, 'stockOnHand cannot be a negative value'),
-            );
-
-            it('can set negative stockOnHand that is not less than outOfStockThreshold', async () => {
-                const result = await adminClient.query<UpdateStock.Mutation, UpdateStock.Variables>(
-                    UPDATE_STOCK_ON_HAND,
-                    {
+                    const result = await adminClient.query<
+                        Codegen.UpdateStockMutation,
+                        Codegen.UpdateStockMutationVariables
+                    >(UPDATE_STOCK_ON_HAND, {
                         input: [
                             {
                                 id: variant1Id,
-                                stockOnHand: -10,
+                                stockOnHand: -21,
                             },
                         ] as UpdateProductVariantInput[],
-                    },
-                );
+                    });
+                }, 'stockOnHand cannot be a negative value'),
+            );
+
+            it('can set negative stockOnHand that is not less than outOfStockThreshold', async () => {
+                const result = await adminClient.query<
+                    Codegen.UpdateStockMutation,
+                    Codegen.UpdateStockMutationVariables
+                >(UPDATE_STOCK_ON_HAND, {
+                    input: [
+                        {
+                            id: variant1Id,
+                            stockOnHand: -10,
+                        },
+                    ] as UpdateProductVariantInput[],
+                });
                 expect(result.updateProductVariants[0]!.stockOnHand).toBe(-10);
             });
         });
@@ -1235,8 +1234,8 @@ describe('Stock control', () => {
 
                 await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
                 const { addItemToOrder: add1 } = await shopClient.query<
-                    AddItemToOrder.Mutation,
-                    AddItemToOrder.Variables
+                    CodegenShop.AddItemToOrderMutation,
+                    CodegenShop.AddItemToOrderMutationVariables
                 >(ADD_ITEM_TO_ORDER, {
                     productVariantId: variant6.id,
                     quantity: 1,
@@ -1249,19 +1248,19 @@ describe('Stock control', () => {
                     input: { customFields: { test1557: true } },
                 });
 
-                await shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(
-                    SET_SHIPPING_ADDRESS,
-                    {
-                        input: {
-                            streetLine1: '1 Test Street',
-                            countryCode: 'GB',
-                        } as CreateAddressInput,
-                    },
-                );
+                await shopClient.query<
+                    CodegenShop.SetShippingAddressMutation,
+                    CodegenShop.SetShippingAddressMutationVariables
+                >(SET_SHIPPING_ADDRESS, {
+                    input: {
+                        streetLine1: '1 Test Street',
+                        countryCode: 'GB',
+                    } as CreateAddressInput,
+                });
                 await setFirstEligibleShippingMethod();
                 const { transitionOrderToState } = await shopClient.query<
-                    TransitionToState.Mutation,
-                    TransitionToState.Variables
+                    CodegenShop.TransitionToStateMutation,
+                    CodegenShop.TransitionToStateMutationVariables
                 >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
                 orderGuard.assertSuccess(transitionOrderToState);
                 expect(transitionOrderToState.state).toBe('ArrangingPayment');
@@ -1272,19 +1271,19 @@ describe('Stock control', () => {
                 expect(variant6_2.stockOnHand).toBe(3);
                 expect(variant6_2.stockAllocated).toBe(0);
 
-                const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(
-                    CANCEL_ORDER,
-                    {
-                        input: {
-                            orderId: transitionOrderToState.id,
-                            lines: transitionOrderToState.lines.map(l => ({
-                                orderLineId: l.id,
-                                quantity: l.quantity,
-                            })),
-                            reason: 'Cancelled by test',
-                        },
+                const { cancelOrder } = await adminClient.query<
+                    Codegen.CancelOrderMutation,
+                    Codegen.CancelOrderMutationVariables
+                >(CANCEL_ORDER, {
+                    input: {
+                        orderId: transitionOrderToState.id,
+                        lines: transitionOrderToState.lines.map(l => ({
+                            orderLineId: l.id,
+                            quantity: l.quantity,
+                        })),
+                        reason: 'Cancelled by test',
                     },
-                );
+                });
                 orderGuard.assertSuccess(cancelOrder);
 
                 const product3 = await getProductWithStockMovement('T_2');
@@ -1425,6 +1424,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<
+                Codegen.UpdateStockMutation,
+                Codegen.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<
+                CodegenShop.AddItemToOrderMutation,
+                CodegenShop.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<
+                CodegenShop.AddItemToOrderMutation,
+                CodegenShop.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<
+                CodegenShop.SetShippingAddressMutation,
+                CodegenShop.SetShippingAddressMutationVariables
+            >(SET_SHIPPING_ADDRESS, {
+                input: {
+                    fullName: 'name',
+                    streetLine1: '12 the street',
+                    city: 'foo',
+                    postalCode: '123456',
+                    countryCode: 'US',
+                },
+            });
+
+            const { eligibleShippingMethods } = await shopClient.query<CodegenShop.GetShippingMethodsQuery>(
+                GET_ELIGIBLE_SHIPPING_METHODS,
+            );
+            const { setOrderShippingMethod } = await shopClient.query<
+                CodegenShop.SetShippingMethodMutation,
+                CodegenShop.SetShippingMethodMutationVariables
+            >(SET_SHIPPING_METHOD, {
+                id: eligibleShippingMethods[1].id,
+            });
+            orderGuard.assertSuccess(setOrderShippingMethod);
+            const { transitionOrderToState } = await shopClient.query<
+                CodegenShop.TransitionToStateMutation,
+                CodegenShop.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`

+ 9 - 2
packages/core/src/api/middleware/id-interceptor.ts

@@ -1,5 +1,6 @@
 import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
 import { GqlExecutionContext } from '@nestjs/graphql';
+import { IdOperators } from '@vendure/common/lib/generated-types';
 import { VariableValues } from 'apollo-server-core';
 import { GraphQLNamedType, GraphQLSchema, OperationDefinitionNode } from 'graphql';
 import { Observable } from 'rxjs';
@@ -55,8 +56,14 @@ export class IdInterceptor implements NestInterceptor {
     ) {
         const typeTree = graphqlValueTransformer.getInputTypeTree(definition);
         graphqlValueTransformer.transformValues(typeTree, variables, (value, type) => {
-            const isIdType = type && type.name === 'ID';
-            return isIdType ? this.idCodecService.decode(value) : value;
+            if (type?.name === 'ID') {
+                return this.idCodecService.decode(value);
+            }
+            if (type?.name === 'IDOperators') {
+                const keys: Array<keyof IdOperators> = ['eq', 'notEq', 'in', 'notIn'];
+                return this.idCodecService.decode(value, keys);
+            }
+            return value;
         });
         return variables;
     }

+ 1 - 1
packages/core/src/common/types/common-types.ts

@@ -86,7 +86,7 @@ export type SortOrder = 'ASC' | 'DESC';
 
 // prettier-ignore
 export type PrimitiveFields<T extends VendureEntity> = {
-    [K in keyof T]: T[K] extends LocaleString | number | string | boolean | Date ? K : never
+    [K in keyof T]: NonNullable<T[K]> extends LocaleString | number | string | boolean | Date ? K : never
 }[keyof T];
 
 // prettier-ignore

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

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

+ 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",

+ 28 - 17
packages/core/src/service/helpers/list-query-builder/parse-filter-params.ts

@@ -87,7 +87,6 @@ function buildWhereCondition(
     argIndex: number,
     dbType: ConnectionOptions['type'],
 ): WhereCondition {
-    const emptySetPlaceholder = '___empty_set_placeholder___';
     switch (operator) {
         case 'eq':
             return {
@@ -116,22 +115,34 @@ function buildWhereCondition(
                 parameters: { [`arg${argIndex}`]: `%${operand.trim()}%` },
             };
         }
-        case 'in':
-            return {
-                clause: `${fieldName} IN (:...arg${argIndex})`,
-                parameters: {
-                    [`arg${argIndex}`]:
-                        Array.isArray(operand) && operand.length ? operand : [emptySetPlaceholder],
-                },
-            };
-        case 'notIn':
-            return {
-                clause: `${fieldName} NOT IN (:...arg${argIndex})`,
-                parameters: {
-                    [`arg${argIndex}`]:
-                        Array.isArray(operand) && operand.length ? operand : [emptySetPlaceholder],
-                },
-            };
+        case 'in': {
+            if (Array.isArray(operand) && operand.length) {
+                return {
+                    clause: `${fieldName} IN (:...arg${argIndex})`,
+                    parameters: { [`arg${argIndex}`]: operand },
+                };
+            } else {
+                // "in" with an empty set should always return nothing
+                return {
+                    clause: '1 = 0',
+                    parameters: {},
+                };
+            }
+        }
+        case 'notIn': {
+            if (Array.isArray(operand) && operand.length) {
+                return {
+                    clause: `${fieldName} NOT IN (:...arg${argIndex})`,
+                    parameters: { [`arg${argIndex}`]: operand },
+                };
+            } else {
+                // "notIn" with an empty set should always return all
+                return {
+                    clause: '1 = 1',
+                    parameters: {},
+                };
+            }
+        }
         case 'regex':
             return {
                 clause: getRegexpClause(fieldName, argIndex, dbType),

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

@@ -155,6 +155,37 @@ describe('OrderCalculator', () => {
             expect(order.totalWithTax).toBe(order.subTotalWithTax + 500);
             assertOrderTotalsAddUp(order);
         });
+
+        it('multiple shipping lines', async () => {
+            const ctx = createRequestContext({ pricesIncludeTax: false });
+            const order = createOrder({
+                ctx,
+                lines: [
+                    {
+                        listPrice: 100,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 1,
+                    },
+                ],
+            });
+            order.shippingLines = [
+                new ShippingLine({
+                    shippingMethodId: mockShippingMethodId,
+                }),
+                new ShippingLine({
+                    shippingMethodId: mockShippingMethodId,
+                }),
+            ];
+            await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+            expect(order.shippingLines.length).toBe(2);
+            expect(order.subTotal).toBe(100);
+            expect(order.shipping).toBe(1000);
+            expect(order.shippingWithTax).toBe(1200);
+            expect(order.total).toBe(order.subTotal + 1000);
+            expect(order.totalWithTax).toBe(order.subTotalWithTax + 1200);
+            assertOrderTotalsAddUp(order);
+        });
     });
 
     describe('promotions', () => {

+ 34 - 33
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -398,45 +398,46 @@ export class OrderCalculator {
     }
 
     private async applyShipping(ctx: RequestContext, order: Order) {
-        const shippingLine: ShippingLine | undefined = order.shippingLines[0];
-        const currentShippingMethod =
-            shippingLine?.shippingMethodId &&
-            (await this.shippingMethodService.findOne(ctx, shippingLine.shippingMethodId));
-        if (!currentShippingMethod) {
-            return;
-        }
-        const currentMethodStillEligible = await currentShippingMethod.test(ctx, order);
-        if (currentMethodStillEligible) {
-            const result = await currentShippingMethod.apply(ctx, order);
-            if (result) {
-                shippingLine.listPrice = result.price;
-                shippingLine.listPriceIncludesTax = result.priceIncludesTax;
+        for (const shippingLine of order.shippingLines) {
+            const currentShippingMethod =
+                shippingLine?.shippingMethodId &&
+                (await this.shippingMethodService.findOne(ctx, shippingLine.shippingMethodId));
+            if (!currentShippingMethod) {
+                return;
+            }
+            const currentMethodStillEligible = await currentShippingMethod.test(ctx, order);
+            if (currentMethodStillEligible) {
+                const result = await currentShippingMethod.apply(ctx, order);
+                if (result) {
+                    shippingLine.listPrice = result.price;
+                    shippingLine.listPriceIncludesTax = result.priceIncludesTax;
+                    shippingLine.taxLines = [
+                        {
+                            description: 'shipping tax',
+                            taxRate: result.taxRate,
+                        },
+                    ];
+                }
+                continue;
+            }
+            const results = await this.shippingCalculator.getEligibleShippingMethods(ctx, order, [
+                currentShippingMethod.id,
+            ]);
+            if (results && results.length) {
+                const cheapest = results[0];
+                shippingLine.listPrice = cheapest.result.price;
+                shippingLine.listPriceIncludesTax = cheapest.result.priceIncludesTax;
+                shippingLine.shippingMethod = cheapest.method;
+                shippingLine.shippingMethodId = cheapest.method.id;
                 shippingLine.taxLines = [
                     {
                         description: 'shipping tax',
-                        taxRate: result.taxRate,
+                        taxRate: cheapest.result.taxRate,
                     },
                 ];
+            } else {
+                order.shippingLines = order.shippingLines.filter(sl => sl !== shippingLine);
             }
-            return;
-        }
-        const results = await this.shippingCalculator.getEligibleShippingMethods(ctx, order, [
-            currentShippingMethod.id,
-        ]);
-        if (results && results.length) {
-            const cheapest = results[0];
-            shippingLine.listPrice = cheapest.result.price;
-            shippingLine.listPriceIncludesTax = cheapest.result.priceIncludesTax;
-            shippingLine.shippingMethod = cheapest.method;
-            shippingLine.shippingMethodId = cheapest.method.id;
-            shippingLine.taxLines = [
-                {
-                    description: 'shipping tax',
-                    taxRate: cheapest.result.taxRate,
-                },
-            ];
-        } else {
-            order.shippingLines = [];
         }
     }
 

+ 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');

+ 1 - 6
packages/core/src/service/services/promotion.service.ts

@@ -165,12 +165,7 @@ export class PromotionService {
         }
         promotion.priorityScore = this.calculatePriorityScore(input);
         await this.connection.getRepository(ctx, Promotion).save(updatedPromotion, { reload: false });
-        await this.customFieldRelationService.updateRelations(
-            ctx,
-            Promotion,
-            input,
-            updatedPromotion,
-        );
+        await this.customFieldRelationService.updateRelations(ctx, Promotion, input, updatedPromotion);
         this.eventBus.publish(new PromotionEvent(ctx, promotion, 'updated', input));
         return assertFound(this.findOne(ctx, updatedPromotion.id));
     }