diff --git a/.changeset/spicy-trainers-smile.md b/.changeset/spicy-trainers-smile.md new file mode 100644 index 0000000000..ebbd4375f2 --- /dev/null +++ b/.changeset/spicy-trainers-smile.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): Applying Discounts (with Conditions) on DraftOrders and Carts diff --git a/integration-tests/api/__tests__/admin/discount.js b/integration-tests/api/__tests__/admin/discount.js index bcd2a4ecf4..b6a6d6aa31 100644 --- a/integration-tests/api/__tests__/admin/discount.js +++ b/integration-tests/api/__tests__/admin/discount.js @@ -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` ) }) diff --git a/integration-tests/api/__tests__/admin/draft-order/draft-order.js b/integration-tests/api/__tests__/admin/draft-order/draft-order.js index 695f647a32..4e290d7cb9 100644 --- a/integration-tests/api/__tests__/admin/draft-order/draft-order.js +++ b/integration-tests/api/__tests__/admin/draft-order/draft-order.js @@ -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() diff --git a/integration-tests/api/__tests__/store/cart/cart.js b/integration-tests/api/__tests__/store/cart/cart.js index 2d8e3be2f0..5cdaa3bbe9 100644 --- a/integration-tests/api/__tests__/store/cart/cart.js +++ b/integration-tests/api/__tests__/store/cart/cart.js @@ -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 () => { diff --git a/integration-tests/api/helpers/cart-seeder.js b/integration-tests/api/helpers/cart-seeder.js index b9b71e8970..32bc01ccbd 100644 --- a/integration-tests/api/helpers/cart-seeder.js +++ b/integration-tests/api/helpers/cart-seeder.js @@ -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 }) diff --git a/packages/medusa/src/repositories/discount-condition.ts b/packages/medusa/src/repositories/discount-condition.ts index 8e59b2f1c4..f464d5b776 100644 --- a/packages/medusa/src/repositories/discount-condition.ts +++ b/packages/medusa/src/repositories/discount-condition.ts @@ -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 { // 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, diff --git a/packages/medusa/src/services/__mocks__/line-item-adjustment.js b/packages/medusa/src/services/__mocks__/line-item-adjustment.js index 8e0df20bc5..167b93a5d4 100644 --- a/packages/medusa/src/services/__mocks__/line-item-adjustment.js +++ b/packages/medusa/src/services/__mocks__/line-item-adjustment.js @@ -1,6 +1,3 @@ -import { IdMap } from "medusa-test-utils" -import { MedusaError } from "medusa-core-utils" - export const LineItemAdjustmentServiceMock = { withTransaction: function () { return this diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 6294dfef20..9bab2f7538 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -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({ diff --git a/packages/medusa/src/services/__tests__/discount.js b/packages/medusa/src/services/__tests__/discount.js index 48ec11131d..8782c77a69 100644 --- a/packages/medusa/src/services/__tests__/discount.js +++ b/packages/medusa/src/services/__tests__/discount.js @@ -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`, }) }) }) diff --git a/packages/medusa/src/services/__tests__/draft-order.js b/packages/medusa/src/services/__tests__/draft-order.js index 2ade8a28a3..d789dee979 100644 --- a/packages/medusa/src/services/__tests__/draft-order.js +++ b/packages/medusa/src/services/__tests__/draft-order.js @@ -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, diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index 159d8e0265..42f219d515 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -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 { + 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 { 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 = 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) } } diff --git a/packages/medusa/src/services/discount.ts b/packages/medusa/src/services/discount.ts index 23ea97b274..c4b55f53eb 100644 --- a/packages/medusa/src/services/discount.ts +++ b/packages/medusa/src/services/discount.ts @@ -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} 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 = {} + ): Promise { + 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 { + 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` + ) + } + } + }) + ) }) } diff --git a/packages/medusa/src/services/draft-order.ts b/packages/medusa/src/services/draft-order.ts index 21ba6e2628..b91ff2a2bc 100644 --- a/packages/medusa/src/services/draft-order.ts +++ b/packages/medusa/src/services/draft-order.ts @@ -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_ diff --git a/packages/medusa/src/services/line-item.ts b/packages/medusa/src/services/line-item.ts index 54a5385b95..6a4bad67aa 100644 --- a/packages/medusa/src/services/line-item.ts +++ b/packages/medusa/src/services/line-item.ts @@ -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 { return await this.atomicPhase_( async (transactionManager: EntityManager) => {