Files
medusa-store/packages/medusa/src/services/discount.ts
2022-10-07 09:07:32 +02:00

761 lines
22 KiB
TypeScript

import { parse, toSeconds } from "iso8601-duration"
import { isEmpty, omit } from "lodash"
import { MedusaError } from "medusa-core-utils"
import {
Brackets,
DeepPartial,
EntityManager,
ILike,
SelectQueryBuilder,
} from "typeorm"
import {
EventBusService,
ProductService,
RegionService,
TotalsService,
} from "."
import { TransactionBaseService } from "../interfaces"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { Cart, Discount, LineItem, Region } from "../models"
import {
AllocationType as DiscountAllocation,
DiscountRule,
DiscountRuleType,
} from "../models/discount-rule"
import { DiscountRepository } from "../repositories/discount"
import { DiscountConditionRepository } from "../repositories/discount-condition"
import { DiscountRuleRepository } from "../repositories/discount-rule"
import { GiftCardRepository } from "../repositories/gift-card"
import { FindConfig, Selector } from "../types/common"
import {
CreateDiscountInput,
CreateDiscountRuleInput,
CreateDynamicDiscountInput,
FilterableDiscountProps,
UpdateDiscountInput,
UpdateDiscountRuleInput,
} from "../types/discount"
import { buildQuery, setMetadata } from "../utils"
import { isFuture, isPast } from "../utils/date-helpers"
import { formatException } from "../utils/exception-formatter"
import { FlagRouter } from "../utils/flag-router"
import CustomerService from "./customer"
import DiscountConditionService from "./discount-condition"
/**
* Provides layer to manipulate discounts.
* @implements {BaseService}
*/
class DiscountService extends TransactionBaseService {
protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined
protected readonly discountRepository_: typeof DiscountRepository
protected readonly customerService_: CustomerService
protected readonly discountRuleRepository_: typeof DiscountRuleRepository
protected readonly giftCardRepository_: typeof GiftCardRepository
// eslint-disable-next-line max-len
protected readonly discountConditionRepository_: typeof DiscountConditionRepository
protected readonly discountConditionService_: DiscountConditionService
protected readonly totalsService_: TotalsService
protected readonly productService_: ProductService
protected readonly regionService_: RegionService
protected readonly eventBus_: EventBusService
protected readonly featureFlagRouter_: FlagRouter
constructor({
manager,
discountRepository,
discountRuleRepository,
giftCardRepository,
discountConditionRepository,
discountConditionService,
totalsService,
productService,
regionService,
customerService,
eventBusService,
featureFlagRouter,
}) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
this.manager_ = manager
this.discountRepository_ = discountRepository
this.discountRuleRepository_ = discountRuleRepository
this.giftCardRepository_ = giftCardRepository
this.discountConditionRepository_ = discountConditionRepository
this.discountConditionService_ = discountConditionService
this.totalsService_ = totalsService
this.productService_ = productService
this.regionService_ = regionService
this.customerService_ = customerService
this.eventBus_ = eventBusService
this.featureFlagRouter_ = featureFlagRouter
}
/**
* Creates a discount rule with provided data given that the data is validated.
* @param {DiscountRule} discountRule - the discount rule to create
* @return {Promise} the result of the create operation
*/
validateDiscountRule_<T extends { type: DiscountRuleType; value: number }>(
discountRule: T
): T {
if (discountRule.type === "percentage" && discountRule.value > 100) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Discount value above 100 is not allowed when type is percentage"
)
}
return discountRule
}
/**
* @param {Object} selector - the query object for find
* @param {Object} config - the config object containing query settings
* @return {Promise} the result of the find operation
*/
async list(
selector: FilterableDiscountProps = {},
config: FindConfig<Discount> = { relations: [], skip: 0, take: 10 }
): Promise<Discount[]> {
const manager = this.manager_
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const query = buildQuery(selector as Selector<Discount>, config)
return await discountRepo.find(query)
}
/**
* @param {Object} selector - the query object for find
* @param {Object} config - the config object containing query settings
* @return {Promise} the result of the find operation
*/
async listAndCount(
selector: FilterableDiscountProps = {},
config: FindConfig<Discount> = {
take: 20,
skip: 0,
order: { created_at: "DESC" },
}
): Promise<[Discount[], number]> {
const manager = this.manager_
const discountRepo = manager.getCustomRepository(this.discountRepository_)
let q
if ("q" in selector) {
q = selector.q
delete selector.q
}
const query = buildQuery(selector as Selector<Discount>, config)
if (q) {
const where = query.where
delete where.code
query.where = (qb: SelectQueryBuilder<Discount>): void => {
qb.where(where)
qb.andWhere(
new Brackets((qb) => {
qb.where({ code: ILike(`%${q}%`) })
})
)
}
}
const [discounts, count] = await discountRepo.findAndCount(query)
return [discounts, count]
}
/**
* Creates a discount with provided data given that the data is validated.
* Normalizes discount code to uppercase.
* @param {Discount} discount - the discount data to create
* @return {Promise} the result of the create operation
*/
async create(discount: CreateDiscountInput): Promise<Discount> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const ruleRepo = manager.getCustomRepository(this.discountRuleRepository_)
const conditions = discount.rule?.conditions
const ruleToCreate = omit(discount.rule, ["conditions"])
const validatedRule: Omit<CreateDiscountRuleInput, "conditions"> =
this.validateDiscountRule_(ruleToCreate)
if (
discount?.regions &&
discount?.regions.length > 1 &&
discount?.rule?.type === "fixed"
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Fixed discounts can have one region"
)
}
try {
if (discount.regions) {
discount.regions = (await Promise.all(
discount.regions.map(async (regionId) =>
this.regionService_.withTransaction(manager).retrieve(regionId)
)
)) as Region[]
}
const discountRule = ruleRepo.create(validatedRule)
const createdDiscountRule = await ruleRepo.save(discountRule)
const created: Discount = discountRepo.create(
discount as DeepPartial<Discount>
)
created.rule = createdDiscountRule
const result = await discountRepo.save(created)
if (conditions?.length) {
await Promise.all(
conditions.map(async (cond) => {
await this.discountConditionService_
.withTransaction(manager)
.upsertCondition({ rule_id: result.rule_id, ...cond })
})
)
}
return result
} catch (error) {
throw formatException(error)
}
})
}
/**
* Gets a discount by id.
* @param {string} discountId - id of discount to retrieve
* @param {Object} config - the config object containing query settings
* @return {Promise<Discount>} the discount
*/
async retrieve(
discountId: string,
config: FindConfig<Discount> = {}
): Promise<Discount> {
const manager = this.manager_
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const query = buildQuery({ id: discountId }, config)
const discount = await discountRepo.findOne(query)
if (!discount) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Discount with ${discountId} was not found`
)
}
return discount
}
/**
* Gets a discount by discount code.
* @param {string} discountCode - discount code of discount to retrieve
* @param {Object} config - the config object containing query settings
* @return {Promise<Discount>} the discount document
*/
async retrieveByCode(
discountCode: string,
config: FindConfig<Discount> = {}
): Promise<Discount> {
const manager = this.manager_
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const normalizedCode = discountCode.toUpperCase().trim()
let query = buildQuery({ code: normalizedCode, is_dynamic: false }, config)
let discount = await discountRepo.findOne(query)
if (!discount) {
query = buildQuery({ code: normalizedCode, is_dynamic: true }, config)
discount = await discountRepo.findOne(query)
if (!discount) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Discount with code ${discountCode} was not found`
)
}
}
return discount
}
/**
* Updates a discount.
* @param {string} discountId - discount id of discount to update
* @param {Discount} update - the data to update the discount with
* @return {Promise} the result of the update operation
*/
async update(
discountId: string,
update: UpdateDiscountInput
): Promise<Discount> {
return await this.atomicPhase_(async (manager) => {
const discountRepo: DiscountRepository = manager.getCustomRepository(
this.discountRepository_
)
const ruleRepo: DiscountRuleRepository = manager.getCustomRepository(
this.discountRuleRepository_
)
const discount = await this.retrieve(discountId, {
relations: ["rule"],
})
const conditions = update?.rule?.conditions
const ruleToUpdate = omit(update.rule, "conditions")
if (!isEmpty(ruleToUpdate)) {
update.rule = ruleToUpdate as UpdateDiscountRuleInput
}
const { rule, metadata, regions, ...rest } = update
if (rest.ends_at) {
if (discount.starts_at >= new Date(rest.ends_at)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"ends_at" must be greater than "starts_at"`
)
}
}
if (regions && regions?.length > 1 && discount.rule.type === "fixed") {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Fixed discounts can have one region"
)
}
if (conditions?.length) {
await Promise.all(
conditions.map(async (cond) => {
await this.discountConditionService_
.withTransaction(manager)
.upsertCondition({ rule_id: discount.rule_id, ...cond })
})
)
}
if (regions) {
discount.regions = await Promise.all(
regions.map(async (regionId) =>
this.regionService_.retrieve(regionId)
)
)
}
if (metadata) {
discount.metadata = setMetadata(discount, metadata)
}
if (rule) {
const ruleUpdate: Omit<UpdateDiscountRuleInput, "conditions"> = rule
if (rule.value) {
this.validateDiscountRule_({
value: rule.value,
type: discount.rule.type,
})
}
discount.rule = ruleRepo.create({
...discount.rule,
...ruleUpdate,
} as DiscountRule)
}
for (const key of Object.keys(rest).filter(
(k) => typeof rest[k] !== `undefined`
)) {
discount[key] = rest[key]
}
discount.code = discount.code.toUpperCase()
return await discountRepo.save(discount)
})
}
/**
* Creates a dynamic code for a discount id.
* @param {string} discountId - the id of the discount to create a code for
* @param {Object} data - the object containing a code to identify the discount by
* @return {Promise} the newly created dynamic code
*/
async createDynamicCode(
discountId: string,
data: CreateDynamicDiscountInput
): Promise<Discount> {
return await this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const discount = await this.retrieve(discountId)
if (!discount.is_dynamic) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount must be set to dynamic"
)
}
if (!data.code) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Discount must have a code"
)
}
const toCreate = {
...data,
rule_id: discount.rule_id,
is_dynamic: true,
is_disabled: false,
code: data.code.toUpperCase(),
parent_discount_id: discount.id,
usage_limit: discount.usage_limit,
}
if (discount.valid_duration) {
const lastValidDate = new Date()
lastValidDate.setSeconds(
lastValidDate.getSeconds() + toSeconds(parse(discount.valid_duration))
)
toCreate.ends_at = lastValidDate
}
const created: Discount = discountRepo.create(toCreate)
return await discountRepo.save(created)
})
}
/**
* Deletes a dynamic code for a discount id.
* @param {string} discountId - the id of the discount to create a code for
* @param {string} code - the code to identify the discount by
* @return {Promise} the newly created dynamic code
*/
async deleteDynamicCode(discountId: string, code: string): Promise<void> {
return await this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const discount = await discountRepo.findOne({
where: { parent_discount_id: discountId, code },
})
if (!discount) {
return
}
await discountRepo.softRemove(discount)
})
}
/**
* Adds a region to the discount regions array.
* @param {string} discountId - id of discount
* @param {string} regionId - id of region to add
* @return {Promise} the result of the update operation
*/
async addRegion(discountId: string, regionId: string): Promise<Discount> {
return await this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const discount = await this.retrieve(discountId, {
relations: ["regions", "rule"],
})
const exists = discount.regions.find((r) => r.id === regionId)
// If region is already present, we return early
if (exists) {
return discount
}
if (discount.regions?.length === 1 && discount.rule.type === "fixed") {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Fixed discounts can have one region"
)
}
const region = await this.regionService_.retrieve(regionId)
discount.regions = [...discount.regions, region]
return await discountRepo.save(discount)
})
}
/**
* Removes a region from the discount regions array.
* @param {string} discountId - id of discount
* @param {string} regionId - id of region to remove
* @return {Promise} the result of the update operation
*/
async removeRegion(discountId: string, regionId: string): Promise<Discount> {
return await this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const discount = await this.retrieve(discountId, {
relations: ["regions"],
})
const exists = discount.regions.find((r) => r.id === regionId)
// If region is not present, we return early
if (!exists) {
return discount
}
discount.regions = discount.regions.filter((r) => r.id !== regionId)
return await discountRepo.save(discount)
})
}
/**
* Deletes a discount idempotently
* @param {string} discountId - id of discount to delete
* @return {Promise} the result of the delete operation
*/
async delete(discountId: string): Promise<void> {
return await this.atomicPhase_(async (manager) => {
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const discount = await discountRepo.findOne({ where: { id: discountId } })
if (!discount) {
return
}
await discountRepo.softRemove(discount)
})
}
async validateDiscountForProduct(
discountRuleId: string,
productId: string | undefined
): Promise<boolean> {
return await this.atomicPhase_(async (manager) => {
const discountConditionRepo: DiscountConditionRepository =
manager.getCustomRepository(this.discountConditionRepository_)
// In case of custom line items, we don't have a product id.
// Instead of throwing, we simply invalidate the discount.
if (!productId) {
return false
}
const product = await this.productService_
.withTransaction(manager)
.retrieve(productId, {
relations: ["tags"],
})
return await discountConditionRepo.isValidForProduct(
discountRuleId,
product.id
)
})
}
async calculateDiscountForLineItem(
discountId: string,
lineItem: LineItem,
cart: Cart
): Promise<number> {
return await this.atomicPhase_(async () => {
let adjustment = 0
if (!lineItem.allow_discounts) {
return adjustment
}
const discount = await this.retrieve(discountId, { relations: ["rule"] })
const { type, value, allocation } = discount.rule
let fullItemPrice = lineItem.unit_price * lineItem.quantity
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) &&
lineItem.includes_tax
) {
const lineItemTotals = await this.totalsService_.getLineItemTotals(
lineItem,
cart,
{
include_tax: true,
exclude_gift_cards: true,
}
)
fullItemPrice = lineItemTotals.subtotal
}
if (type === DiscountRuleType.PERCENTAGE) {
adjustment = Math.round((fullItemPrice / 100) * value)
} else if (
type === DiscountRuleType.FIXED &&
allocation === DiscountAllocation.TOTAL
) {
// when a fixed discount should be applied to the total,
// we create line adjustments for each item with an amount
// relative to the subtotal
const subtotal = await this.totalsService_.getSubtotal(cart, {
excludeNonDiscounts: true,
})
const nominator = Math.min(value, subtotal)
const totalItemPercentage = fullItemPrice / subtotal
adjustment = Math.round(nominator * totalItemPercentage)
} else {
adjustment = value * lineItem.quantity
}
// if the amount of the discount exceeds the total price of the item,
// we return the total item price, else the fixed amount
return adjustment >= fullItemPrice ? fullItemPrice : adjustment
})
}
async validateDiscountForCartOrThrow(
cart: Cart,
discount: Discount
): Promise<void> {
return await this.atomicPhase_(async () => {
if (this.hasReachedLimit(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount has been used maximum allowed times"
)
}
if (this.hasNotStarted(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid yet"
)
}
if (this.hasExpired(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is expired"
)
}
if (this.isDisabled(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"The discount code is disabled"
)
}
const isValidForRegion = await this.isValidForRegion(
discount,
cart.region_id
)
if (!isValidForRegion) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The discount is not available in current region"
)
}
if (cart.customer_id) {
const canApplyForCustomer = await this.canApplyForCustomer(
discount.rule.id,
cart.customer_id
)
if (!canApplyForCustomer) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid for customer"
)
}
}
})
}
hasReachedLimit(discount: Discount): boolean {
const count = discount.usage_count || 0
const limit = discount.usage_limit
return !!limit && count >= limit
}
hasNotStarted(discount: Discount): boolean {
return isFuture(discount.starts_at)
}
hasExpired(discount: Discount): boolean {
if (!discount.ends_at) {
return false
}
return isPast(discount.ends_at)
}
isDisabled(discount: Discount): boolean {
return discount.is_disabled
}
async isValidForRegion(
discount: Discount,
region_id: string
): Promise<boolean> {
return await this.atomicPhase_(async () => {
let regions = discount.regions
if (discount.parent_discount_id) {
const parent = await this.retrieve(discount.parent_discount_id, {
relations: ["rule", "regions"],
})
regions = parent.regions
}
return regions.find(({ id }) => id === region_id) !== undefined
})
}
async canApplyForCustomer(
discountRuleId: string,
customerId: string | undefined
): Promise<boolean> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const discountConditionRepo: DiscountConditionRepository =
manager.getCustomRepository(this.discountConditionRepository_)
// Instead of throwing on missing customer id, we simply invalidate the discount
if (!customerId) {
return false
}
const customer = await this.customerService_
.withTransaction(manager)
.retrieve(customerId, {
relations: ["groups"],
})
return await discountConditionRepo.canApplyForCustomer(
discountRuleId,
customer.id
)
})
}
}
export default DiscountService