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

This commit is contained in:
Adrien de Peretti authored and GitHub committed 2023-02-08 19:01:23 +01:00
1 parent 4d3210bfbb
commit bfa33f444c
14 files changed
+452 -181

No files matched your search

+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, is_dynamic: false,
}, },
adminReqConfig adminReqConfig
).catch(e => e) )
.catch((e) => e)
expect(err.response.status).toEqual(400) expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual( expect(err.response.data.message).toEqual(
@@ -1026,7 +1027,8 @@ describe("/admin/discounts", () => {
regions: [validRegionId, "test-region-2"], regions: [validRegionId, "test-region-2"],
}, },
adminReqConfig adminReqConfig
).catch(e => e) )
.catch((e) => e)
expect(err.response.status).toEqual(400) expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual( expect(err.response.data.message).toEqual(
@@ -1060,7 +1062,8 @@ describe("/admin/discounts", () => {
regions: [validRegionId, "test-region-2"], regions: [validRegionId, "test-region-2"],
}, },
adminReqConfig adminReqConfig
).catch(e => e) )
.catch((e) => e)
expect(err.response.status).toEqual(400) expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual( expect(err.response.data.message).toEqual(
@@ -1092,7 +1095,8 @@ describe("/admin/discounts", () => {
`/admin/discounts/${response.data.discount.id}/regions/test-region-2`, `/admin/discounts/${response.data.discount.id}/regions/test-region-2`,
{}, {},
adminReqConfig adminReqConfig
).catch(e => e) )
.catch((e) => e)
expect(err.response.status).toEqual(400) expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual( expect(err.response.data.message).toEqual(
@@ -1246,7 +1250,8 @@ describe("/admin/discounts", () => {
ends_at: new Date("09/14/2021 17:50"), ends_at: new Date("09/14/2021 17:50"),
}, },
adminReqConfig adminReqConfig
).catch(e => e) )
.catch((e) => e)
expect(err.response.status).toEqual(400) expect(err.response.status).toEqual(400)
expect(err.response.data).toEqual( expect(err.response.data).toEqual(
@@ -1259,7 +1264,8 @@ describe("/admin/discounts", () => {
it("fails to create a discount if the regions contains an invalid regionId ", async () => { it("fails to create a discount if the regions contains an invalid regionId ", async () => {
const api = useApi() const api = useApi()
const err = await api.post( const err = await api
.post(
"/admin/discounts", "/admin/discounts",
{ {
code: "HELLOWORLD", code: "HELLOWORLD",
@@ -1272,7 +1278,8 @@ describe("/admin/discounts", () => {
regions: [validRegionId, invalidRegionId], regions: [validRegionId, invalidRegionId],
}, },
adminReqConfig adminReqConfig
).catch(e => e) )
.catch((e) => e)
expect(err.response.status).toEqual(404) expect(err.response.status).toEqual(404)
expect(err.response.data.message).toEqual( expect(err.response.data.message).toEqual(
@@ -1283,7 +1290,8 @@ describe("/admin/discounts", () => {
it("fails to create a discount if the regions contains only invalid regionIds ", async () => { it("fails to create a discount if the regions contains only invalid regionIds ", async () => {
const api = useApi() const api = useApi()
const err = await api.post( const err = await api
.post(
"/admin/discounts", "/admin/discounts",
{ {
code: "HELLOWORLD", code: "HELLOWORLD",
@@ -1296,7 +1304,8 @@ describe("/admin/discounts", () => {
regions: [invalidRegionId], regions: [invalidRegionId],
}, },
adminReqConfig adminReqConfig
).catch(e => e) )
.catch((e) => e)
expect(err.response.status).toEqual(404) expect(err.response.status).toEqual(404)
expect(err.response.data.message).toEqual( expect(err.response.data.message).toEqual(
@@ -1307,7 +1316,8 @@ describe("/admin/discounts", () => {
it("fails to create a discount if regions are not present ", async () => { it("fails to create a discount if regions are not present ", async () => {
const api = useApi() const api = useApi()
const err = await api.post( const err = await api
.post(
"/admin/discounts", "/admin/discounts",
{ {
code: "HELLOWORLD", code: "HELLOWORLD",
@@ -1319,7 +1329,8 @@ describe("/admin/discounts", () => {
}, },
}, },
adminReqConfig adminReqConfig
).catch((e) => e) )
.catch((e) => e)
expect(err.response.status).toEqual(400) expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual( expect(err.response.data.message).toEqual(
@@ -2204,11 +2215,16 @@ describe("/admin/discounts", () => {
it("should respond with 404 on non-existing code", async () => { it("should respond with 404 on non-existing code", async () => {
const api = useApi() 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.status).toEqual(404)
expect(err.response.data.message).toBe( 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 draftOrderSeeder = require("../../../helpers/draft-order-seeder")
const adminSeeder = require("../../../helpers/admin-seeder") const adminSeeder = require("../../../helpers/admin-seeder")
const { const { simpleDiscountFactory } = require("../../../factories")
simpleRegionFactory,
simpleDiscountFactory,
} = require("../../../factories")
jest.setTimeout(30000) 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 () => { it("creates a draft order with discount and free shipping along the line item", async () => {
const api = useApi() const api = useApi()
@@ -14,7 +14,6 @@ const { useApi } = require("../../../../helpers/use-api")
const { initDb, useDb } = require("../../../../helpers/use-db") const { initDb, useDb } = require("../../../../helpers/use-db")
const cartSeeder = require("../../../helpers/cart-seeder") const cartSeeder = require("../../../helpers/cart-seeder")
const productSeeder = require("../../../helpers/product-seeder")
const swapSeeder = require("../../../helpers/swap-seeder") const swapSeeder = require("../../../helpers/swap-seeder")
const { const {
simpleCartFactory, simpleCartFactory,
@@ -980,16 +979,18 @@ describe("/store/carts", () => {
it("fails on apply discount if limit has been reached", async () => { it("fails on apply discount if limit has been reached", async () => {
const api = useApi() const api = useApi()
const code = "SPENT"
const err = await api const err = await api
.post("/store/carts/test-cart", { .post("/store/carts/test-cart", {
discounts: [{ code: "SPENT" }], discounts: [{ code }],
}) })
.catch((err) => err) .catch((err) => err)
expect(err).toBeTruthy() expect(err).toBeTruthy()
expect(err.response.status).toEqual(400) expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual( 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 { try {
await api.post("/store/carts/test-customer-discount", { await api.post("/store/carts/test-customer-discount", {
discounts: [{ code: "TEST" }], discounts: [{ code }],
}) })
} catch (error) { } catch (error) {
expect(error.response.status).toEqual(400) expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual( 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 { try {
await api.post("/store/carts/test-customer-discount", { await api.post("/store/carts/test-customer-discount", {
discounts: [{ code: "TEST" }], discounts: [{ code }],
}) })
} catch (error) { } catch (error) {
expect(error.response.status).toEqual(400) expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual( 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) expect.assertions(2)
const api = useApi() const api = useApi()
try { const code = "EXP_DISC"
await api.post("/store/carts/test-cart", {
discounts: [{ code: "EXP_DISC" }], const err = await api
.post("/store/carts/test-cart", {
discounts: [{ code }],
}) })
} catch (error) { .catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("Discount is expired") expect(err.response.status).toEqual(400)
} expect(err.response.data.message).toEqual(`Discount ${code} is expired`)
}) })
it("fails on discount before start day", async () => { it("fails on discount before start day", async () => {
expect.assertions(2) expect.assertions(2)
const api = useApi() const api = useApi()
try { const code = "PREM_DISC"
await api.post("/store/carts/test-cart", {
discounts: [{ code: "PREM_DISC" }], const err = await api
.post("/store/carts/test-cart", {
discounts: [{ code }],
}) })
} catch (error) { .catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("Discount is not valid yet") 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 () => { it("fails on apply invalid dynamic discount", async () => {
const api = useApi() const api = useApi()
try { const code = "INV_DYN_DISC"
await api.post("/store/carts/test-cart", {
discounts: [{ code: "INV_DYN_DISC" }], const err = await api
.post("/store/carts/test-cart", {
discounts: [{ code }],
}) })
} catch (error) { .catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual("Discount is expired") expect(err.response.status).toEqual(400)
} expect(err.response.data.message).toEqual(`Discount ${code} is expired`)
}) })
it("Applies dynamic discount to cart correctly", async () => { 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_dynamic: true,
is_disabled: false, is_disabled: false,
starts_at: tenDaysAgo, starts_at: tenDaysAgo,
ends_at: tenDaysFromToday, ends_at: yesterday,
valid_duration: "P1D", // one day valid_duration: "P1D", // one day
}) })
@@ -6,17 +6,17 @@ import {
Not, Not,
Repository, Repository,
} from "typeorm" } from "typeorm"
import { Discount } from "../models"
import { import {
Discount,
DiscountCondition, DiscountCondition,
DiscountConditionCustomerGroup,
DiscountConditionOperator, DiscountConditionOperator,
DiscountConditionProduct,
DiscountConditionProductCollection,
DiscountConditionProductTag,
DiscountConditionProductType,
DiscountConditionType, DiscountConditionType,
} from "../models/discount-condition" } from "../models"
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"
import { isString } from "../utils" import { isString } from "../utils"
export enum DiscountConditionJoinTableForeignKey { 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 `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 // 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) { for (const condition of discountConditions) {
if (condition.type === DiscountConditionType.CUSTOMER_GROUPS) {
continue
}
const numConditions = await this.queryConditionTable({ const numConditions = await this.queryConditionTable({
type: condition.type, type: condition.type,
condId: condition.id, condId: condition.id,
@@ -1,6 +1,3 @@
import { IdMap } from "medusa-test-utils"
import { MedusaError } from "medusa-core-utils"
export const LineItemAdjustmentServiceMock = { export const LineItemAdjustmentServiceMock = {
withTransaction: function () { withTransaction: function () {
return this return this
+28 -13
View File
@@ -2062,14 +2062,19 @@ describe("CartService", () => {
withTransaction: function () { withTransaction: function () {
return this return this
}, },
retrieveByCode: jest.fn().mockImplementation((code) => { listByCodes: jest.fn().mockImplementation((code) => {
const codes = Array.isArray(code) ? code : [code]
const data = []
for (const code of codes) {
if (code === "US10") { if (code === "US10") {
return Promise.resolve({ data.push({
regions: [{ id: IdMap.getId("bad") }], regions: [{ id: IdMap.getId("bad") }],
}) })
} }
if (code === "limit-reached") { if (code === "limit-reached") {
return Promise.resolve({ data.push({
id: IdMap.getId("limit-reached"), id: IdMap.getId("limit-reached"),
code: "limit-reached", code: "limit-reached",
regions: [{ id: IdMap.getId("good") }], regions: [{ id: IdMap.getId("good") }],
@@ -2079,7 +2084,7 @@ describe("CartService", () => {
}) })
} }
if (code === "null-count") { if (code === "null-count") {
return Promise.resolve({ data.push({
id: IdMap.getId("null-count"), id: IdMap.getId("null-count"),
code: "null-count", code: "null-count",
regions: [{ id: IdMap.getId("good") }], regions: [{ id: IdMap.getId("good") }],
@@ -2089,7 +2094,7 @@ describe("CartService", () => {
}) })
} }
if (code === "FREESHIPPING") { if (code === "FREESHIPPING") {
return Promise.resolve({ data.push({
id: IdMap.getId("freeship"), id: IdMap.getId("freeship"),
code: "FREESHIPPING", code: "FREESHIPPING",
regions: [{ id: IdMap.getId("good") }], regions: [{ id: IdMap.getId("good") }],
@@ -2099,7 +2104,7 @@ describe("CartService", () => {
}) })
} }
if (code === "EarlyDiscount") { if (code === "EarlyDiscount") {
return Promise.resolve({ data.push({
id: IdMap.getId("10off"), id: IdMap.getId("10off"),
code: "10%OFF", code: "10%OFF",
regions: [{ id: IdMap.getId("good") }], regions: [{ id: IdMap.getId("good") }],
@@ -2111,7 +2116,7 @@ describe("CartService", () => {
}) })
} }
if (code === "ExpiredDiscount") { if (code === "ExpiredDiscount") {
return Promise.resolve({ data.push({
id: IdMap.getId("10off"), id: IdMap.getId("10off"),
code: "10%OFF", code: "10%OFF",
regions: [{ id: IdMap.getId("good") }], regions: [{ id: IdMap.getId("good") }],
@@ -2123,7 +2128,7 @@ describe("CartService", () => {
}) })
} }
if (code === "ExpiredDynamicDiscount") { if (code === "ExpiredDynamicDiscount") {
return Promise.resolve({ data.push({
id: IdMap.getId("10off"), id: IdMap.getId("10off"),
code: "10%OFF", code: "10%OFF",
is_dynamic: true, is_dynamic: true,
@@ -2136,7 +2141,7 @@ describe("CartService", () => {
}) })
} }
if (code === "ExpiredDynamicDiscountEndDate") { if (code === "ExpiredDynamicDiscountEndDate") {
return Promise.resolve({ data.push({
id: IdMap.getId("10off"), id: IdMap.getId("10off"),
is_dynamic: true, is_dynamic: true,
code: "10%OFF", code: "10%OFF",
@@ -2150,7 +2155,7 @@ describe("CartService", () => {
}) })
} }
if (code === "ValidDiscount") { if (code === "ValidDiscount") {
return Promise.resolve({ data.push({
id: IdMap.getId("10off"), id: IdMap.getId("10off"),
code: "10%OFF", code: "10%OFF",
regions: [{ id: IdMap.getId("good") }], regions: [{ id: IdMap.getId("good") }],
@@ -2162,7 +2167,7 @@ describe("CartService", () => {
}) })
} }
if (code === "ApplicableForCustomer") { if (code === "ApplicableForCustomer") {
return Promise.resolve({ data.push({
id: "ApplicableForCustomer", id: "ApplicableForCustomer",
code: "ApplicableForCustomer", code: "ApplicableForCustomer",
regions: [{ id: IdMap.getId("good") }], regions: [{ id: IdMap.getId("good") }],
@@ -2174,7 +2179,9 @@ describe("CartService", () => {
ends_at: getOffsetDate(10), ends_at: getOffsetDate(10),
}) })
} }
return Promise.resolve({
if (!data.length) {
data.push({
id: IdMap.getId("10off"), id: IdMap.getId("10off"),
code: "10%OFF", code: "10%OFF",
regions: [{ id: IdMap.getId("good") }], regions: [{ id: IdMap.getId("good") }],
@@ -2182,6 +2189,14 @@ describe("CartService", () => {
type: "percentage", type: "percentage",
}, },
}) })
}
}
if (Array.isArray(code)) {
return Promise.resolve(data)
}
return Promise.resolve(data[0])
}), }),
canApplyForCustomer: jest canApplyForCustomer: jest
.fn() .fn()
@@ -2333,7 +2348,7 @@ describe("CartService", () => {
discounts: [{ code: "10%OFF" }, { code: "FREESHIPPING" }], discounts: [{ code: "10%OFF" }, { code: "FREESHIPPING" }],
}) })
expect(discountService.retrieveByCode).toHaveBeenCalledTimes(2) expect(discountService.listByCodes).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledTimes(1) expect(cartRepository.save).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledWith( expect(cartRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -3,6 +3,7 @@ import { FlagRouter } from "../../utils/flag-router"
import DiscountService from "../discount" import DiscountService from "../discount"
import { TotalsServiceMock } from "../__mocks__/totals" import { TotalsServiceMock } from "../__mocks__/totals"
import { newTotalsServiceMock } from "../__mocks__/new-totals" import { newTotalsServiceMock } from "../__mocks__/new-totals"
import { In } from "typeorm"
const featureFlagRouter = new FlagRouter({}) const featureFlagRouter = new FlagRouter({})
@@ -54,14 +55,16 @@ describe("DiscountService", () => {
}) })
it("fails to create a discount without regions", async () => { it("fails to create a discount without regions", async () => {
const err = await discountService.create({ const err = await discountService
.create({
code: "test", code: "test",
rule: { rule: {
type: "fixed", type: "fixed",
allocation: "total", allocation: "total",
value: 20, value: 20,
}, },
}).catch(e => e) })
.catch((e) => e)
expect(err.type).toEqual("invalid_data") expect(err.type).toEqual("invalid_data")
expect(err.message).toEqual("Discount must have atleast 1 region") expect(err.message).toEqual("Discount must have atleast 1 region")
@@ -237,7 +240,6 @@ describe("DiscountService", () => {
expect(discountRepository.findOne).toHaveBeenCalledWith({ expect(discountRepository.findOne).toHaveBeenCalledWith({
where: { where: {
code: "10%OFF", code: "10%OFF",
is_dynamic: false,
}, },
}) })
}) })
@@ -248,7 +250,54 @@ describe("DiscountService", () => {
expect(discountRepository.findOne).toHaveBeenCalledWith({ expect(discountRepository.findOne).toHaveBeenCalledWith({
where: { where: {
code: "10%OFF", 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", () => { describe("validateDiscountForCartOrThrow", () => {
const discount = { code: "TEST" }
const getCart = (id) => { const getCart = (id) => {
if (id === "with-d") { if (id === "with-d") {
return { return {
@@ -878,50 +928,46 @@ describe("DiscountService", () => {
}) })
it("throws when hasReachedLimit returns true", async () => { it("throws when hasReachedLimit returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer") const cart = getCart("with-d-and-customer")
discountService.hasReachedLimit = jest.fn().mockImplementation(() => true) discountService.hasReachedLimit = jest.fn().mockImplementation(() => true)
expect( expect(
discountService.validateDiscountForCartOrThrow(cart, discount) discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({ ).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 () => { it("throws when hasNotStarted returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer") const cart = getCart("with-d-and-customer")
discountService.hasNotStarted = jest.fn().mockImplementation(() => true) discountService.hasNotStarted = jest.fn().mockImplementation(() => true)
expect( expect(
discountService.validateDiscountForCartOrThrow(cart, discount) discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({ ).rejects.toThrow({
message: "Discount is not valid yet", message: `Discount ${discount.code} is not valid yet`,
}) })
}) })
it("throws when hasExpired returns true", async () => { it("throws when hasExpired returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer") const cart = getCart("with-d-and-customer")
discountService.hasExpired = jest.fn().mockImplementation(() => true) discountService.hasExpired = jest.fn().mockImplementation(() => true)
expect( expect(
discountService.validateDiscountForCartOrThrow(cart, discount) discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({ ).rejects.toThrow({
message: "Discount is expired", message: `Discount ${discount.code} is expired`,
}) })
}) })
it("throws when isDisabled returns true", async () => { it("throws when isDisabled returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer") const cart = getCart("with-d-and-customer")
discountService.isDisabled = jest.fn().mockImplementation(() => true) discountService.isDisabled = jest.fn().mockImplementation(() => true)
expect( expect(
discountService.validateDiscountForCartOrThrow(cart, discount) discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({ ).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 () => { 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") const cart = getCart("with-d-and-customer")
discountService.canApplyForCustomer = jest discountService.canApplyForCustomer = jest
.fn() .fn()
.mockImplementation(() => Promise.resolve(false)) .mockImplementation(() => Promise.resolve(false))
expect( expect(
discountService.validateDiscountForCartOrThrow(cart, discount) discountService.validateDiscountForCartOrThrow(cart, discount_)
).rejects.toThrow({ ).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 { MockManager, MockRepository } from "medusa-test-utils"
import { EventBusServiceMock } from "../__mocks__/event-bus" import { EventBusServiceMock } from "../__mocks__/event-bus"
import DraftOrderService from "../draft-order" import DraftOrderService from "../draft-order"
import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment"
const eventBusService = { const eventBusService = {
emit: jest.fn(), emit: jest.fn(),
@@ -10,30 +11,7 @@ const eventBusService = {
} }
describe("DraftOrderService", () => { 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", () => { describe("create", () => {
let result
const regionService = { const regionService = {
retrieve: () => retrieve: () =>
Promise.resolve({ id: "test-region", countries: [{ iso_2: "dk" }] }), Promise.resolve({ id: "test-region", countries: [{ iso_2: "dk" }] }),
@@ -62,7 +40,7 @@ describe("DraftOrderService", () => {
variant_id: "test-variant", variant_id: "test-variant",
}) })
), ),
create: jest.fn(), create: jest.fn().mockImplementation((data) => data),
withTransaction: function () { withTransaction: function () {
return this return this
}, },
@@ -86,7 +64,9 @@ describe("DraftOrderService", () => {
shipping_address_id: "test-shipping", shipping_address_id: "test-shipping",
billing_address_id: "test-billing", billing_address_id: "test-billing",
customer_id: "test-customer", customer_id: "test-customer",
items: [{ variant_id: "test-variant", quantity: 2, metadata: {} }], items: [
{ id: "test", variant_id: "test-variant", quantity: 2, metadata: {} },
],
shipping_methods: [ shipping_methods: [
{ {
option_id: "test-option", option_id: "test-option",
@@ -108,6 +88,8 @@ describe("DraftOrderService", () => {
...testOrder, ...testOrder,
}) })
), ),
update: jest.fn(),
applyDiscount: jest.fn(),
addShippingMethod: jest.fn(), addShippingMethod: jest.fn(),
withTransaction: function () { withTransaction: function () {
return this return this
@@ -136,6 +118,7 @@ describe("DraftOrderService", () => {
cartService, cartService,
shippingOptionService, shippingOptionService,
lineItemService, lineItemService,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
productVariantService, productVariantService,
draftOrderRepository, draftOrderRepository,
addressRepository, addressRepository,
@@ -147,11 +130,14 @@ describe("DraftOrderService", () => {
}) })
it("creates a draft order", async () => { it("creates a draft order", async () => {
const cartId = "test-cart"
const title = "test-item"
await draftOrderService.create(testOrder) await draftOrderService.create(testOrder)
expect(draftOrderRepository.create).toHaveBeenCalledTimes(1) expect(draftOrderRepository.create).toHaveBeenCalledTimes(1)
expect(draftOrderRepository.create).toHaveBeenCalledWith({ expect(draftOrderRepository.create).toHaveBeenCalledWith({
cart_id: "test-cart", cart_id: cartId,
}) })
expect(cartService.create).toHaveBeenCalledTimes(1) expect(cartService.create).toHaveBeenCalledTimes(1)
@@ -163,11 +149,6 @@ describe("DraftOrderService", () => {
type: "draft_order", 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).toHaveBeenCalledTimes(1)
expect(cartService.addShippingMethod).toHaveBeenCalledWith( expect(cartService.addShippingMethod).toHaveBeenCalledWith(
"test-cart", "test-cart",
@@ -183,18 +164,69 @@ describe("DraftOrderService", () => {
{ {
metadata: {}, metadata: {},
unit_price: undefined, unit_price: undefined,
cart: expect.objectContaining({
id: "test-cart",
}),
} }
) )
expect(lineItemService.create).toHaveBeenCalledTimes(1) expect(lineItemService.create).toHaveBeenCalledTimes(1)
expect(lineItemService.create).toHaveBeenCalledWith({ expect(lineItemService.create).toHaveBeenCalledWith({
cart_id: "test-cart", cart_id: cartId,
title: "test-item", title,
variant_id: "test-variant", 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 () => { it("fails on missing region", async () => {
@@ -213,7 +245,7 @@ describe("DraftOrderService", () => {
await draftOrderService.create({ await draftOrderService.create({
region_id: "test-region", region_id: "test-region",
items: [], items: [],
shipping_methods: [] shipping_methods: [],
}) })
}) })
}) })
@@ -262,6 +294,7 @@ describe("DraftOrderService", () => {
cartService: undefined, cartService: undefined,
shippingOptionService: undefined, shippingOptionService: undefined,
lineItemService: undefined, lineItemService: undefined,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
productVariantService: undefined, productVariantService: undefined,
draftOrderRepository, draftOrderRepository,
addressRepository: undefined, addressRepository: undefined,
+46 -19
View File
@@ -9,6 +9,7 @@ import {
Customer, Customer,
CustomShippingOption, CustomShippingOption,
Discount, Discount,
DiscountRule,
DiscountRuleType, DiscountRuleType,
LineItem, LineItem,
PaymentSession, PaymentSession,
@@ -1122,10 +1123,9 @@ class CartService extends TransactionBaseService {
const previousDiscounts = [...cart.discounts] const previousDiscounts = [...cart.discounts]
cart.discounts.length = 0 cart.discounts.length = 0
await Promise.all( await this.applyDiscounts(
data.discounts.map(async ({ code }) => { cart,
return this.applyDiscount(cart, code) data.discounts.map((d) => d.code)
})
) )
const hasFreeShipping = cart.discounts.some( const hasFreeShipping = cart.discounts.some(
@@ -1410,41 +1410,65 @@ class CartService extends TransactionBaseService {
* Throws if discount regions does not include the cart region * Throws if discount regions does not include the cart region
* @param cart - the cart to update * @param cart - the cart to update
* @param discountCode - the discount code * @param discountCode - the discount code
* @return the result of the update operation
*/ */
async applyDiscount(cart: Cart, discountCode: string): Promise<void> { 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_( return await this.atomicPhase_(
async (transactionManager: EntityManager) => { async (transactionManager: EntityManager) => {
const discount = await this.discountService_ const discounts = await this.discountService_
.withTransaction(transactionManager) .withTransaction(transactionManager)
.retrieveByCode(discountCode, { relations: ["rule", "regions"] }) .listByCodes(discountCodes, { relations: ["rule", "regions"] })
await this.discountService_ await this.discountService_
.withTransaction(transactionManager) .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 cart.discounts.forEach((discount) => {
if (cart.discounts.find(({ id }) => id === discount.id)) { if (discountsMap.has(discount.id)) {
return discountsMap.delete(discount.id)
} }
})
const toParse = [...cart.discounts, discount] const toParse = [...cart.discounts, ...discountsMap.values()]
let sawNotShipping = false let sawNotShipping = false
const newDiscounts = toParse.map((discountToParse) => { const newDiscounts = toParse.map((discountToParse) => {
switch (discountToParse.rule?.type) { switch (discountToParse.rule?.type) {
case DiscountRuleType.FREE_SHIPPING: case DiscountRuleType.FREE_SHIPPING:
if (discountToParse.rule.type === rule.type) { if (
return discount discountToParse.rule.type ===
rules.get(discountToParse.id)!.type
) {
return discountsMap.get(discountToParse.id)
} }
return discountToParse return discountToParse
default: default:
if (!sawNotShipping) { if (!sawNotShipping) {
sawNotShipping = true sawNotShipping = true
if (rule?.type !== DiscountRuleType.FREE_SHIPPING) { if (
return discount rules.get(discountToParse.id)!.type !==
DiscountRuleType.FREE_SHIPPING
) {
return discountsMap.get(discountToParse.id)
} }
return discountToParse return discountToParse
} }
@@ -1458,8 +1482,11 @@ class CartService extends TransactionBaseService {
} }
) )
// ignore if free shipping const hadNonFreeShippingDiscounts = [...rules.values()].some(
if (rule?.type !== DiscountRuleType.FREE_SHIPPING && cart?.items) { (rule) => rule.type !== DiscountRuleType.FREE_SHIPPING
)
if (hadNonFreeShippingDiscounts && cart?.items) {
await this.refreshAdjustments_(cart) await this.refreshAdjustments_(cart)
} }
} }
+55 -24
View File
@@ -6,6 +6,7 @@ import {
DeepPartial, DeepPartial,
EntityManager, EntityManager,
ILike, ILike,
In,
SelectQueryBuilder, SelectQueryBuilder,
} from "typeorm" } from "typeorm"
import { import {
@@ -277,10 +278,10 @@ class DiscountService extends TransactionBaseService {
} }
/** /**
* Gets a discount by discount code. * Gets the discount by discount code.
* @param {string} discountCode - discount code of discount to retrieve * @param discountCode - discount code of discount to retrieve
* @param {Object} config - the config object containing query settings * @param config - the config object containing query settings
* @return {Promise<Discount>} the discount document * @return the discount
*/ */
async retrieveByCode( async retrieveByCode(
discountCode: string, discountCode: string,
@@ -291,24 +292,49 @@ class DiscountService extends TransactionBaseService {
const normalizedCode = discountCode.toUpperCase().trim() const normalizedCode = discountCode.toUpperCase().trim()
let query = buildQuery({ code: normalizedCode, is_dynamic: false }, config) const query = buildQuery({ code: normalizedCode }, config)
let discount = await discountRepo.findOne(query) const discount = await discountRepo.findOne(query)
if (!discount) {
query = buildQuery({ code: normalizedCode, is_dynamic: true }, config)
discount = await discountRepo.findOne(query)
if (!discount) { if (!discount) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_FOUND, MedusaError.Types.NOT_FOUND,
`Discount with code ${discountCode} was not found` `Discounts with code ${discountCode} was not found`
) )
} }
}
return discount 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. * Updates a discount.
* @param {string} discountId - discount id of discount to update * @param {string} discountId - discount id of discount to update
@@ -660,39 +686,42 @@ class DiscountService extends TransactionBaseService {
async validateDiscountForCartOrThrow( async validateDiscountForCartOrThrow(
cart: Cart, cart: Cart,
discount: Discount discount: Discount | Discount[]
): Promise<void> { ): Promise<void> {
const discounts = Array.isArray(discount) ? discount : [discount]
return await this.atomicPhase_(async () => { return await this.atomicPhase_(async () => {
if (this.hasReachedLimit(discount)) { await Promise.all(
discounts.map(async (disc) => {
if (this.hasReachedLimit(disc)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_ALLOWED, MedusaError.Types.NOT_ALLOWED,
"Discount has been used maximum allowed times" `Discount ${disc.code} has been used maximum allowed times`
) )
} }
if (this.hasNotStarted(discount)) { if (this.hasNotStarted(disc)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_ALLOWED, MedusaError.Types.NOT_ALLOWED,
"Discount is not valid yet" `Discount ${disc.code} is not valid yet`
) )
} }
if (this.hasExpired(discount)) { if (this.hasExpired(disc)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_ALLOWED, MedusaError.Types.NOT_ALLOWED,
"Discount is expired" `Discount ${disc.code} is expired`
) )
} }
if (this.isDisabled(discount)) { if (this.isDisabled(disc)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_ALLOWED, MedusaError.Types.NOT_ALLOWED,
"The discount code is disabled" `The discount code ${disc.code} is disabled`
) )
} }
const isValidForRegion = await this.isValidForRegion( const isValidForRegion = await this.isValidForRegion(
discount, disc,
cart.region_id cart.region_id
) )
if (!isValidForRegion) { if (!isValidForRegion) {
@@ -704,18 +733,20 @@ class DiscountService extends TransactionBaseService {
if (cart.customer_id) { if (cart.customer_id) {
const canApplyForCustomer = await this.canApplyForCustomer( const canApplyForCustomer = await this.canApplyForCustomer(
discount.rule.id, disc.rule.id,
cart.customer_id cart.customer_id
) )
if (!canApplyForCustomer) { if (!canApplyForCustomer) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.NOT_ALLOWED, MedusaError.Types.NOT_ALLOWED,
"Discount is not valid for customer" `Discount ${disc.code} is not valid for customer`
) )
} }
} }
}) })
)
})
} }
hasReachedLimit(discount: Discount): boolean { hasReachedLimit(discount: Discount): boolean {
+9 -16
View File
@@ -1,7 +1,7 @@
import { isDefined, MedusaError } from "medusa-core-utils" import { isDefined, MedusaError } from "medusa-core-utils"
import { Brackets, EntityManager, FindManyOptions, UpdateResult } from "typeorm" import { Brackets, EntityManager, FindManyOptions, UpdateResult } from "typeorm"
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { Cart, CartType, DraftOrder, DraftOrderStatus } from "../models" import { CartType, DraftOrder, DraftOrderStatus } from "../models"
import { DraftOrderRepository } from "../repositories/draft-order" import { DraftOrderRepository } from "../repositories/draft-order"
import { OrderRepository } from "../repositories/order" import { OrderRepository } from "../repositories/order"
import { PaymentRepository } from "../repositories/payment" import { PaymentRepository } from "../repositories/payment"
@@ -264,34 +264,24 @@ class DraftOrderService extends TransactionBaseService {
no_notification_order, no_notification_order,
items, items,
idempotency_key, idempotency_key,
discounts,
...rawCart ...rawCart
} = data } = data
const cartServiceTx = const cartServiceTx =
this.cartService_.withTransaction(transactionManager) this.cartService_.withTransaction(transactionManager)
if (rawCart.discounts) { const createdCart = await cartServiceTx.create({
const { discounts } = rawCart
rawCart.discounts = []
for (const { code } of discounts) {
await cartServiceTx.applyDiscount(rawCart as Cart, code)
}
}
let createdCart = await cartServiceTx.create({
type: CartType.DRAFT_ORDER, type: CartType.DRAFT_ORDER,
...rawCart, ...rawCart,
}) })
createdCart = await cartServiceTx.retrieve(createdCart.id, {
relations: ["discounts", "discounts.rule", "items", "region"],
})
const draftOrder = draftOrderRepo.create({ const draftOrder = draftOrderRepo.create({
cart_id: createdCart.id, cart_id: createdCart.id,
no_notification_order, no_notification_order,
idempotency_key, idempotency_key,
}) })
const result = await draftOrderRepo.save(draftOrder) const result = await draftOrderRepo.save(draftOrder)
await this.eventBus_ await this.eventBus_
@@ -303,7 +293,7 @@ class DraftOrderService extends TransactionBaseService {
const lineItemServiceTx = const lineItemServiceTx =
this.lineItemService_.withTransaction(transactionManager) this.lineItemService_.withTransaction(transactionManager)
for (const item of (items || [])) { for (const item of items || []) {
if (item.variant_id) { if (item.variant_id) {
const line = await lineItemServiceTx.generate( const line = await lineItemServiceTx.generate(
item.variant_id, item.variant_id,
@@ -312,7 +302,6 @@ class DraftOrderService extends TransactionBaseService {
{ {
metadata: item?.metadata || {}, metadata: item?.metadata || {},
unit_price: item.unit_price, 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) { for (const method of shipping_methods) {
if (typeof method.price !== "undefined") { if (typeof method.price !== "undefined") {
await this.customShippingOptionService_ await this.customShippingOptionService_
+1 -1
View File
@@ -367,7 +367,7 @@ class LineItemService extends TransactionBaseService {
*/ */
async create< async create<
T = LineItem | LineItem[], T = LineItem | LineItem[],
TResult = T extends LineItem ? LineItem : LineItem[] TResult = T extends LineItem[] ? LineItem[] : LineItem
>(data: T): Promise<TResult> { >(data: T): Promise<TResult> {
return await this.atomicPhase_( return await this.atomicPhase_(
async (transactionManager: EntityManager) => { async (transactionManager: EntityManager) => {