Browse Source

feat(core): Introduce new `ProductVariantPriceUpdateStrategy`

Closes #2651
Michael Bromley 1 year ago
parent
commit
9099f35e15

+ 305 - 3
packages/core/e2e/product-prices.e2e-spec.ts

@@ -1,13 +1,23 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
-import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import { pick } from '@vendure/common/lib/pick';
+import { mergeConfig } from '@vendure/core';
+import {
+    createErrorResultGuard,
+    createTestEnvironment,
+    E2E_DEFAULT_CHANNEL_TOKEN,
+    ErrorResultGuard,
+} from '@vendure/testing';
 import path from 'path';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { ProductVariantPrice, ProductVariantPriceUpdateStrategy, RequestContext } from '../src/index';
 
 import * as Codegen from './graphql/generated-e2e-admin-types';
 import {
+    AssignProductsToChannelDocument,
+    CreateChannelDocument,
     CreateProductDocument,
     CreateProductVariantsDocument,
     CurrencyCode,
@@ -25,8 +35,48 @@ import {
 } from './graphql/generated-e2e-shop-types';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
+class TestProductVariantPriceUpdateStrategy implements ProductVariantPriceUpdateStrategy {
+    static syncAcrossChannels = false;
+    static onCreatedSpy = vi.fn();
+    static onUpdatedSpy = vi.fn();
+    static onDeletedSpy = vi.fn();
+
+    onPriceCreated(ctx: RequestContext, price: ProductVariantPrice, prices: ProductVariantPrice[]) {
+        TestProductVariantPriceUpdateStrategy.onCreatedSpy(price, prices);
+        return [];
+    }
+
+    onPriceUpdated(ctx: RequestContext, updatedPrice: ProductVariantPrice, prices: ProductVariantPrice[]) {
+        TestProductVariantPriceUpdateStrategy.onUpdatedSpy(updatedPrice, prices);
+        if (TestProductVariantPriceUpdateStrategy.syncAcrossChannels) {
+            return prices
+                .filter(p => p.currencyCode === updatedPrice.currencyCode)
+                .map(p => ({
+                    id: p.id,
+                    price: updatedPrice.price,
+                }));
+        } else {
+            return [];
+        }
+    }
+
+    onPriceDeleted(ctx: RequestContext, deletedPrice: ProductVariantPrice, prices: ProductVariantPrice[]) {
+        TestProductVariantPriceUpdateStrategy.onDeletedSpy(deletedPrice, prices);
+        return [];
+    }
+}
+
 describe('Product prices', () => {
-    const { server, adminClient, shopClient } = createTestEnvironment({ ...testConfig() });
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(
+            { ...testConfig() },
+            {
+                catalogOptions: {
+                    productVariantPriceUpdateStrategy: new TestProductVariantPriceUpdateStrategy(),
+                },
+            },
+        ),
+    );
 
     let multiPriceProduct: Codegen.CreateProductMutation['createProduct'];
     let multiPriceVariant: NonNullable<
@@ -36,6 +86,10 @@ describe('Product prices', () => {
     const orderResultGuard: ErrorResultGuard<TestOrderFragmentFragment | UpdatedOrderFragment> =
         createErrorResultGuard(input => !!input.lines);
 
+    const createChannelResultGuard: ErrorResultGuard<{ id: string }> = createErrorResultGuard(
+        input => !!input.id,
+    );
+
     beforeAll(async () => {
         await server.init({
             initialData,
@@ -294,4 +348,252 @@ describe('Product prices', () => {
             expect(addItemToOrder.currencyCode).toBe('EUR');
         });
     });
+
+    describe('ProductVariantPriceUpdateStrategy', () => {
+        const SECOND_CHANNEL_TOKEN = 'second_channel_token';
+        const THIRD_CHANNEL_TOKEN = 'third_channel_token';
+        beforeAll(async () => {
+            const { createChannel: channel2Result } = await adminClient.query(CreateChannelDocument, {
+                input: {
+                    code: 'second-channel',
+                    token: SECOND_CHANNEL_TOKEN,
+                    defaultLanguageCode: LanguageCode.en,
+                    currencyCode: CurrencyCode.GBP,
+                    pricesIncludeTax: true,
+                    defaultShippingZoneId: 'T_1',
+                    defaultTaxZoneId: 'T_1',
+                },
+            });
+            createChannelResultGuard.assertSuccess(channel2Result);
+
+            const { createChannel: channel3Result } = await adminClient.query(CreateChannelDocument, {
+                input: {
+                    code: 'third-channel',
+                    token: THIRD_CHANNEL_TOKEN,
+                    defaultLanguageCode: LanguageCode.en,
+                    currencyCode: CurrencyCode.GBP,
+                    pricesIncludeTax: true,
+                    defaultShippingZoneId: 'T_1',
+                    defaultTaxZoneId: 'T_1',
+                },
+            });
+            createChannelResultGuard.assertSuccess(channel3Result);
+
+            await adminClient.query(AssignProductsToChannelDocument, {
+                input: {
+                    channelId: channel2Result.id,
+                    productIds: [multiPriceProduct.id],
+                },
+            });
+
+            await adminClient.query(AssignProductsToChannelDocument, {
+                input: {
+                    channelId: channel3Result.id,
+                    productIds: [multiPriceProduct.id],
+                },
+            });
+        });
+
+        it('onPriceCreated() is called when a new price is created', async () => {
+            await adminClient.asSuperAdmin();
+            const onCreatedSpy = TestProductVariantPriceUpdateStrategy.onCreatedSpy;
+            onCreatedSpy.mockClear();
+            await adminClient.query(UpdateChannelDocument, {
+                input: {
+                    id: 'T_1',
+                    availableCurrencyCodes: [
+                        CurrencyCode.USD,
+                        CurrencyCode.GBP,
+                        CurrencyCode.EUR,
+                        CurrencyCode.MYR,
+                    ],
+                },
+            });
+            await adminClient.query(UpdateProductVariantsDocument, {
+                input: {
+                    id: multiPriceVariant.id,
+                    prices: [{ currencyCode: CurrencyCode.MYR, price: 5500 }],
+                },
+            });
+
+            expect(onCreatedSpy).toHaveBeenCalledTimes(1);
+            expect(onCreatedSpy.mock.calls[0][0].currencyCode).toBe(CurrencyCode.MYR);
+            expect(onCreatedSpy.mock.calls[0][0].price).toBe(5500);
+            expect(onCreatedSpy.mock.calls[0][1].length).toBe(4);
+            expect(getOrderedPricesArray(onCreatedSpy.mock.calls[0][1])).toEqual([
+                {
+                    channelId: 1,
+                    currencyCode: 'USD',
+                    id: 35,
+                    price: 1200,
+                },
+                {
+                    channelId: 1,
+                    currencyCode: 'GBP',
+                    id: 36,
+                    price: 900,
+                },
+                {
+                    channelId: 2,
+                    currencyCode: 'GBP',
+                    id: 44,
+                    price: 1440,
+                },
+                {
+                    channelId: 3,
+                    currencyCode: 'GBP',
+                    id: 45,
+                    price: 1440,
+                },
+            ]);
+        });
+
+        it('onPriceUpdated() is called when a new price is created', async () => {
+            adminClient.setChannelToken(THIRD_CHANNEL_TOKEN);
+
+            TestProductVariantPriceUpdateStrategy.syncAcrossChannels = true;
+            const onUpdatedSpy = TestProductVariantPriceUpdateStrategy.onUpdatedSpy;
+            onUpdatedSpy.mockClear();
+
+            await adminClient.query(UpdateProductVariantsDocument, {
+                input: {
+                    id: multiPriceVariant.id,
+                    prices: [
+                        {
+                            currencyCode: CurrencyCode.GBP,
+                            price: 4242,
+                        },
+                    ],
+                },
+            });
+
+            expect(onUpdatedSpy).toHaveBeenCalledTimes(1);
+            expect(onUpdatedSpy.mock.calls[0][0].currencyCode).toBe(CurrencyCode.GBP);
+            expect(onUpdatedSpy.mock.calls[0][0].price).toBe(4242);
+            expect(onUpdatedSpy.mock.calls[0][1].length).toBe(5);
+            expect(getOrderedPricesArray(onUpdatedSpy.mock.calls[0][1])).toEqual([
+                {
+                    channelId: 1,
+                    currencyCode: 'USD',
+                    id: 35,
+                    price: 1200,
+                },
+                {
+                    channelId: 1,
+                    currencyCode: 'GBP',
+                    id: 36,
+                    price: 900,
+                },
+                {
+                    channelId: 2,
+                    currencyCode: 'GBP',
+                    id: 44,
+                    price: 1440,
+                },
+                {
+                    channelId: 3,
+                    currencyCode: 'GBP',
+                    id: 45,
+                    price: 4242,
+                },
+                {
+                    channelId: 1,
+                    currencyCode: 'MYR',
+                    id: 46,
+                    price: 5500,
+                },
+            ]);
+        });
+
+        it('syncing prices in other channels', async () => {
+            const { product: productChannel3 } = await adminClient.query(GetProductWithVariantsDocument, {
+                id: multiPriceProduct.id,
+            });
+            expect(productChannel3?.variants[0].prices).toEqual([
+                { currencyCode: CurrencyCode.GBP, price: 4242 },
+            ]);
+
+            adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
+            const { product: productChannel2 } = await adminClient.query(GetProductWithVariantsDocument, {
+                id: multiPriceProduct.id,
+            });
+            expect(productChannel2?.variants[0].prices).toEqual([
+                { currencyCode: CurrencyCode.GBP, price: 4242 },
+            ]);
+
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const { product: productDefaultChannel } = await adminClient.query(
+                GetProductWithVariantsDocument,
+                {
+                    id: multiPriceProduct.id,
+                },
+            );
+            expect(productDefaultChannel?.variants[0].prices).toEqual([
+                { currencyCode: CurrencyCode.USD, price: 1200 },
+                { currencyCode: CurrencyCode.GBP, price: 4242 },
+                { currencyCode: CurrencyCode.MYR, price: 5500 },
+            ]);
+        });
+
+        it('onPriceDeleted() is called when a price is deleted', async () => {
+            adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
+            const onDeletedSpy = TestProductVariantPriceUpdateStrategy.onDeletedSpy;
+            onDeletedSpy.mockClear();
+
+            const result = await adminClient.query(UpdateProductVariantsDocument, {
+                input: {
+                    id: multiPriceVariant.id,
+                    prices: [
+                        {
+                            currencyCode: CurrencyCode.MYR,
+                            price: 4242,
+                            delete: true,
+                        },
+                    ],
+                },
+            });
+
+            expect(result.updateProductVariants[0]?.prices).toEqual([
+                { currencyCode: CurrencyCode.USD, price: 1200 },
+                { currencyCode: CurrencyCode.GBP, price: 4242 },
+            ]);
+
+            expect(onDeletedSpy).toHaveBeenCalledTimes(1);
+            expect(onDeletedSpy.mock.calls[0][0].currencyCode).toBe(CurrencyCode.MYR);
+            expect(onDeletedSpy.mock.calls[0][0].price).toBe(5500);
+            expect(onDeletedSpy.mock.calls[0][1].length).toBe(4);
+            expect(getOrderedPricesArray(onDeletedSpy.mock.calls[0][1])).toEqual([
+                {
+                    channelId: 1,
+                    currencyCode: 'USD',
+                    id: 35,
+                    price: 1200,
+                },
+                {
+                    channelId: 1,
+                    currencyCode: 'GBP',
+                    id: 36,
+                    price: 4242,
+                },
+                {
+                    channelId: 2,
+                    currencyCode: 'GBP',
+                    id: 44,
+                    price: 4242,
+                },
+                {
+                    channelId: 3,
+                    currencyCode: 'GBP',
+                    id: 45,
+                    price: 4242,
+                },
+            ]);
+        });
+    });
 });
+
+function getOrderedPricesArray(input: ProductVariantPrice[]) {
+    return input
+        .map(p => pick(p, ['channelId', 'currencyCode', 'price', 'id']))
+        .sort((a, b) => (a.id < b.id ? -1 : 1));
+}

+ 77 - 0
packages/core/src/config/catalog/default-product-variant-price-update-strategy.ts

@@ -0,0 +1,77 @@
+import { RequestContext } from '../../api/common/request-context';
+import { ProductVariantPrice } from '../../entity/product-variant/product-variant-price.entity';
+
+import { ProductVariantPriceUpdateStrategy } from './product-variant-price-update-strategy';
+
+/**
+ * @description
+ * The options available to the {@link DefaultProductVariantPriceUpdateStrategy}.
+ *
+ * @docsCategory configuration
+ * @docsPage ProductVariantPriceUpdateStrategy
+ * @since 2.2.0
+ */
+export interface DefaultProductVariantPriceUpdateStrategyOptions {
+    /**
+     * @description
+     * When `true`, any price changes to a ProductVariant in one Channel will update any other
+     * prices of the same currencyCode in other Channels. Note that if there are different
+     * tax settings across the channels, these will not be taken into account. To handle this
+     * case, a custom strategy should be implemented.
+     */
+    syncPricesAcrossChannels: boolean;
+}
+
+/**
+ * @description
+ * The default {@link ProductVariantPriceUpdateStrategy} which by default will not update any other
+ * prices when a price is created, updated or deleted.
+ *
+ * If the `syncPricesAcrossChannels` option is set to `true`, then when a price is updated in one Channel,
+ * the price of the same currencyCode in other Channels will be updated to match.  Note that if there are different
+ * tax settings across the channels, these will not be taken into account. To handle this
+ * case, a custom strategy should be implemented.
+ *
+ * @example
+ * ```TypeScript
+ * import { DefaultProductVariantPriceUpdateStrategy, VendureConfig } from '\@vendure/core';
+ *
+ * export const config: VendureConfig = {
+ *   // ...
+ *   catalogOptions: {
+ *     productVariantPriceUpdateStrategy: new DefaultProductVariantPriceUpdateStrategy({
+ *       syncPricesAcrossChannels: true,
+ *     }),
+ *   },
+ *   // ...
+ * };
+ * ```
+ *
+ * @docsCategory configuration
+ * @docsPage ProductVariantPriceUpdateStrategy
+ * @since 2.2.0
+ */
+export class DefaultProductVariantPriceUpdateStrategy implements ProductVariantPriceUpdateStrategy {
+    constructor(private options: DefaultProductVariantPriceUpdateStrategyOptions) {}
+
+    onPriceCreated(ctx: RequestContext, price: ProductVariantPrice) {
+        return [];
+    }
+
+    onPriceUpdated(ctx: RequestContext, updatedPrice: ProductVariantPrice, prices: ProductVariantPrice[]) {
+        if (this.options.syncPricesAcrossChannels) {
+            return prices
+                .filter(p => p.currencyCode === updatedPrice.currencyCode)
+                .map(p => ({
+                    id: p.id,
+                    price: updatedPrice.price,
+                }));
+        } else {
+            return [];
+        }
+    }
+
+    onPriceDeleted(ctx: RequestContext, deletedPrice: ProductVariantPrice, prices: ProductVariantPrice[]) {
+        return [];
+    }
+}

+ 90 - 0
packages/core/src/config/catalog/product-variant-price-update-strategy.ts

@@ -0,0 +1,90 @@
+import { ID } from '@vendure/common/lib/shared-types';
+
+import { RequestContext } from '../../api/common/request-context';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import { ProductVariantPrice } from '../../entity/product-variant/product-variant-price.entity';
+
+/**
+ * @description
+ * The return value of the `onPriceCreated`, `onPriceUpdated` and `onPriceDeleted` methods
+ * of the {@link ProductVariantPriceUpdateStrategy}.
+ *
+ * @docsPage ProductVariantPriceUpdateStrategy
+ * @since 2.2.0
+ */
+export interface UpdatedProductVariantPrice {
+    /**
+     * @description
+     * The ID of the ProductVariantPrice to update.
+     */
+    id: ID;
+    /**
+     * @description
+     * The new price to set.
+     */
+    price: number;
+}
+
+/**
+ * @description
+ * This strategy determines how updates to a ProductVariantPrice is handled in regard to
+ * any other prices which may be associated with the same ProductVariant.
+ *
+ * For instance, in a multichannel setup, if a price is updated for a ProductVariant in one
+ * Channel, this strategy can be used to update the prices in other Channels.
+ *
+ * :::info
+ *
+ * This is configured via the `catalogOptions.productVariantPriceUpdateStrategy` property of
+ * your VendureConfig.
+ *
+ * :::
+ *
+ * @docsCategory configuration
+ * @docsPage ProductVariantPriceUpdateStrategy
+ * @docsWeight 0
+ * @since 2.2.0
+ */
+export interface ProductVariantPriceUpdateStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * This method is called when a ProductVariantPrice is created. It receives the created
+     * ProductVariantPrice and the array of all prices associated with the ProductVariant.
+     *
+     * It should return an array of UpdatedProductVariantPrice objects which will be used to update
+     * the prices of the specific ProductVariantPrices.
+     */
+    onPriceCreated(
+        ctx: RequestContext,
+        createdPrice: ProductVariantPrice,
+        prices: ProductVariantPrice[],
+    ): UpdatedProductVariantPrice[] | Promise<UpdatedProductVariantPrice[]>;
+
+    /**
+     * @description
+     * This method is called when a ProductVariantPrice is updated. It receives the updated
+     * ProductVariantPrice and the array of all prices associated with the ProductVariant.
+     *
+     * It should return an array of UpdatedProductVariantPrice objects which will be used to update
+     * the prices of the specific ProductVariantPrices.
+     */
+    onPriceUpdated(
+        ctx: RequestContext,
+        updatedPrice: ProductVariantPrice,
+        prices: ProductVariantPrice[],
+    ): UpdatedProductVariantPrice[] | Promise<UpdatedProductVariantPrice[]>;
+
+    /**
+     * @description
+     * This method is called when a ProductVariantPrice is deleted. It receives the deleted
+     * ProductVariantPrice and the array of all prices associated with the ProductVariant.
+     *
+     * It should return an array of UpdatedProductVariantPrice objects which will be used to update
+     * the prices of the specific ProductVariantPrices.
+     */
+    onPriceDeleted(
+        ctx: RequestContext,
+        deletedPrice: ProductVariantPrice,
+        prices: ProductVariantPrice[],
+    ): UpdatedProductVariantPrice[] | Promise<UpdatedProductVariantPrice[]>;
+}

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

@@ -70,6 +70,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
         const {
             productVariantPriceCalculationStrategy,
             productVariantPriceSelectionStrategy,
+            productVariantPriceUpdateStrategy,
             stockDisplayStrategy,
             stockLocationStrategy,
         } = this.configService.catalogOptions;
@@ -125,6 +126,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             entityIdStrategyDeprecated,
             ...[entityIdStrategy].filter(notNullOrUndefined),
             productVariantPriceCalculationStrategy,
+            productVariantPriceUpdateStrategy,
             orderItemPriceCalculationStrategy,
             ...orderProcess,
             ...customFulfillmentProcess,

+ 4 - 0
packages/core/src/config/default-config.ts

@@ -20,6 +20,7 @@ import { NativeAuthenticationStrategy } from './auth/native-authentication-strat
 import { defaultCollectionFilters } from './catalog/default-collection-filters';
 import { DefaultProductVariantPriceCalculationStrategy } from './catalog/default-product-variant-price-calculation-strategy';
 import { DefaultProductVariantPriceSelectionStrategy } from './catalog/default-product-variant-price-selection-strategy';
+import { DefaultProductVariantPriceUpdateStrategy } from './catalog/default-product-variant-price-update-strategy';
 import { DefaultStockDisplayStrategy } from './catalog/default-stock-display-strategy';
 import { DefaultStockLocationStrategy } from './catalog/default-stock-location-strategy';
 import { AutoIncrementIdStrategy } from './entity/auto-increment-id-strategy';
@@ -109,6 +110,9 @@ export const defaultConfig: RuntimeVendureConfig = {
         collectionFilters: defaultCollectionFilters,
         productVariantPriceSelectionStrategy: new DefaultProductVariantPriceSelectionStrategy(),
         productVariantPriceCalculationStrategy: new DefaultProductVariantPriceCalculationStrategy(),
+        productVariantPriceUpdateStrategy: new DefaultProductVariantPriceUpdateStrategy({
+            syncPricesAcrossChannels: false,
+        }),
         stockDisplayStrategy: new DefaultStockDisplayStrategy(),
         stockLocationStrategy: new DefaultStockLocationStrategy(),
     },

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

@@ -12,10 +12,12 @@ export * from './auth/password-validation-strategy';
 export * from './catalog/collection-filter';
 export * from './catalog/default-collection-filters';
 export * from './catalog/default-product-variant-price-selection-strategy';
+export * from './catalog/default-product-variant-price-update-strategy';
 export * from './catalog/default-stock-display-strategy';
 export * from './catalog/default-stock-location-strategy';
 export * from './catalog/product-variant-price-calculation-strategy';
 export * from './catalog/product-variant-price-selection-strategy';
+export * from './catalog/product-variant-price-update-strategy';
 export * from './catalog/stock-display-strategy';
 export * from './catalog/stock-location-strategy';
 export * from './config.module';

+ 11 - 0
packages/core/src/config/vendure-config.ts

@@ -20,6 +20,7 @@ import { PasswordValidationStrategy } from './auth/password-validation-strategy'
 import { CollectionFilter } from './catalog/collection-filter';
 import { ProductVariantPriceCalculationStrategy } from './catalog/product-variant-price-calculation-strategy';
 import { ProductVariantPriceSelectionStrategy } from './catalog/product-variant-price-selection-strategy';
+import { ProductVariantPriceUpdateStrategy } from './catalog/product-variant-price-update-strategy';
 import { StockDisplayStrategy } from './catalog/stock-display-strategy';
 import { StockLocationStrategy } from './catalog/stock-location-strategy';
 import { CustomFields } from './custom-field/custom-field-types';
@@ -694,6 +695,16 @@ export interface CatalogOptions {
      * @default DefaultTaxCalculationStrategy
      */
     productVariantPriceCalculationStrategy?: ProductVariantPriceCalculationStrategy;
+    /**
+     * @description
+     * Defines the strategy which determines what happens to a ProductVariant's prices
+     * when one of the prices gets updated. For instance, this can be used to synchronize
+     * prices across multiple Channels.
+     *
+     * @default DefaultProductVariantPriceUpdateStrategy
+     * @since 2.2.0
+     */
+    productVariantPriceUpdateStrategy?: ProductVariantPriceUpdateStrategy;
     /**
      * @description
      * Defines how the `ProductVariant.stockLevel` value is obtained. It is usually not desirable

+ 91 - 23
packages/core/src/service/services/product-variant.service.ts

@@ -7,9 +7,7 @@ import {
     DeletionResult,
     GlobalFlag,
     Permission,
-    ProductListOptions,
     ProductVariantFilterParameter,
-    ProductVariantListOptions,
     RemoveProductVariantsFromChannelInput,
     UpdateProductVariantInput,
 } from '@vendure/common/lib/generated-types';
@@ -26,6 +24,7 @@ import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
+import { UpdatedProductVariantPrice } from '../../config/index';
 import { TransactionalConnection } from '../../connection/transactional-connection';
 import {
     Channel,
@@ -43,6 +42,7 @@ import { ProductVariant } from '../../entity/product-variant/product-variant.ent
 import { EventBus } from '../../event-bus/event-bus';
 import { ProductVariantChannelEvent } from '../../event-bus/events/product-variant-channel-event';
 import { ProductVariantEvent } from '../../event-bus/events/product-variant-event';
+import { ProductVariantPriceEvent } from '../../event-bus/events/product-variant-price-event';
 import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { ProductPriceApplicator } from '../helpers/product-price-applicator/product-price-applicator';
@@ -539,18 +539,12 @@ export class ProductVariantService {
         if (input.prices) {
             for (const priceInput of input.prices) {
                 if (priceInput.delete === true) {
-                    const variantPrice = await this.connection
-                        .getRepository(ctx, ProductVariantPrice)
-                        .findOne({
-                            where: {
-                                variant: { id: input.id },
-                                channelId: ctx.channelId,
-                                currencyCode: priceInput.currencyCode,
-                            },
-                        });
-                    if (variantPrice) {
-                        await this.connection.getRepository(ctx, ProductVariantPrice).remove(variantPrice);
-                    }
+                    await this.deleteProductVariantPrice(
+                        ctx,
+                        input.id,
+                        ctx.channelId,
+                        priceInput.currencyCode,
+                    );
                 } else {
                     await this.createOrUpdateProductVariantPrice(
                         ctx,
@@ -577,13 +571,17 @@ export class ProductVariantService {
         channelId: ID,
         currencyCode?: CurrencyCode,
     ): Promise<ProductVariantPrice> {
-        let variantPrice = await this.connection.getRepository(ctx, ProductVariantPrice).findOne({
+        const { productVariantPriceUpdateStrategy } = this.configService.catalogOptions;
+        const allPrices = await this.connection.getRepository(ctx, ProductVariantPrice).find({
             where: {
                 variant: { id: productVariantId },
-                channelId,
-                currencyCode: currencyCode ?? ctx.channel.defaultCurrencyCode,
             },
         });
+        let targetPrice = allPrices.find(
+            p =>
+                idsAreEqual(p.channelId, channelId) &&
+                p.currencyCode === (currencyCode ?? ctx.channel.defaultCurrencyCode),
+        );
         if (currencyCode) {
             const channel = await this.channelService.findOne(ctx, channelId);
             if (!channel?.availableCurrencyCodes.includes(currencyCode)) {
@@ -592,15 +590,85 @@ export class ProductVariantService {
                 });
             }
         }
-        if (!variantPrice) {
-            variantPrice = new ProductVariantPrice({
+        let additionalPricesToUpdate: UpdatedProductVariantPrice[] = [];
+        if (!targetPrice) {
+            const createdPrice = await this.connection.getRepository(ctx, ProductVariantPrice).save(
+                new ProductVariantPrice({
+                    channelId,
+                    price,
+                    variant: new ProductVariant({ id: productVariantId }),
+                    currencyCode: currencyCode ?? ctx.channel.defaultCurrencyCode,
+                }),
+            );
+            this.eventBus.publish(new ProductVariantPriceEvent(ctx, [createdPrice], 'created'));
+            additionalPricesToUpdate = await productVariantPriceUpdateStrategy.onPriceCreated(
+                ctx,
+                createdPrice,
+                allPrices,
+            );
+            targetPrice = createdPrice;
+        } else {
+            targetPrice.price = price;
+            const updatedPrice = await this.connection
+                .getRepository(ctx, ProductVariantPrice)
+                .save(targetPrice);
+            this.eventBus.publish(new ProductVariantPriceEvent(ctx, [updatedPrice], 'updated'));
+            additionalPricesToUpdate = await productVariantPriceUpdateStrategy.onPriceUpdated(
+                ctx,
+                updatedPrice,
+                allPrices,
+            );
+        }
+        const uniqueAdditionalPricesToUpdate = unique(additionalPricesToUpdate, 'id').filter(
+            p =>
+                // We don't save the targetPrice again unless it has been assigned
+                // a different price by the ProductVariantPriceUpdateStrategy.
+                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                !(idsAreEqual(p.id, targetPrice!.id) && p.price === targetPrice!.price),
+        );
+        if (uniqueAdditionalPricesToUpdate.length) {
+            const updatedAdditionalPrices = await this.connection
+                .getRepository(ctx, ProductVariantPrice)
+                .save(uniqueAdditionalPricesToUpdate);
+            this.eventBus.publish(new ProductVariantPriceEvent(ctx, updatedAdditionalPrices, 'updated'));
+        }
+        return targetPrice;
+    }
+
+    async deleteProductVariantPrice(
+        ctx: RequestContext,
+        variantId: ID,
+        channelId: ID,
+        currencyCode: CurrencyCode,
+    ) {
+        const variantPrice = await this.connection.getRepository(ctx, ProductVariantPrice).findOne({
+            where: {
+                variant: { id: variantId },
                 channelId,
-                variant: new ProductVariant({ id: productVariantId }),
-                currencyCode: currencyCode ?? ctx.channel.defaultCurrencyCode,
+                currencyCode,
+            },
+        });
+        if (variantPrice) {
+            await this.connection.getRepository(ctx, ProductVariantPrice).remove(variantPrice);
+            this.eventBus.publish(new ProductVariantPriceEvent(ctx, [variantPrice], 'deleted'));
+            const { productVariantPriceUpdateStrategy } = this.configService.catalogOptions;
+            const allPrices = await this.connection.getRepository(ctx, ProductVariantPrice).find({
+                where: {
+                    variant: { id: variantId },
+                },
             });
+            const additionalPricesToUpdate = await productVariantPriceUpdateStrategy.onPriceDeleted(
+                ctx,
+                variantPrice,
+                allPrices,
+            );
+            if (additionalPricesToUpdate.length) {
+                const updatedAdditionalPrices = await this.connection
+                    .getRepository(ctx, ProductVariantPrice)
+                    .save(additionalPricesToUpdate);
+                this.eventBus.publish(new ProductVariantPriceEvent(ctx, updatedAdditionalPrices, 'updated'));
+            }
         }
-        variantPrice.price = price;
-        return this.connection.getRepository(ctx, ProductVariantPrice).save(variantPrice);
     }
 
     async softDelete(ctx: RequestContext, id: ID | ID[]): Promise<DeletionResponse> {