From 5544303b9128406aee10042fc8e51efbdc666534 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Thu, 4 Jul 2024 16:50:09 +0200 Subject: [PATCH] feat: Add support for managing tax inclusivity (#7943) UI / HTTP / Workflows will come in separate PRs REF CORE-2376 --- .../__tests__/product/store/product.spec.ts | 4 + .../core/types/src/pricing/common/index.ts | 1 + .../src/pricing/common/price-preference.ts | 95 +++++++ .../types/src/pricing/common/price-set.ts | 8 + packages/core/types/src/pricing/service.ts | 264 ++++++++++++++++++ .../pricing-module/calculate-price.spec.ts | 150 +++++++++- packages/modules/pricing/src/joiner-config.ts | 3 +- .../migrations/.snapshot-medusa-pricing.json | 106 +++++++ .../src/migrations/Migration20240704094505.ts | 15 + packages/modules/pricing/src/models/index.ts | 1 + .../pricing/src/models/price-preference.ts | 75 +++++ .../pricing/src/repositories/pricing.ts | 5 - .../pricing/src/services/pricing-module.ts | 237 ++++++++++++++-- .../pricing/src/types/services/index.ts | 39 ++- .../pricing/src/types/services/price-list.ts | 20 -- .../pricing/src/types/services/price-set.ts | 5 - .../pricing/src/types/services/price.ts | 8 - 17 files changed, 970 insertions(+), 66 deletions(-) create mode 100644 packages/core/types/src/pricing/common/price-preference.ts create mode 100644 packages/modules/pricing/src/migrations/Migration20240704094505.ts create mode 100644 packages/modules/pricing/src/models/price-preference.ts delete mode 100644 packages/modules/pricing/src/types/services/price-list.ts delete mode 100644 packages/modules/pricing/src/types/services/price-set.ts delete mode 100644 packages/modules/pricing/src/types/services/price.ts diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts index 6d36cec22e..b3ebd2ca41 100644 --- a/integration-tests/http/__tests__/product/store/product.spec.ts +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -918,8 +918,10 @@ medusaIntegrationTestRunner({ calculated_price: { id: expect.any(String), is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 3000, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 3000, currency_code: "usd", calculated_price: { @@ -1228,8 +1230,10 @@ medusaIntegrationTestRunner({ calculated_price: { id: expect.any(String), is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 3000, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 3000, currency_code: "usd", calculated_price: { diff --git a/packages/core/types/src/pricing/common/index.ts b/packages/core/types/src/pricing/common/index.ts index 0000be1707..6f72f3e2b8 100644 --- a/packages/core/types/src/pricing/common/index.ts +++ b/packages/core/types/src/pricing/common/index.ts @@ -3,4 +3,5 @@ export * from "./price" export * from "./price-list" export * from "./price-rule" export * from "./price-set" +export * from "./price-preference" export * from "./pricing-context" diff --git a/packages/core/types/src/pricing/common/price-preference.ts b/packages/core/types/src/pricing/common/price-preference.ts new file mode 100644 index 0000000000..8ecc9d66a9 --- /dev/null +++ b/packages/core/types/src/pricing/common/price-preference.ts @@ -0,0 +1,95 @@ +import { BaseFilterable } from "../../dal" + +/** + * @interface + * + * A price preference's data. + */ +export interface PricePreferenceDTO { + /** + * The ID of a price preference. + */ + id: string + /** + * The rule attribute for the preference + */ + attribute: string | null + /** + * The rule value for the preference + */ + value: string | null + /** + * Flag specifying whether prices for the specified rule are tax inclusive. + */ + is_tax_inclusive: boolean + /** + * When the price preference was created. + */ + created_at: Date + /** + * When the price preference was updated. + */ + updated_at: Date + /** + * When the price preference was deleted. + */ + deleted_at: null | Date +} + +export interface UpsertPricePreferenceDTO extends UpdatePricePreferenceDTO { + /** + * The ID of a price preference. + */ + id?: string +} + +export interface UpdatePricePreferenceDTO { + /** + * The rule attribute for the preference + */ + attribute?: string | null + /** + * The rule value for the preference + */ + value?: string | null + /** + * Flag specifying whether prices for the specified rule are tax inclusive. + */ + is_tax_inclusive?: boolean +} + +export interface CreatePricePreferenceDTO { + /** + * The rule attribute for the preference + */ + attribute?: string + /** + * The rule value for the preference + */ + value?: string + /** + * Flag specifying whether prices for the specified rule are tax inclusive. + */ + is_tax_inclusive?: boolean +} + +/** + * @interface + * + * Filters to apply on prices. + */ +export interface FilterablePricePreferenceProps + extends BaseFilterable { + /** + * The IDs to filter the price preferences by. + */ + id?: string[] + /** + * Attributes to filter price preferences by. + */ + attribute?: string | string[] + /** + * Values to filter price preferences by. + */ + value?: string | string[] +} diff --git a/packages/core/types/src/pricing/common/price-set.ts b/packages/core/types/src/pricing/common/price-set.ts index 37dae37d86..69e8e6cead 100644 --- a/packages/core/types/src/pricing/common/price-set.ts +++ b/packages/core/types/src/pricing/common/price-set.ts @@ -121,6 +121,10 @@ export interface CalculatedPriceSet { * the calculated price is set to the original price, which doesn't belong to a price list. In that case, the value of this property is `false`. */ is_calculated_price_price_list?: boolean + /** + * Whether the calculated price is tax inclusive or not. + */ + is_calculated_price_tax_inclusive?: boolean /** * The amount of the calculated price, or `null` if there isn't a calculated price. */ @@ -131,6 +135,10 @@ export interface CalculatedPriceSet { * the original price will be the same as the calculated price. In that case, the value of this property is `true`. */ is_original_price_price_list?: boolean + /** + * Whether the original price is tax inclusive or not. + */ + is_original_price_tax_inclusive?: boolean /** * The amount of the original price, or `null` if there isn't a calculated price. */ diff --git a/packages/core/types/src/pricing/service.ts b/packages/core/types/src/pricing/service.ts index 4e688cbf3d..dccb409f6d 100644 --- a/packages/core/types/src/pricing/service.ts +++ b/packages/core/types/src/pricing/service.ts @@ -29,6 +29,13 @@ import { UpdatePriceSetDTO, UpsertPriceSetDTO, } from "./common" +import { + CreatePricePreferenceDTO, + FilterablePricePreferenceProps, + PricePreferenceDTO, + UpdatePricePreferenceDTO, + UpsertPricePreferenceDTO, +} from "./common/price-preference" /** * The main service interface for the Pricing Module. @@ -1488,4 +1495,261 @@ export interface IPricingModuleService extends IModuleService { * ]) */ removePrices(ids: string[], sharedContext?: Context): Promise + + /** + * This method is used to retrieve a price preference by its ID. + * + * @param {string} id - The ID of the price preference to retrieve. + * @param {FindConfig} config - + * The configurations determining how the price preference is retrieved. Its properties, such as `select` or `relations`, accept the + * attributes or relations associated with a price preference. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The retrieved price preference. + * + * @example + * A simple example that retrieves a price preference by its ID: + * + * ```ts + * const pricePreference = + * await pricingModuleService.retrievePricePreference("prpref_123") + * ``` + */ + retrievePricePreference( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * This method is used to retrieve a paginated list of price preferences based on optional filters and configuration. + * + * @param {FilterablePricePreferenceProps} filters - The filters to apply on the retrieved price lists. + * @param {FindConfig} config - + * The configurations determining how the price preferences are retrieved. Its properties, such as `select` or `relations`, accept the + * attributes or relations associated with a price preference. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The list of price preferences. + * + * @example + * + * To retrieve a list of price preferences using their IDs: + * + * ```ts + * const pricePreferences = await pricingModuleService.listPricePreferences({ + * id: ["prpref_123", "prpref_321"], + * }) + * ``` + * + * To specify relations that should be retrieved within the price preferences: + * + * By default, only the first `15` records are retrieved. You can control pagination by specifying the `skip` and `take` properties of the `config` parameter: + * + * ```ts + * const pricePreferences = await pricingModuleService.listPricePreferences( + * { + * id: ["prpref_123", "prpref_321"], + * }, + * { + * take: 20, + * skip: 2, + * } + * ) + * ``` + */ + listPricePreferences( + filters?: FilterablePricePreferenceProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * This method is used to create a new price preference. + * + * @param {CreatePricePreferenceDTO} data - The attributes of the price preference to create. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The created price preference. + * + * @example + * To create a price preference with rule: + * + * ```ts + * const pricePreference = await pricingModuleService.createPricePreferences({ + * attribute: 'region_id', + * value: 'DE', + * is_tax_inclusive: true + * }) + * ``` + */ + createPricePreferences( + data: CreatePricePreferenceDTO, + sharedContext?: Context + ): Promise + + /** + * This method is used to create multiple price preferences. + * + * @param {CreatePricePreferenceDTO[]} data - The price preferences to create. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The list of created price preferences. + * + * @example + * const pricePreferences = await pricingModuleService.createPricePreferences([{ + * attribute: 'region_id', + * value: 'DE', + * is_tax_inclusive: true + * }]) + */ + createPricePreferences( + data: CreatePricePreferenceDTO[], + sharedContext?: Context + ): Promise + + /** + * This method updates existing price preferences, or creates new ones if they don't exist. + * + * @param {UpsertPricePreferenceDTO[]} data - The attributes to update or create for each price preference. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The updated and created price preferences. + * + * @example + * const pricePreferences = await pricingModuleService.upsertPricePreferences([ + * { + * id: "prpref_123", + * attribute: 'region_id', + * value: 'DE', + * is_tax_inclusive: true + * }, + * ]) + */ + upsertPricePreferences( + data: UpsertPricePreferenceDTO[], + sharedContext?: Context + ): Promise + + /** + * This method updates the price preference if it exists, or creates a new ones if it doesn't. + * + * @param {UpsertPricePreferenceDTO} data - The attributes to update or create for the new price preference. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The updated or created price preference. + * + * @example + * const pricePreference = await pricingModuleService.upsertPricePreferences( + * { + * id: "prpref_123", + * attribute: 'region_id', + * value: 'DE', + * is_tax_inclusive: true + * } + * ) + */ + upsertPricePreferences( + data: UpsertPricePreferenceDTO, + sharedContext?: Context + ): Promise + + /** + * This method is used to update a price preference. + * + * @param {string} id - The ID of the price preference to be updated. + * @param {UpdatePricePreferenceDTO} data - The attributes of the price preference to be updated + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The updated price preference. + * + * @example + * const pricePreference = await pricingModuleService.updatePricePreferences( + * "prpref_123", + * { + * is_tax_inclusive: false + * } + * ) + */ + updatePricePreferences( + id: string, + data: UpdatePricePreferenceDTO, + sharedContext?: Context + ): Promise + + /** + * This method is used to update a list of price preferences determined by the selector filters. + * + * @param {FilterablePricePreferenceProps} selector - The filters that will determine which price preferences will be updated. + * @param {UpdatePricePreferenceDTO} data - The attributes to be updated on the selected price preferences + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The updated price preferences. + * + * @example + * const pricePreferences = await pricingModuleService.updatePricePreferences( + * { + * id: ["prpref_123", "prpref_321"], + * }, + * { + * is_tax_inclusive: false + * } + * ) + */ + updatePricePreferences( + selector: FilterablePricePreferenceProps, + data: UpdatePricePreferenceDTO, + sharedContext?: Context + ): Promise + + /** + * This method soft deletes price preferences by their IDs. + * + * @param {string[]} pricePreferenceIds - The IDs of the price preferences. + * @param {SoftDeleteReturn} config - An object that is used to specify an entity's related entities that should be soft-deleted when the main entity is soft-deleted. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise>} An object that includes the IDs of related records that were also soft deleted. + * The object's keys are the ID attribute names of the price preference entity's relations, and its value is an array of strings, each being the ID of a record associated. + * + * If there are no related records, the promise resolves to `void`. + * + * @example + * await pricingModuleService.softDeletePricePreferences([ + * "prpref_123", + * "prpref_321", + * ]) + */ + softDeletePricePreferences( + pricePreferenceIds: string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + /** + * This method restores soft deleted price preferences by their IDs. + * + * @param {string[]} pricePreferenceIds - The IDs of the price preferences. + * @param {RestoreReturn} config - Configurations determining which relations to restore along with each of the price preferences. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise>} An object that includes the IDs of related records that were restored. + * The object's keys are the ID attribute names of the price preferences entity's relations, + * and its value is an array of strings, each being the ID of the record associated with the price preferences through this relation. + * + * If there are no related records restored, the promise resolves to `void`. + * + * @example + * await pricingModuleService.restorePricePreferences([ + * "prpref_123", + * "prpref_321", + * ]) + */ + restorePricePreferences( + pricePreferenceIds: string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> + + /** + * This method deletes price preferences by their IDs. + * + * @param {string[]} ids - The IDs of the price preferences to delete. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the price preferences are successfully deleted. + * + * @example + * await pricingModuleService.deletePricePreferences(["prpref_123", "prpref_321"]) + */ + deletePricePreferences(ids: string[], sharedContext?: Context): Promise } diff --git a/packages/modules/pricing/integration-tests/__tests__/services/pricing-module/calculate-price.spec.ts b/packages/modules/pricing/integration-tests/__tests__/services/pricing-module/calculate-price.spec.ts index 5fc8b63dec..dbcb92992f 100644 --- a/packages/modules/pricing/integration-tests/__tests__/services/pricing-module/calculate-price.spec.ts +++ b/packages/modules/pricing/integration-tests/__tests__/services/pricing-module/calculate-price.spec.ts @@ -319,8 +319,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 1000, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 1000, currency_code: "PLN", calculated_price: { @@ -353,8 +355,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 300, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 300, currency_code: "PLN", calculated_price: { @@ -387,8 +391,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 1000, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 1000, currency_code: "PLN", calculated_price: { @@ -432,8 +438,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 300, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 300, currency_code: "PLN", calculated_price: { @@ -466,8 +474,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 1000, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 1000, currency_code: "PLN", calculated_price: { @@ -500,8 +510,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 250, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 250, currency_code: "PLN", calculated_price: { @@ -539,8 +551,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 300, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 300, currency_code: "PLN", calculated_price: { @@ -579,8 +593,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 100, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 100, currency_code: "EUR", calculated_price: { @@ -619,8 +635,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 300, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 300, currency_code: "PLN", calculated_price: { @@ -659,8 +677,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 1000, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 1000, currency_code: "PLN", calculated_price: { @@ -708,8 +728,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 300, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 300, currency_code: "PLN", calculated_price: { @@ -750,8 +772,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: true, + is_calculated_price_tax_inclusive: false, calculated_amount: 232, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 400, currency_code: "PLN", calculated_price: { @@ -799,8 +823,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: true, + is_calculated_price_tax_inclusive: false, calculated_amount: 232, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 400, currency_code: "PLN", calculated_price: { @@ -840,8 +866,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: true, + is_calculated_price_tax_inclusive: false, calculated_amount: 232, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 400, currency_code: "PLN", calculated_price: { @@ -878,8 +906,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: true, + is_calculated_price_tax_inclusive: false, calculated_amount: 232, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 1000, currency_code: "PLN", calculated_price: { @@ -919,8 +949,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: true, + is_calculated_price_tax_inclusive: false, calculated_amount: 232, is_original_price_price_list: true, + is_original_price_tax_inclusive: false, original_amount: 232, currency_code: "PLN", calculated_price: { @@ -959,8 +991,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 300, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 300, currency_code: "PLN", calculated_price: { @@ -999,8 +1033,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 300, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 300, currency_code: "PLN", calculated_price: { @@ -1052,8 +1088,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: true, + is_calculated_price_tax_inclusive: false, calculated_amount: 232, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 400, currency_code: "PLN", calculated_price: { @@ -1106,8 +1144,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 400, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 400, currency_code: "PLN", calculated_price: { @@ -1159,8 +1199,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: false, + is_calculated_price_tax_inclusive: false, calculated_amount: 400, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 400, currency_code: "PLN", calculated_price: { @@ -1181,7 +1223,7 @@ moduleIntegrationTestRunner({ ]) }) - it("should return price list prices for price list with customer groupst", async () => { + it("should return price list prices for price list with customer groups", async () => { const [{ id }] = await createPriceLists( service, {}, @@ -1214,8 +1256,10 @@ moduleIntegrationTestRunner({ { id: "price-set-EUR", is_calculated_price_price_list: true, + is_calculated_price_tax_inclusive: false, calculated_amount: 200, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: null, currency_code: "EUR", calculated_price: { @@ -1265,8 +1309,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: true, + is_calculated_price_tax_inclusive: false, calculated_amount: 111, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 400, currency_code: "PLN", calculated_price: { @@ -1316,8 +1362,10 @@ moduleIntegrationTestRunner({ { id: "price-set-PLN", is_calculated_price_price_list: true, + is_calculated_price_tax_inclusive: false, calculated_amount: 232, is_original_price_price_list: false, + is_original_price_tax_inclusive: false, original_amount: 400, currency_code: "PLN", calculated_price: { @@ -1338,6 +1386,106 @@ moduleIntegrationTestRunner({ ]) }) }) + + describe("Tax inclusivity", () => { + it("should return the currency tax inclusivity for the selected price when it is not region-based", async () => { + await (service as any).createPricePreferences([ + { + attribute: "currency_code", + value: "PLN", + is_tax_inclusive: true, + }, + ]) + + const priceSetsResult = await service.calculatePrices( + { id: ["price-set-PLN"] }, + { + context: { currency_code: "PLN" }, + } + ) + + expect(priceSetsResult).toEqual([ + expect.objectContaining({ + id: "price-set-PLN", + is_calculated_price_tax_inclusive: true, + calculated_amount: 1000, + is_original_price_tax_inclusive: true, + original_amount: 1000, + currency_code: "PLN", + }), + ]) + }) + + it("should return the region tax inclusivity for the selected price when it is region-based", async () => { + await (service as any).createPricePreferences([ + { + attribute: "currency_code", + value: "PLN", + is_tax_inclusive: false, + }, + { + attribute: "region_id", + value: "PL", + is_tax_inclusive: true, + }, + ]) + + const priceSetsResult = await service.calculatePrices( + { id: ["price-set-PLN"] }, + { + context: { currency_code: "PLN", region_id: "PL" }, + } + ) + + expect(priceSetsResult).toEqual([ + expect.objectContaining({ + id: "price-set-PLN", + is_calculated_price_tax_inclusive: true, + calculated_amount: 300, + is_original_price_tax_inclusive: true, + original_amount: 300, + currency_code: "PLN", + }), + ]) + }) + + it("should return the appropriate tax inclusive setting for each calculated and original price", async () => { + await createPriceLists(service, {}, {}) + await (service as any).createPricePreferences([ + { + attribute: "currency_code", + value: "PLN", + is_tax_inclusive: false, + }, + { + attribute: "region_id", + value: "PL", + is_tax_inclusive: true, + }, + ]) + + const priceSetsResult = await service.calculatePrices( + { id: ["price-set-PLN"] }, + { + context: { + currency_code: "PLN", + region_id: "PL", + }, + } + ) + + expect(priceSetsResult).toEqual([ + expect.objectContaining({ + id: "price-set-PLN", + is_calculated_price_tax_inclusive: false, + calculated_amount: 232, + is_original_price_tax_inclusive: true, + original_amount: 300, + currency_code: "PLN", + }), + ]) + }) + }) }) }) }, diff --git a/packages/modules/pricing/src/joiner-config.ts b/packages/modules/pricing/src/joiner-config.ts index 99cc1d948c..4df8d39575 100644 --- a/packages/modules/pricing/src/joiner-config.ts +++ b/packages/modules/pricing/src/joiner-config.ts @@ -1,5 +1,5 @@ import { defineJoinerConfig, Modules } from "@medusajs/utils" -import { Price, PriceList, PriceSet } from "@models" +import { Price, PriceList, PricePreference, PriceSet } from "@models" export const joinerConfig = defineJoinerConfig(Modules.PRICING, { models: [PriceSet, PriceList, Price], @@ -7,5 +7,6 @@ export const joinerConfig = defineJoinerConfig(Modules.PRICING, { price_set_id: PriceSet.name, price_list_id: PriceList.name, price_id: Price.name, + price_preference_id: PricePreference.name, }, }) diff --git a/packages/modules/pricing/src/migrations/.snapshot-medusa-pricing.json b/packages/modules/pricing/src/migrations/.snapshot-medusa-pricing.json index 548347e3e7..7439ea81fa 100644 --- a/packages/modules/pricing/src/migrations/.snapshot-medusa-pricing.json +++ b/packages/modules/pricing/src/migrations/.snapshot-medusa-pricing.json @@ -271,6 +271,112 @@ } } }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "attribute": { + "name": "attribute", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "value": { + "name": "value", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "is_tax_inclusive": { + "name": "is_tax_inclusive", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "false", + "mappedType": "boolean" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "price_preference", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_price_preference_deleted_at", + "columnNames": [ + "deleted_at" + ], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_price_preference_deleted_at\" ON \"price_preference\" (deleted_at) WHERE deleted_at IS NOT NULL" + }, + { + "keyName": "IDX_price_preference_attribute_value", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_price_preference_attribute_value\" ON \"price_preference\" (attribute, value) WHERE deleted_at IS NULL" + }, + { + "keyName": "price_preference_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {} + }, { "columns": { "id": { diff --git a/packages/modules/pricing/src/migrations/Migration20240704094505.ts b/packages/modules/pricing/src/migrations/Migration20240704094505.ts new file mode 100644 index 0000000000..0afcefc05d --- /dev/null +++ b/packages/modules/pricing/src/migrations/Migration20240704094505.ts @@ -0,0 +1,15 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240704094505 extends Migration { + + async up(): Promise { + this.addSql('create table if not exists "price_preference" ("id" text not null, "attribute" text not null, "value" text null, "is_tax_inclusive" boolean not null default false, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "price_preference_pkey" primary key ("id"));'); + this.addSql('CREATE INDEX IF NOT EXISTS "IDX_price_preference_deleted_at" ON "price_preference" (deleted_at) WHERE deleted_at IS NOT NULL;'); + this.addSql('CREATE UNIQUE INDEX IF NOT EXISTS "IDX_price_preference_attribute_value" ON "price_preference" (attribute, value) WHERE deleted_at IS NULL;'); + } + + async down(): Promise { + this.addSql('drop table if exists "price_preference" cascade;'); + } + +} diff --git a/packages/modules/pricing/src/models/index.ts b/packages/modules/pricing/src/models/index.ts index 729f870b6b..9997acf09e 100644 --- a/packages/modules/pricing/src/models/index.ts +++ b/packages/modules/pricing/src/models/index.ts @@ -3,3 +3,4 @@ export { default as PriceList } from "./price-list" export { default as PriceListRule } from "./price-list-rule" export { default as PriceRule } from "./price-rule" export { default as PriceSet } from "./price-set" +export { default as PricePreference } from "./price-preference" diff --git a/packages/modules/pricing/src/models/price-preference.ts b/packages/modules/pricing/src/models/price-preference.ts new file mode 100644 index 0000000000..04fc533a15 --- /dev/null +++ b/packages/modules/pricing/src/models/price-preference.ts @@ -0,0 +1,75 @@ +import { + createPsqlIndexStatementHelper, + DALUtils, + generateEntityId, +} from "@medusajs/utils" +import { + BeforeCreate, + Entity, + Filter, + OnInit, + PrimaryKey, + Property, +} from "@mikro-orm/core" + +export const uniquePreferenceRuleIndexName = + "IDX_price_preference_attribute_value" +const UniquePreferenceRuleIndexStatement = createPsqlIndexStatementHelper({ + name: uniquePreferenceRuleIndexName, + tableName: "price_preference", + columns: ["attribute", "value"], + unique: true, + where: "deleted_at IS NULL", +}) + +const DeletedAtIndex = createPsqlIndexStatementHelper({ + tableName: "price_preference", + columns: "deleted_at", + where: "deleted_at IS NOT NULL", +}) + +@Entity() +@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) +@UniquePreferenceRuleIndexStatement.MikroORMIndex() +export default class PricePreference { + @PrimaryKey({ columnType: "text" }) + id: string + + @Property({ columnType: "text" }) + attribute: string + + @Property({ columnType: "text", nullable: true }) + value: string | null = null + + @Property({ default: false }) + is_tax_inclusive: boolean + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @DeletedAtIndex.MikroORMIndex() + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null = null + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "prpref") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "prpref") + } +} diff --git a/packages/modules/pricing/src/repositories/pricing.ts b/packages/modules/pricing/src/repositories/pricing.ts index a20bf83583..95208123ab 100644 --- a/packages/modules/pricing/src/repositories/pricing.ts +++ b/packages/modules/pricing/src/repositories/pricing.ts @@ -83,10 +83,6 @@ export class PricingRepository ) }) .leftJoin("price_list_rule as plr", "plr.price_list_id", "pl.id") - .orderBy([ - { column: "rules_count", order: "desc" }, - { column: "pl.rules_count", order: "desc" }, - ]) .groupBy("price.id", "pl.id") .having( knex.raw( @@ -168,7 +164,6 @@ export class PricingRepository ), }) .join(priceSubQueryKnex.as("price"), "price.price_set_id", "ps.id") - .leftJoin("price_rule as pr", "pr.price_id", "price.id") .whereIn("ps.id", pricingFilters.id) .andWhere("price.currency_code", "=", currencyCode) diff --git a/packages/modules/pricing/src/services/pricing-module.ts b/packages/modules/pricing/src/services/pricing-module.ts index aec9b06501..6c96c39434 100644 --- a/packages/modules/pricing/src/services/pricing-module.ts +++ b/packages/modules/pricing/src/services/pricing-module.ts @@ -1,6 +1,7 @@ import { AddPricesDTO, Context, + CreatePricePreferenceDTO, CreatePriceRuleDTO, CreatePricesDTO, CreatePriceSetDTO, @@ -9,15 +10,18 @@ import { InternalModuleDeclaration, ModuleJoinerConfig, ModulesSdkTypes, + PricePreferenceDTO, PriceSetDTO, PricingContext, PricingFilters, PricingRepositoryService, PricingTypes, + UpsertPricePreferenceDTO, UpsertPriceSetDTO, } from "@medusajs/types" import { arrayDifference, + deduplicate, EmitEvents, GetIsoStringFromDate, groupBy, @@ -34,7 +38,14 @@ import { simpleHash, } from "@medusajs/utils" -import { Price, PriceList, PriceListRule, PriceRule, PriceSet } from "@models" +import { + Price, + PriceList, + PriceListRule, + PriceRule, + PriceSet, + PricePreference, +} from "@models" import { ServiceTypes } from "@types" import { eventBuilders, validatePriceListDates } from "@utils" @@ -48,6 +59,7 @@ type InjectedDependencies = { priceRuleService: ModulesSdkTypes.IMedusaInternalService priceService: ModulesSdkTypes.IMedusaInternalService priceListService: ModulesSdkTypes.IMedusaInternalService + pricePreferenceService: ModulesSdkTypes.IMedusaInternalService priceListRuleService: ModulesSdkTypes.IMedusaInternalService } @@ -57,6 +69,7 @@ const generateMethodForModels = { PriceListRule, PriceRule, Price, + PricePreference, } export default class PricingModuleService @@ -70,6 +83,8 @@ export default class PricingModuleService } PriceList: { dto: PricingTypes.PriceListDTO } PriceListRule: { dto: PricingTypes.PriceListRuleDTO } + // PricePreference: { dto: PricingTypes.PricePreferenceDTO } + PricePreference: { dto: any } }>(generateMethodForModels) implements PricingTypes.IPricingModuleService { @@ -80,6 +95,7 @@ export default class PricingModuleService protected readonly priceService_: ModulesSdkTypes.IMedusaInternalService protected readonly priceListService_: ModulesSdkTypes.IMedusaInternalService protected readonly priceListRuleService_: ModulesSdkTypes.IMedusaInternalService + protected readonly pricePreferenceService_: ModulesSdkTypes.IMedusaInternalService constructor( { @@ -88,6 +104,7 @@ export default class PricingModuleService priceSetService, priceRuleService, priceService, + pricePreferenceService, priceListService, priceListRuleService, }: InjectedDependencies, @@ -101,6 +118,7 @@ export default class PricingModuleService this.priceSetService_ = priceSetService this.priceRuleService_ = priceRuleService this.priceService_ = priceService + this.pricePreferenceService_ = pricePreferenceService this.priceListService_ = priceListService this.priceListRuleService_ = priceListRuleService } @@ -240,41 +258,93 @@ export default class PricingModuleService ) const pricesSetPricesMap = groupBy(results, "price_set_id") + const priceIds: string[] = [] + pricesSetPricesMap.forEach( + (prices: PricingTypes.CalculatedPriceSetDTO[], key) => { + const priceListPrice = prices.find((p) => p.price_list_id) + const defaultPrice = prices?.find((p) => !p.price_list_id) + if (!prices.length || (!priceListPrice && !defaultPrice)) { + pricesSetPricesMap.delete(key) + return + } + + let calculatedPrice: PricingTypes.CalculatedPriceSetDTO | undefined = + defaultPrice + let originalPrice: PricingTypes.CalculatedPriceSetDTO | undefined = + defaultPrice + if (priceListPrice) { + calculatedPrice = priceListPrice + + if (priceListPrice.price_list_type === PriceListType.OVERRIDE) { + originalPrice = priceListPrice + } + } + + pricesSetPricesMap.set(key, { calculatedPrice, originalPrice }) + priceIds.push( + ...(deduplicate( + [calculatedPrice?.id, originalPrice?.id].filter(Boolean) + ) as string[]) + ) + } + ) + + // We use the price rules to get the right preferences for the price + const priceRulesForPrices = await this.priceRuleService_.list( + { price_id: priceIds }, + { take: null } + ) + + const priceRulesPriceMap = groupBy(priceRulesForPrices, "price_id") + + // Note: For now the preferences are intentionally kept very simple and explicit - they use either the region or currency, + // so we hard-code those as the possible filters here. This can be made more flexible if needed later on. + const pricingPreferences = await this.pricePreferenceService_.list( + { + $or: Object.entries(pricingContext) + .filter(([key, val]) => { + return key === "region_id" || key === "currency_code" + }) + .map(([key, val]) => ({ + attribute: key, + value: val, + })), + }, + {}, + sharedContext + ) const calculatedPrices: PricingTypes.CalculatedPriceSet[] = pricingFilters.id .map((priceSetId: string): PricingTypes.CalculatedPriceSet | null => { - // This is where we select prices, for now we just do a first match based on the database results - // which is prioritized by rules_count first for exact match and then deafult_priority of the rule_type - - // TODO: inject custom price selection here - - const prices = pricesSetPricesMap.get(priceSetId) || [] - if (!prices.length) { + const prices = pricesSetPricesMap.get(priceSetId) + if (!prices) { return null } - - const priceListPrice = prices.find((p) => p.price_list_id) - - const defaultPrice = prices?.find((p) => !p.price_list_id) - - let calculatedPrice: PricingTypes.CalculatedPriceSetDTO = defaultPrice - let originalPrice: PricingTypes.CalculatedPriceSetDTO = defaultPrice - - if (priceListPrice) { - calculatedPrice = priceListPrice - - if (priceListPrice.price_list_type === PriceListType.OVERRIDE) { - originalPrice = priceListPrice - } - } + const { + calculatedPrice, + originalPrice, + }: { + calculatedPrice: PricingTypes.CalculatedPriceSetDTO + originalPrice: PricingTypes.CalculatedPriceSetDTO | undefined + } = prices return { id: priceSetId, is_calculated_price_price_list: !!calculatedPrice?.price_list_id, + is_calculated_price_tax_inclusive: isTaxInclusive( + priceRulesPriceMap.get(calculatedPrice.id), + pricingPreferences + ), calculated_amount: parseInt(calculatedPrice?.amount || "") || null, is_original_price_price_list: !!originalPrice?.price_list_id, + is_original_price_tax_inclusive: originalPrice?.id + ? isTaxInclusive( + priceRulesPriceMap.get(originalPrice.id), + pricingPreferences + ) + : false, original_amount: parseInt(originalPrice?.amount || "") || null, currency_code: calculatedPrice?.currency_code || null, @@ -641,6 +711,102 @@ export default class PricingModuleService ) } + // @ts-expect-error + async createPricePreferences( + data: PricingTypes.CreatePricePreferenceDTO, + sharedContext?: Context + ): Promise + async createPricePreferences( + data: PricingTypes.CreatePricePreferenceDTO[], + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + @EmitEvents() + async createPricePreferences( + data: + | PricingTypes.CreatePricePreferenceDTO + | PricingTypes.CreatePricePreferenceDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const preferences = await this.pricePreferenceService_.create( + data, + sharedContext + ) + + return await this.baseRepository_.serialize(preferences) + } + + async upsertPricePreferences( + data: UpsertPricePreferenceDTO[], + sharedContext?: Context + ): Promise + async upsertPricePreferences( + data: UpsertPricePreferenceDTO, + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async upsertPricePreferences( + data: UpsertPricePreferenceDTO | UpsertPricePreferenceDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + const forUpdate = input.filter( + ( + pricePreference + ): pricePreference is ServiceTypes.UpdatePricePreferenceInput => + !!pricePreference.id + ) + const forCreate = input.filter( + (pricePreference): pricePreference is CreatePricePreferenceDTO => + !pricePreference.id + ) + + const operations: Promise[] = [] + + if (forCreate.length) { + operations.push( + this.pricePreferenceService_.create(forCreate, sharedContext) + ) + } + if (forUpdate.length) { + operations.push( + this.pricePreferenceService_.update(forUpdate, sharedContext) + ) + } + + const result = (await promiseAll(operations)).flat() + return await this.baseRepository_.serialize< + PricePreferenceDTO[] | PricePreferenceDTO + >(Array.isArray(data) ? result : result[0]) + } + + // @ts-expect-error + async updatePricePreferences( + id: string, + data: PricingTypes.UpdatePricePreferenceDTO, + sharedContext?: Context + ): Promise + async updatePricePreferences( + selector: PricingTypes.FilterablePricePreferenceProps, + data: PricingTypes.UpdatePricePreferenceDTO, + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async updatePricePreferences( + idOrSelector: string | PricingTypes.FilterablePricePreferenceProps, + data: PricingTypes.UpdatePricePreferenceDTO, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const preferences = await this.pricePreferenceService_.update( + data, + sharedContext + ) + return await this.baseRepository_.serialize(preferences) + } + @InjectTransactionManager("baseRepository_") protected async createPriceSets_( data: PricingTypes.CreatePriceSetDTO[], @@ -1253,6 +1419,31 @@ export default class PricingModuleService } } +const isTaxInclusive = ( + priceRules: PriceRule[], + preferences: PricePreference[] +) => { + const regionPreference = preferences.find((p) => p.attribute === "region_id") + const currencyPreference = preferences.find( + (p) => p.attribute === "currency_code" + ) + const regionRule = priceRules?.find((rule) => rule.attribute === "region_id") + + if ( + regionRule && + regionPreference && + regionRule.value === regionPreference.value + ) { + return regionPreference.is_tax_inclusive + } + + if (currencyPreference) { + return currencyPreference.is_tax_inclusive + } + + return false +} + const hashPrice = ( price: PricingTypes.PriceDTO | PricingTypes.CreatePricesDTO ): string => { diff --git a/packages/modules/pricing/src/types/services/index.ts b/packages/modules/pricing/src/types/services/index.ts index b32edafa95..bdbe560974 100644 --- a/packages/modules/pricing/src/types/services/index.ts +++ b/packages/modules/pricing/src/types/services/index.ts @@ -1,3 +1,36 @@ -export * from "./price-list" -export * from "./price-set" -export * from "./price" +import { PriceListStatus, PricingTypes } from "@medusajs/types" + +export interface CreatePriceListDTO extends PricingTypes.CreatePriceListDTO { + rules_count?: number + price_list_rules?: { + attribute: string + value: string + }[] + prices?: PricingTypes.CreatePriceListPriceDTO[] +} + +export interface UpdatePriceListDTO { + id: string + title?: string + description?: string | null + starts_at?: string | null + ends_at?: string | null + status?: PriceListStatus + number_rules?: number +} + +export interface UpdatePriceSetInput extends PricingTypes.UpdatePriceSetDTO { + id: string +} + +export interface UpsertPriceDTO + extends Omit { + id?: string + price_list_id?: string + price_rules: PricingTypes.CreatePriceRuleDTO[] +} + +export interface UpdatePricePreferenceInput + extends PricingTypes.UpdatePricePreferenceDTO { + id: string +} diff --git a/packages/modules/pricing/src/types/services/price-list.ts b/packages/modules/pricing/src/types/services/price-list.ts deleted file mode 100644 index ecf2b063ec..0000000000 --- a/packages/modules/pricing/src/types/services/price-list.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { PriceListStatus, PricingTypes } from "@medusajs/types" - -export interface CreatePriceListDTO extends PricingTypes.CreatePriceListDTO { - rules_count?: number - price_list_rules?: { - attribute: string - value: string - }[] - prices?: PricingTypes.CreatePriceListPriceDTO[] -} - -export interface UpdatePriceListDTO { - id: string - title?: string - description?: string | null - starts_at?: string | null - ends_at?: string | null - status?: PriceListStatus - number_rules?: number -} diff --git a/packages/modules/pricing/src/types/services/price-set.ts b/packages/modules/pricing/src/types/services/price-set.ts deleted file mode 100644 index 63a018e290..0000000000 --- a/packages/modules/pricing/src/types/services/price-set.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { UpdatePriceSetDTO } from "@medusajs/types" - -export interface UpdatePriceSetInput extends UpdatePriceSetDTO { - id: string -} diff --git a/packages/modules/pricing/src/types/services/price.ts b/packages/modules/pricing/src/types/services/price.ts deleted file mode 100644 index 5db82435c0..0000000000 --- a/packages/modules/pricing/src/types/services/price.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { PricingTypes } from "@medusajs/types" - -export interface UpsertPriceDTO - extends Omit { - id?: string - price_list_id?: string - price_rules: PricingTypes.CreatePriceRuleDTO[] -}