Files
medusa-store/packages/medusa/src/services/product-variant.ts

832 lines
24 KiB
TypeScript

import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { Brackets, EntityManager, ILike, SelectQueryBuilder } from "typeorm"
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"
import {
FindWithRelationsOptions,
ProductVariantRepository,
} from "../repositories/product-variant"
import EventBusService from "./event-bus"
import RegionService from "./region"
import { FindConfig } from "../types/common"
import {
CreateProductVariantInput,
FilterableProductVariantProps,
GetRegionPriceContext,
ProductVariantPrice,
UpdateProductVariantInput,
} from "../types/product-variant"
import { isDefined } from "../utils"
/**
* Provides layer to manipulate product variants.
* @extends BaseService
*/
class ProductVariantService extends BaseService {
static Events = {
UPDATED: "product-variant.updated",
CREATED: "product-variant.created",
DELETED: "product-variant.deleted",
}
private manager_: EntityManager
private productVariantRepository_: typeof ProductVariantRepository
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,
productVariantRepository,
productRepository,
eventBusService,
regionService,
moneyAmountRepository,
productOptionValueRepository,
cartRepository,
priceSelectionStrategy,
}) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ProductVariantModel} */
this.productVariantRepository_ = productVariantRepository
/** @private @const {ProductModel} */
this.productRepository_ = productRepository
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
/** @private @const {RegionService} */
this.regionService_ = regionService
this.moneyAmountRepository_ = moneyAmountRepository
this.productOptionValueRepository_ = productOptionValueRepository
this.cartRepository_ = cartRepository
this.priceSelectionStrategy_ = priceSelectionStrategy
}
withTransaction(transactionManager: EntityManager): ProductVariantService {
if (!transactionManager) {
return this
}
const cloned = new ProductVariantService({
manager: transactionManager,
productVariantRepository: this.productVariantRepository_,
productRepository: this.productRepository_,
eventBusService: this.eventBus_,
regionService: this.regionService_,
moneyAmountRepository: this.moneyAmountRepository_,
productOptionValueRepository: this.productOptionValueRepository_,
cartRepository: this.cartRepository_,
priceSelectionStrategy: this.priceSelectionStrategy_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Gets a product variant by id.
* @param {string} variantId - the id of the product to get.
* @param {FindConfig<ProductVariant>} config - query config object for variant retrieval.
* @return {Promise<Product>} the product document.
*/
async retrieve(
variantId: string,
config: FindConfig<ProductVariant> & PriceSelectionContext = {
include_discount_prices: false,
}
): Promise<ProductVariant> {
const variantRepo = this.manager_.getCustomRepository(
this.productVariantRepository_
)
const validatedId = this.validateId_(variantId)
const query = this.buildQuery_({ id: validatedId }, config)
const variant = await variantRepo.findOne(query)
if (!variant) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Variant with id: ${variantId} was not found`
)
}
return variant
}
/**
* Gets a product variant by id.
* @param {string} sku - The unique stock keeping unit used to identify the product variant.
* @param {FindConfig<ProductVariant>} config - query config object for variant retrieval.
* @return {Promise<Product>} the product document.
*/
async retrieveBySKU(
sku: string,
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)
if (!variant) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Variant with sku: ${sku} was not found`
)
}
return variant
}
/**
* Creates an unpublished product variant. Will validate against parent product
* to ensure that the variant can in fact be created.
* @param {string} productOrProductId - the product the variant will be added to
* @param {object} variant - the variant to create
* @return {Promise} resolves to the creation result.
*/
async create(
productOrProductId: string | Product,
variant: CreateProductVariantInput
): Promise<ProductVariant> {
return this.atomicPhase_(async (manager: EntityManager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const variantRepo = manager.getCustomRepository(
this.productVariantRepository_
)
const { prices, ...rest } = variant
let product = productOrProductId as Product
if (typeof product === `string`) {
product = (await productRepo.findOne({
where: { id: productOrProductId },
relations: ["variants", "variants.options", "options"],
})) as Product
} else if (!product.id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Product id missing`
)
}
if (product.options.length !== variant.options.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Product options length does not match variant options length. Product has ${product.options.length} and variant has ${variant.options.length}.`
)
}
product.options.forEach((option) => {
if (!variant.options.find((vo) => option.id === vo.option_id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variant options do not contain value for ${option.title}`
)
}
})
const variantExists = product.variants.find((v) => {
return v.options.every((option) => {
const variantOption = variant.options.find(
(o) => option.option_id === o.option_id
)
return option.value === variantOption?.value
})
})
if (variantExists) {
throw new MedusaError(
MedusaError.Types.DUPLICATE_ERROR,
`Variant with title ${variantExists.title} with provided options already exists`
)
}
if (!rest.variant_rank) {
rest.variant_rank = product.variants.length
}
const toCreate = {
...rest,
product_id: product.id,
}
const productVariant = variantRepo.create(toCreate)
const result = await variantRepo.save(productVariant)
if (prices) {
for (const price of prices) {
if (price.region_id) {
const region = await this.regionService_.retrieve(price.region_id)
await this.setRegionPrice(result.id, {
amount: price.amount,
region_id: price.region_id,
currency_code: region.currency_code,
})
} else {
await this.setCurrencyPrice(result.id, price)
}
}
}
await this.eventBus_
.withTransaction(manager)
.emit(ProductVariantService.Events.CREATED, {
id: result.id,
product_id: result.product_id,
})
return result
})
}
/**
* Updates a variant.
* Price updates should use dedicated methods.
* 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(
variantOrVariantId: string | Partial<ProductVariant>,
update: UpdateProductVariantInput
): Promise<ProductVariant> {
return this.atomicPhase_(async (manager: EntityManager) => {
const variantRepo = manager.getCustomRepository(
this.productVariantRepository_
)
let variant = variantOrVariantId as ProductVariant
if (typeof variant === `string`) {
const variantRes = await variantRepo.findOne({
where: { id: variantOrVariantId as string },
})
if (!isDefined(variantRes)) {
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,
`Variant id missing`
)
}
const { prices, options, metadata, inventory_quantity, ...rest } = update
if (prices) {
await this.updateVariantPrices(variant.id, prices)
}
if (options) {
for (const option of options) {
await this.updateOptionValue(
variant.id,
option.option_id,
option.value
)
}
}
if (typeof metadata === "object") {
variant.metadata = this.setMetadata_(variant, metadata as object)
}
if (typeof inventory_quantity === "number") {
variant.inventory_quantity = inventory_quantity as number
}
for (const [key, value] of Object.entries(rest)) {
variant[key] = value
}
const result = await variantRepo.save(variant)
await this.eventBus_
.withTransaction(manager)
.emit(ProductVariantService.Events.UPDATED, {
id: result.id,
product_id: result.product_id,
fields: Object.keys(update),
})
return result
})
}
/**
* Updates a variant's prices.
* Deletes any prices that are not in the update object, and is not associated with a price list.
* @param variantId - the id of variant
* @param prices - the update prices
* @returns {Promise<void>} empty promise
*/
async updateVariantPrices(
variantId: string,
prices: ProductVariantPrice[]
): Promise<void> {
return this.atomicPhase_(async (manager: EntityManager) => {
const moneyAmountRepo = manager.getCustomRepository(
this.moneyAmountRepository_
)
// get prices to be deleted
const obsoletePrices = await moneyAmountRepo.findVariantPricesNotIn(
variantId,
prices
)
for (const price of prices) {
if (price.region_id) {
const region = await this.regionService_.retrieve(price.region_id)
await this.setRegionPrice(variantId, {
currency_code: region.currency_code,
region_id: price.region_id,
amount: price.amount,
})
} else {
await this.setCurrencyPrice(variantId, price)
}
}
await moneyAmountRepo.remove(obsoletePrices)
})
}
/**
* Gets the price specific to a region. If no region specific money amount
* 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 {GetRegionPriceContext} context - context for getting region price
* @return {number} the price specific to the region
*/
async getRegionPrice(
variantId: string,
context: GetRegionPriceContext
): Promise<number> {
return this.atomicPhase_(async (manager: EntityManager) => {
const region = await this.regionService_
.withTransaction(manager)
.retrieve(context.regionId)
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,
})
return prices.calculatedPrice
})
}
/**
* Sets the default price of a specific region
* @param {string} variantId - the id of the variant to update
* @param {string} price - the price for the variant.
* @return {Promise} the result of the update operation
*/
async setRegionPrice(
variantId: string,
price: ProductVariantPrice
): Promise<MoneyAmount> {
return this.atomicPhase_(async (manager: EntityManager) => {
const moneyAmountRepo = manager.getCustomRepository(
this.moneyAmountRepository_
)
let moneyAmount = await moneyAmountRepo.findOne({
where: {
variant_id: variantId,
region_id: price.region_id,
price_list_id: null,
},
})
if (!moneyAmount) {
moneyAmount = moneyAmountRepo.create({
...price,
variant_id: variantId,
})
} else {
moneyAmount.amount = price.amount
}
const result = await moneyAmountRepo.save(moneyAmount)
return result
})
}
/**
* Sets the default price for the given currency.
* @param {string} variantId - the id of the variant to set prices for
* @param {ProductVariantPrice} price - the price for the variant
* @return {Promise} the result of the update operation
*/
async setCurrencyPrice(
variantId: string,
price: ProductVariantPrice
): Promise<MoneyAmount> {
return this.atomicPhase_(async (manager: EntityManager) => {
const moneyAmountRepo = manager.getCustomRepository(
this.moneyAmountRepository_
)
return await moneyAmountRepo.upsertVariantCurrencyPrice(variantId, price)
})
}
/**
* Updates variant's option value.
* Option value must be of type string or number.
* @param {string} variantId - the variant to decorate.
* @param {string} optionId - the option from product.
* @param {string} optionValue - option value to add.
* @return {Promise} the result of the update operation.
*/
async updateOptionValue(
variantId: string,
optionId: string,
optionValue: string
): Promise<ProductOptionValue> {
return this.atomicPhase_(async (manager: EntityManager) => {
const productOptionValueRepo = manager.getCustomRepository(
this.productOptionValueRepository_
)
const productOptionValue = await productOptionValueRepo.findOne({
where: { variant_id: variantId, option_id: optionId },
})
if (!productOptionValue) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product option value not found`
)
}
productOptionValue.value = optionValue
return await productOptionValueRepo.save(productOptionValue)
})
}
/**
* Adds option value to a variant.
* Fails when product with variant does not exist or
* if that product does not have an option with the given
* option id. Fails if given variant is not found.
* Option value must be of type string or number.
* @param {string} variantId - the variant to decorate.
* @param {string} optionId - the option from product.
* @param {string} optionValue - option value to add.
* @return {Promise} the result of the update operation.
*/
async addOptionValue(
variantId: string,
optionId: string,
optionValue: string
): Promise<ProductOptionValue> {
return this.atomicPhase_(async (manager: EntityManager) => {
const productOptionValueRepo = manager.getCustomRepository(
this.productOptionValueRepository_
)
const productOptionValue = productOptionValueRepo.create({
variant_id: variantId,
option_id: optionId,
value: optionValue,
})
return await productOptionValueRepo.save(productOptionValue)
})
}
/**
* Deletes option value from given variant.
* Will never fail due to delete being idempotent.
* @param {string} variantId - the variant to decorate.
* @param {string} optionId - the option from product.
* @return {Promise} empty promise
*/
async deleteOptionValue(variantId: string, optionId: string): Promise<void> {
return this.atomicPhase_(async (manager: EntityManager) => {
const productOptionValueRepo: ProductOptionValueRepository =
manager.getCustomRepository(this.productOptionValueRepository_)
const productOptionValue = await productOptionValueRepo.findOne({
where: {
variant_id: variantId,
option_id: optionId,
},
})
if (!productOptionValue) {
return Promise.resolve()
}
await productOptionValueRepo.softRemove(productOptionValue)
return Promise.resolve()
})
}
/**
* @param {object} selector - the query object for find
* @param {FindConfig<ProductVariant>} config - query config object for variant retrieval
* @return {Promise} the result of the find operation
*/
async listAndCount(
selector: FilterableProductVariantProps,
config: FindConfig<ProductVariant> & PriceSelectionContext = {
relations: [],
skip: 0,
take: 20,
include_discount_prices: false,
}
): Promise<[ProductVariant[], number]> {
const variantRepo = this.manager_.getCustomRepository(
this.productVariantRepository_
)
const { q, query, relations } = this.prepareListQuery_(selector, config)
if (q) {
const qb = this.getFreeTextQueryBuilder_(variantRepo, query, q)
const [raw, count] = await qb.getManyAndCount()
const variants = await variantRepo.findWithRelations(
relations,
raw.map((i) => i.id),
query.withDeleted ?? false
)
return [variants, count]
}
const [variants, count] = await variantRepo.findWithRelationsAndCount(
relations,
query
)
return [variants, count]
}
/**
* @param {FilterableProductVariantProps} selector - the query object for find
* @param {FindConfig<ProductVariant>} config - query config object for variant retrieval
* @return {Promise} the result of the find operation
*/
async list(
selector: FilterableProductVariantProps,
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
delete selector.q
}
const query = this.buildQuery_(selector, config)
if (q) {
const where = query.where
delete where.sku
delete where.title
query.join = {
alias: "variant",
innerJoin: {
product: "variant.product",
},
}
query.where = (qb: SelectQueryBuilder<ProductVariant>): void => {
qb.where(where).andWhere([
{ sku: ILike(`%${q}%`) },
{ title: ILike(`%${q}%`) },
{ product: { title: ILike(`%${q}%`) } },
])
}
}
return await productVariantRepo.find(query)
}
/**
* Deletes variant.
* Will never fail due to delete being idempotent.
* @param {string} variantId - the id of the variant to delete. Must be
* castable as an ObjectId
* @return {Promise<void>} empty promise
*/
async delete(variantId: string): Promise<void> {
return this.atomicPhase_(async (manager: EntityManager) => {
const variantRepo = manager.getCustomRepository(
this.productVariantRepository_
)
const variant = await variantRepo.findOne({
where: { id: variantId },
relations: ["prices", "options"],
})
if (!variant) {
return Promise.resolve()
}
await variantRepo.softRemove(variant)
await this.eventBus_
.withTransaction(manager)
.emit(ProductVariantService.Events.DELETED, {
id: variant.id,
product_id: variant.product_id,
metadata: variant.metadata,
})
return Promise.resolve()
})
}
/**
* Dedicated method to set metadata for a variant.
* @param {string} variant - the variant to set metadata for.
* @param {Object} metadata - the metadata to set
* @return {Object} updated metadata object
*/
setMetadata_(
variant: ProductVariant,
metadata: object
): Record<string, unknown> {
const existing = variant.metadata || {}
const newData = {}
for (const [key, value] of Object.entries(metadata)) {
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
newData[key] = value
}
const updated = {
...existing,
...newData,
}
return updated
}
/**
* Creates a query object to be used for list queries.
* @param {object} selector - the selector to create the query from
* @param {object} config - the config to use for the query
* @return {object} an object containing the query, relations and free-text
* search param.
*/
prepareListQuery_(
selector: FilterableProductVariantProps,
config: FindConfig<ProductVariant>
): { query: FindWithRelationsOptions; relations: string[]; q?: string } {
let q: string | undefined
if (isDefined(selector.q)) {
q = selector.q
delete selector.q
}
const query = this.buildQuery_(selector, config)
if (config.relations && config.relations.length > 0) {
query.relations = config.relations
}
if (config.select && config.select.length > 0) {
query.select = config.select
}
const rels = query.relations
delete query.relations
return {
query,
relations: rels,
q,
}
}
/**
* Lists variants based on the provided parameters and includes the count of
* variants that match the query.
* @param {object} variantRepo - the variant repository
* @param {object} query - object that defines the scope for what should be returned
* @param {object} q - free text query
* @return {Promise<[ProductVariant[], number]>} an array containing the products as the first element and the total
* count of products that matches the query as the second element.
*/
getFreeTextQueryBuilder_(
variantRepo: ProductVariantRepository,
query: FindWithRelationsOptions,
q?: string
): SelectQueryBuilder<ProductVariant> {
const where = query.where
if (typeof where === "object") {
if ("title" in where) {
delete where.title
}
}
let qb = variantRepo
.createQueryBuilder("pv")
.take(query.take)
.skip(Math.max(query.skip ?? 0, 0))
.leftJoinAndSelect("pv.product", "product")
.select(["pv.id"])
.where(where!)
.andWhere(
new Brackets((qb) => {
qb.where(`product.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`pv.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`pv.sku ILIKE :q`, { q: `%${q}%` })
})
)
if (query.withDeleted) {
qb = qb.withDeleted()
}
return qb
}
}
export default ProductVariantService