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

Merge branch 'master' into minor

Michael Bromley 2 лет назад
Родитель
Сommit
08d4b3bfa6
45 измененных файлов с 703 добавлено и 188 удалено
  1. 10 0
      docs/docs/guides/core-concepts/money/index.mdx
  2. 80 73
      docs/docs/guides/core-concepts/promotions/index.md
  3. 1 1
      docs/docs/guides/developer-guide/plugins/index.mdx
  4. 6 0
      packages/admin-ui/src/lib/catalog/src/catalog.module.ts
  5. 2 1
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  6. 5 0
      packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts
  7. 7 4
      packages/admin-ui/src/lib/customer/src/customer.module.ts
  8. 7 1
      packages/admin-ui/src/lib/marketing/src/marketing.module.ts
  9. 7 1
      packages/admin-ui/src/lib/order/src/order.module.ts
  10. 18 10
      packages/admin-ui/src/lib/settings/src/components/channel-list/channel-list-bulk-actions.ts
  11. 7 1
      packages/admin-ui/src/lib/settings/src/settings.module.ts
  12. 0 1
      packages/admin-ui/src/lib/system/src/system.module.ts
  13. 2 1
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  14. 1 0
      packages/common/src/generated-shop-types.ts
  15. 2 1
      packages/common/src/generated-types.ts
  16. 34 3
      packages/core/e2e/administrator.e2e-spec.ts
  17. 114 13
      packages/core/e2e/channel.e2e-spec.ts
  18. 17 8
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  19. 19 18
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  20. 4 0
      packages/core/e2e/graphql/shop-definitions.ts
  21. 19 0
      packages/core/e2e/product-channel.e2e-spec.ts
  22. 125 8
      packages/core/e2e/product-prices.e2e-spec.ts
  23. 1 1
      packages/core/package.json
  24. 26 0
      packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts
  25. 18 2
      packages/core/src/common/finite-state-machine/validate-transition-definition.ts
  26. 54 1
      packages/core/src/common/utils.spec.ts
  27. 12 1
      packages/core/src/common/utils.ts
  28. 1 1
      packages/core/src/config/catalog/default-product-variant-price-selection-strategy.ts
  29. 1 0
      packages/core/src/i18n/messages/en.json
  30. 5 0
      packages/core/src/plugin/default-search-plugin/indexer/mutable-request-context.ts
  31. 7 4
      packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state-machine.ts
  32. 7 6
      packages/core/src/service/helpers/order-state-machine/order-state-machine.ts
  33. 6 2
      packages/core/src/service/helpers/payment-state-machine/payment-state-machine.ts
  34. 8 1
      packages/core/src/service/helpers/request-context/request-context.service.ts
  35. 33 6
      packages/core/src/service/services/channel.service.ts
  36. 8 0
      packages/core/src/service/services/order.service.ts
  37. 4 3
      packages/core/src/service/services/product-variant.service.ts
  38. 13 7
      packages/core/src/service/services/user.service.ts
  39. 2 1
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  40. 2 1
      packages/payments-plugin/e2e/graphql/generated-admin-types.ts
  41. 1 0
      packages/payments-plugin/e2e/graphql/generated-shop-types.ts
  42. 1 0
      packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts
  43. 0 0
      schema-admin.json
  44. 0 0
      schema-shop.json
  45. 6 6
      yarn.lock

+ 10 - 0
docs/docs/guides/core-concepts/money/index.mdx

