fix(medusa): Applying Discounts (with Conditions) on DraftOrders and Carts (#3197)

This commit is contained in:
Adrien de Peretti
2023-02-08 19:01:23 +01:00
committed by GitHub
parent 4d3210bfbb
commit bfa33f444c
14 changed files with 636 additions and 365 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
fix(medusa): Applying Discounts (with Conditions) on DraftOrders and Carts
@@ -907,7 +907,8 @@ describe("/admin/discounts", () => {
is_dynamic: false,
},
adminReqConfig
).catch(e => e)
)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
@@ -1026,7 +1027,8 @@ describe("/admin/discounts", () => {
regions: [validRegionId, "test-region-2"],
},
adminReqConfig
).catch(e => e)
)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
@@ -1060,7 +1062,8 @@ describe("/admin/discounts", () => {
regions: [validRegionId, "test-region-2"],
},
adminReqConfig
).catch(e => e)
)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
@@ -1092,7 +1095,8 @@ describe("/admin/discounts", () => {
`/admin/discounts/${response.data.discount.id}/regions/test-region-2`,
{},
adminReqConfig
).catch(e => e)
)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
@@ -1246,7 +1250,8 @@ describe("/admin/discounts", () => {
ends_at: new Date("09/14/2021 17:50"),
},
adminReqConfig
).catch(e => e)
)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data).toEqual(
@@ -1259,20 +1264,22 @@ describe("/admin/discounts", () => {
it("fails to create a discount if the regions contains an invalid regionId ", async () => {
const api = useApi()
const err = await api.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
const err = await api
.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
},
regions: [validRegionId, invalidRegionId],
},
regions: [validRegionId, invalidRegionId],
},
adminReqConfig
).catch(e => e)
adminReqConfig
)
.catch((e) => e)
expect(err.response.status).toEqual(404)
expect(err.response.data.message).toEqual(
@@ -1283,20 +1290,22 @@ describe("/admin/discounts", () => {
it("fails to create a discount if the regions contains only invalid regionIds ", async () => {
const api = useApi()
const err = await api.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
const err = await api
.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
},
regions: [invalidRegionId],
},
regions: [invalidRegionId],
},
adminReqConfig
).catch(e => e)
adminReqConfig
)
.catch((e) => e)
expect(err.response.status).toEqual(404)
expect(err.response.data.message).toEqual(
@@ -1307,19 +1316,21 @@ describe("/admin/discounts", () => {
it("fails to create a discount if regions are not present ", async () => {
const api = useApi()
const err = await api.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
const err = await api
.post(
"/admin/discounts",
{
code: "HELLOWORLD",
rule: {
description: "test",
type: "percentage",
value: 10,
allocation: "total",
},
},
},
adminReqConfig
).catch((e) => e)
adminReqConfig
)
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
@@ -2204,11 +2215,16 @@ describe("/admin/discounts", () => {
it("should respond with 404 on non-existing code", async () => {
const api = useApi()
const err = await api.get("/admin/discounts/code/non-existing", adminReqConfig).catch(e => e)
const code = "non-existing"
const err = await api
.get(`/admin/discounts/code/${code}`, adminReqConfig)
.catch((e) => e)
expect(err.response.status).toEqual(404)
expect(err.response.data.message).toBe(
"Discount with code non-existing was not found"
`Discounts with code ${code} was not found`
)
})
@@ -6,10 +6,7 @@ const { initDb, useDb } = require("../../../../helpers/use-db")
const draftOrderSeeder = require("../../../helpers/draft-order-seeder")
const adminSeeder = require("../../../helpers/admin-seeder")
const {
simpleRegionFactory,
simpleDiscountFactory,
} = require("../../../factories")
const { simpleDiscountFactory } = require("../../../factories")
jest.setTimeout(30000)
@@ -517,6 +514,102 @@ describe("/admin/draft-orders", () => {
)
})
it("creates a draft order with fixed discount amount allocated to the total", async () => {
const api = useApi()
const testVariantId = "test-variant"
const testVariant2Id = "test-variant-2"
const discountAmount = 1000
const discount = await simpleDiscountFactory(dbConnection, {
code: "test-fixed",
regions: ["test-region"],
rule: {
type: "fixed",
allocation: "total",
value: discountAmount,
},
})
const payload = {
email: "oli@test.dk",
shipping_address: "oli-shipping",
discounts: [{ code: discount.code }],
items: [
{
variant_id: testVariantId,
quantity: 2,
metadata: {},
},
{
variant_id: testVariant2Id,
quantity: 2,
metadata: {},
},
],
region_id: "test-region",
customer_id: "oli-test",
shipping_methods: [
{
option_id: "test-option",
},
],
}
const response = await api.post(
"/admin/draft-orders",
payload,
adminReqConfig
)
expect(response.status).toEqual(200)
const draftOrder = response.data.draft_order
const lineItem1 = draftOrder.cart.items.find(
(item) => item.variant_id === testVariantId
)
const lineItem2 = draftOrder.cart.items.find(
(item) => item.variant_id === testVariant2Id
)
const lineItem1WeightInTotal = 0.444 // line item amount / amount
const lineItem2WeightInTotal = 0.556 // line item amount / amount
expect(draftOrder.cart.items).toHaveLength(2)
expect(lineItem1).toEqual(
expect.objectContaining({
variant_id: testVariantId,
unit_price: lineItem1.unit_price,
quantity: lineItem1.quantity,
adjustments: expect.arrayContaining([
expect.objectContaining({
item_id: lineItem1.id,
amount: discountAmount * lineItem1WeightInTotal,
description: "discount",
discount_id: discount.id,
}),
]),
})
)
expect(lineItem2).toEqual(
expect.objectContaining({
variant_id: testVariant2Id,
unit_price: lineItem2.unit_price,
quantity: lineItem2.quantity,
adjustments: expect.arrayContaining([
expect.objectContaining({
item_id: lineItem2.id,
amount: discountAmount * lineItem2WeightInTotal,
description: "discount",
discount_id: discount.id,
}),
]),
})
)
})
it("creates a draft order with discount and free shipping along the line item", async () => {
const api = useApi()
@@ -14,7 +14,6 @@ const { useApi } = require("../../../../helpers/use-api")
const { initDb, useDb } = require("../../../../helpers/use-db")
const cartSeeder = require("../../../helpers/cart-seeder")
const productSeeder = require("../../../helpers/product-seeder")
const swapSeeder = require("../../../helpers/swap-seeder")
const {
simpleCartFactory,
@@ -980,16 +979,18 @@ describe("/store/carts", () => {
it("fails on apply discount if limit has been reached", async () => {
const api = useApi()
const code = "SPENT"
const err = await api
.post("/store/carts/test-cart", {
discounts: [{ code: "SPENT" }],
discounts: [{ code }],
})
.catch((err) => err)
expect(err).toBeTruthy()
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
"Discount has been used maximum allowed times"
`Discount ${code} has been used maximum allowed times`
)
})
@@ -1330,14 +1331,15 @@ describe("/store/carts", () => {
},
})
const code = "TEST"
try {
await api.post("/store/carts/test-customer-discount", {
discounts: [{ code: "TEST" }],
discounts: [{ code }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual(
"Discount is not valid for customer"
`Discount ${code} is not valid for customer`
)
}
})
@@ -1399,14 +1401,15 @@ describe("/store/carts", () => {
},
})
const code = "TEST"
try {
await api.post("/store/carts/test-customer-discount", {
discounts: [{ code: "TEST" }],
discounts: [{ code }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual(
"Discount is not valid for customer"
`Discount ${code} is not valid for customer`
)
}
})
@@ -1415,41 +1418,49 @@ describe("/store/carts", () => {
expect.assertions(2)
const api = useApi()
try {
await api.post("/store/carts/test-cart", {
discounts: [{ code: "EXP_DISC" }],
const code = "EXP_DISC"
const err = await api
.post("/store/carts/test-cart", {
discounts: [{ code }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("Discount is expired")
}
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(`Discount ${code} is expired`)
})
it("fails on discount before start day", async () => {
expect.assertions(2)
const api = useApi()
try {
await api.post("/store/carts/test-cart", {
discounts: [{ code: "PREM_DISC" }],
const code = "PREM_DISC"
const err = await api
.post("/store/carts/test-cart", {
discounts: [{ code }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("Discount is not valid yet")
}
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(
`Discount ${code} is not valid yet`
)
})
it("fails on apply invalid dynamic discount", async () => {
const api = useApi()
try {
await api.post("/store/carts/test-cart", {
discounts: [{ code: "INV_DYN_DISC" }],
const code = "INV_DYN_DISC"
const err = await api
.post("/store/carts/test-cart", {
discounts: [{ code }],
})
} catch (error) {
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("Discount is expired")
}
.catch((e) => e)
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual(`Discount ${code} is expired`)
})
it("Applies dynamic discount to cart correctly", async () => {
+1 -1
View File
@@ -327,7 +327,7 @@ module.exports = async (connection, data = {}) => {
is_dynamic: true,
is_disabled: false,
starts_at: tenDaysAgo,
ends_at: tenDaysFromToday,
ends_at: yesterday,
valid_duration: "P1D", // one day
})
@@ -6,17 +6,17 @@ import {
Not,
Repository,
} from "typeorm"
import { Discount } from "../models"
import {
Discount,
DiscountCondition,
DiscountConditionCustomerGroup,
DiscountConditionOperator,
DiscountConditionProduct,
DiscountConditionProductCollection,
DiscountConditionProductTag,
DiscountConditionProductType,
DiscountConditionType,
} from "../models/discount-condition"
import { DiscountConditionCustomerGroup } from "../models/discount-condition-customer-group"
import { DiscountConditionProduct } from "../models/discount-condition-product"
import { DiscountConditionProductCollection } from "../models/discount-condition-product-collection"
import { DiscountConditionProductTag } from "../models/discount-condition-product-tag"
import { DiscountConditionProductType } from "../models/discount-condition-product-type"
} from "../models"
import { isString } from "../utils"
export enum DiscountConditionJoinTableForeignKey {
@@ -256,6 +256,10 @@ export class DiscountConditionRepository extends Repository<DiscountCondition> {
// if condition operation is `in` and the query for conditions defined for the given type is empty, the discount is invalid
// if condition operation is `not_in` and the query for conditions defined for the given type is not empty, the discount is invalid
for (const condition of discountConditions) {
if (condition.type === DiscountConditionType.CUSTOMER_GROUPS) {
continue
}
const numConditions = await this.queryConditionTable({
type: condition.type,
condId: condition.id,
@@ -1,6 +1,3 @@
import { IdMap } from "medusa-test-utils"
import { MedusaError } from "medusa-core-utils"
export const LineItemAdjustmentServiceMock = {
withTransaction: function () {
return this
+134 -119
View File
@@ -2062,126 +2062,141 @@ describe("CartService", () => {
withTransaction: function () {
return this
},
retrieveByCode: jest.fn().mockImplementation((code) => {
if (code === "US10") {
return Promise.resolve({
regions: [{ id: IdMap.getId("bad") }],
})
listByCodes: jest.fn().mockImplementation((code) => {
const codes = Array.isArray(code) ? code : [code]
const data = []
for (const code of codes) {
if (code === "US10") {
data.push({
regions: [{ id: IdMap.getId("bad") }],
})
}
if (code === "limit-reached") {
data.push({
id: IdMap.getId("limit-reached"),
code: "limit-reached",
regions: [{ id: IdMap.getId("good") }],
rule: {},
usage_count: 2,
usage_limit: 2,
})
}
if (code === "null-count") {
data.push({
id: IdMap.getId("null-count"),
code: "null-count",
regions: [{ id: IdMap.getId("good") }],
rule: {},
usage_count: null,
usage_limit: 2,
})
}
if (code === "FREESHIPPING") {
data.push({
id: IdMap.getId("freeship"),
code: "FREESHIPPING",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "free_shipping",
},
})
}
if (code === "EarlyDiscount") {
data.push({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(1),
ends_at: getOffsetDate(10),
})
}
if (code === "ExpiredDiscount") {
data.push({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
ends_at: getOffsetDate(-1),
starts_at: getOffsetDate(-10),
})
}
if (code === "ExpiredDynamicDiscount") {
data.push({
id: IdMap.getId("10off"),
code: "10%OFF",
is_dynamic: true,
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(-1),
})
}
if (code === "ExpiredDynamicDiscountEndDate") {
data.push({
id: IdMap.getId("10off"),
is_dynamic: true,
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(-3),
valid_duration: "P0Y0M1D",
})
}
if (code === "ValidDiscount") {
data.push({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(10),
})
}
if (code === "ApplicableForCustomer") {
data.push({
id: "ApplicableForCustomer",
code: "ApplicableForCustomer",
regions: [{ id: IdMap.getId("good") }],
rule: {
id: "test-rule",
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(10),
})
}
if (!data.length) {
data.push({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
})
}
}
if (code === "limit-reached") {
return Promise.resolve({
id: IdMap.getId("limit-reached"),
code: "limit-reached",
regions: [{ id: IdMap.getId("good") }],
rule: {},
usage_count: 2,
usage_limit: 2,
})
if (Array.isArray(code)) {
return Promise.resolve(data)
}
if (code === "null-count") {
return Promise.resolve({
id: IdMap.getId("null-count"),
code: "null-count",
regions: [{ id: IdMap.getId("good") }],
rule: {},
usage_count: null,
usage_limit: 2,
})
}
if (code === "FREESHIPPING") {
return Promise.resolve({
id: IdMap.getId("freeship"),
code: "FREESHIPPING",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "free_shipping",
},
})
}
if (code === "EarlyDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(1),
ends_at: getOffsetDate(10),
})
}
if (code === "ExpiredDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
ends_at: getOffsetDate(-1),
starts_at: getOffsetDate(-10),
})
}
if (code === "ExpiredDynamicDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
is_dynamic: true,
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(-1),
})
}
if (code === "ExpiredDynamicDiscountEndDate") {
return Promise.resolve({
id: IdMap.getId("10off"),
is_dynamic: true,
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(-3),
valid_duration: "P0Y0M1D",
})
}
if (code === "ValidDiscount") {
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(10),
})
}
if (code === "ApplicableForCustomer") {
return Promise.resolve({
id: "ApplicableForCustomer",
code: "ApplicableForCustomer",
regions: [{ id: IdMap.getId("good") }],
rule: {
id: "test-rule",
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(10),
})
}
return Promise.resolve({
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
})
return Promise.resolve(data[0])
}),
canApplyForCustomer: jest
.fn()
@@ -2333,7 +2348,7 @@ describe("CartService", () => {
discounts: [{ code: "10%OFF" }, { code: "FREESHIPPING" }],
})
expect(discountService.retrieveByCode).toHaveBeenCalledTimes(2)
expect(discountService.listByCodes).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
@@ -3,6 +3,7 @@ import { FlagRouter } from "../../utils/flag-router"
import DiscountService from "../discount"
import { TotalsServiceMock } from "../__mocks__/totals"
import { newTotalsServiceMock } from "../__mocks__/new-totals"
import { In } from "typeorm"
const featureFlagRouter = new FlagRouter({})
@@ -54,14 +55,16 @@ describe("DiscountService", () => {
})
it("fails to create a discount without regions", async () => {
const err = await discountService.create({
code: "test",
rule: {
type: "fixed",
allocation: "total",
value: 20,
},
}).catch(e => e)
const err = await discountService
.create({
code: "test",
rule: {
type: "fixed",
allocation: "total",
value: 20,
},
})
.catch((e) => e)
expect(err.type).toEqual("invalid_data")
expect(err.message).toEqual("Discount must have atleast 1 region")
@@ -237,7 +240,6 @@ describe("DiscountService", () => {
expect(discountRepository.findOne).toHaveBeenCalledWith({
where: {
code: "10%OFF",
is_dynamic: false,
},
})
})
@@ -248,7 +250,54 @@ describe("DiscountService", () => {
expect(discountRepository.findOne).toHaveBeenCalledWith({
where: {
code: "10%OFF",
is_dynamic: false,
},
})
})
})
describe("listByCodes", () => {
const discountRepository = MockRepository({
find: (query) => {
if (query.where.code.value.includes("10%OFF")) {
return Promise.resolve([
{ id: IdMap.getId("total10"), code: "10%OFF" },
])
}
if (query.where.code.value.includes("DYNAMIC")) {
return Promise.resolve([
{ id: IdMap.getId("total10"), code: "10%OFF" },
])
}
return Promise.resolve([])
},
})
const discountService = new DiscountService({
manager: MockManager,
discountRepository,
featureFlagRouter,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("successfully finds discount by code", async () => {
await discountService.listByCodes(["10%OFF"])
expect(discountRepository.find).toHaveBeenCalledTimes(1)
expect(discountRepository.find).toHaveBeenCalledWith({
where: {
code: In(["10%OFF"]),
},
})
})
it("successfully trims, uppdercases, and finds discount by code", async () => {
await discountService.listByCodes([" 10%Off "])
expect(discountRepository.find).toHaveBeenCalledTimes(1)
expect(discountRepository.find).toHaveBeenCalledWith({
where: {
code: In(["10%OFF"]),
},
})
})
@@ -746,6 +795,7 @@ describe("DiscountService", () => {
})
describe("validateDiscountForCartOrThrow", () => {
const discount = { code: "TEST" }
const getCart = (id) => {
if (id === "with-d") {
return {
@@ -878,50 +928,46 @@ describe("DiscountService", () => {
})
it("throws when hasReachedLimit returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer")
discountService.hasReachedLimit = jest.fn().mockImplementation(() => true)
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "Discount has been used maximum allowed times",
message: `Discount ${discount.code} has been used maximum allowed times`,
})
})
it("throws when hasNotStarted returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer")
discountService.hasNotStarted = jest.fn().mockImplementation(() => true)
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "Discount is not valid yet",
message: `Discount ${discount.code} is not valid yet`,
})
})
it("throws when hasExpired returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer")
discountService.hasExpired = jest.fn().mockImplementation(() => true)
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "Discount is expired",
message: `Discount ${discount.code} is expired`,
})
})
it("throws when isDisabled returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer")
discountService.isDisabled = jest.fn().mockImplementation(() => true)
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "The discount code is disabled",
message: `The discount code ${discount.code} is disabled`,
})
})
@@ -940,16 +986,16 @@ describe("DiscountService", () => {
})
it("throws when canApplyForCustomer returns false", async () => {
const discount = { rule: { id: "" } }
const discount_ = { code: discount.code, rule: { id: "" } }
const cart = getCart("with-d-and-customer")
discountService.canApplyForCustomer = jest
.fn()
.mockImplementation(() => Promise.resolve(false))
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
discountService.validateDiscountForCartOrThrow(cart, discount_)
).rejects.toThrow({
message: "Discount is not valid for customer",
message: `Discount ${discount.code} is not valid for customer`,
})
})
})
@@ -1,6 +1,7 @@
import { MockManager, MockRepository } from "medusa-test-utils"
import { EventBusServiceMock } from "../__mocks__/event-bus"
import DraftOrderService from "../draft-order"
import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment"
const eventBusService = {
emit: jest.fn(),
@@ -10,30 +11,7 @@ const eventBusService = {
}
describe("DraftOrderService", () => {
const totalsService = {
getTotal: (o) => {
return o.total || 0
},
getSubtotal: (o) => {
return o.subtotal || 0
},
getTaxTotal: (o) => {
return o.tax_total || 0
},
getDiscountTotal: (o) => {
return o.discount_total || 0
},
getShippingTotal: (o) => {
return o.shipping_total || 0
},
getGiftCardTotal: (o) => {
return o.gift_card_total || 0
},
}
describe("create", () => {
let result
const regionService = {
retrieve: () =>
Promise.resolve({ id: "test-region", countries: [{ iso_2: "dk" }] }),
@@ -62,7 +40,7 @@ describe("DraftOrderService", () => {
variant_id: "test-variant",
})
),
create: jest.fn(),
create: jest.fn().mockImplementation((data) => data),
withTransaction: function () {
return this
},
@@ -86,7 +64,9 @@ describe("DraftOrderService", () => {
shipping_address_id: "test-shipping",
billing_address_id: "test-billing",
customer_id: "test-customer",
items: [{ variant_id: "test-variant", quantity: 2, metadata: {} }],
items: [
{ id: "test", variant_id: "test-variant", quantity: 2, metadata: {} },
],
shipping_methods: [
{
option_id: "test-option",
@@ -108,6 +88,8 @@ describe("DraftOrderService", () => {
...testOrder,
})
),
update: jest.fn(),
applyDiscount: jest.fn(),
addShippingMethod: jest.fn(),
withTransaction: function () {
return this
@@ -136,6 +118,7 @@ describe("DraftOrderService", () => {
cartService,
shippingOptionService,
lineItemService,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
productVariantService,
draftOrderRepository,
addressRepository,
@@ -147,11 +130,14 @@ describe("DraftOrderService", () => {
})
it("creates a draft order", async () => {
const cartId = "test-cart"
const title = "test-item"
await draftOrderService.create(testOrder)
expect(draftOrderRepository.create).toHaveBeenCalledTimes(1)
expect(draftOrderRepository.create).toHaveBeenCalledWith({
cart_id: "test-cart",
cart_id: cartId,
})
expect(cartService.create).toHaveBeenCalledTimes(1)
@@ -163,11 +149,6 @@ describe("DraftOrderService", () => {
type: "draft_order",
})
expect(cartService.retrieve).toHaveBeenCalledTimes(1)
expect(cartService.retrieve).toHaveBeenCalledWith("test-cart", {
relations: ["discounts", "discounts.rule", "items", "region"],
})
expect(cartService.addShippingMethod).toHaveBeenCalledTimes(1)
expect(cartService.addShippingMethod).toHaveBeenCalledWith(
"test-cart",
@@ -183,18 +164,69 @@ describe("DraftOrderService", () => {
{
metadata: {},
unit_price: undefined,
cart: expect.objectContaining({
id: "test-cart",
}),
}
)
expect(lineItemService.create).toHaveBeenCalledTimes(1)
expect(lineItemService.create).toHaveBeenCalledWith({
cart_id: "test-cart",
title: "test-item",
cart_id: cartId,
title,
variant_id: "test-variant",
})
expect(cartService.applyDiscount).toHaveBeenCalledTimes(0)
})
it("creates a draft order with a discount", async () => {
const cartId = "test-cart"
const title = "test-item"
testOrder["discounts"] = [{ code: "TEST" }]
await draftOrderService.create(testOrder)
expect(draftOrderRepository.create).toHaveBeenCalledTimes(1)
expect(draftOrderRepository.create).toHaveBeenCalledWith({
cart_id: cartId,
})
expect(cartService.create).toHaveBeenCalledTimes(1)
expect(cartService.create).toHaveBeenCalledWith({
region_id: "test-region",
shipping_address_id: "test-shipping",
billing_address_id: "test-billing",
customer_id: "test-customer",
type: "draft_order",
})
expect(cartService.addShippingMethod).toHaveBeenCalledTimes(1)
expect(cartService.addShippingMethod).toHaveBeenCalledWith(
"test-cart",
"test-option",
{}
)
expect(lineItemService.generate).toHaveBeenCalledTimes(1)
expect(lineItemService.generate).toHaveBeenCalledWith(
"test-variant",
"test-region",
2,
{
metadata: {},
unit_price: undefined,
}
)
expect(lineItemService.create).toHaveBeenCalledTimes(1)
expect(lineItemService.create).toHaveBeenCalledWith({
cart_id: cartId,
title,
variant_id: "test-variant",
})
expect(cartService.update).toHaveBeenCalledTimes(1)
expect(cartService.update).toHaveBeenCalledWith(cartId, {
discounts: testOrder.discounts,
})
})
it("fails on missing region", async () => {
@@ -213,7 +245,7 @@ describe("DraftOrderService", () => {
await draftOrderService.create({
region_id: "test-region",
items: [],
shipping_methods: []
shipping_methods: [],
})
})
})
@@ -262,6 +294,7 @@ describe("DraftOrderService", () => {
cartService: undefined,
shippingOptionService: undefined,
lineItemService: undefined,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
productVariantService: undefined,
draftOrderRepository,
addressRepository: undefined,
+47 -20
View File
@@ -9,6 +9,7 @@ import {
Customer,
CustomShippingOption,
Discount,
DiscountRule,
DiscountRuleType,
LineItem,
PaymentSession,
@@ -1122,10 +1123,9 @@ class CartService extends TransactionBaseService {
const previousDiscounts = [...cart.discounts]
cart.discounts.length = 0
await Promise.all(
data.discounts.map(async ({ code }) => {
return this.applyDiscount(cart, code)
})
await this.applyDiscounts(
cart,
data.discounts.map((d) => d.code)
)
const hasFreeShipping = cart.discounts.some(
@@ -1410,41 +1410,65 @@ class CartService extends TransactionBaseService {
* Throws if discount regions does not include the cart region
* @param cart - the cart to update
* @param discountCode - the discount code
* @return the result of the update operation
*/
async applyDiscount(cart: Cart, discountCode: string): Promise<void> {
return await this.applyDiscounts(cart, [discountCode])
}
/**
* Updates the cart's discounts.
* If discount besides free shipping is already applied, this
* will be overwritten
* Throws if discount regions does not include the cart region
* @param cart - the cart to update
* @param discountCodes - the discount code(s) to apply
*/
async applyDiscounts(cart: Cart, discountCodes: string[]): Promise<void> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const discount = await this.discountService_
const discounts = await this.discountService_
.withTransaction(transactionManager)
.retrieveByCode(discountCode, { relations: ["rule", "regions"] })
.listByCodes(discountCodes, { relations: ["rule", "regions"] })
await this.discountService_
.withTransaction(transactionManager)
.validateDiscountForCartOrThrow(cart, discount)
.validateDiscountForCartOrThrow(cart, discounts)
const rule = discount.rule
const rules: Map<string, DiscountRule> = new Map()
const discountsMap = new Map(
discounts.map((d) => {
rules.set(d.id, d.rule)
return [d.id, d]
})
)
// if discount is already there, we simply resolve
if (cart.discounts.find(({ id }) => id === discount.id)) {
return
}
cart.discounts.forEach((discount) => {
if (discountsMap.has(discount.id)) {
discountsMap.delete(discount.id)
}
})
const toParse = [...cart.discounts, discount]
const toParse = [...cart.discounts, ...discountsMap.values()]
let sawNotShipping = false
const newDiscounts = toParse.map((discountToParse) => {
switch (discountToParse.rule?.type) {
case DiscountRuleType.FREE_SHIPPING:
if (discountToParse.rule.type === rule.type) {
return discount
if (
discountToParse.rule.type ===
rules.get(discountToParse.id)!.type
) {
return discountsMap.get(discountToParse.id)
}
return discountToParse
default:
if (!sawNotShipping) {
sawNotShipping = true
if (rule?.type !== DiscountRuleType.FREE_SHIPPING) {
return discount
if (
rules.get(discountToParse.id)!.type !==
DiscountRuleType.FREE_SHIPPING
) {
return discountsMap.get(discountToParse.id)
}
return discountToParse
}
@@ -1458,8 +1482,11 @@ class CartService extends TransactionBaseService {
}
)
// ignore if free shipping
if (rule?.type !== DiscountRuleType.FREE_SHIPPING && cart?.items) {
const hadNonFreeShippingDiscounts = [...rules.values()].some(
(rule) => rule.type !== DiscountRuleType.FREE_SHIPPING
)
if (hadNonFreeShippingDiscounts && cart?.items) {
await this.refreshAdjustments_(cart)
}
}
+94 -63
View File
@@ -6,6 +6,7 @@ import {
DeepPartial,
EntityManager,
ILike,
In,
SelectQueryBuilder,
} from "typeorm"
import {
@@ -277,10 +278,10 @@ class DiscountService extends TransactionBaseService {
}
/**
* 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
* Gets the discount by discount code.
* @param discountCode - discount code of discount to retrieve
* @param config - the config object containing query settings
* @return the discount
*/
async retrieveByCode(
discountCode: string,
@@ -291,24 +292,49 @@ class DiscountService extends TransactionBaseService {
const normalizedCode = discountCode.toUpperCase().trim()
let query = buildQuery({ code: normalizedCode, is_dynamic: false }, config)
let discount = await discountRepo.findOne(query)
const query = buildQuery({ code: normalizedCode }, config)
const 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`
)
}
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Discounts with code ${discountCode} was not found`
)
}
return discount
}
/**
* List all the discounts corresponding to the given codes
* @param discountCodes - discount codes of discounts to retrieve
* @param config - the config object containing query settings
* @return the discounts
*/
async listByCodes(
discountCodes: string[],
config: FindConfig<Discount> = {}
): Promise<Discount[]> {
const manager = this.manager_
const discountRepo = manager.getCustomRepository(this.discountRepository_)
const normalizedCodes = discountCodes.map((code) =>
code.toUpperCase().trim()
)
const query = buildQuery({ code: In(normalizedCodes) }, config)
const discounts = await discountRepo.find(query)
if (discounts?.length !== discountCodes.length) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Discounts with code [${normalizedCodes.join(", ")}] was not found`
)
}
return discounts
}
/**
* Updates a discount.
* @param {string} discountId - discount id of discount to update
@@ -660,61 +686,66 @@ class DiscountService extends TransactionBaseService {
async validateDiscountForCartOrThrow(
cart: Cart,
discount: Discount
discount: Discount | Discount[]
): Promise<void> {
const discounts = Array.isArray(discount) ? discount : [discount]
return await this.atomicPhase_(async () => {
if (this.hasReachedLimit(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount has been used maximum allowed times"
)
}
await Promise.all(
discounts.map(async (disc) => {
if (this.hasReachedLimit(disc)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Discount ${disc.code} has been used maximum allowed times`
)
}
if (this.hasNotStarted(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid yet"
)
}
if (this.hasNotStarted(disc)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Discount ${disc.code} is not valid yet`
)
}
if (this.hasExpired(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is expired"
)
}
if (this.hasExpired(disc)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Discount ${disc.code} is expired`
)
}
if (this.isDisabled(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"The discount code is disabled"
)
}
if (this.isDisabled(disc)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`The discount code ${disc.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"
const isValidForRegion = await this.isValidForRegion(
disc,
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(
disc.rule.id,
cart.customer_id
)
if (!canApplyForCustomer) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Discount ${disc.code} is not valid for customer`
)
}
}
})
)
})
}
+9 -16
View File
@@ -1,7 +1,7 @@
import { isDefined, MedusaError } from "medusa-core-utils"
import { Brackets, EntityManager, FindManyOptions, UpdateResult } from "typeorm"
import { TransactionBaseService } from "../interfaces"
import { Cart, CartType, DraftOrder, DraftOrderStatus } from "../models"
import { CartType, DraftOrder, DraftOrderStatus } from "../models"
import { DraftOrderRepository } from "../repositories/draft-order"
import { OrderRepository } from "../repositories/order"
import { PaymentRepository } from "../repositories/payment"
@@ -264,34 +264,24 @@ class DraftOrderService extends TransactionBaseService {
no_notification_order,
items,
idempotency_key,
discounts,
...rawCart
} = data
const cartServiceTx =
this.cartService_.withTransaction(transactionManager)
if (rawCart.discounts) {
const { discounts } = rawCart
rawCart.discounts = []
for (const { code } of discounts) {
await cartServiceTx.applyDiscount(rawCart as Cart, code)
}
}
let createdCart = await cartServiceTx.create({
const createdCart = await cartServiceTx.create({
type: CartType.DRAFT_ORDER,
...rawCart,
})
createdCart = await cartServiceTx.retrieve(createdCart.id, {
relations: ["discounts", "discounts.rule", "items", "region"],
})
const draftOrder = draftOrderRepo.create({
cart_id: createdCart.id,
no_notification_order,
idempotency_key,
})
const result = await draftOrderRepo.save(draftOrder)
await this.eventBus_
@@ -303,7 +293,7 @@ class DraftOrderService extends TransactionBaseService {
const lineItemServiceTx =
this.lineItemService_.withTransaction(transactionManager)
for (const item of (items || [])) {
for (const item of items || []) {
if (item.variant_id) {
const line = await lineItemServiceTx.generate(
item.variant_id,
@@ -312,7 +302,6 @@ class DraftOrderService extends TransactionBaseService {
{
metadata: item?.metadata || {},
unit_price: item.unit_price,
cart: createdCart,
}
)
@@ -340,6 +329,10 @@ class DraftOrderService extends TransactionBaseService {
}
}
if (discounts?.length) {
await cartServiceTx.update(createdCart.id, { discounts })
}
for (const method of shipping_methods) {
if (typeof method.price !== "undefined") {
await this.customShippingOptionService_
+1 -1
View File
@@ -367,7 +367,7 @@ class LineItemService extends TransactionBaseService {
*/
async create<
T = LineItem | LineItem[],
TResult = T extends LineItem ? LineItem : LineItem[]
TResult = T extends LineItem[] ? LineItem[] : LineItem
>(data: T): Promise<TResult> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {