From 9099f35e15c674a0fd37f99032d7d6e2fac42c75 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Fri, 9 Feb 2024 14:58:47 +0100 Subject: [PATCH] feat(core): Introduce new `ProductVariantPriceUpdateStrategy` Closes #2651 --- packages/core/e2e/product-prices.e2e-spec.ts | 308 +++++++++++++++++- ...t-product-variant-price-update-strategy.ts | 77 +++++ .../product-variant-price-update-strategy.ts | 90 +++++ packages/core/src/config/config.module.ts | 2 + packages/core/src/config/default-config.ts | 4 + packages/core/src/config/index.ts | 2 + packages/core/src/config/vendure-config.ts | 11 + .../services/product-variant.service.ts | 114 +++++-- 8 files changed, 582 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/config/catalog/default-product-variant-price-update-strategy.ts create mode 100644 packages/core/src/config/catalog/product-variant-price-update-strategy.ts diff --git a/packages/core/e2e/product-prices.e2e-spec.ts b/packages/core/e2e/product-prices.e2e-spec.ts index 1343b46cf7..c8680af136 100644 --- a/packages/core/e2e/product-prices.e2e-spec.ts +++ b/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 = 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)); +} diff --git a/packages/core/src/config/catalog/default-product-variant-price-update-strategy.ts b/packages/core/src/config/catalog/default-product-variant-price-update-strategy.ts new file mode 100644 index 0000000000..fc33598d63 --- /dev/null +++ b/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 []; + } +} diff --git a/packages/core/src/config/catalog/product-variant-price-update-strategy.ts b/packages/core/src/config/catalog/product-variant-price-update-strategy.ts new file mode 100644 index 0000000000..efe2745cfd --- /dev/null +++ b/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; + + /** + * @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; + + /** + * @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; +} diff --git a/packages/core/src/config/config.module.ts b/packages/core/src/config/config.module.ts index 57fbe4f698..804b2dac6e 100644 --- a/packages/core/src/config/config.module.ts +++ b/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, diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index 49a7b08225..0ae4cba14a 100644 --- a/packages/core/src/config/default-config.ts +++ b/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(), }, diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 5b5c84289b..815ae28279 100644 --- a/packages/core/src/config/index.ts +++ b/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'; diff --git a/packages/core/src/config/vendure-config.ts b/packages/core/src/config/vendure-config.ts index 0d7a97633d..c12300b8e8 100644 --- a/packages/core/src/config/vendure-config.ts +++ b/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 diff --git a/packages/core/src/service/services/product-variant.service.ts b/packages/core/src/service/services/product-variant.service.ts index fc1e69b05d..b6db999e43 100644 --- a/packages/core/src/service/services/product-variant.service.ts +++ b/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 { - 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 {