Feat: use price selection strategy (#1165)

* init

* add query params

* added buld id validation to repo

* admin done

* updated price reqs

* initial price selection strategy

* update customer seeder

* format models

* price selection strategy

* price selection testing

* update price selection tests

* update price selection strategy

* remove console.warn

* update price seleciton strategy

* use price selection strategy in "get-product"

* price selection strategy integration testing

* update integration tests

* update price selection strat

* remove console.log

* fix unit tests

* update product snapshot integration tests

* fix failing unit tests

* update variant test snapshots

* update variant test snapshots

* fix failing unit tests

* update product snapshot integration tests

* intial implementation of PriceList

* add price selection strategy test to list-products

* add price selection to list products

* add price selection strategy to getRegionPrice

* add price selection strategy to get variant

* update product snapshot test

* store testing of price selection

* variant services

* update cart service

* update product tests

* update test

* unit testing with price selection strategy

* integration tests for price lists

* update sort prices for consistent results

* update snapshot

* update product snapshot with product ids

* this time pipelines work

* swap tests

* redo ordering

* updated admin/product integration tests

* update updateVariantPrices method

* remove comment from error handler

* add integration test for batch deleting prices associated with price list

* named ids

* run with verbose

* add console.log

* sort products in integration test

* remove verbose flag

* make update to prices through variant service limited to default prices

* update store/products.js snapshot

* update comment

* add todo

* lift existing price calculations to variant level

* remove unused import

* add api unit tests and update product integration tests to validate that prices from Price List are ignored

* fix product test

* update integration tests

* pre merge commit

* requested changes

* cascade

* ensure delete variant cascades to MoneyAmount

* fetch variants correctly

* use find options

* add pricelist to relevant seeders

* update integration tests

* update price selection with "includeDiscountPrices"

* use transaction with price selection strategy

* add await to prevent store test errors

* remove verbose

* addresses PR feedback

* removed unused endpoint

* remove unused repository from constructor

* remove from constructor argument

* update mock

* update unit tests

* fix failing store integration tests

* remove medusajs ressource

* re add env.template

* price selection strategy methods

* fix integration tests

* update unit tests

* remove commented out code

* update jsdoc

* update price selection strategy parameter

* update snapshots

* integration tests cleanup

* pr feedback

* update integration tests

* pr feedback

* price selection unit tests

* add calculated price type to results

* cleanup include discount prices and price selection configs

* refactor price selection params to a separate file

* update tests and refactor price selection seeder

* remove prices from variant update

* rename result

* pr feedback

* remove unused import

* create getRegionPriceContext

* remove from params in search

* remove unused import

Co-authored-by: Kasper <kasper@medusajs.com>
Co-authored-by: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2022-03-23 17:17:19 +01:00
1 parent 491b6eba2d
commit e2423020c0
36 files changed
+3794 -533

No files matched your search

@@ -8,8 +8,10 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { MedusaError } from "medusa-core-utils"
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { ProductService, ProductVariantService } from "../../../../services"
import { PriceSelectionParams } from "../../../../types/price-selection"
import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant"
import { validator } from "../../../../utils/validator"
@@ -130,6 +132,8 @@ export default async (req, res) => {
req.body
)
const validatedQueryParams = await validator(PriceSelectionParams, req.query)
const productService: ProductService = req.scope.resolve("productService")
const productVariantService: ProductVariantService = req.scope.resolve(
"productVariantService"
@@ -140,9 +144,12 @@ export default async (req, res) => {
...validated,
})
await productVariantService.retrieve(variant_id)
const product = await productService.retrieve(id, {
select: defaultAdminProductFields,
relations: defaultAdminProductRelations,
...validatedQueryParams,
})
res.json({ product })
@@ -21,7 +21,13 @@ describe("Get product by id", () => {
expect(ProductServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("product1"),
{ relations: defaultStoreProductsRelations }
{
relations: defaultStoreProductsRelations,
cart_id: undefined,
currency_code: undefined,
region_id: undefined,
include_discount_prices: true,
}
)
})
@@ -51,7 +57,13 @@ describe("Get product by id", () => {
it("endpoint called with defaultRelations", () => {
expect(ProductServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("variantsWithPrices"),
{ relations: defaultStoreProductsRelations }
{
relations: defaultStoreProductsRelations,
cart_id: undefined,
currency_code: undefined,
region_id: undefined,
include_discount_prices: true,
}
)
})
@@ -19,7 +19,15 @@ describe("GET /store/products", () => {
expect(ProductServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.listAndCount).toHaveBeenCalledWith(
{ status: ["published"] },
{ relations: defaultStoreProductsRelations, skip: 0, take: 100 }
{
relations: defaultStoreProductsRelations,
skip: 0,
take: 100,
cart_id: undefined,
currency_code: undefined,
region_id: undefined,
include_discount_prices: true,
}
)
})
@@ -42,7 +50,15 @@ describe("GET /store/products", () => {
expect(ProductServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(ProductServiceMock.listAndCount).toHaveBeenCalledWith(
{ is_giftcard: true, status: ["published"] },
{ relations: defaultStoreProductsRelations, skip: 0, take: 100 }
{
relations: defaultStoreProductsRelations,
skip: 0,
take: 100,
cart_id: undefined,
currency_code: undefined,
region_id: undefined,
include_discount_prices: true,
}
)
})
})
@@ -1,5 +1,7 @@
import { defaultStoreProductsRelations } from "."
import { ProductService } from "../../../../services"
import { PriceSelectionParams } from "../../../../types/price-selection"
import { validator } from "../../../../utils/validator"
/**
* @oas [get] /products/{id}
@@ -23,9 +25,18 @@ import { ProductService } from "../../../../services"
export default async (req, res) => {
const { id } = req.params
const validated = await validator(PriceSelectionParams, req.query)
const customer_id = req.user?.customer_id
const productService: ProductService = req.scope.resolve("productService")
const product = await productService.retrieve(id, {
relations: defaultStoreProductsRelations,
cart_id: validated.cart_id,
customer_id: customer_id,
region_id: validated.region_id,
currency_code: validated.currency_code,
include_discount_prices: true,
})
res.json({ product })
@@ -11,6 +11,7 @@ import { omit, pickBy } from "lodash"
import { defaultStoreProductsRelations } from "."
import { ProductService } from "../../../../services"
import { DateComparisonOperator } from "../../../../types/common"
import { PriceSelectionParams } from "../../../../types/price-selection"
import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
@@ -68,6 +69,9 @@ export default async (req, res) => {
"expand",
"limit",
"offset",
"cart_id",
"region_id",
"currency_code",
])
// get only published products for store endpoint
@@ -92,6 +96,11 @@ export default async (req, res) => {
: defaultStoreProductsRelations,
skip: validated.offset,
take: validated.limit,
cart_id: validated.cart_id,
region_id: validated.region_id,
currency_code: validated.currency_code,
customer_id: req.user?.customer_id,
include_discount_prices: true,
}
const [products, count] = await productService.listAndCount(
@@ -107,7 +116,7 @@ export default async (req, res) => {
})
}
export class StoreGetProductsPaginationParams {
export class StoreGetProductsPaginationParams extends PriceSelectionParams {
@IsString()
@IsOptional()
fields?: string
@@ -16,6 +16,10 @@ describe("Get variant by id", () => {
expect(ProductVariantServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(ProductVariantServiceMock.retrieve).toHaveBeenCalledWith("1", {
relations: ["prices", "options"],
cart_id: undefined,
currency_code: undefined,
region_id: undefined,
include_discount_prices: true,
})
})
@@ -1,5 +1,7 @@
import { defaultStoreVariantRelations } from "."
import ProductVariantService from "../../../../services/product-variant"
import { PriceSelectionParams } from "../../../../types/price-selection"
import { validator } from "../../../../utils/validator"
/**
* @oas [get] /variants/{variant_id}
@@ -23,12 +25,21 @@ import ProductVariantService from "../../../../services/product-variant"
export default async (req, res) => {
const { id } = req.params
const validated = await validator(PriceSelectionParams, req.query)
const variantService: ProductVariantService = req.scope.resolve(
"productVariantService"
)
const customer_id = req.user?.customer_id
const variant = await variantService.retrieve(id, {
relations: defaultStoreVariantRelations,
cart_id: validated.cart_id,
customer_id: customer_id,
region_id: validated.region_id,
currency_code: validated.currency_code,
include_discount_prices: true,
})
res.json({ variant })
@@ -7,6 +7,7 @@ import ProductVariantService from "../../../../services/product-variant"
import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
import { NumericalComparisonOperator } from "../../../../types/common"
import { PriceSelectionParams } from "../../../../types/price-selection"
/**
* @oas [get] /variants
@@ -41,12 +42,19 @@ export default async (req, res) => {
expandFields = expand.split(",")
}
const customer_id = req.user?.customer_id
const listConfig = {
relations: expandFields.length
? expandFields
: defaultStoreVariantRelations,
skip: offset,
take: limit,
cart_id: validated.cart_id,
region_id: validated.region_id,
currency_code: validated.currency_code,
customer_id: customer_id,
include_discount_prices: true,
}
const filterableFields: FilterableProductVariantProps = omit(validated, [
@@ -54,6 +62,9 @@ export default async (req, res) => {
"limit",
"offset",
"expand",
"cart_id",
"region_id",
"currency_code",
])
if (validated.ids) {
@@ -68,7 +79,7 @@ export default async (req, res) => {
res.json({ variants })
}
export class StoreGetVariantsParams {
export class StoreGetVariantsParams extends PriceSelectionParams {
@IsOptional()
@IsInt()
@Type(() => Number)
@@ -1,6 +1,5 @@
import { EntityManager } from "typeorm"
import { MoneyAmount } from ".."
import { MoneyAmountRepository } from "../repositories/money-amount"
import { PriceListType } from "../types/price-list"
export interface IPriceSelectionStrategy {
+5 -6
View File
@@ -8,7 +8,7 @@ import {
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn
UpdateDateColumn,
} from "typeorm"
import { ulid } from "ulid"
import { resolveDbType } from "../utils/db-aware-column"
@@ -41,11 +41,10 @@ export class MoneyAmount {
@Column({ nullable: true })
price_list_id: string | null
@ManyToOne(
() => PriceList,
(priceList) => priceList.prices,
{ cascade: true, onDelete: "CASCADE" }
)
@ManyToOne(() => PriceList, (priceList) => priceList.prices, {
cascade: true,
onDelete: "CASCADE",
})
@JoinColumn({ name: "price_list_id" })
price_list: PriceList | null
+7 -11
View File
@@ -8,7 +8,7 @@ import {
ManyToMany,
OneToMany,
PrimaryColumn,
UpdateDateColumn
UpdateDateColumn,
} from "typeorm"
import { ulid } from "ulid"
import { PriceListStatus, PriceListType } from "../types/price-list"
@@ -53,18 +53,14 @@ export class PriceList {
referencedColumnName: "id",
},
})
@ManyToMany(
() => CustomerGroup,
(cg) => cg.price_lists,
{ onDelete: "CASCADE" }
)
@ManyToMany(() => CustomerGroup, (cg) => cg.price_lists, {
onDelete: "CASCADE",
})
customer_groups: CustomerGroup[]
@OneToMany(
() => MoneyAmount,
(moneyAmount) => moneyAmount.price_list,
{ onDelete: "CASCADE" }
)
@OneToMany(() => MoneyAmount, (moneyAmount) => moneyAmount.price_list, {
onDelete: "CASCADE",
})
prices: MoneyAmount[]
@CreateDateColumn({ type: resolveDbType("timestamptz") })
@@ -1,4 +1,5 @@
import partition from "lodash/partition"
import { MedusaError } from "medusa-core-utils"
import {
Brackets,
EntityRepository,
@@ -130,7 +131,7 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
"price_list",
"ma.price_list_id = price_list.id "
)
.where({ variant_id: variant_id }) // "ma.variant_id = :variant_id",
.where({ variant_id: variant_id })
.andWhere("(ma.price_list_id is null or price_list.status = 'active')")
.andWhere(
"(price_list is null or price_list.ends_at is null OR price_list.ends_at > :date) ",
+15 -1
View File
@@ -1040,6 +1040,19 @@ describe("CartService", () => {
return this
},
}
const priceSelectionStrat = {
calculateVariantPrice: async (variantId, context) => {
if (variantId === IdMap.getId("fail")) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Money amount for variant with id ${variantId} in region ${context.region_id} does not exist`
)
} else {
return { calculatedPrice: 100 }
}
},
}
const cartService = new CartService({
manager: MockManager,
paymentProviderService,
@@ -1050,6 +1063,7 @@ describe("CartService", () => {
lineItemService,
productVariantService,
eventBusService,
priceSelectionStrategy: priceSelectionStrat,
})
beforeEach(() => {
@@ -1316,7 +1330,7 @@ describe("CartService", () => {
jest.clearAllMocks()
})
let cartService = new CartService({})
const cartService = new CartService({})
it("given a cart with custom shipping options and a shipping option id corresponding to a custom shipping option, then it should return a custom shipping option", async () => {
const cartCSO = [
@@ -3,7 +3,7 @@ import ProductVariantService from "../product-variant"
const eventBusService = {
emit: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -18,9 +18,33 @@ describe("ProductVariantService", () => {
return Promise.resolve({ id: IdMap.getId("ironman") })
},
})
const cartRepository = MockRepository({
findOne: (data) => {
return Promise.resolve({})
},
})
const priceSelectionStrat = {
calculateVariantPrice: (variantId, context) => {
return {
originalPrice: null,
calculatedPrice: null,
prices: [],
}
},
}
const priceSelectionStrategy = {
withTransaction: (manager) => {
return priceSelectionStrat
},
}
const productVariantService = new ProductVariantService({
manager: MockManager,
productVariantRepository,
cartRepository,
priceSelectionStrategy,
})
beforeEach(async () => {
@@ -227,6 +251,29 @@ describe("ProductVariantService", () => {
})
describe("update", () => {
const cartRepository = MockRepository({
findOne: (data) => {
return Promise.resolve({})
},
})
const priceSelectionStrat = {
calculateVariantPrice: (variantId, context) => {
return {
originalPrice: null,
calculatedPrice: null,
calculatedPriceType: undefined,
prices: [],
}
},
}
const priceSelectionStrategy = {
withTransaction: (manager) => {
return priceSelectionStrat
},
}
const productVariantRepository = MockRepository({
findOne: (query) => Promise.resolve({ id: IdMap.getId("ironman") }),
})
@@ -246,6 +293,8 @@ describe("ProductVariantService", () => {
moneyAmountRepository,
productVariantRepository,
productOptionValueRepository,
cartRepository,
priceSelectionStrategy,
})
productVariantService.updateOptionValue = jest
@@ -464,13 +513,13 @@ describe("ProductVariantService", () => {
describe("getRegionPrice", () => {
const regionService = {
retrieve: function() {
retrieve: function () {
return Promise.resolve({
id: IdMap.getId("california"),
name: "California",
})
},
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -498,11 +547,35 @@ describe("ProductVariantService", () => {
},
})
const cartRepository = MockRepository({
findOne: (data) => {
return Promise.resolve({})
},
})
const priceSelectionStrat = {
calculateVariantPrice: (variantId, context) => {
return Promise.resolve({
originalPrice: null,
calculatedPrice: 1000,
prices: [],
})
},
}
const priceSelectionStrategy = {
withTransaction: (manager) => {
return priceSelectionStrat
},
}
const productVariantService = new ProductVariantService({
manager: MockManager,
eventBusService,
regionService,
moneyAmountRepository,
cartRepository,
priceSelectionStrategy,
})
beforeEach(async () => {
@@ -3,7 +3,7 @@ import ProductService from "../product"
const eventBusService = {
emit: jest.fn(),
withTransaction: function() {
withTransaction: function () {
return this
},
}
@@ -54,7 +54,7 @@ describe("ProductService", () => {
describe("create", () => {
const productRepository = MockRepository({
create: product => ({
create: (product) => ({
id: IdMap.getId("ironman"),
title: "Suit",
options: [],
@@ -71,7 +71,7 @@ describe("ProductService", () => {
const productTagRepository = MockRepository({
findOne: () => Promise.resolve(undefined),
create: data => {
create: (data) => {
if (data.value === "title") {
return { id: "tag-1", value: "title" }
}
@@ -83,16 +83,16 @@ describe("ProductService", () => {
})
const productTypeRepository = MockRepository({
findOne: () => Promise.resolve(undefined),
create: data => {
create: (data) => {
return { id: "type", value: "type1" }
},
})
const productCollectionService = {
withTransaction: function() {
withTransaction: function () {
return this
},
retrieve: id =>
retrieve: (id) =>
Promise.resolve({ id: IdMap.getId("cat"), title: "Suits" }),
}
@@ -223,7 +223,7 @@ describe("ProductService", () => {
const productTypeRepository = MockRepository({
findOne: () => Promise.resolve(undefined),
create: data => {
create: (data) => {
return { id: "type", value: "type1" }
},
})
@@ -231,7 +231,7 @@ describe("ProductService", () => {
const productVariantRepository = MockRepository()
const productVariantService = {
withTransaction: function() {
withTransaction: function () {
return this
},
update: (variant, update) => {
@@ -243,7 +243,7 @@ describe("ProductService", () => {
}
const productTagRepository = MockRepository({
findOne: data => {
findOne: (data) => {
if (data.where.value === "test") {
return Promise.resolve({ id: IdMap.getId("test"), value: "test" })
}
@@ -253,6 +253,25 @@ describe("ProductService", () => {
},
})
const cartRepository = MockRepository({
findOne: (data) => {
return Promise.resolve({})
},
})
const priceSelectionStrategy = {
withTransaction: (manager) => {
return this
},
calculateVariantPrice: (variantId, context) => {
return {
originalPrice: null,
calculatedPrice: null,
prices: [],
}
},
}
const productService = new ProductService({
manager: MockManager,
productRepository,
@@ -261,6 +280,8 @@ describe("ProductService", () => {
productTagRepository,
productTypeRepository,
eventBusService,
cartRepository,
priceSelectionStrategy,
})
beforeEach(() => {
@@ -440,7 +461,7 @@ describe("ProductService", () => {
describe("addOption", () => {
const productRepository = MockRepository({
findOneWithRelations: query =>
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
options: [{ title: "Color" }],
@@ -449,7 +470,7 @@ describe("ProductService", () => {
})
const productVariantService = {
withTransaction: function() {
withTransaction: function () {
return this
},
addOptionValue: jest.fn(),
@@ -513,7 +534,7 @@ describe("ProductService", () => {
describe("reorderVariants", () => {
const productRepository = MockRepository({
findOneWithRelations: query =>
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
variants: [{ id: IdMap.getId("green") }, { id: IdMap.getId("blue") }],
@@ -571,7 +592,7 @@ describe("ProductService", () => {
describe("reorderOptions", () => {
const productRepository = MockRepository({
findOneWithRelations: query =>
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
options: [
@@ -637,7 +658,7 @@ describe("ProductService", () => {
describe("updateOption", () => {
const productRepository = MockRepository({
findOneWithRelations: query =>
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
options: [
@@ -712,7 +733,7 @@ describe("ProductService", () => {
describe("deleteOption", () => {
const productRepository = MockRepository({
findOneWithRelations: query =>
findOneWithRelations: (query) =>
Promise.resolve({
id: IdMap.getId("ironman"),
variants: [
@@ -751,7 +772,7 @@ describe("ProductService", () => {
})
const productOptionRepository = MockRepository({
findOne: query => {
findOne: (query) => {
if (query.where.id === IdMap.getId("material")) {
return undefined
}
+79 -36
View File
@@ -1,5 +1,9 @@
import _ from "lodash"
import { EntityManager, DeepPartial } from "typeorm"
import {
EntityManager,
DeepPartial,
AlreadyHasActiveConnectionError,
} from "typeorm"
import { MedusaError, Validator } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
@@ -38,6 +42,7 @@ import GiftCardService from "./gift-card"
import TotalsService from "./totals"
import InventoryService from "./inventory"
import CustomShippingOptionService from "./custom-shipping-option"
import { IPriceSelectionStrategy } from "../interfaces/price-selection-strategy"
type CartConstructorProps = {
manager: EntityManager
@@ -59,6 +64,7 @@ type CartConstructorProps = {
totalsService: TotalsService
inventoryService: InventoryService
customShippingOptionService: CustomShippingOptionService
priceSelectionStrategy: IPriceSelectionStrategy
}
type TotalsConfig = {
@@ -94,6 +100,7 @@ class CartService extends BaseService {
private paymentSessionRepository_: typeof PaymentSessionRepository
private inventoryService_: InventoryService
private customShippingOptionService_: CustomShippingOptionService
private priceSelectionStrategy_: IPriceSelectionStrategy
constructor({
manager,
@@ -115,6 +122,7 @@ class CartService extends BaseService {
paymentSessionRepository,
inventoryService,
customShippingOptionService,
priceSelectionStrategy,
}: CartConstructorProps) {
super()
@@ -137,6 +145,7 @@ class CartService extends BaseService {
this.inventoryService_ = inventoryService
this.customShippingOptionService_ = customShippingOptionService
this.taxProviderService_ = taxProviderService
this.priceSelectionStrategy_ = priceSelectionStrategy
}
withTransaction(transactionManager: EntityManager): CartService {
@@ -164,6 +173,7 @@ class CartService extends BaseService {
giftCardService: this.giftCardService_,
inventoryService: this.inventoryService_,
customShippingOptionService: this.customShippingOptionService_,
priceSelectionStrategy: this.priceSelectionStrategy_,
})
cloned.transactionManager_ = transactionManager
@@ -701,12 +711,6 @@ class CartService extends BaseService {
],
})
if (typeof update.region_id !== "undefined") {
const countryCode =
(update.country_code || update.shipping_address?.country_code) ?? null
await this.setRegion_(cart, update.region_id, countryCode)
}
if (typeof update.customer_id !== "undefined") {
await this.updateCustomerId_(cart, update.customer_id)
} else {
@@ -718,6 +722,19 @@ class CartService extends BaseService {
}
}
if (typeof update.region_id !== "undefined") {
const countryCode =
(update.country_code || update.shipping_address?.country_code) ?? null
await this.setRegion_(cart, update.region_id, countryCode)
}
if (
typeof update.customer_id !== "undefined" ||
typeof update.region_id !== "undefined"
) {
await this.updateUnitPrices(cart, update.region_id, update.customer_id)
}
const addrRepo = manager.getCustomRepository(this.addressRepository_)
if ("shipping_address_id" in update || "shipping_address" in update) {
let address: string | Partial<Address> | undefined
@@ -1169,7 +1186,10 @@ class CartService extends BaseService {
* this could be IP address or similar for fraud handling.
* @return the resulting cart
*/
async authorizePayment(cartId: string, context: Record<string, any> = {}): Promise<Cart> {
async authorizePayment(
cartId: string,
context: Record<string, any> = {}
): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
const cartRepository = manager.getCustomRepository(this.cartRepository_)
@@ -1592,6 +1612,57 @@ class CartService extends BaseService {
return customOption
}
async updateUnitPrices(
cart: Cart,
regionId?: string,
customer_id?: string
): Promise<void> {
// If the cart contains items, we update the price of the items
// to match the updated region or customer id (keeping the old
// value if it exists)
if (cart.items.length) {
const region = await this.regionService_.retrieve(
regionId || cart.region_id,
{
relations: ["countries"],
}
)
cart.items = await Promise.all(
cart.items
.map(async (item) => {
const availablePrice = await this.priceSelectionStrategy_
.calculateVariantPrice(item.variant_id, {
region_id: region.id,
currency_code: region.currency_code,
quantity: item.quantity,
customer_id: customer_id || cart.customer_id,
include_discount_prices: true,
})
.catch(() => undefined)
if (
availablePrice !== undefined &&
availablePrice.calculatedPrice !== null
) {
return this.lineItemService_
.withTransaction(this.transactionManager_)
.update(item.id, {
has_shipping: false,
unit_price: availablePrice.calculatedPrice,
})
} else {
await this.lineItemService_
.withTransaction(this.transactionManager_)
.delete(item.id)
return null
}
})
.filter(Boolean)
)
}
}
/**
* Set's the region of a cart.
* @param cart - the cart to set region on
@@ -1619,34 +1690,6 @@ class CartService extends BaseService {
cart.region = region
cart.region_id = region.id
// If the cart contains items we want to change the unit_price field of each
// item to correspond to the price given in the region
if (cart.items.length) {
cart.items = await Promise.all(
cart.items
.map(async (item) => {
const availablePrice = await this.productVariantService_
.getRegionPrice(item.variant_id, regionId)
.catch(() => undefined)
if (availablePrice !== undefined) {
return this.lineItemService_
.withTransaction(this.transactionManager_)
.update(item.id, {
has_shipping: false,
unit_price: availablePrice,
})
} else {
await this.lineItemService_
.withTransaction(this.transactionManager_)
.delete(item.id)
return null
}
})
.filter(Boolean)
)
}
/*
* When changing the region you are changing the set of countries that your
* cart can be shipped to so we need to make sure that the current shipping
+7 -1
View File
@@ -143,6 +143,7 @@ class LineItemService extends BaseService {
.withTransaction(manager)
.retrieve(variantId, {
relations: ["product"],
include_discount_prices: true,
})
const region = await this.regionService_
@@ -164,7 +165,12 @@ class LineItemService extends BaseService {
} else {
price = await this.productVariantService_
.withTransaction(manager)
.getRegionPrice(variant.id, region.id)
.getRegionPrice(variant.id, {
regionId: region.id,
quantity: quantity,
customer_id: undefined,
include_discount_prices: true,
})
}
const toCreate = {
+220 -40
View File
@@ -1,10 +1,16 @@
import { JsonWebTokenError } from "jsonwebtoken"
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { Brackets, EntityManager, ILike, SelectQueryBuilder } from "typeorm"
import { MoneyAmount } from ".."
import {
IPriceSelectionStrategy,
PriceSelectionContext,
} from "../interfaces/price-selection-strategy"
import { MoneyAmount } from "../models/money-amount"
import { Product } from "../models/product"
import { ProductOptionValue } from "../models/product-option-value"
import { ProductVariant } from "../models/product-variant"
import { CartRepository } from "../repositories/cart"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { ProductRepository } from "../repositories/product"
import { ProductOptionValueRepository } from "../repositories/product-option-value"
@@ -18,6 +24,7 @@ import { FindConfig } from "../types/common"
import {
CreateProductVariantInput,
FilterableProductVariantProps,
GetRegionPriceContext,
ProductVariantPrice,
UpdateProductVariantInput,
} from "../types/product-variant"
@@ -38,8 +45,10 @@ class ProductVariantService extends BaseService {
private productRepository_: typeof ProductRepository
private eventBus_: EventBusService
private regionService_: RegionService
private priceSelectionStrategy_: IPriceSelectionStrategy
private moneyAmountRepository_: typeof MoneyAmountRepository
private productOptionValueRepository_: typeof ProductOptionValueRepository
private cartRepository_: typeof CartRepository
constructor({
manager,
@@ -49,6 +58,8 @@ class ProductVariantService extends BaseService {
regionService,
moneyAmountRepository,
productOptionValueRepository,
cartRepository,
priceSelectionStrategy,
}) {
super()
@@ -70,6 +81,10 @@ class ProductVariantService extends BaseService {
this.moneyAmountRepository_ = moneyAmountRepository
this.productOptionValueRepository_ = productOptionValueRepository
this.cartRepository_ = cartRepository
this.priceSelectionStrategy_ = priceSelectionStrategy
}
withTransaction(transactionManager: EntityManager): ProductVariantService {
@@ -85,6 +100,8 @@ class ProductVariantService extends BaseService {
regionService: this.regionService_,
moneyAmountRepository: this.moneyAmountRepository_,
productOptionValueRepository: this.productOptionValueRepository_,
cartRepository: this.cartRepository_,
priceSelectionStrategy: this.priceSelectionStrategy_,
})
cloned.transactionManager_ = transactionManager
@@ -100,12 +117,21 @@ class ProductVariantService extends BaseService {
*/
async retrieve(
variantId: string,
config: FindConfig<ProductVariant> = {}
config: FindConfig<ProductVariant> & PriceSelectionContext = {
include_discount_prices: false,
}
): Promise<ProductVariant> {
const variantRepo = this.manager_.getCustomRepository(
this.productVariantRepository_
)
const validatedId = this.validateId_(variantId)
const priceIndex = config.relations?.indexOf("prices") ?? -1
if (priceIndex >= 0 && config.relations) {
config.relations = [...config.relations]
config.relations.splice(priceIndex, 1)
}
const query = this.buildQuery_({ id: validatedId }, config)
const variant = await variantRepo.findOne(query)
@@ -116,7 +142,16 @@ class ProductVariantService extends BaseService {
)
}
return variant
return priceIndex >= 0
? ((await this.setAdditionalPrices(
variant,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)) as ProductVariant)
: variant
}
/**
@@ -127,11 +162,20 @@ class ProductVariantService extends BaseService {
*/
async retrieveBySKU(
sku: string,
config: FindConfig<ProductVariant> = {}
config: FindConfig<ProductVariant> & PriceSelectionContext = {
include_discount_prices: false,
}
): Promise<ProductVariant> {
const variantRepo = this.manager_.getCustomRepository(
this.productVariantRepository_
)
const priceIndex = config.relations?.indexOf("prices") ?? -1
if (priceIndex >= 0 && config.relations) {
config.relations = [...config.relations]
config.relations.splice(priceIndex, 1)
}
const query = this.buildQuery_({ sku }, config)
const variant = await variantRepo.findOne(query)
@@ -142,7 +186,16 @@ class ProductVariantService extends BaseService {
)
}
return variant
return priceIndex >= 0
? ((await this.setAdditionalPrices(
variant,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)) as ProductVariant)
: variant
}
/**
@@ -254,6 +307,7 @@ class ProductVariantService extends BaseService {
* The function will throw, if price updates are attempted.
* @param {string | ProductVariant} variantOrVariantId - variant or id of a variant.
* @param {object} update - an object with the update values.
* @param {object} config - an object with the config values for returning the variant.
* @return {Promise} resolves to the update result.
*/
async update(
@@ -267,7 +321,17 @@ class ProductVariantService extends BaseService {
let variant = variantOrVariantId as ProductVariant
if (typeof variant === `string`) {
variant = await this.retrieve(variantOrVariantId as string)
const variantRes = await variantRepo.findOne({
where: { id: variantOrVariantId as string },
})
if (typeof variant === "undefined") {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Variant with id ${variantOrVariantId} was not found`
)
} else {
variant = variantRes as ProductVariant
}
} else if (!variant.id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
@@ -291,12 +355,12 @@ class ProductVariantService extends BaseService {
}
}
if (metadata) {
variant.metadata = this.setMetadata_(variant, metadata)
if (typeof metadata === "object") {
variant.metadata = this.setMetadata_(variant, metadata as object)
}
if (typeof inventory_quantity === "number") {
variant.inventory_quantity = inventory_quantity
variant.inventory_quantity = inventory_quantity as number
}
for (const [key, value] of Object.entries(rest)) {
@@ -312,6 +376,7 @@ class ProductVariantService extends BaseService {
product_id: result.product_id,
fields: Object.keys(update),
})
return result
})
}
@@ -358,41 +423,29 @@ class ProductVariantService extends BaseService {
* exists the function will try to use a currency price. If no default
* currency price exists the function will throw an error.
* @param {string} variantId - the id of the variant to get price from
* @param {string} regionId - the id of the region to get price for
* @param {GetRegionPriceContext} context - context for getting region price
* @return {number} the price specific to the region
*/
async getRegionPrice(variantId: string, regionId: string): Promise<number> {
async getRegionPrice(
variantId: string,
context: GetRegionPriceContext
): Promise<number> {
return this.atomicPhase_(async (manager: EntityManager) => {
const moneyAmountRepo = manager.getCustomRepository(
this.moneyAmountRepository_
)
const region = await this.regionService_
.withTransaction(manager)
.retrieve(regionId)
.retrieve(context.regionId)
// Find region price based on region id
let moneyAmount = await moneyAmountRepo.findOne({
where: { region_id: regionId, variant_id: variantId },
})
// If no price could be find based on region id, we try to fetch
// based on the region currency code
if (!moneyAmount) {
moneyAmount = await moneyAmountRepo.findOne({
where: { variant_id: variantId, currency_code: region.currency_code },
const prices = await this.priceSelectionStrategy_
.withTransaction(manager)
.calculateVariantPrice(variantId, {
region_id: context.regionId,
currency_code: region.currency_code,
quantity: context.quantity,
customer_id: context.customer_id,
include_discount_prices: !!context.include_discount_prices,
})
}
// Still, if no price is found, we throw
if (!moneyAmount) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`A price for region: ${region.name} could not be found`
)
}
return moneyAmount.amount
return prices.calculatedPrice
})
}
@@ -554,12 +607,23 @@ class ProductVariantService extends BaseService {
*/
async listAndCount(
selector: FilterableProductVariantProps,
config: FindConfig<ProductVariant> = { relations: [], skip: 0, take: 20 }
config: FindConfig<ProductVariant> & PriceSelectionContext = {
relations: [],
skip: 0,
take: 20,
include_discount_prices: false,
}
): Promise<[ProductVariant[], number]> {
const variantRepo = this.manager_.getCustomRepository(
this.productVariantRepository_
)
const priceIndex = config.relations?.indexOf("prices") ?? -1
if (priceIndex >= 0 && config.relations) {
config.relations = [...config.relations]
config.relations.splice(priceIndex, 1)
}
const { q, query, relations } = this.prepareListQuery_(selector, config)
if (q) {
@@ -571,10 +635,39 @@ class ProductVariantService extends BaseService {
raw.map((i) => i.id),
query.withDeleted ?? false
)
if (priceIndex >= 0) {
const res = await this.setAdditionalPrices(
variants,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)
return [res as ProductVariant[], count]
}
return [variants, count]
}
return await variantRepo.findWithRelationsAndCount(relations, query)
const [variants, count] = await variantRepo.findWithRelationsAndCount(
relations,
query
)
if (priceIndex >= 0) {
const res = await this.setAdditionalPrices(
variants,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)
return [res as ProductVariant[], count]
}
return [variants, count]
}
/**
@@ -584,12 +677,22 @@ class ProductVariantService extends BaseService {
*/
async list(
selector: FilterableProductVariantProps,
config: FindConfig<ProductVariant> = { relations: [], skip: 0, take: 20 }
config: FindConfig<ProductVariant> & PriceSelectionContext = {
relations: [],
skip: 0,
take: 20,
}
): Promise<ProductVariant[]> {
const productVariantRepo = this.manager_.getCustomRepository(
this.productVariantRepository_
)
const priceIndex = config.relations?.indexOf("prices") ?? -1
if (priceIndex >= 0 && config.relations) {
config.relations = [...config.relations]
config.relations.splice(priceIndex, 1)
}
let q: string | undefined
if ("q" in selector) {
q = selector.q
@@ -620,7 +723,18 @@ class ProductVariantService extends BaseService {
}
}
return await productVariantRepo.find(query)
const variants = await productVariantRepo.find(query)
return priceIndex >= 0
? ((await this.setAdditionalPrices(
variants,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)) as ProductVariant[])
: variants
}
/**
@@ -770,6 +884,72 @@ class ProductVariantService extends BaseService {
return qb
}
/**
* Set additional prices on a list of variants.
* @param {ProductVariant | ProductVariant[] } variant variant on which to set additional prices
* @param {string} currency_code currency code to fetch prices for
* @param {string} region_id region to fetch prices for
* @param {string} cart_id string of cart to use as a basis for getting currency and region
* @param {string} customer_id id of potentially logged in customer, used to get prices valid for their customer groups
* @param {boolean} include_discount_prices should result include discount pricing
* @return {Promise<ProductVariant | ProductVariant[]>} A list of variants with variants decorated with "additional_prices"
*/
async setAdditionalPrices(
variant,
currency_code,
region_id,
cart_id,
customer_id,
include_discount_prices = false
): Promise<ProductVariant | ProductVariant[]> {
return this.atomicPhase_(async (manager) => {
const cartRepo = manager.getCustomRepository(this.cartRepository_)
let regionId = region_id
let currencyCode = currency_code
if (cart_id) {
const cart = await cartRepo.findOne({
where: { id: cart_id },
relations: ["region"],
})
regionId = cart.region.id
currencyCode = cart.region.currency_code
}
const variantArray = Array.isArray(variant) ? variant : [variant]
const priceSelectionStrategy =
this.priceSelectionStrategy_.withTransaction(manager)
const variantsWithPrices = await Promise.all(
variantArray.map(async (v) => {
const prices = await priceSelectionStrategy.calculateVariantPrice(
v.id,
{
region_id: regionId,
currency_code: currencyCode,
cart_id: cart_id,
customer_id: customer_id,
include_discount_prices: include_discount_prices,
}
)
return {
...v,
prices: prices.prices,
original_price: prices.originalPrice,
calculated_price: prices.calculatedPrice,
calculated_price_type: prices.calculatedPriceType,
}
})
)
return Array.isArray(variant) ? variantsWithPrices : variantsWithPrices[0]
})
}
}
export default ProductVariantService
+209 -11
View File
@@ -27,6 +27,8 @@ class ProductService extends BaseService {
productTagRepository,
imageRepository,
searchService,
cartRepository,
priceSelectionStrategy,
}) {
super()
@@ -62,6 +64,12 @@ class ProductService extends BaseService {
/** @private @const {SearchService} */
this.searchService_ = searchService
/** @private @const {CartRepository} */
this.cartRepository_ = cartRepository
/** @private @const {IPriceSelectionStrategy} */
this.priceSelectionStrategy_ = priceSelectionStrategy
}
withTransaction(transactionManager) {
@@ -80,6 +88,8 @@ class ProductService extends BaseService {
productTagRepository: this.productTagRepository_,
productTypeRepository: this.productTypeRepository_,
imageRepository: this.imageRepository_,
cartRepository: this.cartRepository_,
priceSelectionStrategy: this.priceSelectionStrategy_,
})
cloned.transactionManager_ = transactionManager
@@ -95,10 +105,23 @@ class ProductService extends BaseService {
* returned
* @return {Promise<Product[]>} the result of the find operation
*/
async list(selector = {}, config = { relations: [], skip: 0, take: 20 }) {
async list(
selector = {},
config = {
relations: [],
skip: 0,
take: 20,
include_discount_prices: false,
}
) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const priceIndex = config.relations?.indexOf("variants.prices") ?? -1
if (priceIndex >= 0 && config.relations) {
config.relations = [...config.relations]
config.relations.splice(priceIndex, 1)
}
const { q, query, relations } = this.prepareListQuery_(selector, config)
@@ -112,7 +135,18 @@ class ProductService extends BaseService {
)
}
return productRepo.findWithRelations(relations, query)
const products = productRepo.findWithRelations(relations, query)
return priceIndex > -1
? await this.setAdditionalPrices(
products,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)
: products
}
/**
@@ -128,12 +162,23 @@ class ProductService extends BaseService {
*/
async listAndCount(
selector = {},
config = { relations: [], skip: 0, take: 20 }
config = {
relations: [],
skip: 0,
take: 20,
include_discount_prices: false,
}
) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const priceIndex = config.relations?.indexOf("variants.prices") ?? -1
if (priceIndex >= 0 && config.relations) {
config.relations = [...config.relations]
config.relations.splice(priceIndex, 1)
}
const { q, query, relations } = this.prepareListQuery_(selector, config)
if (q) {
@@ -148,7 +193,25 @@ class ProductService extends BaseService {
return [products, count]
}
return await productRepo.findWithRelationsAndCount(relations, query)
const [products, count] = await productRepo.findWithRelationsAndCount(
relations,
query
)
if (priceIndex > -1) {
const productsWithAdditionalPrices = await this.setAdditionalPrices(
products,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)
return [productsWithAdditionalPrices, count]
} else {
return [products, count]
}
}
/**
@@ -172,12 +235,18 @@ class ProductService extends BaseService {
* query response
* @return {Promise<Product>} the result of the find one operation.
*/
async retrieve(productId, config = {}) {
async retrieve(productId, config = { include_discount_prices: false }) {
const productRepo = this.manager_.getCustomRepository(
this.productRepository_
)
const validatedId = this.validateId_(productId)
const priceIndex = config.relations?.indexOf("variants.prices") ?? -1
if (priceIndex >= 0 && config.relations) {
config.relations = [...config.relations]
config.relations.splice(priceIndex, 1)
}
const query = { where: { id: validatedId } }
if (config.relations && config.relations.length > 0) {
@@ -199,7 +268,16 @@ class ProductService extends BaseService {
)
}
return product
return priceIndex > -1
? await this.setAdditionalPrices(
product,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)
: product
}
/**
@@ -214,6 +292,12 @@ class ProductService extends BaseService {
this.productRepository_
)
const priceIndex = config.relations?.indexOf("variants.prices") ?? -1
if (priceIndex >= 0 && config.relations) {
config.relations = [...config.relations]
config.relations.splice(priceIndex, 1)
}
const query = { where: { handle: productHandle } }
if (config.relations && config.relations.length > 0) {
@@ -235,7 +319,16 @@ class ProductService extends BaseService {
)
}
return product
return priceIndex > -1
? await this.setAdditionalPrices(
product,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)
: product
}
/**
@@ -250,6 +343,12 @@ class ProductService extends BaseService {
this.productRepository_
)
const priceIndex = config.relations?.indexOf("variants.prices") ?? -1
if (priceIndex >= 0 && config.relations) {
config.relations = [...config.relations]
config.relations.splice(priceIndex, 1)
}
const query = { where: { external_id: externalId } }
if (config.relations && config.relations.length > 0) {
@@ -271,7 +370,16 @@ class ProductService extends BaseService {
)
}
return product
return priceIndex > -1
? await this.setAdditionalPrices(
product,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)
: product
}
/**
@@ -535,6 +643,7 @@ class ProductService extends BaseService {
}
const result = await productRepo.save(product)
await this.eventBus_
.withTransaction(manager)
.emit(ProductService.Events.UPDATED, {
@@ -821,11 +930,18 @@ class ProductService extends BaseService {
* @param {string} productId - the productId to decorate.
* @param {string[]} fields - the fields to include.
* @param {string[]} expandFields - fields to expand.
* @param {object} config - retrieve config for price calculation.
* @return {Product} return the decorated product.
*/
async decorate(productId, fields = [], expandFields = []) {
async decorate(productId, fields = [], expandFields = [], config = {}) {
const requiredFields = ["id", "metadata"]
const priceIndex = expandFields.indexOf("variants.prices") ?? -1
if (priceIndex >= 0 && expandFields.length) {
expandFields = [...expandFields]
expandFields.splice(priceIndex, 1)
}
fields = fields.concat(requiredFields)
const product = await this.retrieve(productId, {
@@ -833,8 +949,16 @@ class ProductService extends BaseService {
relations: expandFields,
})
// const final = await this.runDecorators_(decorated)
return product
return priceIndex > -1
? await this.setAdditionalPrices(
product,
config.currency_code,
config.region_id,
config.cart_id,
config.customer_id,
config.include_discount_prices
)
: product
}
/**
@@ -908,6 +1032,80 @@ class ProductService extends BaseService {
return qb
}
/**
* Set additional prices on a list of products.
* @param {Product[] | Product} products list of products on which to set additional prices
* @param {string} currency_code currency code to fetch prices for
* @param {string} region_id region to fetch prices for
* @param {string} cart_id string of cart to use as a basis for getting currency and region
* @param {string} customer_id id of potentially logged in customer, used to get prices valid for their customer groups
* @param {boolean} include_discount_prices indication wether or not to include sales prices in result
* @return {Promise<Product[]>} A list of products with variants decorated with "additional_prices"
*/
async setAdditionalPrices(
products,
currency_code,
region_id,
cart_id,
customer_id,
include_discount_prices = false
) {
return this.atomicPhase_(async (manager) => {
const cartRepo = this.manager_.getCustomRepository(this.cartRepository_)
let regionId = region_id
let currencyCode = currency_code
if (cart_id) {
const cart = await cartRepo.findOne({
where: { id: cart_id },
relations: ["region"],
})
regionId = cart.region.id
currencyCode = cart.region.currency_code
}
const productArray = Array.isArray(products) ? products : [products]
const priceSelectionStrategy =
this.priceSelectionStrategy_.withTransaction(manager)
const productsWithPrices = await Promise.all(
productArray.map(async (p) => {
if (p.variants?.length) {
p.variants = await Promise.all(
p.variants.map(async (v) => {
const prices =
await priceSelectionStrategy.calculateVariantPrice(v.id, {
region_id: regionId,
currency_code: currencyCode,
cart_id: cart_id,
customer_id: customer_id,
include_discount_prices,
})
return {
...v,
prices: prices.prices,
original_price: prices.originalPrice,
calculated_price: prices.calculatedPrice,
calculated_price_type: prices.calculatedPriceType,
}
})
)
}
return p
})
)
return Array.isArray(products)
? productsWithPrices
: productsWithPrices[0]
})
}
}
export default ProductService
@@ -7,6 +7,7 @@ import {
} from "../interfaces/price-selection-strategy"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { EntityManager } from "typeorm"
import { MedusaError } from "medusa-core-utils"
class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
private moneyAmountRepository_: typeof MoneyAmountRepository
@@ -0,0 +1,15 @@
import { IsOptional, IsString } from "class-validator"
export class PriceSelectionParams {
@IsOptional()
@IsString()
cart_id?: string
@IsOptional()
@IsString()
region_id?: string
@IsOptional()
@IsString()
currency_code?: string
}
@@ -25,6 +25,13 @@ export type ProductVariantPrice = {
max_quantity?: number
}
export type GetRegionPriceContext = {
regionId: string
quantity?: number
customer_id?: string
include_discount_prices?: boolean
}
export type ProductVariantOption = {
option_id: string
value: string