@@ -77,6 +77,16 @@ If you are building an Admin UI extension, you can use the built-in [`LocaleCurr
 </div>
 ```
 
+## Support for multiple currencies
+
+Vendure supports multiple currencies out-of-the-box. The available currencies must first be set at the Channel level
+(see the [Channels, Currencies & Prices section](/guides/core-concepts/channels/#channels-currencies--prices)), and then
+a price may be set on a `ProductVariant` in each of the available currencies.
+
+When using multiple currencies, the [ProductVariantPriceSelectionStrategy](/reference/typescript-api/configuration/product-variant-price-selection-strategy/)
+is used to determine which of the available prices to return when fetching the details of a `ProductVariant`. The default strategy
+is to return the price in the currency of the current session request context, which is determined firstly by any `?currencyCode=XXX` query parameter
+on the request, and secondly by the `defaultCurrencyCode` of the Channel.
 
 ## The GraphQL `Money` scalar
 

+ 80 - 73
docs/docs/guides/core-concepts/promotions/index.md

@@ -164,88 +164,95 @@ A primary use-case of this API is to add a free gift to the Order. Here's an exa
 
 ```ts title="src/plugins/free-gift/free-gift.plugin.ts"
 import {
-    ID, idsAreEqual, isGraphQlErrorResult, LanguageCode,
-    Logger, OrderLine, OrderService, PromotionItemAction, VendurePlugin,
-} from '@vendure/core';
-import { createHash } from 'crypto';
+	ID, idsAreEqual, isGraphQlErrorResult, LanguageCode, Logger,
+	OrderLine, OrderService, PromotionItemAction, VendurePlugin,
+} from "@vendure/core";
+import { createHash } from "crypto";
 
 let orderService: OrderService;
 export const freeGiftAction = new PromotionItemAction({
-    code: 'free_gift',
-    description: [{languageCode: LanguageCode.en, value: 'Add free gifts to the order'}],
-    args: {
-        productVariantIds: {
-            type: 'ID',
-            list: true,
-            ui: {component: 'product-selector-form-input'},
-            label: [{languageCode: LanguageCode.en, value: 'Gift product variants'}],
-        },
-    },
-    init(injector) {
-        orderService = injector.get(OrderService);
-    },
-    execute(ctx, orderItem, orderLine, args) {
-        // This part is responsible for ensuring the variants marked as 
-        // "free gifts" have their price reduced to zero.  
-        if (lineContainsIds(args.productVariantIds, orderLine)) {
-            const unitPrice = orderLine.productVariant.listPriceIncludesTax
-                ? orderLine.unitPriceWithTax
-                : orderLine.unitPrice;
-            return -unitPrice;
-        }
-        return 0;
-    },
-    // The onActivate function is part of the side effect API, and
-    // allows us to perform some action whenever a Promotion becomes active
-    // due to it's conditions & constraints being satisfied.  
-    async onActivate(ctx, order, args, promotion) {
-        for (const id of args.productVariantIds) {
-            if (
-                !order.lines.find(
-                    (line) =>
-                        idsAreEqual(line.productVariant.id, id) &&
-                        line.customFields.freeGiftDescription == null,
-                )
-            ) {
-                // The order does not yet contain this free gift, so add it
-                const result = await orderService.addItemToOrder(ctx, order.id, id, 1, {
-                    freeGiftPromotionId: promotion.id.toString(),
-                });
-                if (isGraphQlErrorResult(result)) {
-                    Logger.error(`Free gift action error for variantId "${id}": ${result.message}`);
-                }
-            }
-        }
-    },
-    // The onDeactivate function is the other part of the side effect API and is called 
-    // when an active Promotion becomes no longer active. It should reverse any 
-    // side effect performed by the onActivate function.
-    async onDeactivate(ctx, order, args, promotion) {
-        const linesWithFreeGift = order.lines.filter(
-            (line) => line.customFields.freeGiftPromotionId === promotion.id.toString(),
-        );
-        for (const line of linesWithFreeGift) {
-            await orderService.removeItemFromOrder(ctx, order.id, line.id);
-        }
-    },
+	code: "free_gift",
+	description: [{ languageCode: LanguageCode.en, value: "Add free gifts to the order" }],
+	args: {
+		productVariantIds: {
+			type: "ID",
+			list: true,
+			ui: { component: "product-selector-form-input" },
+			label: [{ languageCode: LanguageCode.en, value: "Gift product variants" }],
+		},
+	},
+	init(injector) {
+		orderService = injector.get(OrderService);
+	},
+	execute(ctx, orderLine, args) {
+		// This part is responsible for ensuring the variants marked as
+		// "free gifts" have their price reduced to zero
+		if (lineContainsIds(args.productVariantIds, orderLine)) {
+			const unitPrice = orderLine.productVariant.listPriceIncludesTax
+				? orderLine.unitPriceWithTax
+				: orderLine.unitPrice;
+			return -unitPrice;
+		}
+		return 0;
+	},
+	// The onActivate function is part of the side effect API, and
+	// allows us to perform some action whenever a Promotion becomes active
+	// due to it's conditions & constraints being satisfied.
+	async onActivate(ctx, order, args, promotion) {
+		for (const id of args.productVariantIds) {
+			if (
+				!order.lines.find(
+					(line) =>
+						idsAreEqual(line.productVariant.id, id) &&
+						line.customFields.freeGiftPromotionId == null
+				)
+			) {
+				// The order does not yet contain this free gift, so add it
+				const result = await orderService.addItemToOrder(ctx, order.id, id, 1, {
+					freeGiftPromotionId: promotion.id.toString(),
+				});
+				if (isGraphQlErrorResult(result)) {
+					Logger.error(`Free gift action error for variantId "${id}": ${result.message}`);
+				}
+			}
+		}
+	},
+	// The onDeactivate function is the other part of the side effect API and is called
+	// when an active Promotion becomes no longer active. It should reverse any
+	// side effect performed by the onActivate function.
+	async onDeactivate(ctx, order, args, promotion) {
+		const linesWithFreeGift = order.lines.filter(
+			(line) => line.customFields.freeGiftPromotionId === promotion.id.toString()
+		);
+		for (const line of linesWithFreeGift) {
+			await orderService.removeItemFromOrder(ctx, order.id, line.id);
+		}
+	},
 });
 
 function lineContainsIds(ids: ID[], line: OrderLine): boolean {
-    return !!ids.find((id) => idsAreEqual(id, line.productVariant.id));
+	return !!ids.find((id) => idsAreEqual(id, line.productVariant.id));
 }
 
 @VendurePlugin({
-    configuration: config => {
-        config.promotionOptions.promotionActions.push(freeGiftAction);
-        config.customFields.OrderItem.push(
-            {
-                name: 'freeGiftPromotionId',
-                type: 'string',
-                public: true,
-                readonly: true,
-                nullable: true,
-            })
-    }
+	configuration: (config) => {
+		config.customFields.OrderLine.push({
+			name: "freeGiftPromotionId",
+			type: "string",
+			public: true,
+			readonly: true,
+			nullable: true,
+		});
+		config.customFields.OrderLine.push({
+			name: "freeGiftDescription",
+			type: "string",
+			public: true,
+			readonly: true,
+			nullable: true,
+		});
+		config.promotionOptions.promotionActions.push(freeGiftAction);
+		return config;
+	},
 })
 export class FreeGiftPromotionPlugin {}
 ```

+ 1 - 1
docs/docs/guides/developer-guide/plugins/index.mdx

@@ -40,7 +40,7 @@ import { LanguageCode, PluginCommonModule, VendurePlugin } from '@vendure/core';
 
 @VendurePlugin({
     imports: [PluginCommonModule],
-    configure: config => {
+    configuration: config => {
         config.customFields.Customer.push({
             type: 'string',
             name: 'avatarUrl',

+ 6 - 0
packages/admin-ui/src/lib/catalog/src/catalog.module.ts

@@ -117,7 +117,12 @@ const CATALOG_COMPONENTS = [
     ],
 })
 export class CatalogModule {
+    private static hasRegisteredTabsAndBulkActions = false;
+
     constructor(bulkActionRegistryService: BulkActionRegistryService, pageService: PageService) {
+        if (CatalogModule.hasRegisteredTabsAndBulkActions) {
+            return;
+        }
         bulkActionRegistryService.registerBulkAction(assignFacetValuesToProductsBulkAction);
         bulkActionRegistryService.registerBulkAction(assignProductsToChannelBulkAction);
         bulkActionRegistryService.registerBulkAction(assignProductVariantsToChannelBulkAction);
@@ -259,5 +264,6 @@ export class CatalogModule {
                 ],
             }),
         });
+        CatalogModule.hasRegisteredTabsAndBulkActions = true;
     }
 }

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

@@ -3988,6 +3988,7 @@ export type OrderLine = Node & {
   proratedUnitPrice: Scalars['Money']['output'];
   /** The proratedUnitPrice including tax */
   proratedUnitPriceWithTax: Scalars['Money']['output'];
+  /** The quantity of items purchased */
   quantity: Scalars['Int']['output'];
   taxLines: Array<TaxLine>;
   taxRate: Scalars['Float']['output'];
@@ -4731,7 +4732,7 @@ export type ProductVariantListOptions = {
 export type ProductVariantPrice = {
   __typename?: 'ProductVariantPrice';
   currencyCode: CurrencyCode;
-  price: Scalars['Int']['output'];
+  price: Scalars['Money']['output'];
 };
 
 /**

+ 5 - 0
packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts

@@ -28,6 +28,11 @@ export function addCustomFields(documentNode: DocumentNode, customFields: Custom
             entityType = 'Address';
         }
 
+        if (entityType === ('Country' as any)) {
+            // Country is an alias of Region
+            entityType = 'Region';
+        }
+
         const customFieldsForType = customFields[entityType];
         if (customFieldsForType && customFieldsForType.length) {
             (fragmentDef.selectionSet.selections as SelectionNode[]).push({

+ 7 - 4
packages/admin-ui/src/lib/customer/src/customer.module.ts

@@ -57,10 +57,12 @@ import { CustomerGroupDetailComponent } from './components/customer-group-detail
     exports: [AddressCardComponent],
 })
 export class CustomerModule {
-    constructor(
-        private bulkActionRegistryService: BulkActionRegistryService,
-        private pageService: PageService,
-    ) {
+    private static hasRegisteredTabsAndBulkActions = false;
+
+    constructor(bulkActionRegistryService: BulkActionRegistryService, pageService: PageService) {
+        if (CustomerModule.hasRegisteredTabsAndBulkActions) {
+            return;
+        }
         bulkActionRegistryService.registerBulkAction(deleteCustomersBulkAction);
         bulkActionRegistryService.registerBulkAction(deleteCustomerGroupsBulkAction);
         bulkActionRegistryService.registerBulkAction(removeCustomerGroupMembersBulkAction);
@@ -122,5 +124,6 @@ export class CustomerModule {
                 ],
             }),
         });
+        CustomerModule.hasRegisteredTabsAndBulkActions = true;
     }
 }

+ 7 - 1
packages/admin-ui/src/lib/marketing/src/marketing.module.ts

@@ -32,7 +32,12 @@ import { createRoutes } from './marketing.routes';
     declarations: [PromotionListComponent, PromotionDetailComponent],
 })
 export class MarketingModule {
-    constructor(private bulkActionRegistryService: BulkActionRegistryService, pageService: PageService) {
+    private static hasRegisteredTabsAndBulkActions = false;
+
+    constructor(bulkActionRegistryService: BulkActionRegistryService, pageService: PageService) {
+        if (MarketingModule.hasRegisteredTabsAndBulkActions) {
+            return;
+        }
         bulkActionRegistryService.registerBulkAction(assignPromotionsToChannelBulkAction);
         bulkActionRegistryService.registerBulkAction(removePromotionsFromChannelBulkAction);
         bulkActionRegistryService.registerBulkAction(deletePromotionsBulkAction);
@@ -61,5 +66,6 @@ export class MarketingModule {
                 ],
             }),
         });
+        MarketingModule.hasRegisteredTabsAndBulkActions = true;
     }
 }

+ 7 - 1
packages/admin-ui/src/lib/order/src/order.module.ts

@@ -102,7 +102,12 @@ import { OrderDataTableComponent } from './components/order-data-table/order-dat
     exports: [OrderCustomFieldsCardComponent],
 })
 export class OrderModule {
-    constructor(private pageService: PageService) {
+    private static hasRegisteredTabsAndBulkActions = false;
+
+    constructor(pageService: PageService) {
+        if (OrderModule.hasRegisteredTabsAndBulkActions) {
+            return;
+        }
         pageService.registerPageTab({
             priority: 0,
             location: 'order-list',
@@ -160,5 +165,6 @@ export class OrderModule {
                 ],
             }),
         });
+        OrderModule.hasRegisteredTabsAndBulkActions = true;
     }
 }

+ 18 - 10
packages/admin-ui/src/lib/settings/src/components/channel-list/channel-list-bulk-actions.ts

@@ -1,17 +1,25 @@
-import {
-    createBulkDeleteAction,
-    GetChannelsQuery,
-    GetCustomerListQuery,
-    ItemOf,
-    Permission,
-} from '@vendure/admin-ui/core';
-import { map } from 'rxjs/operators';
+import { createBulkDeleteAction, GetChannelsQuery, ItemOf, Permission } from '@vendure/admin-ui/core';
+import { map, mergeMap } from 'rxjs/operators';
 
 export const deleteChannelsBulkAction = createBulkDeleteAction<ItemOf<GetChannelsQuery, 'channels'>>({
     location: 'channel-list',
     requiresPermission: userPermissions =>
         userPermissions.includes(Permission.SuperAdmin) || userPermissions.includes(Permission.DeleteChannel),
     getItemName: item => item.code,
-    bulkDelete: (dataService, ids) =>
-        dataService.settings.deleteChannels(ids).pipe(map(res => res.deleteChannels)),
+    bulkDelete: (dataService, ids) => {
+        return dataService.settings.deleteChannels(ids).pipe(
+            mergeMap(({ deleteChannels }) =>
+                dataService.auth.currentUser().single$.pipe(
+                    map(({ me }) => ({
+                        me,
+                        deleteChannels,
+                    })),
+                ),
+            ),
+            mergeMap(({ me, deleteChannels }) =>
+                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                dataService.client.updateUserChannels(me!.channels).pipe(map(() => deleteChannels)),
+            ),
+        );
+    },
 });

+ 7 - 1
packages/admin-ui/src/lib/settings/src/settings.module.ts

@@ -128,7 +128,12 @@ import { createRoutes } from './settings.routes';
     ],
 })
 export class SettingsModule {
-    constructor(private bulkActionRegistryService: BulkActionRegistryService, pageService: PageService) {
+    private static hasRegisteredTabsAndBulkActions = false;
+
+    constructor(bulkActionRegistryService: BulkActionRegistryService, pageService: PageService) {
+        if (SettingsModule.hasRegisteredTabsAndBulkActions) {
+            return;
+        }
         bulkActionRegistryService.registerBulkAction(deleteSellersBulkAction);
 
         bulkActionRegistryService.registerBulkAction(deleteChannelsBulkAction);
@@ -451,5 +456,6 @@ export class SettingsModule {
                 ],
             }),
         });
+        SettingsModule.hasRegisteredTabsAndBulkActions = true;
     }
 }

+ 0 - 1
packages/admin-ui/src/lib/system/src/system.module.ts

@@ -1,4 +1,3 @@
-import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
 import { RouterModule } from '@angular/router';
 import { SharedModule } from '@vendure/admin-ui/core';

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

@@ -3823,6 +3823,7 @@ export type OrderLine = Node & {
   proratedUnitPrice: Scalars['Money']['output'];
   /** The proratedUnitPrice including tax */
   proratedUnitPriceWithTax: Scalars['Money']['output'];
+  /** The quantity of items purchased */
   quantity: Scalars['Int']['output'];
   taxLines: Array<TaxLine>;
   taxRate: Scalars['Float']['output'];
@@ -4536,7 +4537,7 @@ export type ProductVariantListOptions = {
 
 export type ProductVariantPrice = {
   currencyCode: CurrencyCode;
-  price: Scalars['Int']['output'];
+  price: Scalars['Money']['output'];
 };
 
 /**

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

@@ -2086,6 +2086,7 @@ export type OrderLine = Node & {
   proratedUnitPrice: Scalars['Money']['output'];
   /** The proratedUnitPrice including tax */
   proratedUnitPriceWithTax: Scalars['Money']['output'];
+  /** The quantity of items purchased */
   quantity: Scalars['Int']['output'];
   taxLines: Array<TaxLine>;
   taxRate: Scalars['Float']['output'];

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

@@ -3914,6 +3914,7 @@ export type OrderLine = Node & {
   proratedUnitPrice: Scalars['Money']['output'];
   /** The proratedUnitPrice including tax */
   proratedUnitPriceWithTax: Scalars['Money']['output'];
+  /** The quantity of items purchased */
   quantity: Scalars['Int']['output'];
   taxLines: Array<TaxLine>;
   taxRate: Scalars['Float']['output'];
@@ -4656,7 +4657,7 @@ export type ProductVariantListOptions = {
 export type ProductVariantPrice = {
   __typename?: 'ProductVariantPrice';
   currencyCode: CurrencyCode;
-  price: Scalars['Int']['output'];
+  price: Scalars['Money']['output'];
 };
 
 /**

+ 34 - 3
packages/core/e2e/administrator.e2e-spec.ts

@@ -1,16 +1,21 @@
 import { SUPER_ADMIN_USER_IDENTIFIER } from '@vendure/common/lib/shared-constants';
-import { createTestEnvironment } from '@vendure/testing';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import { fail } from 'assert';
 import gql from 'graphql-tag';
 import path from 'path';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { ADMINISTRATOR_FRAGMENT } from './graphql/fragments';
 import * as Codegen from './graphql/generated-e2e-admin-types';
-import { AdministratorFragment, DeletionResult } from './graphql/generated-e2e-admin-types';
+import {
+    AdministratorFragment,
+    AttemptLoginDocument,
+    CurrentUser,
+    DeletionResult,
+} from './graphql/generated-e2e-admin-types';
 import { CREATE_ADMINISTRATOR, UPDATE_ADMINISTRATOR } from './graphql/shared-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
@@ -235,6 +240,32 @@ describe('Administrator resolver', () => {
         expect(activeAdministrator?.firstName).toBe('Thomas');
         expect(activeAdministrator?.user.identifier).toBe('neo@metacortex.com');
     });
+
+    it('supports case-sensitive admin identifiers', async () => {
+        const loginResultGuard: ErrorResultGuard<CurrentUser> = createErrorResultGuard(
+            input => !!input.identifier,
+        );
+        const { createAdministrator } = await adminClient.query<
+            Codegen.CreateAdministratorMutation,
+            Codegen.CreateAdministratorMutationVariables
+        >(CREATE_ADMINISTRATOR, {
+            input: {
+                emailAddress: 'NewAdmin',
+                firstName: 'New',
+                lastName: 'Admin',
+                password: 'password',
+                roleIds: ['1'],
+            },
+        });
+
+        const { login } = await adminClient.query(AttemptLoginDocument, {
+            username: 'NewAdmin',
+            password: 'password',
+        });
+
+        loginResultGuard.assertSuccess(login);
+        expect(login.identifier).toBe('NewAdmin');
+    });
 });
 
 export const GET_ADMINISTRATORS = gql`

+ 114 - 13
packages/core/e2e/channel.e2e-spec.ts

@@ -28,6 +28,7 @@ import {
     CREATE_ROLE,
     GET_CHANNELS,
     GET_CUSTOMER_LIST,
+    GET_PRODUCT_LIST,
     GET_PRODUCT_WITH_VARIANTS,
     ME,
     UPDATE_CHANNEL,
@@ -124,19 +125,19 @@ describe('Channels', () => {
         });
     });
 
-    it('update currencyCode', async () => {
-        const { updateChannel } = await adminClient.query<
-            Codegen.UpdateChannelMutation,
-            Codegen.UpdateChannelMutationVariables
-        >(UPDATE_CHANNEL, {
-            input: {
-                id: 'T_1',
-                currencyCode: CurrencyCode.MYR,
-            },
-        });
-        channelGuard.assertSuccess(updateChannel);
-        expect(updateChannel.currencyCode).toBe('MYR');
-    });
+    // it('update currencyCode', async () => {
+    //     const { updateChannel } = await adminClient.query<
+    //         Codegen.UpdateChannelMutation,
+    //         Codegen.UpdateChannelMutationVariables
+    //     >(UPDATE_CHANNEL, {
+    //         input: {
+    //             id: 'T_1',
+    //             currencyCode: CurrencyCode.MYR,
+    //         },
+    //     });
+    //     channelGuard.assertSuccess(updateChannel);
+    //     expect(updateChannel.currencyCode).toBe('MYR');
+    // });
 
     it('superadmin has all permissions on new channel', async () => {
         const { me } = await adminClient.query<Codegen.MeQuery>(ME);
@@ -368,6 +369,90 @@ describe('Channels', () => {
         });
         expect(product!.channels.map(c => c.id)).toEqual(['T_1']);
     });
+
+    describe('currencyCode support', () => {
+        beforeAll(async () => {
+            await adminClient.asSuperAdmin();
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+        });
+
+        it('initial currencyCode values', async () => {
+            const { channel } = await adminClient.query<
+                Codegen.GetChannelQuery,
+                Codegen.GetChannelQueryVariables
+            >(GET_CHANNEL, {
+                id: 'T_1',
+            });
+
+            expect(channel?.defaultCurrencyCode).toBe('USD');
+            expect(channel?.availableCurrencyCodes).toEqual(['USD']);
+        });
+
+        it('setting defaultCurrencyCode adds it to availableCurrencyCodes', async () => {
+            const { updateChannel } = await adminClient.query<
+                Codegen.UpdateChannelMutation,
+                Codegen.UpdateChannelMutationVariables
+            >(UPDATE_CHANNEL, {
+                input: {
+                    id: 'T_1',
+                    defaultCurrencyCode: CurrencyCode.MYR,
+                },
+            });
+            channelGuard.assertSuccess(updateChannel);
+            expect(updateChannel.defaultCurrencyCode).toBe('MYR');
+            expect(updateChannel.currencyCode).toBe('MYR');
+            expect(updateChannel.availableCurrencyCodes).toEqual(['USD', 'MYR']);
+        });
+
+        it('setting defaultCurrencyCode adds it to availableCurrencyCodes 2', async () => {
+            // As above, but this time we set the availableCurrencyCodes explicitly
+            // to exclude the defaultCurrencyCode
+            const { updateChannel } = await adminClient.query<
+                Codegen.UpdateChannelMutation,
+                Codegen.UpdateChannelMutationVariables
+            >(UPDATE_CHANNEL, {
+                input: {
+                    id: 'T_1',
+                    defaultCurrencyCode: CurrencyCode.AUD,
+                    availableCurrencyCodes: [CurrencyCode.GBP],
+                },
+            });
+            channelGuard.assertSuccess(updateChannel);
+            expect(updateChannel.defaultCurrencyCode).toBe('AUD');
+            expect(updateChannel.currencyCode).toBe('AUD');
+            expect(updateChannel.availableCurrencyCodes).toEqual(['GBP', 'AUD']);
+        });
+
+        it(
+            'cannot remove the defaultCurrencyCode from availableCurrencyCodes',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<
+                    Codegen.UpdateChannelMutation,
+                    Codegen.UpdateChannelMutationVariables
+                >(UPDATE_CHANNEL, {
+                    input: {
+                        id: 'T_1',
+                        availableCurrencyCodes: [CurrencyCode.GBP],
+                    },
+                });
+            }, 'availableCurrencyCodes must include the defaultCurrencyCode (AUD)'),
+        );
+
+        it(
+            'specifying an unsupported currencyCode throws',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<Codegen.GetProductListQuery, Codegen.GetProductListQueryVariables>(
+                    GET_PRODUCT_LIST,
+                    {
+                        options: {
+                            take: 1,
+                        },
+                    },
+                    { currencyCode: 'JPY' },
+                );
+            }, 'The currency "JPY" is not available in the current Channel'),
+        );
+    });
 });
 
 const DELETE_CHANNEL = gql`
@@ -379,6 +464,22 @@ const DELETE_CHANNEL = gql`
     }
 `;
 
+const GET_CHANNEL = gql`
+    query GetChannel($id: ID!) {
+        channel(id: $id) {
+            id
+            code
+            token
+            defaultCurrencyCode
+            availableCurrencyCodes
+            defaultLanguageCode
+            availableLanguageCodes
+            outOfStockThreshold
+            pricesIncludeTax
+        }
+    }
+`;
+
 const UPDATE_GLOBAL_LANGUAGES = gql`
     mutation UpdateGlobalLanguages($input: UpdateGlobalSettingsInput!) {
         updateGlobalSettings(input: $input) {

Разница между файлами не показана из-за своего большого размера
+ 17 - 8
packages/core/e2e/graphql/generated-e2e-admin-types.ts


Разница между файлами не показана из-за своего большого размера
+ 19 - 18
packages/core/e2e/graphql/generated-e2e-shop-types.ts


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

@@ -12,6 +12,7 @@ export const TEST_ORDER_FRAGMENT = gql`
         shippingWithTax
         total
         totalWithTax
+        currencyCode
         couponCodes
         discounts {
             adjustmentSource
@@ -74,12 +75,15 @@ export const UPDATED_ORDER_FRAGMENT = gql`
         active
         total
         totalWithTax
+        currencyCode
         lines {
             id
             quantity
             productVariant {
                 id
             }
+            unitPrice
+            unitPriceWithTax
             linePrice
             linePriceWithTax
             discounts {

+ 19 - 0
packages/core/e2e/product-channel.e2e-spec.ts

@@ -506,5 +506,24 @@ describe('ChannelAware Products and ProductVariants', () => {
                 { currencyCode: 'EUR', price: 300 },
             ]);
         });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/2391
+        it('does not duplicate an existing price', async () => {
+            await adminClient.query(UpdateChannelDocument, {
+                input: {
+                    id: secondChannelId,
+                    defaultCurrencyCode: CurrencyCode.GBP,
+                },
+            });
+
+            const { productVariants: after } = await adminClient.query(GetProductVariantListDocument, {});
+
+            expect(after.items.map(i => i.currencyCode)).toEqual(['GBP']);
+            expect(after.items[0]?.prices.sort((a, b) => a.price - b.price)).toEqual([
+                { currencyCode: 'GBP', price: 100 },
+                { currencyCode: 'AUD', price: 200 },
+                { currencyCode: 'EUR', price: 300 },
+            ]);
+        });
     });
 });

+ 125 - 8
packages/core/e2e/product-prices.e2e-spec.ts

@@ -1,5 +1,5 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
-import { createTestEnvironment } from '@vendure/testing';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
 import path from 'path';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
@@ -16,6 +16,13 @@ import {
     UpdateChannelDocument,
     UpdateProductVariantsDocument,
 } from './graphql/generated-e2e-admin-types';
+import {
+    AddItemToOrderDocument,
+    AdjustItemQuantityDocument,
+    GetActiveOrderDocument,
+    TestOrderFragmentFragment,
+    UpdatedOrderFragment,
+} from './graphql/generated-e2e-shop-types';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 describe('Product prices', () => {
@@ -26,6 +33,9 @@ describe('Product prices', () => {
         Codegen.CreateProductVariantsMutation['createProductVariants'][number]
     >;
 
+    const orderResultGuard: ErrorResultGuard<TestOrderFragmentFragment | UpdatedOrderFragment> =
+        createErrorResultGuard(input => !!input.lines);
+
     beforeAll(async () => {
         await server.init({
             initialData,
@@ -164,17 +174,124 @@ describe('Product prices', () => {
             expect(product?.variants[0]?.currencyCode).toEqual(CurrencyCode.GBP);
         });
 
-        it('uses default if unrecognised currency code passed in query string', async () => {
-            const { product } = await adminClient.query(
-                GetProductWithVariantsDocument,
+        it(
+            'throws if unrecognised currency code passed in query string',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(
+                    GetProductWithVariantsDocument,
+                    {
+                        id: multiPriceProduct.id,
+                    },
+                    { currencyCode: 'JPY' },
+                );
+            }, 'The currency "JPY" is not available in the current Channel'),
+        );
+    });
+
+    describe('changing Order currencyCode', () => {
+        beforeAll(async () => {
+            await adminClient.query(UpdateProductVariantsDocument, {
+                input: [
+                    {
+                        id: 'T_1',
+                        prices: [
+                            { currencyCode: CurrencyCode.USD, price: 1000 },
+                            { currencyCode: CurrencyCode.GBP, price: 900 },
+                            { currencyCode: CurrencyCode.EUR, price: 1100 },
+                        ],
+                    },
+                    {
+                        id: 'T_2',
+                        prices: [
+                            { currencyCode: CurrencyCode.USD, price: 2000 },
+                            { currencyCode: CurrencyCode.GBP, price: 1900 },
+                            { currencyCode: CurrencyCode.EUR, price: 2100 },
+                        ],
+                    },
+                    {
+                        id: 'T_3',
+                        prices: [
+                            { currencyCode: CurrencyCode.USD, price: 3000 },
+                            { currencyCode: CurrencyCode.GBP, price: 2900 },
+                            { currencyCode: CurrencyCode.EUR, price: 3100 },
+                        ],
+                    },
+                ],
+            });
+        });
+
+        it('create order in default currency', async () => {
+            await shopClient.query(AddItemToOrderDocument, {
+                productVariantId: 'T_1',
+                quantity: 1,
+            });
+            await shopClient.query(AddItemToOrderDocument, {
+                productVariantId: 'T_2',
+                quantity: 1,
+            });
+
+            const { activeOrder } = await shopClient.query(GetActiveOrderDocument);
+
+            expect(activeOrder?.lines[0]?.unitPrice).toBe(1000);
+            expect(activeOrder?.lines[0]?.unitPriceWithTax).toBe(1200);
+            expect(activeOrder?.lines[1]?.unitPrice).toBe(2000);
+            expect(activeOrder?.lines[1]?.unitPriceWithTax).toBe(2400);
+            expect(activeOrder?.currencyCode).toBe(CurrencyCode.USD);
+        });
+
+        it(
+            'updating an order in an unsupported currency throws',
+            assertThrowsWithMessage(async () => {
+                await shopClient.query(
+                    AddItemToOrderDocument,
+                    {
+                        productVariantId: 'T_1',
+                        quantity: 1,
+                    },
+                    { currencyCode: 'JPY' },
+                );
+            }, 'The currency "JPY" is not available in the current Channel'),
+        );
+
+        it('updating an order line with a new currency updates all lines to that currency', async () => {
+            const { activeOrder } = await shopClient.query(GetActiveOrderDocument);
+            const { adjustOrderLine } = await shopClient.query(
+                AdjustItemQuantityDocument,
                 {
-                    id: multiPriceProduct.id,
+                    orderLineId: activeOrder!.lines[0]?.id,
+                    quantity: 2,
+                },
+                { currencyCode: 'GBP' },
+            );
+
+            orderResultGuard.assertSuccess(adjustOrderLine);
+
+            expect(adjustOrderLine?.lines[0]?.unitPrice).toBe(900);
+            expect(adjustOrderLine?.lines[0]?.unitPriceWithTax).toBe(1080);
+            expect(adjustOrderLine?.lines[1]?.unitPrice).toBe(1900);
+            expect(adjustOrderLine?.lines[1]?.unitPriceWithTax).toBe(2280);
+            expect(adjustOrderLine.currencyCode).toBe('GBP');
+        });
+
+        it('adding a new order line with a new currency updates all lines to that currency', async () => {
+            const { addItemToOrder } = await shopClient.query(
+                AddItemToOrderDocument,
+                {
+                    productVariantId: 'T_3',
+                    quantity: 1,
                 },
-                { currencyCode: 'JPY' },
+                { currencyCode: 'EUR' },
             );
 
-            expect(product?.variants[0]?.price).toEqual(1200);
-            expect(product?.variants[0]?.currencyCode).toEqual(CurrencyCode.USD);
+            orderResultGuard.assertSuccess(addItemToOrder);
+
+            expect(addItemToOrder?.lines[0]?.unitPrice).toBe(1100);
+            expect(addItemToOrder?.lines[0]?.unitPriceWithTax).toBe(1320);
+            expect(addItemToOrder?.lines[1]?.unitPrice).toBe(2100);
+            expect(addItemToOrder?.lines[1]?.unitPriceWithTax).toBe(2520);
+            expect(addItemToOrder?.lines[2]?.unitPrice).toBe(3100);
+            expect(addItemToOrder?.lines[2]?.unitPriceWithTax).toBe(3720);
+            expect(addItemToOrder.currencyCode).toBe('EUR');
         });
     });
 });

+ 1 - 1
packages/core/package.json

@@ -93,7 +93,7 @@
         "@types/progress": "^2.0.3",
         "@types/prompts": "^2.0.9",
         "@types/semver": "^7.3.13",
-        "better-sqlite3": "^7.1.1",
+        "better-sqlite3": "^9.1.1",
         "gulp": "^4.0.2",
         "mysql": "^2.18.1",
         "pg": "^8.10.0",

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

@@ -77,4 +77,30 @@ describe('FSM validateTransitionDefinition()', () => {
         expect(result.valid).toBe(false);
         expect(result.error).toBe('The following states are unreachable: Unreachable');
     });
+
+    it('invalid - non-existent transition', () => {
+        const valid: Transitions<'Start' | 'End' | 'Unreachable'> = {
+            Start: { to: ['End'] },
+            End: { to: ['Bad' as any] },
+            Unreachable: { to: [] },
+        };
+
+        const result = validateTransitionDefinition(valid, 'Start');
+
+        expect(result.valid).toBe(false);
+        expect(result.error).toBe('The state "End" has a transition to an unknown state "Bad"');
+    });
+
+    it('invalid - missing initial state', () => {
+        const valid: Transitions<'Start' | 'End' | 'Unreachable'> = {
+            Start: { to: ['End'] },
+            End: { to: ['Start'] },
+            Unreachable: { to: [] },
+        };
+
+        const result = validateTransitionDefinition(valid, 'Created' as any);
+
+        expect(result.valid).toBe(false);
+        expect(result.error).toBe('The initial state "Created" is not defined');
+    });
 });

+ 18 - 2
packages/core/src/common/finite-state-machine/validate-transition-definition.ts

@@ -10,6 +10,12 @@ export function validateTransitionDefinition<T extends string>(
     transitions: Transitions<T>,
     initialState: T,
 ): { valid: boolean; error?: string } {
+    if (!transitions[initialState]) {
+        return {
+            valid: false,
+            error: `The initial state "${initialState}" is not defined`,
+        };
+    }
     const states = Object.keys(transitions) as T[];
     const result: { [State in T]: ValidationResult } = states.reduce((res, state) => {
         return {
@@ -21,7 +27,7 @@ export function validateTransitionDefinition<T extends string>(
     // walk the state graph starting with the initialState and
     // check whether all states are reachable.
     function allStatesReached(): boolean {
-        return Object.values(result).every((r) => (r as ValidationResult).reachable);
+        return Object.values(result).every(r => (r as ValidationResult).reachable);
     }
     function walkGraph(state: T) {
         const candidates = transitions[state].to;
@@ -30,12 +36,22 @@ export function validateTransitionDefinition<T extends string>(
             return true;
         }
         for (const candidate of candidates) {
+            if (result[candidate] === undefined) {
+                throw new Error(`The state "${state}" has a transition to an unknown state "${candidate}"`);
+            }
             if (!result[candidate].reachable) {
                 walkGraph(candidate);
             }
         }
     }
-    walkGraph(initialState);
+    try {
+        walkGraph(initialState);
+    } catch (e: any) {
+        return {
+            valid: false,
+            error: e.message,
+        };
+    }
 
     if (!allStatesReached()) {
         return {

+ 54 - 1
packages/core/src/common/utils.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest';
 
-import { convertRelationPaths } from './utils';
+import { convertRelationPaths, isEmailAddressLike, normalizeEmailAddress } from './utils';
 
 describe('convertRelationPaths()', () => {
     it('undefined', () => {
@@ -44,3 +44,56 @@ describe('convertRelationPaths()', () => {
         });
     });
 });
+
+describe('normalizeEmailAddress()', () => {
+    it('should trim whitespace', () => {
+        expect(normalizeEmailAddress('  test@test.com  ')).toBe('test@test.com');
+    });
+
+    it('should lowercase email addresses', async () => {
+        expect(normalizeEmailAddress('JoeSmith@test.com')).toBe('joesmith@test.com');
+        expect(normalizeEmailAddress('TEST@TEST.COM')).toBe('test@test.com');
+        expect(normalizeEmailAddress('test.person@TEST.COM')).toBe('test.person@test.com');
+        expect(normalizeEmailAddress('test.person+Extra@TEST.COM')).toBe('test.person+extra@test.com');
+        expect(normalizeEmailAddress('TEST-person+Extra@TEST.COM')).toBe('test-person+extra@test.com');
+        expect(normalizeEmailAddress('我買@屋企.香港')).toBe('我買@屋企.香港');
+    });
+
+    it('ignores surrounding whitespace', async () => {
+        expect(normalizeEmailAddress(' JoeSmith@test.com')).toBe('joesmith@test.com');
+        expect(normalizeEmailAddress('TEST@TEST.COM ')).toBe('test@test.com');
+        expect(normalizeEmailAddress('  test.person@TEST.COM ')).toBe('test.person@test.com');
+    });
+
+    it('should not lowercase non-email address identifiers', async () => {
+        expect(normalizeEmailAddress('Test')).toBe('Test');
+        expect(normalizeEmailAddress('Ucj30Da2.!3rAA')).toBe('Ucj30Da2.!3rAA');
+    });
+});
+
+describe('isEmailAddressLike()', () => {
+    it('returns true for valid email addresses', () => {
+        expect(isEmailAddressLike('simple@example.com')).toBe(true);
+        expect(isEmailAddressLike('very.common@example.com')).toBe(true);
+        expect(isEmailAddressLike('abc@example.co.uk')).toBe(true);
+        expect(isEmailAddressLike('disposable.style.email.with+symbol@example.com')).toBe(true);
+        expect(isEmailAddressLike('other.email-with-hyphen@example.com')).toBe(true);
+        expect(isEmailAddressLike('fully-qualified-domain@example.com')).toBe(true);
+        expect(isEmailAddressLike('user.name+tag+sorting@example.com')).toBe(true);
+        expect(isEmailAddressLike('example-indeed@strange-example.com')).toBe(true);
+        expect(isEmailAddressLike('example-indeed@strange-example.inininini')).toBe(true);
+    });
+
+    it('ignores surrounding whitespace', () => {
+        expect(isEmailAddressLike(' simple@example.com')).toBe(true);
+        expect(isEmailAddressLike('very.common@example.com ')).toBe(true);
+        expect(isEmailAddressLike('  abc@example.co.uk  ')).toBe(true);
+    });
+
+    it('returns false for invalid email addresses', () => {
+        expect(isEmailAddressLike('username')).toBe(false);
+        expect(isEmailAddressLike('823@ee28qje')).toBe(false);
+        expect(isEmailAddressLike('Abc.example.com')).toBe(false);
+        expect(isEmailAddressLike('A@b@')).toBe(false);
+    });
+});

+ 12 - 1
packages/core/src/common/utils.ts

@@ -65,7 +65,18 @@ export function getAssetType(mimeType: string): AssetType {
  * upper/lower case. See more discussion here: https://ux.stackexchange.com/a/16849
  */
 export function normalizeEmailAddress(input: string): string {
-    return input.trim().toLowerCase();
+    return isEmailAddressLike(input) ? input.trim().toLowerCase() : input.trim();
+}
+
+/**
+ * This is a "good enough" check for whether the input is an email address.
+ * From https://stackoverflow.com/a/32686261
+ * It is used to determine whether to apply normalization (lower-casing)
+ * when comparing identifiers in user lookups. This allows case-sensitive
+ * identifiers for other authentication methods.
+ */
+export function isEmailAddressLike(input: string): boolean {
+    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.trim());
 }
 
 /**

+ 1 - 1
packages/core/src/config/catalog/default-product-variant-price-selection-strategy.ts

@@ -18,6 +18,6 @@ export class DefaultProductVariantPriceSelectionStrategy implements ProductVaria
     selectPrice(ctx: RequestContext, prices: ProductVariantPrice[]) {
         const pricesInChannel = prices.filter(p => idsAreEqual(p.channelId, ctx.channelId));
         const priceInCurrency = pricesInChannel.find(p => p.currencyCode === ctx.currencyCode);
-        return priceInCurrency || pricesInChannel[0];
+        return priceInCurrency;
     }
 }

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

@@ -1,6 +1,7 @@
 {
   "error": {
     "active-user-does-not-have-sufficient-permissions": "Active user does not have sufficient permissions",
+    "available-currency-codes-must-include-default": "availableCurrencyCodes must include the defaultCurrencyCode ({ defaultCurrencyCode })",
     "cannot-delete-role": "The role \"{ roleCode }\" cannot be deleted",
     "cannot-delete-sole-superadmin": "The sole SuperAdmin cannot be deleted",
     "cannot-locate-customer-for-user": "Cannot locate a Customer for the user",

+ 5 - 0
packages/core/src/plugin/default-search-plugin/indexer/mutable-request-context.ts

@@ -1,3 +1,4 @@
+import { CurrencyCode } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 
 import { RequestContext, SerializedRequestContext } from '../../../api/common/request-context';
@@ -28,6 +29,10 @@ export class MutableRequestContext extends RequestContext {
         return this.mutatedChannel?.id ?? super.channelId;
     }
 
+    get currencyCode(): CurrencyCode {
+        return this.mutatedChannel?.defaultCurrencyCode ?? super.currencyCode;
+    }
+
     static deserialize(ctxObject: SerializedRequestContext): MutableRequestContext {
         return new MutableRequestContext({
             req: ctxObject._req,

+ 7 - 4
packages/core/src/service/helpers/fulfillment-state-machine/fulfillment-state-machine.ts

@@ -8,7 +8,7 @@ import { StateMachineConfig, Transitions } from '../../../common/finite-state-ma
 import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
 import { awaitPromiseOrObservable } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
-import { TransactionalConnection } from '../../../connection/index';
+import { Logger } from '../../../config/logger/vendure-logger';
 import { Fulfillment } from '../../../entity/fulfillment/fulfillment.entity';
 import { Order } from '../../../entity/order/order.entity';
 
@@ -19,7 +19,7 @@ export class FulfillmentStateMachine {
     readonly config: StateMachineConfig<FulfillmentState, FulfillmentTransitionData>;
     private readonly initialState: FulfillmentState = 'Created';
 
-    constructor(private configService: ConfigService, private connection: TransactionalConnection) {
+    constructor(private configService: ConfigService) {
         this.config = this.initConfig();
     }
 
@@ -58,8 +58,11 @@ export class FulfillmentStateMachine {
             {} as Transitions<FulfillmentState>,
         );
 
-        const validationResult = validateTransitionDefinition(allTransitions, 'Pending');
-
+        const validationResult = validateTransitionDefinition(allTransitions, this.initialState);
+        if (!validationResult.valid && validationResult.error) {
+            Logger.error(`The fulfillment process has an invalid configuration:`);
+            throw new Error(validationResult.error);
+        }
         return {
             transitions: allTransitions,
             onTransitionStart: async (fromState, toState, data) => {

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

@@ -8,8 +8,7 @@ import { StateMachineConfig, Transitions } from '../../../common/finite-state-ma
 import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
 import { awaitPromiseOrObservable } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
-import { OrderProcess } from '../../../config/order/order-process';
-import { TransactionalConnection } from '../../../connection/transactional-connection';
+import { Logger } from '../../../config/logger/vendure-logger';
 import { Order } from '../../../entity/order/order.entity';
 
 import { OrderState, OrderTransitionData } from './order-state';
@@ -19,7 +18,7 @@ export class OrderStateMachine {
     readonly config: StateMachineConfig<OrderState, OrderTransitionData>;
     private readonly initialState: OrderState = 'Created';
 
-    constructor(private configService: ConfigService, private connection: TransactionalConnection) {
+    constructor(private configService: ConfigService) {
         this.config = this.initConfig();
     }
 
@@ -46,15 +45,17 @@ export class OrderStateMachine {
     private initConfig(): StateMachineConfig<OrderState, OrderTransitionData> {
         const orderProcesses = this.configService.orderOptions.process ?? [];
 
-        const emptyProcess: OrderProcess<any> = { transitions: {} };
         const allTransitions = orderProcesses.reduce(
             (transitions, process) =>
                 mergeTransitionDefinitions(transitions, process.transitions as Transitions<any>),
             {} as Transitions<OrderState>,
         );
 
-        const validationResult = validateTransitionDefinition(allTransitions, 'AddingItems');
-
+        const validationResult = validateTransitionDefinition(allTransitions, this.initialState);
+        if (!validationResult.valid && validationResult.error) {
+            Logger.error(`The order process has an invalid configuration:`);
+            throw new Error(validationResult.error);
+        }
         return {
             transitions: allTransitions,
             onTransitionStart: async (fromState, toState, data) => {

+ 6 - 2
packages/core/src/service/helpers/payment-state-machine/payment-state-machine.ts

@@ -8,6 +8,7 @@ import { StateMachineConfig, Transitions } from '../../../common/finite-state-ma
 import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
 import { awaitPromiseOrObservable } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
+import { Logger } from '../../../config/logger/vendure-logger';
 import { Order } from '../../../entity/order/order.entity';
 import { Payment } from '../../../entity/payment/payment.entity';
 
@@ -52,8 +53,11 @@ export class PaymentStateMachine {
             {} as Transitions<PaymentState>,
         );
 
-        validateTransitionDefinition(allTransitions, this.initialState);
-
+        const validationResult = validateTransitionDefinition(allTransitions, this.initialState);
+        if (!validationResult.valid && validationResult.error) {
+            Logger.error(`The payment process has an invalid configuration:`);
+            throw new Error(validationResult.error);
+        }
         return {
             transitions: allTransitions,
             onTransitionStart: async (fromState, toState, data) => {

+ 8 - 1
packages/core/src/service/helpers/request-context/request-context.service.ts

@@ -7,6 +7,7 @@ import ms from 'ms';
 
 import { ApiType, getApiType } from '../../../api/common/get-api-type';
 import { RequestContext } from '../../../api/common/request-context';
+import { UserInputError } from '../../../common/index';
 import { idsAreEqual } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
 import { CachedSession, CachedSessionUser } from '../../../config/session-cache/session-cache-strategy';
@@ -138,7 +139,13 @@ export class RequestContextService {
     }
 
     private getCurrencyCode(req: Request, channel: Channel): CurrencyCode | undefined {
-        return (req.query && (req.query.currencyCode as CurrencyCode)) ?? channel.defaultCurrencyCode;
+        const queryCurrencyCode = req.query && (req.query.currencyCode as CurrencyCode);
+        if (queryCurrencyCode && !channel.availableCurrencyCodes.includes(queryCurrencyCode)) {
+            throw new UserInputError('error.currency-not-available-in-channel', {
+                currencyCode: queryCurrencyCode,
+            });
+        }
+        return queryCurrencyCode ?? channel.defaultCurrencyCode;
     }
 
     /**

+ 33 - 6
packages/core/src/service/services/channel.service.ts

@@ -32,6 +32,7 @@ import { VendureEntity } from '../../entity/base/base.entity';
 import { Channel } from '../../entity/channel/channel.entity';
 import { Order } from '../../entity/order/order.entity';
 import { ProductVariantPrice } from '../../entity/product-variant/product-variant-price.entity';
+import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { Seller } from '../../entity/seller/seller.entity';
 import { Session } from '../../entity/session/session.entity';
 import { Zone } from '../../entity/zone/zone.entity';
@@ -177,6 +178,7 @@ export class ChannelService {
         this.eventBus.publish(new ChangeChannelEvent(ctx, entity, channelIds, 'removed', entityType));
         return entity;
     }
+
     /**
      * @description
      * Given a channel token, returns the corresponding Channel if it exists, else will throw
@@ -322,22 +324,47 @@ export class ChannelService {
         }
         if (input.currencyCode || input.defaultCurrencyCode) {
             const newCurrencyCode = input.defaultCurrencyCode || input.currencyCode;
+            updatedChannel.availableCurrencyCodes = unique([
+                ...updatedChannel.availableCurrencyCodes,
+                updatedChannel.defaultCurrencyCode,
+            ]);
             if (originalDefaultCurrencyCode !== newCurrencyCode) {
                 // When updating the default currency code for a Channel, we also need to update
                 // and ProductVariantPrices in that channel which use the old currency code.
+                const [selectQbQuery, selectQbParams] = this.connection
+                    .getRepository(ctx, ProductVariant)
+                    .createQueryBuilder('variant')
+                    .select('variant.id')
+                    .innerJoin(ProductVariantPrice, 'pvp', 'pvp.variantId = variant.id')
+                    .andWhere('pvp.channelId = :channelId')
+                    .andWhere('pvp.currencyCode = :newCurrencyCode')
+                    .groupBy('variant.id')
+                    .getQueryAndParameters();
+
                 const qb = this.connection
                     .getRepository(ctx, ProductVariantPrice)
                     .createQueryBuilder('pvp')
                     .update()
-                    .where('channelId = :channelId', { channelId: channel.id })
-                    .andWhere('currencyCode = :currencyCode', {
-                        currencyCode: originalDefaultCurrencyCode,
-                    })
-                    .set({ currencyCode: newCurrencyCode });
-
+                    .where('channelId = :channelId')
+                    .andWhere('currencyCode = :oldCurrencyCode')
+                    .andWhere(`variantId NOT IN (${selectQbQuery})`, selectQbParams)
+                    .set({ currencyCode: newCurrencyCode })
+                    .setParameters({
+                        channelId: channel.id,
+                        oldCurrencyCode: originalDefaultCurrencyCode,
+                        newCurrencyCode,
+                    });
                 await qb.execute();
             }
         }
+        if (
+            input.availableCurrencyCodes &&
+            !updatedChannel.availableCurrencyCodes.includes(updatedChannel.defaultCurrencyCode)
+        ) {
+            throw new UserInputError(`error.available-currency-codes-must-include-default`, {
+                defaultCurrencyCode: updatedChannel.defaultCurrencyCode,
+            });
+        }
         await this.connection.getRepository(ctx, Channel).save(updatedChannel, { reload: false });
         await this.customFieldRelationService.updateRelations(ctx, Channel, input, updatedChannel);
         await this.allChannels.refresh(ctx);

+ 8 - 0
packages/core/src/service/services/order.service.ts

@@ -1668,6 +1668,14 @@ export class OrderService {
         const promotions = await this.promotionService.getActivePromotionsInChannel(ctx);
         const activePromotionsPre = await this.promotionService.getActivePromotionsOnOrder(ctx, order.id);
 
+        // When changing the Order's currencyCode (on account of passing
+        // a different currencyCode into the RequestContext), we need to make sure
+        // to update all existing OrderLines to use prices in this new currency.
+        if (ctx.currencyCode !== order.currencyCode) {
+            updatedOrderLines = order.lines;
+            order.currencyCode = ctx.currencyCode;
+        }
+
         if (updatedOrderLines?.length) {
             const { orderItemPriceCalculationStrategy, changedPriceHandlingStrategy } =
                 this.configService.orderOptions;

+ 4 - 3
packages/core/src/service/services/product-variant.service.ts

@@ -443,9 +443,9 @@ export class ProductVariantService {
             );
         }
 
-        const defaultChannelId = (await this.channelService.getDefaultChannel(ctx)).id;
+        const defaultChannel = await this.channelService.getDefaultChannel(ctx);
         await this.createOrUpdateProductVariantPrice(ctx, createdVariant.id, input.price, ctx.channelId);
-        if (!idsAreEqual(ctx.channelId, defaultChannelId)) {
+        if (!idsAreEqual(ctx.channelId, defaultChannel.id)) {
             // When creating a ProductVariant _not_ in the default Channel, we still need to
             // create a ProductVariantPrice for it in the default Channel, otherwise errors will
             // result when trying to query it there.
@@ -453,7 +453,8 @@ export class ProductVariantService {
                 ctx,
                 createdVariant.id,
                 input.price,
-                defaultChannelId,
+                defaultChannel.id,
+                defaultChannel.defaultCurrencyCode,
             );
         }
         return createdVariant.id;

+ 13 - 7
packages/core/src/service/services/user.service.ts

@@ -18,7 +18,7 @@ import {
     VerificationTokenExpiredError,
     VerificationTokenInvalidError,
 } from '../../common/error/generated-graphql-shop-errors';
-import { normalizeEmailAddress } from '../../common/index';
+import { isEmailAddressLike, normalizeEmailAddress } from '../../common/index';
 import { ConfigService } from '../../config/config.service';
 import { TransactionalConnection } from '../../connection/transactional-connection';
 import { NativeAuthenticationMethod } from '../../entity/authentication-method/native-authentication-method.entity';
@@ -68,19 +68,25 @@ export class UserService {
         const entity = userType ?? (ctx.apiType === 'admin' ? 'administrator' : 'customer');
         const table = `${this.configService.dbConnectionOptions.entityPrefix ?? ''}${entity}`;
 
-        return this.connection
+        const qb = this.connection
             .getRepository(ctx, User)
             .createQueryBuilder('user')
             .innerJoin(table, table, `${table}.userId = user.id`)
             .leftJoinAndSelect('user.roles', 'roles')
             .leftJoinAndSelect('roles.channels', 'channels')
             .leftJoinAndSelect('user.authenticationMethods', 'authenticationMethods')
-            .where('LOWER(user.identifier) = :identifier', {
+            .where('user.deletedAt IS NULL');
+
+        if (isEmailAddressLike(emailAddress)) {
+            qb.andWhere('LOWER(user.identifier) = :identifier', {
                 identifier: normalizeEmailAddress(emailAddress),
-            })
-            .andWhere('user.deletedAt IS NULL')
-            .getOne()
-            .then(result => result ?? undefined);
+            });
+        } else {
+            qb.andWhere('user.identifier = :identifier', {
+                identifier: emailAddress,
+            });
+        }
+        return qb.getOne().then(result => result ?? undefined);
     }
 
     /**

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

@@ -3823,6 +3823,7 @@ export type OrderLine = Node & {
   proratedUnitPrice: Scalars['Money']['output'];
   /** The proratedUnitPrice including tax */
   proratedUnitPriceWithTax: Scalars['Money']['output'];
+  /** The quantity of items purchased */
   quantity: Scalars['Int']['output'];
   taxLines: Array<TaxLine>;
   taxRate: Scalars['Float']['output'];
@@ -4536,7 +4537,7 @@ export type ProductVariantListOptions = {
 
 export type ProductVariantPrice = {
   currencyCode: CurrencyCode;
-  price: Scalars['Int']['output'];
+  price: Scalars['Money']['output'];
 };
 
 /**

+ 2 - 1
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -3823,6 +3823,7 @@ export type OrderLine = Node & {
   proratedUnitPrice: Scalars['Money']['output'];
   /** The proratedUnitPrice including tax */
   proratedUnitPriceWithTax: Scalars['Money']['output'];
+  /** The quantity of items purchased */
   quantity: Scalars['Int']['output'];
   taxLines: Array<TaxLine>;
   taxRate: Scalars['Float']['output'];
@@ -4536,7 +4537,7 @@ export type ProductVariantListOptions = {
 
 export type ProductVariantPrice = {
   currencyCode: CurrencyCode;
-  price: Scalars['Int']['output'];
+  price: Scalars['Money']['output'];
 };
 
 /**

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

@@ -2022,6 +2022,7 @@ export type OrderLine = Node & {
   proratedUnitPrice: Scalars['Money']['output'];
   /** The proratedUnitPrice including tax */
   proratedUnitPriceWithTax: Scalars['Money']['output'];
+  /** The quantity of items purchased */
   quantity: Scalars['Int']['output'];
   taxLines: Array<TaxLine>;
   taxRate: Scalars['Float']['output'];

+ 1 - 0
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts

@@ -2143,6 +2143,7 @@ export type OrderLine = Node & {
   proratedUnitPrice: Scalars['Money']['output'];
   /** The proratedUnitPrice including tax */
   proratedUnitPriceWithTax: Scalars['Money']['output'];
+  /** The quantity of items purchased */
   quantity: Scalars['Int']['output'];
   taxLines: Array<TaxLine>;
   taxRate: Scalars['Float']['output'];

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


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


+ 6 - 6
yarn.lock

@@ -6751,13 +6751,13 @@ before-after-hook@^2.2.0:
   resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c"
   integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==
 
-better-sqlite3@^7.1.1:
-  version "7.6.2"
-  resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-7.6.2.tgz#47cd8cad5b9573cace535f950ac321166bc31384"
-  integrity sha512-S5zIU1Hink2AH4xPsN0W43T1/AJ5jrPh7Oy07ocuW/AKYYY02GWzz9NH0nbSMn/gw6fDZ5jZ1QsHt1BXAwJ6Lg==
+better-sqlite3@^9.1.1:
+  version "9.1.1"
+  resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-9.1.1.tgz#f139b180a08ed396e660a0601a46ceefd78b832c"
+  integrity sha512-FhW7bS7cXwkB2SFnPJrSGPmQerVSCzwBgmQ1cIRcYKxLsyiKjljzCbyEqqhYXo5TTBqt5BISiBj2YE2Sy2ynaA==
   dependencies:
     bindings "^1.5.0"
-    prebuild-install "^7.1.0"
+    prebuild-install "^7.1.1"
 
 big.js@^5.2.2:
   version "5.2.2"
@@ -15391,7 +15391,7 @@ postgres-interval@^1.1.0:
   dependencies:
     xtend "^4.0.0"
 
-prebuild-install@^7.1.0, prebuild-install@^7.1.1:
+prebuild-install@^7.1.1:
   version "7.1.1"
   resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.1.tgz#de97d5b34a70a0c81334fd24641f2a1702352e45"
   integrity sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==

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