From 1cfeb5dbd8c1ac0cbcef388ba1455e08965316bc Mon Sep 17 00:00:00 2001 From: Zakaria El Asri <33696020+zakariaelas@users.noreply.github.com> Date: Tue, 12 Apr 2022 13:49:31 +0000 Subject: [PATCH] feat: line item adjustments (#1319) * add: crud services + model + totals * fix: enforce unique constraint on line item adjustment model and update service (#1241) * add: unique constraint on model + fix service * fix: unique constraint * fix: add cascade on delete + fix discount relation * fix: remove optional unique prop * add: tests for ensuring line item adjustment db constraints (#1279) * add: tests for ensuring db constraints * fix: use given when then * feat: adjust cart to include line item adjustments (#1242) * fix: cart service + cart tests * fix: remaining tests * fix: swap tests * fix: add relationship + fix oas * refactor: applyDiscount * fix: refactor applyDiscount and fix + add unit tests * fix: plugins tests * feat: line item adjustments draft orders (#1243) * fix: draft order tests * fix: constraint * fix: wrong variable name * fix: unique constraint * progress: add tests * fix: add cascade on delete + fix discount relation * fix: remove optional unique prop * fix: cart removeLineItem + tests * fix: cart unit tests * fix: update snapshot * remove: verbose option * rename arg Co-authored-by: Sebastian Rindom * add: create adjustments for swap additional_items * add: create adjustments for return lines * fix: unit test for creating adjustment for additional_items * fix: create adjustments only for non return items + no deletion when item is a return item * add: integration tests * refactor: use refreshAdjustments method * refactor test Co-authored-by: Sebastian Rindom Co-authored-by: Sebastian Rindom Co-authored-by: Sebastian Rindom --- .../api/__tests__/admin/draft-order.js | 56 ++ .../api/__tests__/admin/order.js | 110 ++++ .../claims/__snapshots__/index.js.snap | 1 + .../__tests__/line-item-adjustments/index.js | 222 +++++++ .../api/__tests__/returns/index.js | 8 + integration-tests/api/__tests__/store/cart.js | 561 +++++++++++++++++- .../api/factories/simple-line-item-factory.ts | 9 +- .../api/factories/simple-order-factory.ts | 12 +- integration-tests/api/helpers/cart-seeder.js | 131 ++++ integration-tests/api/helpers/order-seeder.js | 8 + integration-tests/api/helpers/swap-seeder.js | 11 +- integration-tests/api/package.json | 6 +- integration-tests/api/yarn.lock | 70 +-- .../__snapshots__/index.js.snap | 5 + .../__tests__/medusa-plugin-sendgrid/index.js | 3 + integration-tests/plugins/package.json | 10 +- integration-tests/plugins/yarn.lock | 92 +-- .../api/routes/admin/draft-orders/index.ts | 8 +- .../routes/admin/swaps/__tests__/get-swap.js | 3 + .../src/api/routes/admin/swaps/index.ts | 3 + .../src/api/routes/store/carts/index.ts | 1 + packages/medusa/src/index.js | 1 + ...1648600574750-add_line_item_adjustments.ts | 39 ++ .../medusa/src/models/line-item-adjustment.ts | 61 ++ packages/medusa/src/models/line-item.ts | 4 + .../src/repositories/line-item-adjustment.ts | 6 + packages/medusa/src/repositories/line-item.ts | 1 + .../medusa/src/services/__mocks__/cart.js | 23 +- .../__mocks__/line-item-adjustment.js | 68 +++ .../medusa/src/services/__tests__/cart.js | 306 ++++++---- .../medusa/src/services/__tests__/discount.js | 472 +++++++++++++++ .../src/services/__tests__/draft-order.js | 34 +- .../__tests__/line-item-adjustment.js | 314 ++++++++++ .../medusa/src/services/__tests__/swap.js | 63 +- .../medusa/src/services/__tests__/totals.js | 367 +++++++----- packages/medusa/src/services/cart.ts | 182 +++--- packages/medusa/src/services/discount.ts | 94 +++ packages/medusa/src/services/draft-order.js | 1 + .../src/services/line-item-adjustment.ts | 303 ++++++++++ packages/medusa/src/services/line-item.js | 31 +- packages/medusa/src/services/order.js | 3 + packages/medusa/src/services/swap.js | 10 + packages/medusa/src/services/totals.ts | 120 ++-- .../medusa/src/types/line-item-adjustment.ts | 27 + packages/medusa/src/utils/date-helpers.ts | 11 + 45 files changed, 3290 insertions(+), 581 deletions(-) create mode 100644 integration-tests/api/__tests__/line-item-adjustments/index.js create mode 100644 packages/medusa/src/migrations/1648600574750-add_line_item_adjustments.ts create mode 100644 packages/medusa/src/models/line-item-adjustment.ts create mode 100644 packages/medusa/src/repositories/line-item-adjustment.ts create mode 100644 packages/medusa/src/services/__mocks__/line-item-adjustment.js create mode 100644 packages/medusa/src/services/__tests__/line-item-adjustment.js create mode 100644 packages/medusa/src/services/line-item-adjustment.ts create mode 100644 packages/medusa/src/types/line-item-adjustment.ts create mode 100644 packages/medusa/src/utils/date-helpers.ts diff --git a/integration-tests/api/__tests__/admin/draft-order.js b/integration-tests/api/__tests__/admin/draft-order.js index 2f267969fb..c010598e82 100644 --- a/integration-tests/api/__tests__/admin/draft-order.js +++ b/integration-tests/api/__tests__/admin/draft-order.js @@ -297,6 +297,62 @@ describe("/admin/draft-orders", () => { ) }) + it("creates a draft order with discount and line item", async () => { + const api = useApi() + + const payload = { + email: "oli@test.dk", + shipping_address: "oli-shipping", + discounts: [{ code: "TEST" }], + items: [ + { + variant_id: "test-variant", + 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, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + const draftOrder = response.data.draft_order + const lineItemId = draftOrder.cart.items[0].id + + expect(response.status).toEqual(200) + expect(draftOrder.cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + variant_id: "test-variant", + unit_price: 8000, + quantity: 2, + adjustments: expect.arrayContaining([ + expect.objectContaining({ + item_id: lineItemId, + amount: 1600, + description: "discount", + discount_id: "test-discount", + }), + ]), + }), + ]) + ) + }) + it("creates a draft order with created shipping address", async () => { const api = useApi() diff --git a/integration-tests/api/__tests__/admin/order.js b/integration-tests/api/__tests__/admin/order.js index 9ee709884f..5aeb20da59 100644 --- a/integration-tests/api/__tests__/admin/order.js +++ b/integration-tests/api/__tests__/admin/order.js @@ -1555,6 +1555,116 @@ describe("/admin/orders", () => { expect(response.status).toEqual(200) }) + describe("Given an existing discount order", () => { + describe("When a store operator attemps to create a swap form the discount order", () => { + it("Then should successfully create the swap", async () => { + const api = useApi() + + const response = await api.post( + "/admin/orders/test-order/swaps", + { + return_items: [ + { + item_id: "test-item", + quantity: 1, + }, + ], + additional_items: [{ variant_id: "test-variant-2", quantity: 1 }], + }, + { + headers: { + authorization: "Bearer test_token", + }, + } + ) + + const swapCartId = response.data.order.swaps[0].cart_id + + const swapCartRes = await api.get(`/store/carts/${swapCartId}`, { + headers: { + authorization: "Bearer test_token", + }, + }) + const cart = swapCartRes.data.cart + + expect(response.status).toEqual(200) + expect(cart.items.length).toEqual(2) + expect(cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + unit_price: -8000, + adjustments: [ + expect.objectContaining({ + amount: -800, + }), + ], + }), + expect.objectContaining({ + unit_price: 8000, + adjustments: [ + expect.objectContaining({ + amount: 800, + }), + ], + }), + ]) + ) + expect(cart.total).toEqual(0) + }) + }) + + describe("And given a swap cart", () => { + describe("When a line item is added to the swap cart", () => { + it("Then should not delete existing return line item adjustments", async () => { + const api = useApi() + + const createSwapRes = await api.post( + "/admin/orders/test-order/swaps", + { + return_items: [ + { + item_id: "test-item", + quantity: 1, + }, + ], + additional_items: [{ variant_id: "test-variant", quantity: 1 }], + }, + { + headers: { + authorization: "Bearer test_token", + }, + } + ) + + const swapCartId = createSwapRes.data.order.swaps[0].cart_id + + const response = await api.post( + `/store/carts/${swapCartId}/line-items`, + { + variant_id: "test-variant-2", + quantity: 1, + }, + { + headers: { + authorization: "Bearer test_token", + }, + } + ) + + const cart = response.data.cart + const items = cart.items + const [returnItem] = items.filter((i) => i.is_return) + expect(returnItem.adjustments).toEqual([ + expect.objectContaining({ + amount: -800, + }), + ]) + expect(cart.total).toBe(7200) + }) + }) + }) + }) + it("creates a swap with custom shipping options", async () => { const api = useApi() diff --git a/integration-tests/api/__tests__/claims/__snapshots__/index.js.snap b/integration-tests/api/__tests__/claims/__snapshots__/index.js.snap index 29f15a80d2..7860a7b7f5 100644 --- a/integration-tests/api/__tests__/claims/__snapshots__/index.js.snap +++ b/integration-tests/api/__tests__/claims/__snapshots__/index.js.snap @@ -35,6 +35,7 @@ exports[`Claims creates a replace claim 1`] = ` Object { "additional_items": Array [ Object { + "adjustments": Array [], "allow_discounts": true, "cart_id": null, "claim_order_id": StringMatching /\\^claim_\\*/, diff --git a/integration-tests/api/__tests__/line-item-adjustments/index.js b/integration-tests/api/__tests__/line-item-adjustments/index.js new file mode 100644 index 0000000000..31ccf99527 --- /dev/null +++ b/integration-tests/api/__tests__/line-item-adjustments/index.js @@ -0,0 +1,222 @@ +const path = require("path") +const { LineItemAdjustment } = require("@medusajs/medusa") +const setupServer = require("../../../helpers/setup-server") +const { useApi } = require("../../../helpers/use-api") +const { initDb, useDb } = require("../../../helpers/use-db") +const cartSeeder = require("../../helpers/cart-seeder") +const { simpleCartFactory, simpleLineItemFactory } = require("../../factories") +const { + simpleDiscountFactory, +} = require("../../factories/simple-discount-factory") + +jest.setTimeout(30000) + +describe("Line Item Adjustments", () => { + let dbConnection + let medusaProcess + + const doAfterEach = async () => { + const db = useDb() + return await db.teardown() + } + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + try { + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd }) + } catch (error) { + console.log(error) + } + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) + + describe("Tests database constraints", () => { + let cart, + discount, + lineItemId = "line-test" + beforeEach(async () => { + try { + await cartSeeder(dbConnection) + discount = await simpleDiscountFactory(dbConnection, { + code: "MEDUSATEST", + id: "discount-test", + rule: { + value: 100, + type: "fixed", + allocation: "total", + }, + regions: ["test-region"], + }) + cart = await simpleCartFactory( + dbConnection, + { + customer: "test-customer", + id: "cart-test", + line_items: [ + { + id: lineItemId, + variant_id: "test-variant", + cart_id: "cart-test", + unit_price: 1000, + quantity: 1, + adjustments: [ + { + amount: 10, + discount_id: discount.id, + description: "discount", + item_id: lineItemId, + }, + ], + }, + ], + region: "test-region", + shipping_address: { + address_1: "test", + country_code: "us", + first_name: "chris", + last_name: "rock", + postal_code: "101", + }, + shipping_methods: [ + { + shipping_option: "test-option", + }, + ], + }, + 100 + ) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + await doAfterEach() + }) + + afterEach(async () => { + await doAfterEach() + }) + + describe("Given an existing line item, a discount, and a line item adjustment for both", () => { + describe("When creating an adjustment for another line item w. same discount", () => { + test("Then should create an adjustment", async () => { + const createLineItemWithAdjustment = async () => { + return await simpleLineItemFactory(dbConnection, { + id: "line-test-2", + variant_id: "test-variant-quantity", + cart_id: "test-cart", + unit_price: 1000, + quantity: 1, + adjustments: [ + { + amount: 10, + discount_id: discount.id, + description: "discount", + item_id: "line-test-2", + }, + ], + }) + } + + expect(createLineItemWithAdjustment()).resolves.toEqual( + expect.anything() + ) + }) + }) + + describe("When creating an adjustment for another line item w. null discount", () => { + test("Then should create an adjustment", async () => { + const createAdjustmentNullDiscount = async () => { + return await dbConnection.manager.insert(LineItemAdjustment, { + id: "lia-1", + item_id: lineItemId, + amount: 35, + description: "custom discount", + discount_id: null, + }) + } + + expect(createAdjustmentNullDiscount()).resolves.toEqual( + expect.anything() + ) + }) + }) + + describe("When creating multiple adjustments w. a null discount_id", () => { + test("Then should create multiple adjustments", async () => { + const createAdjustmentsNullDiscount = async () => { + await dbConnection.manager.insert(LineItemAdjustment, { + id: "lia-1", + item_id: lineItemId, + amount: 35, + description: "custom discount", + discount_id: null, + }) + return await dbConnection.manager.insert(LineItemAdjustment, { + id: "lia-2", + item_id: lineItemId, + amount: 100, + description: "custom discount", + discount_id: null, + }) + } + expect(createAdjustmentsNullDiscount()).resolves.toEqual( + expect.anything() + ) + }) + }) + + describe("When creating an adjustment w. for same line item and different discount", () => { + test("Then should create an adjustment", async () => { + const createAdjustment = async () => { + await simpleDiscountFactory(dbConnection, { + code: "ANOTHER", + id: "discount-2", + rule: { + value: 10, + type: "percentage", + allocation: "item", + }, + regions: ["test-region"], + }) + + return await dbConnection.manager.insert(LineItemAdjustment, { + id: "lia-1", + item_id: lineItemId, + amount: 10, + description: "discount", + discount_id: "discount-2", + }) + } + + expect(createAdjustment()).resolves.toEqual(expect.anything()) + }) + }) + + describe("When creating an adjustment w. existing line item and discount pair", () => { + test("Then should throw a duplicate error", async () => { + const createDuplicateAdjustment = async () => + await dbConnection.manager.insert(LineItemAdjustment, { + id: "lia-1", + item_id: lineItemId, + amount: 20, + description: "discount", + discount_id: discount.id, + }) + + expect(createDuplicateAdjustment()).rejects.toEqual( + expect.objectContaining({ code: "23505" }) + ) + }) + }) + }) + }) +}) diff --git a/integration-tests/api/__tests__/returns/index.js b/integration-tests/api/__tests__/returns/index.js index 5ae7a451ef..1ec817eae5 100644 --- a/integration-tests/api/__tests__/returns/index.js +++ b/integration-tests/api/__tests__/returns/index.js @@ -259,6 +259,14 @@ const createReturnableOrder = async (dbConnection, options) => { variant_id: "test-variant", quantity: 2, unit_price: 1000, + adjustments: [ + { + amount: 200, + discount_code: "TESTCODE", + description: "discount", + item_id: "test-item", + }, + ], tax_lines: [ { name: "default", diff --git a/integration-tests/api/__tests__/store/cart.js b/integration-tests/api/__tests__/store/cart.js index 86dae5b93e..c89c8caa7f 100644 --- a/integration-tests/api/__tests__/store/cart.js +++ b/integration-tests/api/__tests__/store/cart.js @@ -16,7 +16,7 @@ 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 } = require("../../factories") +const { simpleCartFactory, simpleLineItemFactory } = require("../../factories") const { simpleDiscountFactory, } = require("../../factories/simple-discount-factory") @@ -235,6 +235,131 @@ describe("/store/carts", () => { unit_price: 1000, variant_id: "test-variant-quantity", quantity: 1, + adjustments: [], + }), + ]) + }) + + it("adds line item to cart containing a total fixed discount", async () => { + const api = useApi() + + const response = await api + .post( + "/store/carts/test-cart-w-total-fixed-discount/line-items", + { + variant_id: "test-variant-quantity", + quantity: 2, + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + cart_id: "test-cart-w-total-fixed-discount", + unit_price: 1000, + variant_id: "test-variant-quantity", + quantity: 2, + adjustments: [ + expect.objectContaining({ + amount: 100, + discount_id: "total-fixed-100", + description: "discount", + }), + ], + }), + ]) + }) + + it("adds line item to cart containing a total percentage discount", async () => { + const api = useApi() + + const response = await api + .post( + "/store/carts/test-cart-w-total-percentage-discount/line-items", + { + variant_id: "test-variant-quantity", + quantity: 2, + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + cart_id: "test-cart-w-total-percentage-discount", + unit_price: 1000, + variant_id: "test-variant-quantity", + quantity: 2, + adjustments: [ + expect.objectContaining({ + amount: 200, + discount_id: "10Percent", + description: "discount", + }), + ], + }), + ]) + }) + + it("adds line item to cart containing an item fixed discount", async () => { + const api = useApi() + + const response = await api + .post( + "/store/carts/test-cart-w-item-fixed-discount/line-items", + { + variant_id: "test-variant-quantity", + quantity: 2, + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + cart_id: "test-cart-w-item-fixed-discount", + unit_price: 1000, + variant_id: "test-variant-quantity", + quantity: 2, + adjustments: [ + expect.objectContaining({ + amount: 400, + discount_id: "item-fixed-200", + description: "discount", + }), + ], + }), + ]) + }) + + it("adds line item to cart containing an item percentage discount", async () => { + const api = useApi() + + const response = await api + .post( + "/store/carts/test-cart-w-item-percentage-discount/line-items", + { + variant_id: "test-variant-quantity", + quantity: 2, + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + cart_id: "test-cart-w-item-percentage-discount", + unit_price: 1000, + variant_id: "test-variant-quantity", + quantity: 2, + adjustments: [ + expect.objectContaining({ + amount: 300, + discount_id: "item-percentage-15", + description: "discount", + }), + ], }), ]) }) @@ -242,7 +367,6 @@ describe("/store/carts", () => { it("adds line item to cart time limited sale", async () => { const api = useApi() - // Add standard line item to cart const response = await api .post( "/store/carts/test-cart/line-items", @@ -305,7 +429,6 @@ describe("/store/carts", () => { it("adds line item with quantity to cart with quantity discount", async () => { const api = useApi() - // Add standard line item to cart const response = await api .post( "/store/carts/test-cart/line-items", @@ -330,7 +453,6 @@ describe("/store/carts", () => { it("adds line item with quantity to cart with quantity discount no ceiling", async () => { const api = useApi() - // Add standard line item to cart const response = await api .post( "/store/carts/test-cart/line-items", @@ -351,6 +473,421 @@ describe("/store/carts", () => { }), ]) }) + + describe("ensures correct line item adjustment generation", () => { + const discountData = { + code: "MEDUSA185DKK", + id: "medusa-185", + rule: { + allocation: "total", + type: "fixed", + value: 185, + }, + regions: ["test-region"], + } + + let discountCart, discount + beforeEach(async () => { + try { + discount = await simpleDiscountFactory( + dbConnection, + discountData, + 100 + ) + discountCart = await simpleCartFactory( + dbConnection, + { + id: "discount-cart", + customer: "test-customer", + region: "test-region", + shipping_address: { + address_1: "next door", + first_name: "lebron", + last_name: "james", + country_code: "dk", + postal_code: "100", + }, + line_items: [ + { + id: "test-li", + variant_id: "test-variant", + quantity: 1, + unit_price: 100, + adjustments: [ + { + amount: 185, + description: "discount", + discount_id: "medusa-185", + }, + ], + }, + ], + shipping_methods: [ + { + shipping_option: "test-option", + price: 1000, + }, + ], + }, + 100 + ) + await dbConnection.manager + .createQueryBuilder() + .relation(Cart, "discounts") + .of(discountCart) + .add(discount) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + await doAfterEach() + }) + + it("updates an old line item adjustment when a new line item is added to a discount cart", async () => { + const api = useApi() + + const response = await api + .post( + "/store/carts/discount-cart/line-items", + { + quantity: 1, + variant_id: "test-variant-quantity", + }, + { + withCredentials: true, + } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items.length).toEqual(2) + expect(response.data.cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + adjustments: [ + expect.objectContaining({ + item_id: "test-li", + amount: 17, + discount_id: "medusa-185", + }), + ], + }), + expect.objectContaining({ + adjustments: [ + expect.objectContaining({ + amount: 168, + discount_id: "medusa-185", + }), + ], + }), + ]) + ) + }) + + it("updates an existing item adjustment when a line item is updated", async () => { + const api = useApi() + + await simpleLineItemFactory( + dbConnection, + { + id: "line-item-2", + cart_id: discountCart.id, + variant_id: "test-variant-quantity", + unit_price: 950, + quantity: 1, + adjustments: [ + { + id: "lia-2", + amount: 92, + description: "discount", + discount_id: "medusa-185", + }, + ], + }, + 100 + ) + + const response = await api + .post( + "/store/carts/discount-cart/line-items/line-item-2", + { + quantity: 2, + }, + { + withCredentials: true, + } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items.length).toEqual(2) + expect(response.data.cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + adjustments: [ + expect.objectContaining({ + item_id: "test-li", + discount_id: "medusa-185", + amount: 9, + }), + ], + }), + expect.objectContaining({ + adjustments: [ + expect.objectContaining({ + item_id: "line-item-2", + amount: 176, + discount_id: "medusa-185", + }), + ], + }), + ]) + ) + }) + + it("updates an existing item adjustment when a line item is deleted from a discount cart", async () => { + const api = useApi() + + await simpleLineItemFactory( + dbConnection, + { + id: "line-item-2", + cart_id: discountCart.id, + variant_id: "test-variant-quantity", + unit_price: 1000, + quantity: 1, + adjustments: [ + { + id: "lia-2", + amount: 93, + description: "discount", + discount_id: "medusa-185", + }, + ], + }, + 100 + ) + + const response = await api + .delete("/store/carts/discount-cart/line-items/test-li", { + withCredentials: true, + }) + .catch((err) => console.log(err)) + + expect(response.data.cart.items.length).toEqual(1) + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + adjustments: [ + expect.objectContaining({ + item_id: "line-item-2", + amount: 185, + discount_id: "medusa-185", + }), + ], + }), + ]) + }) + }) + }) + + describe("POST /store/carts/:id/line-items/:line_id", () => { + beforeEach(async () => { + try { + await cartSeeder(dbConnection) + await swapSeeder(dbConnection) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + await doAfterEach() + }) + + it("updates line item of cart", async () => { + const api = useApi() + + const response = await api + .post( + "/store/carts/test-cart-3/line-items/test-item3/", + { + quantity: 3, + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + cart_id: "test-cart-3", + unit_price: 8000, + variant_id: "test-variant-sale-cg", + quantity: 3, + adjustments: [], + }), + ]) + }) + + it("updates line item of a cart containing a total fixed discount", async () => { + const api = useApi() + await simpleLineItemFactory(dbConnection, { + id: "test-li-disc", + allow_discounts: true, + title: "Line Item Disc", + thumbnail: "https://test.js/1234", + unit_price: 1000, + quantity: 1, + variant_id: "test-variant-quantity", + cart_id: "test-cart-w-total-fixed-discount", + }) + + const response = await api + .post( + `store/carts/test-cart-w-total-fixed-discount/line-items/test-li-disc`, + { + quantity: 3, + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + cart_id: "test-cart-w-total-fixed-discount", + unit_price: 1000, + variant_id: "test-variant-quantity", + quantity: 3, + adjustments: [ + expect.objectContaining({ + amount: 100, + discount_id: "total-fixed-100", + description: "discount", + }), + ], + }), + ]) + }) + + it("updates line item of a cart containing a total percentage discount", async () => { + const api = useApi() + await simpleLineItemFactory(dbConnection, { + id: "test-li-disc", + allow_discounts: true, + title: "Line Item Disc", + thumbnail: "https://test.js/1234", + unit_price: 1000, + quantity: 1, + variant_id: "test-variant-quantity", + cart_id: "test-cart-w-total-percentage-discount", + }) + + const response = await api + .post( + "/store/carts/test-cart-w-total-percentage-discount/line-items/test-li-disc", + { + quantity: 10, + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + cart_id: "test-cart-w-total-percentage-discount", + unit_price: 1000, + variant_id: "test-variant-quantity", + quantity: 10, + adjustments: [ + expect.objectContaining({ + amount: 1000, + discount_id: "10Percent", + description: "discount", + }), + ], + }), + ]) + }) + + it("updates line item of a cart containing an item fixed discount", async () => { + const api = useApi() + await simpleLineItemFactory(dbConnection, { + id: "test-li-disc", + allow_discounts: true, + title: "Line Item Disc", + thumbnail: "https://test.js/1234", + unit_price: 1000, + quantity: 1, + variant_id: "test-variant-quantity", + cart_id: "test-cart-w-item-fixed-discount", + }) + + const response = await api + .post( + "/store/carts/test-cart-w-item-fixed-discount/line-items/test-li-disc", + { + quantity: 4, + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + cart_id: "test-cart-w-item-fixed-discount", + unit_price: 1000, + variant_id: "test-variant-quantity", + quantity: 4, + adjustments: [ + expect.objectContaining({ + amount: 800, + discount_id: "item-fixed-200", + description: "discount", + }), + ], + }), + ]) + }) + + it("updates line item of a cart containing an item percentage discount", async () => { + const api = useApi() + await simpleLineItemFactory(dbConnection, { + id: "test-li-disc", + allow_discounts: true, + title: "Line Item Disc", + thumbnail: "https://test.js/1234", + unit_price: 1000, + quantity: 1, + variant_id: "test-variant-quantity", + cart_id: "test-cart-w-item-percentage-discount", + }) + + const response = await api + .post( + "/store/carts/test-cart-w-item-percentage-discount/line-items/test-li-disc", + { + quantity: 3, + }, + { withCredentials: true } + ) + .catch((err) => console.log(err)) + + expect(response.data.cart.items).toEqual([ + expect.objectContaining({ + cart_id: "test-cart-w-item-percentage-discount", + unit_price: 1000, + variant_id: "test-variant-quantity", + quantity: 3, + adjustments: [ + expect.objectContaining({ + amount: 450, + discount_id: "item-percentage-15", + description: "discount", + }), + ], + }), + ]) + }) }) describe("POST /store/carts/:id", () => { @@ -1202,6 +1739,22 @@ describe("/store/carts", () => { .catch((err) => console.log(err)) // Ensure that the discount is only applied to the standard item + const itemId = cartWithGiftcard.data.cart.items[0].id + expect(cartWithGiftcard.data.cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + variant_id: "test-variant", + quantity: 1, + adjustments: [ + expect.objectContaining({ + discount_id: "10Percent", + amount: 100, + item_id: itemId, + }), + ], + }), + ]) + ) expect(cartWithGiftcard.data.cart.total).toBe(1900) // 1000 (giftcard) + 900 (standard item with 10% discount) expect(cartWithGiftcard.data.cart.discount_total).toBe(100) expect(cartWithGiftcard.status).toEqual(200) diff --git a/integration-tests/api/factories/simple-line-item-factory.ts b/integration-tests/api/factories/simple-line-item-factory.ts index 84995c79f3..d270b40043 100644 --- a/integration-tests/api/factories/simple-line-item-factory.ts +++ b/integration-tests/api/factories/simple-line-item-factory.ts @@ -1,6 +1,6 @@ import { Connection } from "typeorm" import faker from "faker" -import { LineItem, LineItemTaxLine } from "@medusajs/medusa" +import { LineItem, LineItemAdjustment, LineItemTaxLine } from "@medusajs/medusa" type TaxLineFactoryData = { rate: number @@ -8,6 +8,11 @@ type TaxLineFactoryData = { name: string } +type LineItemAdjustmentFactoryData = Omit & { + discount_id: string + discount_code: string +} + export type LineItemFactoryData = { id?: string cart_id?: string @@ -24,6 +29,7 @@ export type LineItemFactoryData = { shipped_quantity?: boolean returned_quantity?: boolean tax_lines?: TaxLineFactoryData[] + adjustments: LineItemAdjustmentFactoryData[] } export const simpleLineItemFactory = async ( @@ -63,6 +69,7 @@ export const simpleLineItemFactory = async ( fulfilled_quantity: data.fulfilled_quantity || null, shipped_quantity: data.shipped_quantity || null, returned_quantity: data.returned_quantity || null, + adjustments: data.adjustments, }) const line = await manager.save(toSave) diff --git a/integration-tests/api/factories/simple-order-factory.ts b/integration-tests/api/factories/simple-order-factory.ts index 4153aa0fe2..085b3407f3 100644 --- a/integration-tests/api/factories/simple-order-factory.ts +++ b/integration-tests/api/factories/simple-order-factory.ts @@ -101,7 +101,17 @@ export const simpleOrderFactory = async ( await simpleShippingMethodFactory(connection, { ...sm, order_id: order.id }) } - const items = data.line_items + const items = data.line_items.map((item) => { + let adjustments = item?.adjustments || [] + return { + ...item, + adjustments: adjustments.map((adj) => ({ + ...adj, + discount_id: discounts.find((d) => d.code === adj?.discount_code), + })), + } + }) + for (const item of items) { await simpleLineItemFactory(connection, { ...item, order_id: id }) } diff --git a/integration-tests/api/helpers/cart-seeder.js b/integration-tests/api/helpers/cart-seeder.js index 0970d5803e..b3dee44d51 100644 --- a/integration-tests/api/helpers/cart-seeder.js +++ b/integration-tests/api/helpers/cart-seeder.js @@ -144,6 +144,69 @@ module.exports = async (connection, data = {}) => { tenPercent.rule = tenPercentRule await manager.save(tenPercent) + const totalFixed100Rule = manager.create(DiscountRule, { + id: "total-fixed-100-rule", + description: "Fixed 100 total", + type: "fixed", + value: 100, + allocation: "total", + }) + + const totalFixed100 = manager.create(Discount, { + id: "total-fixed-100", + code: "FIXED100", + is_dynamic: false, + is_disabled: false, + starts_at: tenDaysAgo, + ends_at: tenDaysFromToday, + }) + + totalFixed100.regions = [r] + totalFixed100.rule = totalFixed100Rule + await manager.save(totalFixed100) + + const itemFixed200Rule = manager.create(DiscountRule, { + id: "item-fixed-200-rule", + description: "Item 200 fixed", + type: "fixed", + value: 200, + allocation: "item", + }) + + const itemFixed200 = manager.create(Discount, { + id: "item-fixed-200", + code: "FIXED200", + is_dynamic: false, + is_disabled: false, + starts_at: tenDaysAgo, + ends_at: tenDaysFromToday, + }) + + itemFixed200.regions = [r] + itemFixed200.rule = itemFixed200Rule + await manager.save(itemFixed200) + + const itemPerc15Rule = manager.create(DiscountRule, { + id: "item-percentage-15-rule", + description: "Item 15 percentage", + type: "percentage", + value: 15, + allocation: "item", + }) + + const itemPerc15 = manager.create(Discount, { + id: "item-percentage-15", + code: "15PERCENT", + is_dynamic: false, + is_disabled: false, + starts_at: tenDaysAgo, + ends_at: tenDaysFromToday, + }) + + itemPerc15.regions = [r] + itemPerc15.rule = itemPerc15Rule + await manager.save(itemPerc15) + const dUsageLimit = await manager.create(Discount, { id: "test-discount-usage-limit", code: "SPENT", @@ -570,6 +633,74 @@ module.exports = async (connection, data = {}) => { await manager.save(cart) + const cartWithTotalFixedDiscount = manager.create(Cart, { + id: "test-cart-w-total-fixed-discount", + customer_id: "some-customer", + email: "some-customer@email.com", + discounts: [totalFixed100], + shipping_address: { + id: "test-shipping-address", + first_name: "lebron", + country_code: "us", + }, + region_id: "test-region", + currency_code: "usd", + items: [], + }) + + await manager.save(cartWithTotalFixedDiscount) + + const cartWithItemFixedDiscount = manager.create(Cart, { + id: "test-cart-w-item-fixed-discount", + customer_id: "some-customer", + email: "some-customer@email.com", + discounts: [itemFixed200], + shipping_address: { + id: "test-shipping-address", + first_name: "lebron", + country_code: "us", + }, + region_id: "test-region", + currency_code: "usd", + items: [], + }) + + await manager.save(cartWithItemFixedDiscount) + + const cartWithTotalPercDiscount = manager.create(Cart, { + id: "test-cart-w-total-percentage-discount", + customer_id: "some-customer", + email: "some-customer@email.com", + discounts: [tenPercent], + shipping_address: { + id: "test-shipping-address", + first_name: "lebron", + country_code: "us", + }, + region_id: "test-region", + currency_code: "usd", + items: [], + }) + + await manager.save(cartWithTotalPercDiscount) + + const cartWithItemPercDiscount = manager.create(Cart, { + id: "test-cart-w-item-percentage-discount", + customer_id: "some-customer", + email: "some-customer@email.com", + discounts: [itemPerc15], + shipping_address: { + id: "test-shipping-address", + first_name: "lebron", + country_code: "us", + }, + region_id: "test-region", + currency_code: "usd", + items: [], + }) + + await manager.save(cartWithItemPercDiscount) + const cart2 = manager.create(Cart, { id: "test-cart-2", customer_id: "some-customer", diff --git a/integration-tests/api/helpers/order-seeder.js b/integration-tests/api/helpers/order-seeder.js index 2029653dc2..064bbd476b 100644 --- a/integration-tests/api/helpers/order-seeder.js +++ b/integration-tests/api/helpers/order-seeder.js @@ -173,6 +173,14 @@ module.exports = async (connection, data = {}) => { quantity: 1, variant_id: "test-variant", order_id: "test-order", + adjustments: [ + { + amount: 800, + discount_id: "test-discount", + description: "discount", + item_id: "test-item", + }, + ], }) await manager.save(li) diff --git a/integration-tests/api/helpers/swap-seeder.js b/integration-tests/api/helpers/swap-seeder.js index 233cf0177b..84a75a22e9 100644 --- a/integration-tests/api/helpers/swap-seeder.js +++ b/integration-tests/api/helpers/swap-seeder.js @@ -349,7 +349,7 @@ const createSwap = async (options, manager) => { is_disabled: false, rule: dRule, }) - await manager.save(discount) + let discountDb = await manager.save(discount) const cart = manager.create(Cart, { id: `${swapId}-cart`, @@ -396,6 +396,14 @@ const createSwap = async (options, manager) => { thumbnail: "https://test.js/1234", unit_price: 8000, quantity: 1, + adjustments: [ + { + amount: -800, + description: "discount", + discount_id: discountDb.id, + item_id: `${swapId}-return-item-1`, + }, + ], variant_id: "test-variant", order_id: "order-with-swap", cart_id: cart.id, @@ -419,6 +427,7 @@ const createSwap = async (options, manager) => { const return_item1 = manager.create(LineItem, { ...li, + is_return: true, unit_price: -1 * li.unit_price, }) diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index cee84a9ad6..42e6e05c96 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -8,16 +8,16 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { - "@medusajs/medusa": "1.2.1-dev-1648026403166", + "@medusajs/medusa": "1.2.1-dev-1649181615374", "faker": "^5.5.3", - "medusa-interfaces": "1.2.1-dev-1648026403166", + "medusa-interfaces": "1.2.1-dev-1649181615374", "typeorm": "^0.2.31" }, "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/node": "^7.12.10", - "babel-preset-medusa-package": "1.1.19-dev-1648026403166", + "babel-preset-medusa-package": "1.1.19-dev-1649181615374", "jest": "^26.6.3" } } diff --git a/integration-tests/api/yarn.lock b/integration-tests/api/yarn.lock index 16c21ac523..b81b1094d6 100644 --- a/integration-tests/api/yarn.lock +++ b/integration-tests/api/yarn.lock @@ -1301,10 +1301,10 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@medusajs/medusa-cli@1.2.1-dev-1648026403166": - version "1.2.1-dev-1648026403166" - resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.1-dev-1648026403166.tgz#1dc2ad62ba11421d43357596c61eb0b76d3bc69e" - integrity sha512-TScUnBuxI5V5cB6gAAGeGOZU8lXYHIhozpelDcYibZqaHEIwMNs1ekzRYhfMjYowSFCMLlzCsHFUh+gqU/6WSw== +"@medusajs/medusa-cli@1.2.1-dev-1649181615374": + version "1.2.1-dev-1649181615374" + resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.1-dev-1649181615374.tgz#1ea9014e3ec9813a52457b0d6e2fc6bb64d3bfd6" + integrity sha512-8m6Z1ZZqstZKaAaKoFS3v3IzI7BFhcBgpF+iCSRuJoXltQgzVQOAxXuPjkRoi+m1ZZ+Yi/YYEzKmNQ99vmXisQ== dependencies: "@babel/polyfill" "^7.8.7" "@babel/runtime" "^7.9.6" @@ -1322,8 +1322,8 @@ is-valid-path "^0.1.1" joi-objectid "^3.0.1" meant "^1.0.1" - medusa-core-utils "1.1.31-dev-1648026403166" - medusa-telemetry "0.0.11-dev-1648026403166" + medusa-core-utils "1.1.31-dev-1649181615374" + medusa-telemetry "0.0.11-dev-1649181615374" netrc-parser "^3.1.6" open "^8.0.6" ora "^5.4.1" @@ -1337,13 +1337,13 @@ winston "^3.3.3" yargs "^15.3.1" -"@medusajs/medusa@1.2.1-dev-1648026403166": - version "1.2.1-dev-1648026403166" - resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.1-dev-1648026403166.tgz#f6e8fab09ac73b38ed79e15d2080e7ecad80fd99" - integrity sha512-ufYVgcKo+bsAzSnZA3FVLmxseDwnSKkSbIdktQUSZGMJTRvN/+Wcqnjdf59oH11jqqBApJ8T77Nvd/ABasjTWw== +"@medusajs/medusa@1.2.1-dev-1649181615374": + version "1.2.1-dev-1649181615374" + resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.1-dev-1649181615374.tgz#6a62f8628b84b47a8717e9e0c276f3a9c2e376ce" + integrity sha512-eiCGE6JqYuP7GCzTBGg5LI9U0uQ0wlsR+NuMZVEwldj+xc7qwMjBJwUA7gc58gBv6JesfMYj3VZmJComN4+7Bg== dependencies: "@hapi/joi" "^16.1.8" - "@medusajs/medusa-cli" "1.2.1-dev-1648026403166" + "@medusajs/medusa-cli" "1.2.1-dev-1649181615374" "@types/lodash" "^4.14.168" awilix "^4.2.3" body-parser "^1.19.0" @@ -1367,8 +1367,8 @@ joi "^17.3.0" joi-objectid "^3.0.1" jsonwebtoken "^8.5.1" - medusa-core-utils "1.1.31-dev-1648026403166" - medusa-test-utils "1.1.37-dev-1648026403166" + medusa-core-utils "1.1.31-dev-1649181615374" + medusa-test-utils "1.1.37-dev-1649181615374" morgan "^1.9.1" multer "^1.4.2" passport "^0.4.0" @@ -2010,10 +2010,10 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" -babel-preset-medusa-package@1.1.19-dev-1648026403166: - version "1.1.19-dev-1648026403166" - resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1648026403166.tgz#e834a06c2283d5b8709d45de1e4ff559dfd23944" - integrity sha512-HjbSvyeLU9ez0dgBv4+SDX2/nXaskN5Bip7nF36F/fnufw/5FtJ28Lyw30VWz6i5Ci991Zmadfq6TNUZnKgNHw== +babel-preset-medusa-package@1.1.19-dev-1649181615374: + version "1.1.19-dev-1649181615374" + resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1649181615374.tgz#2f13d52fedd336ad4b4c0602b3bf4696d2d08db7" + integrity sha512-N4XL7rTmNM2W+iRR92xvU4bKadP25lY5QR3vndxTxsLNSgcR5tLjKLO/4j7AqiFvcthbE8cF1TcdECH5aJfSuA== dependencies: "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-decorators" "^7.12.1" @@ -5156,25 +5156,25 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -medusa-core-utils@1.1.31-dev-1648026403166: - version "1.1.31-dev-1648026403166" - resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1648026403166.tgz#0f3bde6fc0a77d3027d80a4ca5e1a1ce2426ad21" - integrity sha512-B5eFj3dH8g/HgNd8p8ChwR4bpydl0dqyBJllyvqS1AnFpjW7BcXpO0ZPkdYrIHflQL9e2OiIq3A2Lm6ZsYCI1A== +medusa-core-utils@1.1.31-dev-1649181615374: + version "1.1.31-dev-1649181615374" + resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1649181615374.tgz#60416bba53eaba607d77ca36789aa23756b8db0f" + integrity sha512-w5nusocZweIrAFJ6sl4hD/mN+UtNjz39IIfXukekyJByg3wpv4P+vsW3XdrFTX5OLvKVxuzjl3B2zeZUmdgSKg== dependencies: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-interfaces@1.2.1-dev-1648026403166: - version "1.2.1-dev-1648026403166" - resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.1-dev-1648026403166.tgz#29742128c852c967a94df1e00f0c8dacccff6b86" - integrity sha512-VRnFIFqPeOd0TcPMlng1kIzjhT7AtJzNqIDmKDxeJeE1MqU89g6YME/yQfCPzTXKNMdwN+8Xx+E5gANNn5i1QA== +medusa-interfaces@1.2.1-dev-1649181615374: + version "1.2.1-dev-1649181615374" + resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.1-dev-1649181615374.tgz#0b664f4e3e8e61b67108a41c8f0f9dd58a947075" + integrity sha512-JRD773nZnxjn/2oNrgb/zXn+scBoNHpW97YQYW7+LFX1JBYafzyGQW3vWTFX4X+q08ehz4dc21CgoMeYco8yvQ== dependencies: - medusa-core-utils "1.1.31-dev-1648026403166" + medusa-core-utils "1.1.31-dev-1649181615374" -medusa-telemetry@0.0.11-dev-1648026403166: - version "0.0.11-dev-1648026403166" - resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1648026403166.tgz#dd66a411a214b953f8b6673f59c0b02dd78ec99f" - integrity sha512-SH07C6rOQyx7bHH4/2TzmBgzgMY8fLiE7PH7dk6BGwY8bjx9w6DvF9CvhdwxFnFnzDTed/cjkE/cHNkQFkjSrQ== +medusa-telemetry@0.0.11-dev-1649181615374: + version "0.0.11-dev-1649181615374" + resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1649181615374.tgz#3f4c366ea8d0d0fdde9b289f7e771bb27adae56d" + integrity sha512-RMJR3/qlTb1nV05RnBnX1bNOvYyeuXf4owxLlfbWzKAZirWQ5LAC2GikEGGbHGbw7UgiLgQtu4Rnmg2Uye+VcA== dependencies: axios "^0.21.1" axios-retry "^3.1.9" @@ -5186,13 +5186,13 @@ medusa-telemetry@0.0.11-dev-1648026403166: remove-trailing-slash "^0.1.1" uuid "^8.3.2" -medusa-test-utils@1.1.37-dev-1648026403166: - version "1.1.37-dev-1648026403166" - resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1648026403166.tgz#f32f0a04d7d38f6a84e58a2937bd026767ea5d1f" - integrity sha512-VQCuRal14tl/ZLsN6Ueh+z3FBgQEXgsLLSJxfpsrmABKTgoHONhkoe7UyXxxUozXwPWWqqtHhy80zfCIlfHF+w== +medusa-test-utils@1.1.37-dev-1649181615374: + version "1.1.37-dev-1649181615374" + resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1649181615374.tgz#079c16a791d47c52072c6f0837d0a827208bc9cc" + integrity sha512-hj3iNZsIA01l7qAZrOgt+kT8PDkXKoW4CEL3bhVfIUEwsdv9jID7FGdTIN/7G3diioTypvrVcqRrp0uiWdgp+Q== dependencies: "@babel/plugin-transform-classes" "^7.9.5" - medusa-core-utils "1.1.31-dev-1648026403166" + medusa-core-utils "1.1.31-dev-1649181615374" randomatic "^3.1.1" merge-descriptors@1.0.1: diff --git a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap index 131147d241..b414a3f7a1 100644 --- a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap +++ b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap @@ -427,6 +427,7 @@ Object { "idempotency_key": null, "items": Array [ Object { + "adjustments": Array [], "allow_discounts": true, "cart_id": null, "claim_order_id": null, @@ -761,6 +762,7 @@ Object { "idempotency_key": null, "items": Array [ Object { + "adjustments": Array [], "allow_discounts": true, "cart_id": null, "claim_order_id": null, @@ -982,6 +984,7 @@ Object { "idempotency_key": null, "items": Array [ Object { + "adjustments": Array [], "allow_discounts": true, "cart_id": null, "claim_order_id": null, @@ -1248,6 +1251,7 @@ Object { "idempotency_key": null, "items": Array [ Object { + "adjustments": Array [], "allow_discounts": true, "cart_id": null, "claim_order_id": null, @@ -1569,6 +1573,7 @@ Object { "idempotency_key": null, "items": Array [ Object { + "adjustments": Array [], "allow_discounts": true, "cart_id": null, "claim_order_id": null, diff --git a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/index.js b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/index.js index 686ff99345..d94e82fe3d 100644 --- a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/index.js +++ b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/index.js @@ -107,6 +107,7 @@ describe("medusa-plugin-sendgrid", () => { }, items: [ { + adjustments: [], created_at: expect.any(Date), updated_at: expect.any(Date), order_id: expect.any(String), @@ -227,6 +228,7 @@ describe("medusa-plugin-sendgrid", () => { }, items: [ { + adjustments: [], created_at: expect.any(Date), updated_at: expect.any(Date), order_id: expect.any(String), @@ -285,6 +287,7 @@ describe("medusa-plugin-sendgrid", () => { }, items: [ { + adjustments: [], created_at: expect.any(Date), updated_at: expect.any(Date), order_id: expect.any(String), diff --git a/integration-tests/plugins/package.json b/integration-tests/plugins/package.json index 05ca81ab27..d0869a4edb 100644 --- a/integration-tests/plugins/package.json +++ b/integration-tests/plugins/package.json @@ -8,18 +8,18 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { - "@medusajs/medusa": "1.1.60-dev-1642337635041", + "@medusajs/medusa": "1.2.1-dev-1649009241281", "faker": "^5.5.3", - "medusa-fulfillment-webshipper": "1.1.35-dev-1642337635041", - "medusa-interfaces": "1.1.32-dev-1642337635041", - "medusa-plugin-sendgrid": "1.1.36-dev-1642337635041", + "medusa-fulfillment-webshipper": "1.2.1-dev-1649009241281", + "medusa-interfaces": "1.2.1-dev-1649009241281", + "medusa-plugin-sendgrid": "1.2.1-dev-1649009241281", "typeorm": "^0.2.31" }, "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/node": "^7.12.10", - "babel-preset-medusa-package": "1.1.19-dev-1642337635041", + "babel-preset-medusa-package": "1.1.19-dev-1649009241281", "jest": "^26.6.3" } } diff --git a/integration-tests/plugins/yarn.lock b/integration-tests/plugins/yarn.lock index f40c9eecd4..9d20998310 100644 --- a/integration-tests/plugins/yarn.lock +++ b/integration-tests/plugins/yarn.lock @@ -1268,10 +1268,10 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@medusajs/medusa-cli@1.1.25-dev-1642337635041": - version "1.1.25-dev-1642337635041" - resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.1.25-dev-1642337635041.tgz#e3ca33f453446dddc4922f42b4254995de9874b5" - integrity sha512-8pA5iBMglgrdPRSEZLvjovJczib5zk9qlQJsewlSrw/aolwnusukEE7vBfjAiUXAZKngMx6BnYoORlWSV88wdA== +"@medusajs/medusa-cli@1.2.1-dev-1649009241281": + version "1.2.1-dev-1649009241281" + resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.1-dev-1649009241281.tgz#e0cc327224af6067013e0eba1b882b3f9f0fd0e3" + integrity sha512-3949nJfrBIguXrKDwiKjQ10XxZ06ccTShbcOUsXkIuGIMOHBZ+AuhjErCAnXS3H/JSIO93lnQKVJ8DWSkf9BGQ== dependencies: "@babel/polyfill" "^7.8.7" "@babel/runtime" "^7.9.6" @@ -1289,8 +1289,8 @@ is-valid-path "^0.1.1" joi-objectid "^3.0.1" meant "^1.0.1" - medusa-core-utils "1.1.31-dev-1642337635041" - medusa-telemetry "0.0.11-dev-1642337635041" + medusa-core-utils "1.1.31-dev-1649009241281" + medusa-telemetry "0.0.11-dev-1649009241281" netrc-parser "^3.1.6" open "^8.0.6" ora "^5.4.1" @@ -1304,13 +1304,13 @@ winston "^3.3.3" yargs "^15.3.1" -"@medusajs/medusa@1.1.60-dev-1642337635041": - version "1.1.60-dev-1642337635041" - resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.1.60-dev-1642337635041.tgz#011d64b9b85a3f66e01f3755788370aa444a2e27" - integrity sha512-MBef46mJZMSyE3+dcijMqjFZbNo7AL60mP/PwkOy4W1d/t2MZ+GOBIufLAVxVw7SaiSjAGpQDmCrUOqQrIhIdg== +"@medusajs/medusa@1.2.1-dev-1649009241281": + version "1.2.1-dev-1649009241281" + resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.1-dev-1649009241281.tgz#ed063a9abee468cd71b5d0201ea4f32dd2a59775" + integrity sha512-uNVPZq4rVOLRRP9yTeYWPn9xsLfhh7mgRexMjRL/7QmKTQmspBL/pbjdRk0WCBSSf3o5SFiOV0/n69Oxg6Oaig== dependencies: "@hapi/joi" "^16.1.8" - "@medusajs/medusa-cli" "1.1.25-dev-1642337635041" + "@medusajs/medusa-cli" "1.2.1-dev-1649009241281" "@types/lodash" "^4.14.168" awilix "^4.2.3" body-parser "^1.19.0" @@ -1334,8 +1334,8 @@ joi "^17.3.0" joi-objectid "^3.0.1" jsonwebtoken "^8.5.1" - medusa-core-utils "1.1.31-dev-1642337635041" - medusa-test-utils "1.1.35-dev-1642337635041" + medusa-core-utils "1.1.31-dev-1649009241281" + medusa-test-utils "1.1.37-dev-1649009241281" morgan "^1.9.1" multer "^1.4.2" passport "^0.4.0" @@ -1995,10 +1995,10 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" -babel-preset-medusa-package@1.1.19-dev-1642337635041: - version "1.1.19-dev-1642337635041" - resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1642337635041.tgz#24c6f2db887f35c358270d85cc0f6b6c19f459fe" - integrity sha512-HtrHI6oWUEMyTK9MHKzCg7hzZHgi8+uCIQyTQIY5SYFRA6kZiNl54Wzai/BY26F1j5g0sVNfqmFXdBd53GEz7g== +babel-preset-medusa-package@1.1.19-dev-1649009241281: + version "1.1.19-dev-1649009241281" + resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1649009241281.tgz#253cb95840d3e122a45e2f72e15f623afa57ae61" + integrity sha512-UFH2A+rYUc33d2f+xEVCQOOmcm0RSSPlnTeSVV5bM8SoS4MXgekPdBO+sMjKi/bLbHJncKaapCm/HoYZ19ZJHg== dependencies: "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-decorators" "^7.12.1" @@ -5138,48 +5138,48 @@ media-typer@0.3.0: resolved "http://localhost:4873/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -medusa-core-utils@1.1.31-dev-1642337635041: - version "1.1.31-dev-1642337635041" - resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1642337635041.tgz#d6928f377320ab851eff1750e13e44e2e366c9cb" - integrity sha512-m0VXTM3QwKi0oRk2Q4mqn6sK07xJgiwyz3rs/0gHTjl88B8k75XugbriFceLPEYqHs6AjP6nSNiRDqdRJqUsMg== +medusa-core-utils@1.1.31-dev-1649009241281: + version "1.1.31-dev-1649009241281" + resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1649009241281.tgz#1ff37cdd9057c5716124f250c0b4b261ef8c7cde" + integrity sha512-nYcgpTtUSGAFG00m1rKcWUgnlmL2zu5aOYl5Fas/re+hhc1fhi+jwHUMigK0c28t71RwgtaeJFNKgQNgxli+2A== dependencies: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-fulfillment-webshipper@1.1.35-dev-1642337635041: - version "1.1.35-dev-1642337635041" - resolved "http://localhost:4873/medusa-fulfillment-webshipper/-/medusa-fulfillment-webshipper-1.1.35-dev-1642337635041.tgz#ab4b0a23488094b39582d1c1d1c69e29713b464d" - integrity sha512-P1gRvnJcs5Svgmvb44Qp1Oph9TqtUQsEeawhX7u+3dSz9qDnJ1lo1XB1tBttpCP9qepPe3nc0Sk7mEwlq8eM6Q== +medusa-fulfillment-webshipper@1.2.1-dev-1649009241281: + version "1.2.1-dev-1649009241281" + resolved "http://localhost:4873/medusa-fulfillment-webshipper/-/medusa-fulfillment-webshipper-1.2.1-dev-1649009241281.tgz#d199209df0d3683a53829565713b857bac284282" + integrity sha512-dtMBJ3mObQS2uGKClga66qDOGTKjOenzLOdjScurCwheQqi5a+EOyUrZSssE4oQEMMRsuY+L/MTkcLyKr4f+HA== dependencies: axios "^0.20.0" body-parser "^1.19.0" cors "^2.8.5" express "^4.17.1" - medusa-core-utils "1.1.31-dev-1642337635041" + medusa-core-utils "1.1.31-dev-1649009241281" -medusa-interfaces@1.1.32-dev-1642337635041: - version "1.1.32-dev-1642337635041" - resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.1.32-dev-1642337635041.tgz#8179a344a41e1163502b4ee8af5b294443067f99" - integrity sha512-I7Wp+BEgzJ2wLkGBoDe6VhAlB8lJRMAHJ7wGzWfCMiG3Dj0PX2KE2HZDT+PA5S7IIXHWh9c0n37/cHZDcIF5Hw== +medusa-interfaces@1.2.1-dev-1649009241281: + version "1.2.1-dev-1649009241281" + resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.1-dev-1649009241281.tgz#77b6c906b805e6b1fb092fb7632a7ad8ecbafec5" + integrity sha512-tgDptu8k7aWCKBo1m8RxcxcffsVjy2e2M80rfvTd81gjC3EtGBGxDmiwNGJoaWkFq+Matq2wve2l92xVA2t8uQ== dependencies: - medusa-core-utils "1.1.31-dev-1642337635041" + medusa-core-utils "1.1.31-dev-1649009241281" -medusa-plugin-sendgrid@1.1.36-dev-1642337635041: - version "1.1.36-dev-1642337635041" - resolved "http://localhost:4873/medusa-plugin-sendgrid/-/medusa-plugin-sendgrid-1.1.36-dev-1642337635041.tgz#ae4d1d23167daf3c45ced085f3079ed21d5e876d" - integrity sha512-nJBq0x9h9fMsPfW4BnGzvk8yWpuk1v90zeXP85cDhFeAAWJ50o2zF7HsNcJawPfhKooAF+3kRDApR8706gUyqQ== +medusa-plugin-sendgrid@1.2.1-dev-1649009241281: + version "1.2.1-dev-1649009241281" + resolved "http://localhost:4873/medusa-plugin-sendgrid/-/medusa-plugin-sendgrid-1.2.1-dev-1649009241281.tgz#362fafa54658c641fb482346c0ba6f28007bbbf3" + integrity sha512-2C7mfwR+HZSjm/Y3TGHHr7dJAoNhwUvEVZs2dpJCEfmpbsR2f6TG3J5e+kidubQ4NeCmHdrXGPmjS4lbH1tnhQ== dependencies: "@babel/plugin-transform-classes" "^7.9.5" "@sendgrid/mail" "^7.1.1" body-parser "^1.19.0" express "^4.17.1" - medusa-core-utils "1.1.31-dev-1642337635041" - medusa-test-utils "1.1.35-dev-1642337635041" + medusa-core-utils "1.1.31-dev-1649009241281" + medusa-test-utils "1.1.37-dev-1649009241281" -medusa-telemetry@0.0.11-dev-1642337635041: - version "0.0.11-dev-1642337635041" - resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1642337635041.tgz#290bb20c1712adf99ec5d2f6871b200106203bc0" - integrity sha512-LlJVgdKAQFrp6ULW8D+Qvb/VSEgbQbKjEeoMnUwDaMMswGehsFUXsqBzs1LRo5nKmpLaPvSorw4OMvktR+RxTQ== +medusa-telemetry@0.0.11-dev-1649009241281: + version "0.0.11-dev-1649009241281" + resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1649009241281.tgz#76077de2a5b53f754f1a55701d892716be67876d" + integrity sha512-ZEWmHZI9RByg1Tm8sOOxVCs+dkr2LCvElXqFsJfgIoTdjvGS96tfG1pbod2LHD5rcBY2aXDSXBRf0pA1xRtvsQ== dependencies: axios "^0.21.1" axios-retry "^3.1.9" @@ -5191,13 +5191,13 @@ medusa-telemetry@0.0.11-dev-1642337635041: remove-trailing-slash "^0.1.1" uuid "^8.3.2" -medusa-test-utils@1.1.35-dev-1642337635041: - version "1.1.35-dev-1642337635041" - resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.35-dev-1642337635041.tgz#1daf7f6a671e2fffe43ab349d795581870104a4a" - integrity sha512-pCpMjmo0VyYbSRu/Ga/GdirBscJtS4HK4HtfXepMrut7Yg/kV+GpW/fFtBFkiqGDBpCWYLopzZAdRw+qpdpusA== +medusa-test-utils@1.1.37-dev-1649009241281: + version "1.1.37-dev-1649009241281" + resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1649009241281.tgz#59c9ac8fddf31a410347dac5ef147758a61f13da" + integrity sha512-+3c+2DB3UpJxyIt6LvWCR9aBtXwrABXV4GhUYW6toGl19JE2lZ9PBL3GSVmGi7etZ7Xv7tR9bwZXZURvKMr8Hg== dependencies: "@babel/plugin-transform-classes" "^7.9.5" - medusa-core-utils "1.1.31-dev-1642337635041" + medusa-core-utils "1.1.31-dev-1649009241281" randomatic "^3.1.1" merge-descriptors@1.0.1: diff --git a/packages/medusa/src/api/routes/admin/draft-orders/index.ts b/packages/medusa/src/api/routes/admin/draft-orders/index.ts index 8b2a2345a8..5edffd00b2 100644 --- a/packages/medusa/src/api/routes/admin/draft-orders/index.ts +++ b/packages/medusa/src/api/routes/admin/draft-orders/index.ts @@ -46,11 +46,17 @@ export default (app) => { return app } -export const defaultAdminDraftOrdersRelations = ["order", "cart"] +export const defaultAdminDraftOrdersRelations = [ + "order", + "cart", + "cart.items", + "cart.items.adjustments", +] export const defaultAdminDraftOrdersCartRelations = [ "region", "items", + "items.adjustments", "payment", "shipping_address", "billing_address", diff --git a/packages/medusa/src/api/routes/admin/swaps/__tests__/get-swap.js b/packages/medusa/src/api/routes/admin/swaps/__tests__/get-swap.js index 168582678b..3df4b2f80b 100644 --- a/packages/medusa/src/api/routes/admin/swaps/__tests__/get-swap.js +++ b/packages/medusa/src/api/routes/admin/swaps/__tests__/get-swap.js @@ -5,12 +5,15 @@ import { SwapServiceMock } from "../../../../../services/__mocks__/swap" const defaultRelations = [ "order", "additional_items", + "additional_items.adjustments", "return_order", "fulfillments", "payment", "shipping_address", "shipping_methods", "cart", + "cart.items", + "cart.items.adjustments", ] const defaultFields = [ diff --git a/packages/medusa/src/api/routes/admin/swaps/index.ts b/packages/medusa/src/api/routes/admin/swaps/index.ts index 78fed60f0c..8b866c9ac6 100644 --- a/packages/medusa/src/api/routes/admin/swaps/index.ts +++ b/packages/medusa/src/api/routes/admin/swaps/index.ts @@ -24,12 +24,15 @@ export default (app) => { export const defaultAdminSwapRelations = [ "order", "additional_items", + "additional_items.adjustments", "return_order", "fulfillments", "payment", "shipping_address", "shipping_methods", "cart", + "cart.items", + "cart.items.adjustments", ] export const defaultAdminSwapFields = [ diff --git a/packages/medusa/src/api/routes/store/carts/index.ts b/packages/medusa/src/api/routes/store/carts/index.ts index 077b366f19..6e42fb2269 100644 --- a/packages/medusa/src/api/routes/store/carts/index.ts +++ b/packages/medusa/src/api/routes/store/carts/index.ts @@ -115,6 +115,7 @@ export const defaultStoreCartRelations = [ "gift_cards", "region", "items", + "items.adjustments", "payment", "shipping_address", "billing_address", diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index 685e01382c..0bf22d749d 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -14,6 +14,7 @@ export * from "./models/product-type-tax-rate" export * from "./models/tax-rate" export * from "./models/shipping-method-tax-line" export * from "./models/line-item-tax-line" +export * from "./models/line-item-adjustment" export * from "./models/address" export * from "./models/cart" export * from "./models/claim-image" diff --git a/packages/medusa/src/migrations/1648600574750-add_line_item_adjustments.ts b/packages/medusa/src/migrations/1648600574750-add_line_item_adjustments.ts new file mode 100644 index 0000000000..687956398f --- /dev/null +++ b/packages/medusa/src/migrations/1648600574750-add_line_item_adjustments.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addLineItemAdjustments1648600574750 implements MigrationInterface { + name = "addLineItemAdjustments1648600574750" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "line_item_adjustment" ("id" character varying NOT NULL, "item_id" character varying NOT NULL, "description" character varying NOT NULL, "discount_id" character varying, "amount" integer NOT NULL, "metadata" jsonb, CONSTRAINT "PK_2b1360103753df2dc8257c2c8c3" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_be9aea2ccf3567007b6227da4d" ON "line_item_adjustment" ("item_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_2f41b20a71f30e60471d7e3769" ON "line_item_adjustment" ("discount_id") ` + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_bf701b88d2041392a288785ada" ON "line_item_adjustment" ("discount_id", "item_id") WHERE "discount_id" IS NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "line_item_adjustment" ADD CONSTRAINT "FK_be9aea2ccf3567007b6227da4d2" FOREIGN KEY ("item_id") REFERENCES "line_item"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "line_item_adjustment" ADD CONSTRAINT "FK_2f41b20a71f30e60471d7e3769c" FOREIGN KEY ("discount_id") REFERENCES "discount"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "line_item_adjustment" DROP CONSTRAINT "FK_2f41b20a71f30e60471d7e3769c"` + ) + await queryRunner.query( + `ALTER TABLE "line_item_adjustment" DROP CONSTRAINT "FK_be9aea2ccf3567007b6227da4d2"` + ) + await queryRunner.query(`DROP INDEX "IDX_bf701b88d2041392a288785ada"`) + await queryRunner.query(`DROP INDEX "IDX_2f41b20a71f30e60471d7e3769"`) + await queryRunner.query(`DROP INDEX "IDX_be9aea2ccf3567007b6227da4d"`) + await queryRunner.query(`DROP TABLE "line_item_adjustment"`) + } +} diff --git a/packages/medusa/src/models/line-item-adjustment.ts b/packages/medusa/src/models/line-item-adjustment.ts new file mode 100644 index 0000000000..fff820a934 --- /dev/null +++ b/packages/medusa/src/models/line-item-adjustment.ts @@ -0,0 +1,61 @@ +import { + Entity, + BeforeInsert, + Index, + Column, + ManyToOne, + JoinColumn, + PrimaryColumn, + OneToOne, + Unique, +} from "typeorm" +import { ulid } from "ulid" +import { DbAwareColumn } from "../utils/db-aware-column" +import { Discount } from "./discount" +import { LineItem } from "./line-item" + +@Entity() +@Index(["discount_id", "item_id"], { + unique: true, + where: `"discount_id" IS NOT NULL`, +}) +export class LineItemAdjustment { + @PrimaryColumn() + id: string + + @Index() + @Column() + item_id: string + + @ManyToOne( + () => LineItem, + (li) => li.adjustments, + { onDelete: "CASCADE" } + ) + @JoinColumn({ name: "item_id" }) + item: LineItem + + @Column() + description: string + + @ManyToOne(() => Discount) + @JoinColumn({ name: "discount_id" }) + discount: Discount + + @Index() + @Column({ nullable: true }) + discount_id: string + + @Column({ type: "int" }) + amount: number + + @DbAwareColumn({ type: "jsonb", nullable: true }) + metadata: any + + @BeforeInsert() + private beforeInsert() { + if (this.id) return + const id = ulid() + this.id = `lia_${id}` + } +} diff --git a/packages/medusa/src/models/line-item.ts b/packages/medusa/src/models/line-item.ts index d722fe4a70..f4b2cb9686 100644 --- a/packages/medusa/src/models/line-item.ts +++ b/packages/medusa/src/models/line-item.ts @@ -20,6 +20,7 @@ import { Cart } from "./cart" import { Order } from "./order" import { ClaimOrder } from "./claim-order" import { ProductVariant } from "./product-variant" +import { LineItemAdjustment } from "./line-item-adjustment" @Check(`"fulfilled_quantity" <= "quantity"`) @Check(`"shipped_quantity" <= "fulfilled_quantity"`) @@ -65,6 +66,9 @@ export class LineItem { @OneToMany(() => LineItemTaxLine, (tl) => tl.item, { cascade: ["insert"] }) tax_lines: LineItemTaxLine[] + @OneToMany(() => LineItemAdjustment, (lia) => lia.item, { cascade: ["insert"] }) + adjustments: LineItemAdjustment[] + @Column() title: string diff --git a/packages/medusa/src/repositories/line-item-adjustment.ts b/packages/medusa/src/repositories/line-item-adjustment.ts new file mode 100644 index 0000000000..5e81fa3f91 --- /dev/null +++ b/packages/medusa/src/repositories/line-item-adjustment.ts @@ -0,0 +1,6 @@ +import { EntityRepository, Repository } from "typeorm" +import { LineItemAdjustment } from "../models/line-item-adjustment" + +@EntityRepository(LineItemAdjustment) +export class LineItemAdjustmentRepository extends Repository {} + diff --git a/packages/medusa/src/repositories/line-item.ts b/packages/medusa/src/repositories/line-item.ts index 5ef3b96c6b..20ce15c2ad 100644 --- a/packages/medusa/src/repositories/line-item.ts +++ b/packages/medusa/src/repositories/line-item.ts @@ -17,6 +17,7 @@ export class LineItemRepository extends Repository { ): Promise<(LineItem & { return_item: ReturnItem })[]> { const qb = this.createQueryBuilder("li") .leftJoinAndSelect(`li.tax_lines`, "tax_lines") + .leftJoinAndSelect(`li.adjustments`, "adjustments") .leftJoinAndMapOne( `li.return_item`, ReturnItem, diff --git a/packages/medusa/src/services/__mocks__/cart.js b/packages/medusa/src/services/__mocks__/cart.js index 6c99bcd2c7..987aff7abf 100644 --- a/packages/medusa/src/services/__mocks__/cart.js +++ b/packages/medusa/src/services/__mocks__/cart.js @@ -210,7 +210,7 @@ export const CartServiceMock = { withTransaction: function() { return this }, - updatePaymentSession: jest.fn().mockImplementation(data => { + updatePaymentSession: jest.fn().mockImplementation((data) => { return Promise.resolve() }), authorizePayment: jest.fn().mockImplementation((id, data) => { @@ -223,13 +223,13 @@ export const CartServiceMock = { } return Promise.resolve(carts.testCart) }), - refreshPaymentSession: jest.fn().mockImplementation(data => { + refreshPaymentSession: jest.fn().mockImplementation((data) => { return Promise.resolve() }), - update: jest.fn().mockImplementation(data => { + update: jest.fn().mockImplementation((data) => { return Promise.resolve() }), - create: jest.fn().mockImplementation(data => { + create: jest.fn().mockImplementation((data) => { if (data.region_id === IdMap.getId("testRegion")) { return Promise.resolve(carts.regionCart) } @@ -238,7 +238,7 @@ export const CartServiceMock = { } return Promise.resolve(carts.regionCart) }), - retrieve: jest.fn().mockImplementation(cartId => { + retrieve: jest.fn().mockImplementation((cartId) => { if (cartId === IdMap.getId("fr-cart")) { return Promise.resolve(carts.frCart) } @@ -326,20 +326,20 @@ export const CartServiceMock = { applyDiscount: jest.fn().mockImplementation((cartId, code) => { return Promise.resolve() }), - setPaymentSession: jest.fn().mockImplementation(cartId => { + setPaymentSession: jest.fn().mockImplementation((cartId) => { return Promise.resolve() }), - setPaymentSessions: jest.fn().mockImplementation(cartId => { + setPaymentSessions: jest.fn().mockImplementation((cartId) => { return Promise.resolve() }), - setShippingOptions: jest.fn().mockImplementation(cartId => { + setShippingOptions: jest.fn().mockImplementation((cartId) => { return Promise.resolve() }), - decorate: jest.fn().mockImplementation(cart => { + decorate: jest.fn().mockImplementation((cart) => { cart.decorated = true return cart }), - addShippingMethod: jest.fn().mockImplementation(cartId => { + addShippingMethod: jest.fn().mockImplementation((cartId) => { return Promise.resolve() }), retrieveShippingOption: jest.fn().mockImplementation((cartId, optionId) => { @@ -378,6 +378,9 @@ export const CartServiceMock = { } } }), + refreshAdjustments_: jest.fn().mockImplementation((cart) => { + return Promise.resolve({}) + }), } const mock = jest.fn().mockImplementation(() => { diff --git a/packages/medusa/src/services/__mocks__/line-item-adjustment.js b/packages/medusa/src/services/__mocks__/line-item-adjustment.js new file mode 100644 index 0000000000..d5ea7e3fd2 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/line-item-adjustment.js @@ -0,0 +1,68 @@ +import { IdMap } from "medusa-test-utils" +import { MedusaError } from "medusa-core-utils" + +export const LineItemAdjustmentServiceMock = { + withTransaction: function() { + return this + }, + create: jest.fn().mockImplementation((data) => { + return Promise.resolve({ ...data }) + }), + update: jest.fn().mockImplementation((data) => { + return Promise.resolve({ ...data }) + }), + validate: jest.fn().mockImplementation((data) => { + if (data.title === "invalid lineitem") { + throw new Error(`"content" is required`) + } + return data + }), + delete: jest.fn().mockImplementation((data) => { + return Promise.resolve({}) + }), + createAdjustmentForLineItem: jest + .fn() + .mockImplementation((cart, lineItem) => { + return Promise.resolve({ + item_id: lineItem.id, + amount: 1000, + discount_id: "disc_2", + id: "lia-1", + description: "discount", + }) + }), + createAdjustments: jest.fn().mockImplementation((cart, lineItem) => { + if (lineItem) { + return Promise.resolve({ + item_id: lineItem.id, + amount: 1000, + discount_id: "disc_2", + id: "lia-1", + description: "discount", + }) + } + + return Promise.resolve([ + { + item_id: "li-1", + amount: 200, + discount_id: "disc_2", + id: "lia-1", + description: "discount", + }, + { + item_id: "li-3", + amount: 100, + discount_id: "disc_3", + id: "lia-2", + description: "discount", + }, + ]) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return LineItemAdjustmentServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index aab9ab7ff5..991da99d99 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -3,6 +3,7 @@ import { MedusaError } from "medusa-core-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import CartService from "../cart" import { InventoryServiceMock } from "../__mocks__/inventory" +import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment" const eventBusService = { emit: jest.fn(), @@ -347,6 +348,7 @@ describe("CartService", () => { findOneWithRelations: (rels, q) => { if (q.where.id === IdMap.getId("cartWithLine")) { return Promise.resolve({ + id: IdMap.getId("cartWithLine"), items: [ { id: IdMap.getId("merger"), @@ -359,6 +361,7 @@ describe("CartService", () => { }) } return Promise.resolve({ + id: IdMap.getId("emptyCart"), shipping_methods: [ { shipping_option: { @@ -378,13 +381,14 @@ describe("CartService", () => { eventBusService, shippingOptionService, inventoryService, + lineItemAdjustmentService: LineItemAdjustmentServiceMock, }) beforeEach(() => { jest.clearAllMocks() }) - it("successfully creates new line item", async () => { + it("creates a new line item and emits a created event", async () => { const lineItem = { title: "New Line", description: "This is a new line", @@ -393,7 +397,6 @@ describe("CartService", () => { unit_price: 123, quantity: 10, } - await cartService.addLineItem(IdMap.getId("emptyCart"), _.clone(lineItem)) expect(eventBusService.emit).toHaveBeenCalledTimes(1) @@ -408,6 +411,15 @@ describe("CartService", () => { has_shipping: false, cart_id: IdMap.getId("emptyCart"), }) + + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledWith( + expect.objectContaining({ id: IdMap.getId("emptyCart") }) + ) }) it("successfully creates new line item with shipping", async () => { @@ -440,6 +452,15 @@ describe("CartService", () => { has_shipping: false, cart_id: IdMap.getId("emptyCart"), }) + + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledWith( + expect.objectContaining({ id: IdMap.getId("emptyCart") }) + ) }) it("successfully merges existing line item", async () => { @@ -462,6 +483,20 @@ describe("CartService", () => { quantity: 2, } ) + + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ + item_id: [IdMap.getId("merger")], + }) + + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledWith( + expect.objectContaining({ id: IdMap.getId("cartWithLine") }) + ) }) it("throws if inventory isn't covered", async () => { @@ -559,6 +594,7 @@ describe("CartService", () => { lineItemService, shippingOptionService, eventBusService, + lineItemAdjustmentService: LineItemAdjustmentServiceMock, }) beforeEach(() => { @@ -576,6 +612,22 @@ describe("CartService", () => { IdMap.getId("itemToRemove") ) + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ + item_id: [IdMap.getId("itemToRemove")], + }) + + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledWith( + expect.objectContaining({ + items: [{ id: IdMap.getId("itemToRemove") }], + }) + ) + expect(eventBusService.emit).toHaveBeenCalledTimes(1) expect(eventBusService.emit).toHaveBeenCalledWith( "cart.updated", @@ -598,6 +650,22 @@ describe("CartService", () => { profile_id: IdMap.getId("prevPro"), }, }) + + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ + item_id: [IdMap.getId("itemToRemove")], + }) + + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledWith( + expect.objectContaining({ + items: [expect.objectContaining({ id: IdMap.getId("itemToRemove") })], + }) + ) }) it("resolves if line item is not in cart", async () => { @@ -666,7 +734,13 @@ describe("CartService", () => { describe("updateLineItem", () => { const lineItemService = { - update: jest.fn(), + update: jest.fn().mockImplementation(() => + Promise.resolve({ + id: IdMap.getId("existing"), + variant_id: IdMap.getId("good"), + quantity: 1, + }) + ), withTransaction: function() { return this }, @@ -692,6 +766,7 @@ describe("CartService", () => { }) } return Promise.resolve({ + id: IdMap.getId("cartWithLine"), items: [ { id: IdMap.getId("existing"), @@ -709,6 +784,7 @@ describe("CartService", () => { lineItemService, eventBusService, inventoryService, + lineItemAdjustmentService: LineItemAdjustmentServiceMock, }) beforeEach(() => { @@ -733,6 +809,20 @@ describe("CartService", () => { IdMap.getId("existing"), { quantity: 2 } ) + + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ + item_id: [IdMap.getId("existing")], + }) + + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledWith( + expect.objectContaining({ id: IdMap.getId("cartWithLine") }) + ) }) it("throws if inventory isn't covered", async () => { @@ -1599,6 +1689,18 @@ describe("CartService", () => { }, ], region_id: IdMap.getId("good"), + items: [ + { + id: "li1", + quantity: 2, + unit_price: 1000, + }, + { + id: "li2", + quantity: 1, + unit_price: 500, + }, + ], }) } if (q.where.id === "with-d-and-customer") { @@ -1619,6 +1721,18 @@ describe("CartService", () => { id: IdMap.getId("cart"), discounts: [], region_id: IdMap.getId("good"), + items: [ + { + id: "li1", + quantity: 2, + unit_price: 1000, + }, + { + id: "li2", + quantity: 1, + unit_price: 500, + }, + ], }) }, }) @@ -1756,6 +1870,11 @@ describe("CartService", () => { } return Promise.resolve(false) }), + validateDiscountForCartOrThrow: jest + .fn() + .mockImplementation((cart, discount) => { + return Promise.resolve({ hasErrors: () => false }) + }), } const cartService = new CartService({ @@ -1764,6 +1883,7 @@ describe("CartService", () => { cartRepository, discountService, eventBusService, + lineItemAdjustmentService: LineItemAdjustmentServiceMock, }) beforeEach(async () => { @@ -1793,6 +1913,18 @@ describe("CartService", () => { subtotal: 0, tax_total: 0, total: 0, + items: [ + { + id: "li1", + quantity: 2, + unit_price: 1000, + }, + { + id: "li2", + quantity: 1, + unit_price: 500, + }, + ], discounts: [ { id: IdMap.getId("10off"), @@ -1804,6 +1936,20 @@ describe("CartService", () => { }, ], }) + + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ + item_id: ["li1", "li2"], + }) + + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledWith( + expect.objectContaining({ id: IdMap.getId("cart") }) + ) }) it("successfully applies discount to cart and removes old one", async () => { @@ -1820,6 +1966,18 @@ describe("CartService", () => { subtotal: 0, tax_total: 0, total: 0, + items: [ + { + id: "li1", + quantity: 2, + unit_price: 1000, + }, + { + id: "li2", + quantity: 1, + unit_price: 500, + }, + ], discounts: [ { id: IdMap.getId("10off"), @@ -1831,6 +1989,20 @@ describe("CartService", () => { }, ], }) + + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ + item_id: ["li1", "li2"], + }) + + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledWith( + expect.objectContaining({ id: IdMap.getId("cart") }) + ) }) it("successfully applies free shipping", async () => { @@ -1860,32 +2032,16 @@ describe("CartService", () => { }, }, ], - discount_total: 0, - shipping_total: 0, - subtotal: 0, - tax_total: 0, - total: 0, - region_id: IdMap.getId("good"), - }) - }) - - it("successfully applies discount with usage count null", async () => { - await cartService.update(IdMap.getId("cart"), { - discounts: [{ code: "null-count" }], - }) - - expect(discountService.retrieveByCode).toHaveBeenCalledTimes(1) - expect(cartRepository.save).toHaveBeenCalledTimes(1) - expect(cartRepository.save).toHaveBeenCalledWith({ - id: IdMap.getId("cart"), - discounts: [ + items: [ { - id: IdMap.getId("null-count"), - code: "null-count", - regions: [{ id: IdMap.getId("good") }], - usage_count: 0, - usage_limit: 2, - rule: {}, + id: "li1", + quantity: 2, + unit_price: 1000, + }, + { + id: "li2", + quantity: 1, + unit_price: 500, }, ], discount_total: 0, @@ -1895,92 +2051,20 @@ describe("CartService", () => { total: 0, region_id: IdMap.getId("good"), }) - }) - it("successfully applies valid discount with expiration date to cart", async () => { - await cartService.update(IdMap.getId("fr-cart"), { - discounts: [ - { - code: "ValidDiscount", - }, - ], + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) + expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ + item_id: ["li1", "li2"], }) - expect(eventBusService.emit).toHaveBeenCalledTimes(1) - expect(eventBusService.emit).toHaveBeenCalledWith( - "cart.updated", - expect.any(Object) + + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledWith( + expect.objectContaining({ id: IdMap.getId("cart") }) ) - - expect(cartRepository.save).toHaveBeenCalledTimes(1) - expect(cartRepository.save).toHaveBeenCalledWith({ - id: IdMap.getId("cart"), - region_id: IdMap.getId("good"), - discount_total: 0, - shipping_total: 0, - subtotal: 0, - tax_total: 0, - total: 0, - discounts: [ - { - id: IdMap.getId("10off"), - code: "10%OFF", - regions: [{ id: IdMap.getId("good") }], - rule: { - type: "percentage", - }, - starts_at: expect.any(Date), - ends_at: expect.any(Date), - }, - ], - }) - }) - - it("throws if discount is applied too before it's valid", async () => { - await expect( - cartService.update(IdMap.getId("cart"), { - discounts: [{ code: "EarlyDiscount" }], - }) - ).rejects.toThrow("Discount is not valid yet") - }) - - it("throws if expired discount is applied", async () => { - await expect( - cartService.update(IdMap.getId("cart"), { - discounts: [{ code: "ExpiredDiscount" }], - }) - ).rejects.toThrow("Discount is expired") - }) - - it("throws if expired dynamic discount is applied", async () => { - await expect( - cartService.update(IdMap.getId("cart"), { - discounts: [{ code: "ExpiredDynamicDiscount" }], - }) - ).rejects.toThrow("Discount is expired") - }) - - it("throws if expired dynamic discount is applied after ends_at", async () => { - await expect( - cartService.update(IdMap.getId("cart"), { - discounts: [{ code: "ExpiredDynamicDiscountEndDate" }], - }) - ).rejects.toThrow("Discount is expired") - }) - - it("throws if discount limit is reached", async () => { - await expect( - cartService.update(IdMap.getId("cart"), { - discounts: [{ code: "limit-reached" }], - }) - ).rejects.toThrow("Discount has been used maximum allowed times") - }) - - it("throws if discount is not available in region", async () => { - await expect( - cartService.update(IdMap.getId("cart"), { - discounts: [{ code: "US10" }], - }) - ).rejects.toThrow("The discount is not available in current region") }) it("successfully applies discount with a check for customer applicableness", async () => { diff --git a/packages/medusa/src/services/__tests__/discount.js b/packages/medusa/src/services/__tests__/discount.js index d25e7e8d17..0e87887819 100644 --- a/packages/medusa/src/services/__tests__/discount.js +++ b/packages/medusa/src/services/__tests__/discount.js @@ -682,6 +682,472 @@ describe("DiscountService", () => { }) }) + describe("validateDiscountForCartOrThrow", () => { + const getCart = (id) => { + if (id === "with-d") { + return { + id: "cart", + discounts: [ + { + code: "1234", + rule: { + type: "fixed", + }, + }, + { + code: "FS1234", + rule: { + type: "free_shipping", + }, + }, + ], + region_id: "good", + items: [ + { + id: "li1", + quantity: 2, + unit_price: 1000, + }, + { + id: "li2", + quantity: 1, + unit_price: 500, + }, + ], + } + } + if (id === "with-d-and-customer") { + return { + id: "with-d-and-customer", + discounts: [ + { + code: "ApplicableForCustomer", + rule: { + type: "fixed", + }, + }, + ], + region_id: "good", + customer_id: "test-customer", + } + } + return { + id: "cart", + discounts: [ + { + code: "CODE", + rule: { + type: "percentage", + }, + }, + ], + region_id: "good", + items: [ + { + id: "li1", + quantity: 2, + unit_price: 1000, + }, + { + id: "li2", + quantity: 1, + unit_price: 500, + }, + ], + } + } + + let discountService + + beforeEach(async () => { + discountService = new DiscountService({}) + const hasReachedLimitMock = jest.fn().mockImplementation(() => false) + const isDisabledMock = jest.fn().mockImplementation(() => false) + const isValidForRegionMock = jest + .fn() + .mockImplementation(() => Promise.resolve(true)) + const canApplyForCustomerMock = jest.fn().mockImplementation(() => { + return Promise.resolve(true) + }) + discountService.hasReachedLimit = hasReachedLimitMock + discountService.isDisabled = isDisabledMock + discountService.canApplyForCustomer = canApplyForCustomerMock + discountService.isValidForRegion = isValidForRegionMock + }) + + it("calls all validation methods when cart includes a customer_id and doesn't throw", async () => { + const discount = { + id: "10off", + code: "10%OFF", + regions: [{ id: "good" }], + rule: { + id: "10off-rule", + type: "percentage", + }, + } + const cart = getCart("with-d-and-customer") + const result = await discountService.validateDiscountForCartOrThrow( + cart, + discount + ) + expect(result).toBeUndefined() + + expect(discountService.hasReachedLimit).toHaveBeenCalledTimes(1) + expect(discountService.hasReachedLimit).toHaveBeenCalledWith(discount) + + expect(discountService.isDisabled).toHaveBeenCalledTimes(1) + expect(discountService.isDisabled).toHaveBeenCalledWith(discount) + + expect(discountService.isValidForRegion).toHaveBeenCalledTimes(1) + expect(discountService.isValidForRegion).toHaveBeenCalledWith( + discount, + cart.region_id + ) + + expect(discountService.canApplyForCustomer).toHaveBeenCalledTimes(1) + expect(discountService.canApplyForCustomer).toHaveBeenCalledWith( + discount.rule.id, + cart.customer_id + ) + }) + + 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", + }) + }) + + 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", + }) + }) + + 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", + }) + }) + + 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", + }) + }) + + it("throws when isValidForRegion returns false", async () => { + const discount = {} + const cart = getCart("with-d-and-customer") + discountService.isValidForRegion = jest + .fn() + .mockImplementation(() => Promise.resolve(false)) + + expect( + discountService.validateDiscountForCartOrThrow(cart, discount) + ).rejects.toThrow({ + message: "The discount is not available in current region", + }) + }) + + it("throws when canApplyForCustomer returns false", async () => { + const discount = { rule: { id: "" } } + const cart = getCart("with-d-and-customer") + discountService.canApplyForCustomer = jest + .fn() + .mockImplementation(() => Promise.resolve(false)) + + expect( + discountService.validateDiscountForCartOrThrow(cart, discount) + ).rejects.toThrow({ + message: "Discount is not valid for customer", + }) + }) + }) + + describe("hasReachedLimit", () => { + const discountService = new DiscountService({}) + + it("returns true if discount limit is reached", () => { + const discount = { + id: "limit-reached", + code: "limit-reached", + regions: [{ id: "good" }], + rule: {}, + usage_count: 2, + usage_limit: 2, + } + const hasReachedLimit = discountService.hasReachedLimit(discount) + expect(hasReachedLimit).toBe(true) + }) + + it("returns false if discount limit is not reached", () => { + const discount = { + id: "limit-reached", + code: "limit-reached", + regions: [{ id: "good" }], + rule: {}, + usage_count: 1, + usage_limit: 100, + } + + const hasReachedLimit = discountService.hasReachedLimit(discount) + expect(hasReachedLimit).toBe(false) + }) + + it("returns false if discount limit is not set", () => { + const discount = { + id: "limit-reached", + code: "limit-reached", + regions: [{ id: "good" }], + rule: {}, + usage_count: 1, + } + + const hasReachedLimit = discountService.hasReachedLimit(discount) + expect(hasReachedLimit).toBe(false) + }) + }) + + describe("isDisabled", () => { + const discountService = new DiscountService({}) + + it("returns false if discount not disabled", async () => { + const discount = { + id: "10off", + code: "10%OFF", + regions: [{ id: "good" }], + rule: { + id: "10off-rule", + type: "percentage", + }, + is_disabled: false, + } + + const isDisabled = discountService.isDisabled(discount) + expect(isDisabled).toBe(false) + }) + + it("returns true if discount is disabled", async () => { + const discount = { + id: "10off", + code: "10%OFF", + regions: [{ id: "good" }], + rule: { + id: "10off-rule", + type: "percentage", + }, + is_disabled: true, + } + + const isDisabled = discountService.isDisabled(discount) + expect(isDisabled).toBe(true) + }) + }) + + describe("hasNotStarted", () => { + const discountService = new DiscountService({}) + + it("returns true if discount has a future starts_at date", async () => { + const discount = { + id: "10off", + code: "10%OFF", + regions: [{ id: "good" }], + rule: { + type: "percentage", + }, + starts_at: getOffsetDate(1), + ends_at: getOffsetDate(10), + } + + const hasNotStarted = discountService.hasNotStarted(discount) + expect(hasNotStarted).toBe(true) + }) + + it("returns false if discount has a past starts_at date", async () => { + const discount = { + id: "10off", + code: "10%OFF", + regions: [{ id: "good" }], + rule: { + type: "percentage", + }, + starts_at: getOffsetDate(-2), + ends_at: getOffsetDate(10), + } + + const hasNotStarted = discountService.hasNotStarted(discount) + expect(hasNotStarted).toBe(false) + }) + }) + + describe("hasExpired", () => { + const discountService = new DiscountService({}) + + it("returns false if discount has a future ends_at date", async () => { + const discount = { + id: "10off", + code: "10%OFF", + regions: [{ id: "good" }], + rule: { + type: "percentage", + }, + ends_at: getOffsetDate(10), + starts_at: getOffsetDate(-1), + } + + const hasExpired = discountService.hasExpired(discount) + expect(hasExpired).toBe(false) + }) + + it("returns true if discount has a past ends_at date", async () => { + const discount = { + id: "10off", + code: "10%OFF", + regions: [{ id: "good" }], + rule: { + type: "percentage", + }, + starts_at: getOffsetDate(-10), + ends_at: getOffsetDate(-1), + } + + const hasExpired = discountService.hasExpired(discount) + expect(hasExpired).toBe(true) + }) + }) + + describe("isValidForRegion", () => { + const retrieveMock = jest.fn().mockImplementation((id) => { + if (id === "parent-discount-us") { + return Promise.resolve({ + id, + regions: [{ id: "us" }], + rule: { + id: "10off-rule", + type: "percentage", + }, + }) + } else if (id === "parent-discount-dk") { + return Promise.resolve({ + id, + regions: [{ id: "dk" }], + rule: { + id: "10off-rule", + type: "percentage", + }, + }) + } + }) + + const discountService = new DiscountService({}) + discountService.retrieve = retrieveMock + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns false if discount is not available in a given region", async () => { + const discount = { + id: "10off", + code: "10%OFF", + regions: [{ id: "us" }], + rule: { + id: "10off-rule", + type: "percentage", + }, + } + + const isValidForRegion = await discountService.isValidForRegion( + discount, + "dk" + ) + + expect(retrieveMock).toBeCalledTimes(0) + expect(isValidForRegion).toBe(false) + }) + + it("returns true if discount is available in a given region", async () => { + const discount = { + id: "10off", + code: "10%OFF", + regions: [{ id: "us" }], + rule: { + id: "10off-rule", + type: "percentage", + }, + } + + const isValidForRegion = await discountService.isValidForRegion( + discount, + "us" + ) + + expect(retrieveMock).toBeCalledTimes(0) + expect(isValidForRegion).toBe(true) + }) + + it("returns false if discount has a parent discount and is not available in region", async () => { + const discount = { + id: "10off", + code: "10%OFF", + parent_discount_id: "parent-discount-us", + } + + const isValidForRegion = await discountService.isValidForRegion( + discount, + "dk" + ) + + expect(retrieveMock).toBeCalledTimes(1) + expect(retrieveMock).toBeCalledWith(discount.parent_discount_id, { + relations: ["rule", "regions"], + }) + expect(isValidForRegion).toBe(false) + }) + + it("returns true if discount has a parent discount and is available in region", async () => { + const discount = { + id: "10off", + code: "10%OFF", + parent_discount_id: "parent-discount-dk", + } + + const isValidForRegion = await discountService.isValidForRegion( + discount, + "dk" + ) + expect(retrieveMock).toBeCalledTimes(1) + expect(retrieveMock).toBeCalledWith(discount.parent_discount_id, { + relations: ["rule", "regions"], + }) + expect(isValidForRegion).toBe(true) + }) + }) + describe("canApplyForCustomer", () => { const discountConditionRepository = { canApplyForCustomer: jest @@ -736,3 +1202,9 @@ describe("DiscountService", () => { }) }) }) + +const getOffsetDate = (offset) => { + const date = new Date() + date.setDate(date.getDate() + offset) + return date +} diff --git a/packages/medusa/src/services/__tests__/draft-order.js b/packages/medusa/src/services/__tests__/draft-order.js index dceedd6091..0b0bb5471d 100644 --- a/packages/medusa/src/services/__tests__/draft-order.js +++ b/packages/medusa/src/services/__tests__/draft-order.js @@ -12,22 +12,22 @@ const eventBusService = { describe("DraftOrderService", () => { const totalsService = { - getTotal: o => { + getTotal: (o) => { return o.total || 0 }, - getSubtotal: o => { + getSubtotal: (o) => { return o.subtotal || 0 }, - getTaxTotal: o => { + getTaxTotal: (o) => { return o.tax_total || 0 }, - getDiscountTotal: o => { + getDiscountTotal: (o) => { return o.discount_total || 0 }, - getShippingTotal: o => { + getShippingTotal: (o) => { return o.shipping_total || 0 }, - getGiftCardTotal: o => { + getGiftCardTotal: (o) => { return o.gift_card_total || 0 }, } @@ -83,7 +83,7 @@ describe("DraftOrderService", () => { } const cartService = { - create: jest.fn().mockImplementation(data => + create: jest.fn().mockImplementation((data) => Promise.resolve({ id: "test-cart", ...data, @@ -110,16 +110,16 @@ describe("DraftOrderService", () => { } const addressRepository = MockRepository({ - create: addr => ({ + create: (addr) => ({ ...addr, }), }) const draftOrderRepository = MockRepository({ - create: d => ({ + create: (d) => ({ ...d, }), - save: d => ({ + save: (d) => ({ id: "test-draft-order", ...d, }), @@ -170,7 +170,13 @@ describe("DraftOrderService", () => { "test-variant", "test-region", 2, - { metadata: {}, unit_price: undefined } + { + metadata: {}, + unit_price: undefined, + cart: expect.objectContaining({ + id: "test-cart", + }), + } ) expect(lineItemService.create).toHaveBeenCalledTimes(1) @@ -228,14 +234,14 @@ describe("DraftOrderService", () => { } const draftOrderRepository = MockRepository({ - create: d => ({ + create: (d) => ({ ...d, }), - save: d => ({ + save: (d) => ({ id: "test-draft-order", ...d, }), - findOne: q => { + findOne: (q) => { switch (q.where.id) { case "completed": return Promise.resolve(completedOrder) diff --git a/packages/medusa/src/services/__tests__/line-item-adjustment.js b/packages/medusa/src/services/__tests__/line-item-adjustment.js new file mode 100644 index 0000000000..a57dd6b7b1 --- /dev/null +++ b/packages/medusa/src/services/__tests__/line-item-adjustment.js @@ -0,0 +1,314 @@ +import LineItemAdjustmentService from "../line-item-adjustment" +import { MockManager, MockRepository, IdMap } from "medusa-test-utils" +import { EventBusServiceMock } from "../__mocks__/event-bus" +import { DiscountServiceMock } from "../__mocks__/discount" +import { In } from "typeorm" + +describe("LineItemAdjustmentService", () => { + describe("list", () => { + const lineItemAdjustmentRepo = MockRepository({ + find: (q) => { + return Promise.resolve([ + { + id: "lia-1", + description: "discount", + amount: 1000, + item: "li-1", + discount_id: "disc_1", + }, + ]) + }, + }) + + const lineItemAdjustmentService = new LineItemAdjustmentService({ + manager: MockManager, + lineItemAdjustmentRepository: lineItemAdjustmentRepo, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("calls lineItemAdjustment list method", async () => { + await lineItemAdjustmentService.list( + { item_id: "li-1" }, + { + relations: ["item"], + } + ) + expect(lineItemAdjustmentRepo.find).toHaveBeenCalledTimes(1) + expect(lineItemAdjustmentRepo.find).toHaveBeenCalledWith({ + where: { + item_id: "li-1", + }, + relations: ["item"], + }) + }) + }) + + describe("retrieve", () => { + const lineItemAdjustmentRepo = MockRepository({ + findOne: (q) => { + switch (q.where.id) { + case "lia-1": + return Promise.resolve({ + id: "lia-1", + description: "discount", + }) + default: + return Promise.resolve() + } + }, + }) + + const lineItemAdjustmentService = new LineItemAdjustmentService({ + manager: MockManager, + lineItemAdjustmentRepository: lineItemAdjustmentRepo, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("calls lineItemAdjustment retrieve method", async () => { + await lineItemAdjustmentService.retrieve("lia-1", { + relations: ["item"], + }) + + expect(lineItemAdjustmentRepo.findOne).toHaveBeenCalledTimes(1) + expect(lineItemAdjustmentRepo.findOne).toHaveBeenCalledWith({ + where: { id: "lia-1" }, + relations: ["item"], + }) + }) + + it("fails when lineItemAdjustment is not found", async () => { + await expect( + lineItemAdjustmentService.retrieve("not-existing") + ).rejects.toThrow( + `Line item adjustment with id: not-existing was not found` + ) + }) + }) + + describe("create", () => { + const lineItemAdjustment = { + id: "lia-1", + amount: 2000, + description: "discount", + item_id: "li-3", + discount_id: "disc_999", + } + + const lineItemAdjustmentRepo = MockRepository({ + create: (f) => Promise.resolve(lineItemAdjustment), + save: (f) => Promise.resolve(lineItemAdjustment), + }) + + const lineItemAdjustmentService = new LineItemAdjustmentService({ + manager: MockManager, + lineItemAdjustmentRepository: lineItemAdjustmentRepo, + eventBusService: EventBusServiceMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("calls lineItemAdjustment create method", async () => { + await lineItemAdjustmentService.create({ + amount: 2000, + description: "discount", + item_id: "li-3", + discount_id: "disc_999", + }) + + expect(lineItemAdjustmentRepo.create).toHaveBeenCalledTimes(1) + expect(lineItemAdjustmentRepo.create).toHaveBeenCalledWith({ + amount: 2000, + description: "discount", + item_id: "li-3", + discount_id: "disc_999", + }) + + expect(lineItemAdjustmentRepo.save).toHaveBeenCalledTimes(1) + expect(lineItemAdjustmentRepo.save).toHaveBeenCalledWith({ + id: "lia-1", + amount: 2000, + description: "discount", + item_id: "li-3", + discount_id: "disc_999", + }) + }) + }) + + describe("update", () => { + const lineItemAdjustment = { id: "lia-1" } + + const lineItemAdjustmentRepo = MockRepository({ + findOne: (f) => Promise.resolve(lineItemAdjustment), + save: (f) => Promise.resolve(lineItemAdjustment), + }) + + const lineItemAdjustmentService = new LineItemAdjustmentService({ + manager: MockManager, + lineItemAdjustmentRepository: lineItemAdjustmentRepo, + eventBusService: EventBusServiceMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("calls lineItemAdjustment uppdate method", async () => { + await lineItemAdjustmentService.update("lia-1", { + amount: 6000, + }) + + expect(lineItemAdjustmentRepo.save).toHaveBeenCalledTimes(1) + expect(lineItemAdjustmentRepo.save).toHaveBeenCalledWith({ + ...lineItemAdjustment, + }) + }) + }) + + describe("delete", () => { + beforeAll(async () => { + jest.clearAllMocks() + }) + + describe("delete by line item adjustment id", () => { + const lineItemAdjustment = { id: "lia-1", item_id: "li-1" } + const lineItemAdjustmentRepo = MockRepository({ + find: (f) => Promise.resolve(lineItemAdjustment), + }) + + const lineItemAdjustmentService = new LineItemAdjustmentService({ + manager: MockManager, + lineItemAdjustmentRepository: lineItemAdjustmentRepo, + eventBusService: EventBusServiceMock, + }) + + it("calls lineItemAdjustment delete method with the right params", async () => { + await lineItemAdjustmentService.delete("lia-1") + + expect(lineItemAdjustmentRepo.find).toHaveBeenCalledTimes(1) + expect(lineItemAdjustmentRepo.find).toHaveBeenCalledWith({ + where: { + id: "lia-1", + }, + }) + + expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledTimes(1) + expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledWith( + lineItemAdjustment + ) + }) + }) + + describe("delete by item ids", () => { + const lineItemAdjustment = [ + { id: "lia-1", item: "li-1" }, + { id: "lia-2", item_id: "li-2" }, + ] + const lineItemAdjustmentRepo = MockRepository({ + find: (f) => Promise.resolve(lineItemAdjustment), + }) + + const lineItemAdjustmentService = new LineItemAdjustmentService({ + manager: MockManager, + lineItemAdjustmentRepository: lineItemAdjustmentRepo, + eventBusService: EventBusServiceMock, + }) + + it("calls lineItemAdjustment delete method with the right query", async () => { + const query = { item_id: ["li-1", "li-2", "li-3"] } + await lineItemAdjustmentService.delete(query) + + expect(lineItemAdjustmentRepo.find).toHaveBeenCalledTimes(1) + expect(lineItemAdjustmentRepo.find).toHaveBeenCalledWith({ + where: { + item_id: In(query.item_id), + }, + }) + + expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledTimes(1) + expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledWith( + lineItemAdjustment + ) + }) + }) + }) + + describe("createAdjustments", () => { + beforeEach(async () => { + jest.clearAllMocks() + }) + + const lineItemAdjustmentRepo = MockRepository({ + find: (f) => Promise.resolve(lineItemAdjustment), + }) + + const lineItemAdjustmentService = new LineItemAdjustmentService({ + manager: MockManager, + lineItemAdjustmentRepository: lineItemAdjustmentRepo, + discountService: DiscountServiceMock, + eventBusService: EventBusServiceMock, + }) + + lineItemAdjustmentService.createAdjustmentForLineItem = jest + .fn() + .mockImplementation(() => { + return Promise.resolve({ + item_id: "li-1", + amount: 1000, + discount_id: "disc-1", + id: "lia-1", + description: "discount", + }) + }) + + it("calls createAdjustmentForLineItem once when given a line item", () => { + const cart = { + id: "cart1", + discounts: ["disc-1"], + items: [{ id: "li-1" }], + }, + lineItem = { id: "li-1" } + + lineItemAdjustmentService.createAdjustments(cart, lineItem) + expect( + lineItemAdjustmentService.createAdjustmentForLineItem + ).toHaveBeenCalledTimes(1) + expect( + lineItemAdjustmentService.createAdjustmentForLineItem + ).toHaveBeenCalledWith(cart, lineItem) + }) + + it("calls createAdjustmentForLineItem 3 times when given a cart containing 3 line items", () => { + const cart = { + id: "cart1", + discounts: ["disc-1"], + items: [ + { + id: "li-2", + }, + { + id: "li-3", + }, + { + id: "li-4", + }, + ], + } + + lineItemAdjustmentService.createAdjustments(cart) + expect( + lineItemAdjustmentService.createAdjustmentForLineItem + ).toHaveBeenCalledTimes(3) + expect( + lineItemAdjustmentService.createAdjustmentForLineItem + ).toHaveBeenNthCalledWith(1, cart, { id: "li-2" }) + }) + }) +}) diff --git a/packages/medusa/src/services/__tests__/swap.js b/packages/medusa/src/services/__tests__/swap.js index 8d276e30ab..413714b37c 100644 --- a/packages/medusa/src/services/__tests__/swap.js +++ b/packages/medusa/src/services/__tests__/swap.js @@ -2,10 +2,11 @@ import paymentService from "medusa-interfaces/dist/payment-service" import { IdMap, MockRepository, MockManager } from "medusa-test-utils" import SwapService from "../swap" import { InventoryServiceMock } from "../__mocks__/inventory" +import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment" const eventBusService = { emit: jest.fn(), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -166,7 +167,7 @@ describe("SwapService", () => { Promise.resolve({ id: "cart", items: [{ id: "test-item" }] }) ), update: jest.fn().mockReturnValue(Promise.resolve()), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -178,7 +179,7 @@ describe("SwapService", () => { const customShippingOptionService = { create: jest.fn().mockReturnValue(Promise.resolve({ id: "cso-test" })), update: jest.fn().mockReturnValue(Promise.resolve()), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -188,7 +189,7 @@ describe("SwapService", () => { update: jest.fn().mockImplementation((d) => Promise.resolve(d)), retrieve: () => Promise.resolve({}), createReturnLines: jest.fn(() => Promise.resolve()), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -200,6 +201,7 @@ describe("SwapService", () => { cartService, lineItemService, customShippingOptionService, + lineItemAdjustmentService: LineItemAdjustmentServiceMock, }) it("finds swap and calls return create cart", async () => { @@ -219,6 +221,7 @@ describe("SwapService", () => { "order.claims", "order.claims.additional_items", "additional_items", + "additional_items.variant", "return_order", "return_order.items", "return_order.shipping_method", @@ -251,6 +254,24 @@ describe("SwapService", () => { expect(cartService.create).toHaveBeenCalledTimes(1) // expect(cartService.update).toHaveBeenCalledTimes(1) + expect(lineItemService.update).toHaveBeenCalledTimes(1) + expect(lineItemService.update).toHaveBeenCalledWith("test", { + cart_id: "cart", + }) + + expect( + LineItemAdjustmentServiceMock.createAdjustmentForLineItem + ).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustmentForLineItem + ).toHaveBeenCalledWith( + { id: "cart" }, + { + id: "test", + data: "lines", + } + ) + expect(swapRepo.save).toHaveBeenCalledTimes(1) expect(swapRepo.save).toHaveBeenCalledWith({ ...existing, @@ -330,7 +351,7 @@ describe("SwapService", () => { const swapRepo = MockRepository() const returnService = { create: jest.fn().mockReturnValue(Promise.resolve({ id: "ret" })), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -431,7 +452,7 @@ describe("SwapService", () => { { items: [{ item_id: "1234", quantity: 2 }], data: "new" }, ]) ), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -452,7 +473,7 @@ describe("SwapService", () => { const lineItemService = { update: jest.fn(), retrieve: () => Promise.resolve({}), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -541,7 +562,7 @@ describe("SwapService", () => { }) } }), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -595,14 +616,14 @@ describe("SwapService", () => { data: "new", }) }), - withTransaction: function () { + withTransaction: function() { return this }, } const eventBusService = { emit: jest.fn().mockReturnValue(Promise.resolve()), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -643,7 +664,7 @@ describe("SwapService", () => { const lineItemService = { update: jest.fn(), retrieve: () => Promise.resolve({}), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -655,7 +676,7 @@ describe("SwapService", () => { .mockReturnValue( Promise.resolve({ id: "cart", items: [{ id: "test-item" }] }) ), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -730,7 +751,7 @@ describe("SwapService", () => { const eventBusService = { emit: jest.fn().mockReturnValue(Promise.resolve()), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -745,7 +766,7 @@ describe("SwapService", () => { updateShippingMethod: () => { return Promise.resolve() }, - withTransaction: function () { + withTransaction: function() { return this }, } @@ -760,7 +781,7 @@ describe("SwapService", () => { update: () => { return Promise.resolve() }, - withTransaction: function () { + withTransaction: function() { return this }, } @@ -775,14 +796,14 @@ describe("SwapService", () => { cancelPayment: jest.fn(() => { return Promise.resolve() }), - withTransaction: function () { + withTransaction: function() { return this }, } const inventoryService = { ...InventoryServiceMock, - withTransaction: function () { + withTransaction: function() { return this }, } @@ -904,7 +925,7 @@ describe("SwapService", () => { describe("success", () => { const eventBusService = { emit: jest.fn().mockReturnValue(Promise.resolve()), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -916,7 +937,7 @@ describe("SwapService", () => { refundPayment: jest.fn((g) => g[0].id === "good" ? Promise.resolve() : Promise.reject() ), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -1044,7 +1065,7 @@ describe("SwapService", () => { const eventBusService = { emit: jest.fn().mockReturnValue(Promise.resolve()), - withTransaction: function () { + withTransaction: function() { return this }, } @@ -1107,7 +1128,7 @@ describe("SwapService", () => { const paymentProviderService = { cancelPayment: jest.fn(() => Promise.resolve({})), - withTransaction: function () { + withTransaction: function() { return this }, } diff --git a/packages/medusa/src/services/__tests__/totals.js b/packages/medusa/src/services/__tests__/totals.js index b696ae307f..7cda6b5217 100644 --- a/packages/medusa/src/services/__tests__/totals.js +++ b/packages/medusa/src/services/__tests__/totals.js @@ -55,136 +55,180 @@ const discounts = { }, } +const applyDiscount = (cart, discount) => { + let newCart = { ...cart } + if (newCart.items) { + newCart.items = cart.items.map((item) => { + return { + ...item, + adjustments: [ + { + item_id: item.id, + amount: calculateAdjustment(newCart, item, discount), + description: "discount", + discount_id: discount.id, + }, + ], + } + }) + } + + return newCart +} + +const calculateAdjustment = (cart, lineItem, discount) => { + let amount = discount.rule.value * lineItem.quantity + + let lineItemPrice = lineItem.unit_price * lineItem.quantity + + if (discount.rule.type === "fixed" && discount.rule.allocation === "total") { + let subtotal = cart.items.reduce( + (total, item) => total + item.unit_price * item.quantity, + 0 + ) + const nominator = Math.min(discount.rule.value, subtotal) + amount = Math.round((lineItemPrice / subtotal) * nominator) + } else if (discount.rule.type === "percentage") { + amount = Math.round((lineItemPrice * discount.rule.value) / 100) + } + return amount > lineItemPrice ? lineItemPrice : amount +} + describe("TotalsService", () => { const container = { taxProviderService: {}, taxCalculationStrategy: {}, } - // TODO: Redo tests to include new line item adjustments + describe("getAllocationItemDiscounts", () => { + let res - // describe("getAllocationItemDiscounts", () => { - // let res + const totalsService = new TotalsService(container) - // const totalsService = new TotalsService(container) + beforeEach(() => { + jest.clearAllMocks() + }) - // beforeEach(() => { - // jest.clearAllMocks() - // }) + it("calculates item with percentage discount", async () => { + const cart = { + items: [ + { + id: "test", + allow_discounts: true, + unit_price: 10, + quantity: 10, + variant: { + id: "testv", + product_id: "testp", + }, + adjustments: [{ amount: 10 }], + }, + ], + } - // it("calculates item with percentage discount", async () => { - // const cart = { - // items: [ - // { - // id: "test", - // allow_discounts: true, - // unit_price: 10, - // quantity: 10, - // variant: { - // id: "testv", - // product_id: "testp", - // }, - // }, - // ], - // } + const discount = { + rule: { + type: "percentage", + value: 10, + }, + } - // const discount = { - // rule: { - // type: "percentage", - // value: 10, - // }, - // } + res = totalsService.getAllocationItemDiscounts(discount, cart) - // res = totalsService.getAllocationItemDiscounts(discount, cart) + expect(res).toEqual([ + { + lineItem: { + id: "test", + allow_discounts: true, + unit_price: 10, + quantity: 10, + variant: { + id: "testv", + product_id: "testp", + }, + adjustments: [{ amount: 10 }], + }, + variant: "testv", + amount: 10, + }, + ]) + }) - // expect(res).toEqual([ - // { - // lineItem: { - // id: "test", - // allow_discounts: true, - // unit_price: 10, - // quantity: 10, - // variant: { - // id: "testv", - // product_id: "testp", - // }, - // }, - // variant: "testv", - // amount: 10, - // }, - // ]) - // }) + it("calculates item with fixed discount", async () => { + const cart = { + items: [ + { + id: "exists", + allow_discounts: true, + unit_price: 10, + variant: { + id: "testv", + product_id: "testp", + }, + quantity: 10, + adjustments: [{ amount: 90 }], + }, + ], + } - // it("calculates item with fixed discount", async () => { - // const cart = { - // items: [ - // { - // id: "exists", - // allow_discounts: true, - // unit_price: 10, - // variant: { - // id: "testv", - // product_id: "testp", - // }, - // quantity: 10, - // }, - // ], - // } + const discount = { + rule: { + type: "fixed", + value: 9, + // TODO: Add conditions relation + }, + } - // const discount = { - // rule: { - // type: "fixed", - // value: 9, - // }, - // } + res = totalsService.getAllocationItemDiscounts(discount, cart) - // res = totalsService.getAllocationItemDiscounts(discount, cart) + expect(res).toEqual([ + { + lineItem: { + id: "exists", + allow_discounts: true, + unit_price: 10, + variant: { + id: "testv", + product_id: "testp", + }, + quantity: 10, + adjustments: [{ amount: 90 }], + }, + variant: "testv", + amount: 90, + }, + ]) + }) - // expect(res).toEqual([ - // { - // lineItem: { - // id: "exists", - // allow_discounts: true, - // unit_price: 10, - // variant: { - // id: "testv", - // product_id: "testp", - // }, - // quantity: 10, - // }, - // variant: "testv", - // amount: 90, - // }, - // ]) - // }) + // not relevant anymore + // it("does not apply discount if no valid variants are provided", async () => { + // const cart = { + // items: [ + // { + // id: "exists", + // allow_discounts: true, + // unit_price: 10, + // variant: { + // id: "testv", + // product_id: "testp", + // }, + // quantity: 10, + // }, + // ], + // } - // it("does not apply discount if no valid variants are provided", async () => { - // const cart = { - // items: [ - // { - // id: "exists", - // allow_discounts: true, - // unit_price: 10, - // variant: { - // id: "testv", - // product_id: "testp", - // }, - // quantity: 10, - // }, - // ], - // } + // const discount = { + // rule: { + // type: "fixed", + // value: 9, + // // TODO: Add conditions relation + // }, + // } + // res = totalsService.getAllocationItemDiscounts(discount, cart) - // const discount = { - // rule: { - // type: "fixed", - // value: 9, - // }, - // } - // res = totalsService.getAllocationItemDiscounts(discount, cart) - - // expect(res).toEqual([]) - // }) - // }) + // expect(res).toEqual([]) + // }) + }) describe("getDiscountTotal", () => { let res @@ -223,32 +267,36 @@ describe("TotalsService", () => { discountCart.discounts = [] }) - it("calculate total precentage discount", async () => { + it("calculate total percentage discount", async () => { discountCart.discounts.push(discounts.total10Percent) - res = totalsService.getDiscountTotal(discountCart) + let cart = applyDiscount(discountCart, discounts.total10Percent) + res = totalsService.getDiscountTotal(cart) expect(res).toEqual(28) }) // TODO: Redo tests to include new line item adjustments - // it("calculate item fixed discount", async () => { - // discountCart.discounts.push(discounts.item2Fixed) - // res = totalsService.getDiscountTotal(discountCart) + it("calculate item fixed discount", async () => { + discountCart.discounts.push(discounts.item2Fixed) + let cart = applyDiscount(discountCart, discounts.item2Fixed) + res = totalsService.getDiscountTotal(cart) - // expect(res).toEqual(20) - // }) + expect(res).toEqual(40) + }) - // it("calculate item percentage discount", async () => { - // discountCart.discounts.push(discounts.item10Percent) - // res = totalsService.getDiscountTotal(discountCart) + it("calculate item percentage discount", async () => { + discountCart.discounts.push(discounts.item10Percent) + let cart = applyDiscount(discountCart, discounts.item10Percent) + res = totalsService.getDiscountTotal(cart) - // expect(res).toEqual(10) - // }) + expect(res).toEqual(28) + }) it("calculate total fixed discount", async () => { discountCart.discounts.push(discounts.total10Fixed) - res = totalsService.getDiscountTotal(discountCart) + let cart = applyDiscount(discountCart, discounts.total10Fixed) + res = totalsService.getDiscountTotal(cart) expect(res).toEqual(10) }) @@ -388,43 +436,45 @@ describe("TotalsService", () => { // expect(res).toEqual(1244) // }) - // it("calculates refund with item fixed discount", async () => { - // orderToRefund.discounts.push(discounts.item2Fixed) - // res = totalsService.getRefundTotal(orderToRefund, [ - // { - // id: "line2", - // unit_price: 100, - // allow_discounts: true, - // variant: { - // id: "variant", - // product_id: "testp2", - // }, - // quantity: 10, - // returned_quantity: 0, - // }, - // ]) + it("calculates refund with item fixed discount", async () => { + orderToRefund.discounts.push(discounts.item2Fixed) + let order = applyDiscount(orderToRefund, discounts.item2Fixed) + res = totalsService.getRefundTotal(order, [ + { + id: "line2", + unit_price: 100, + allow_discounts: true, + variant: { + id: "variant", + product_id: "testp2", + }, + quantity: 10, + returned_quantity: 0, + }, + ]) - // expect(res).toEqual(1225) - // }) + expect(res).toEqual(1225) + }) - // it("calculates refund with item percentage discount", async () => { - // orderToRefund.discounts.push(discounts.item10Percent) - // res = totalsService.getRefundTotal(orderToRefund, [ - // { - // id: "line2", - // unit_price: 100, - // allow_discounts: true, - // variant: { - // id: "variant", - // product_id: "testp2", - // }, - // quantity: 10, - // returned_quantity: 0, - // }, - // ]) + it("calculates refund with item percentage discount", async () => { + orderToRefund.discounts.push(discounts.item10Percent) + let order = applyDiscount(orderToRefund, discounts.item10Percent) + res = totalsService.getRefundTotal(order, [ + { + id: "line2", + unit_price: 100, + allow_discounts: true, + variant: { + id: "variant", + product_id: "testp2", + }, + quantity: 10, + returned_quantity: 0, + }, + ]) - // expect(res).toEqual(1125) - // }) + expect(res).toEqual(1125) + }) it("throws if line items to return is not in order", async () => { const work = () => @@ -444,6 +494,7 @@ describe("TotalsService", () => { expect(work).toThrow("Line item does not exist on order") }) }) + describe("getShippingTotal", () => { let res const totalsService = new TotalsService(container) diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index d9c84b36e1..64a114a781 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -21,12 +21,10 @@ import { LineItemUpdate, } from "../types/cart" import { FindConfig, TotalField } from "../types/common" -import CustomShippingOptionService from "./custom-shipping-option" import CustomerService from "./customer" import DiscountService from "./discount" import EventBusService from "./event-bus" import GiftCardService from "./gift-card" -import InventoryService from "./inventory" import LineItemService from "./line-item" import PaymentProviderService from "./payment-provider" import ProductService from "./product" @@ -35,6 +33,9 @@ import RegionService from "./region" import ShippingOptionService from "./shipping-option" import TaxProviderService from "./tax-provider" import TotalsService from "./totals" +import InventoryService from "./inventory" +import CustomShippingOptionService from "./custom-shipping-option" +import LineItemAdjustmentService from "./line-item-adjustment" type CartConstructorProps = { manager: EntityManager @@ -56,6 +57,7 @@ type CartConstructorProps = { totalsService: TotalsService inventoryService: InventoryService customShippingOptionService: CustomShippingOptionService + lineItemAdjustmentService: LineItemAdjustmentService priceSelectionStrategy: IPriceSelectionStrategy } @@ -92,6 +94,7 @@ class CartService extends BaseService { private paymentSessionRepository_: typeof PaymentSessionRepository private inventoryService_: InventoryService private customShippingOptionService_: CustomShippingOptionService + private lineItemAdjustmentService_: LineItemAdjustmentService private priceSelectionStrategy_: IPriceSelectionStrategy constructor({ @@ -114,6 +117,7 @@ class CartService extends BaseService { paymentSessionRepository, inventoryService, customShippingOptionService, + lineItemAdjustmentService, priceSelectionStrategy, }: CartConstructorProps) { super() @@ -137,6 +141,7 @@ class CartService extends BaseService { this.inventoryService_ = inventoryService this.customShippingOptionService_ = customShippingOptionService this.taxProviderService_ = taxProviderService + this.lineItemAdjustmentService_ = lineItemAdjustmentService this.priceSelectionStrategy_ = priceSelectionStrategy } @@ -165,6 +170,7 @@ class CartService extends BaseService { giftCardService: this.giftCardService_, inventoryService: this.inventoryService_, customShippingOptionService: this.customShippingOptionService_, + lineItemAdjustmentService: this.lineItemAdjustmentService_, priceSelectionStrategy: this.priceSelectionStrategy_, }) @@ -457,7 +463,12 @@ class CartService extends BaseService { await this.lineItemService_.withTransaction(manager).delete(lineItem.id) - const result = await this.retrieve(cartId) + const result = await this.retrieve(cartId, { + relations: ["items", "discounts", "discounts.rule"], + }) + + await this.refreshAdjustments_(result) + // Notify subscribers await this.eventBus_ .withTransaction(manager) @@ -512,9 +523,12 @@ class CartService extends BaseService { relations: [ "shipping_methods", "items", + "items.adjustments", "payment_sessions", "items.variant", "items.variant.product", + "discounts", + "discounts.rule", ], }) @@ -570,10 +584,16 @@ class CartService extends BaseService { } } - const result = await this.retrieve(cartId) + const result = await this.retrieve(cartId, { + relations: ["items", "discounts", "discounts.rule"], + }) + + await this.refreshAdjustments_(result) + await this.eventBus_ .withTransaction(manager) .emit(CartService.Events.UPDATED, result) + return result }) } @@ -592,7 +612,7 @@ class CartService extends BaseService { ): Promise { return this.atomicPhase_(async (manager: EntityManager) => { const cart = await this.retrieve(cartId, { - relations: ["items", "payment_sessions"], + relations: ["items", "items.adjustments", "payment_sessions"], }) // Ensure that the line item exists in the cart @@ -621,8 +641,13 @@ class CartService extends BaseService { .withTransaction(manager) .update(lineItemId, lineItemUpdate) + const result = await this.retrieve(cartId, { + relations: ["items", "discounts", "discounts.rule"], + }) + + await this.refreshAdjustments_(result) + // Update the line item - const result = await this.retrieve(cartId) await this.eventBus_ .withTransaction(manager) .emit(CartService.Events.UPDATED, result) @@ -1004,109 +1029,51 @@ class CartService extends BaseService { * @return the result of the update operation */ async applyDiscount(cart: Cart, discountCode: string): Promise { - const discount = await this.discountService_.retrieveByCode(discountCode, [ - "rule", - "regions", - ]) - - if (cart.customer_id) { - const canApply = await this.discountService_.canApplyForCustomer( - discount.rule.id, - cart.customer_id + return this.atomicPhase_(async (manager) => { + const discount = await this.discountService_.retrieveByCode( + discountCode, + ["rule", "regions"] ) - if (!canApply) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Discount is not valid for customer" - ) + await this.discountService_.validateDiscountForCartOrThrow(cart, discount) + + const rule = discount.rule + + // if discount is already there, we simply resolve + if (cart.discounts.find(({ id }) => id === discount.id)) { + return Promise.resolve() } - } - const rule = discount.rule + const toParse = [...cart.discounts, discount] - // if limit is set and reached, we make an early exit - if (discount.usage_limit) { - discount.usage_count = discount.usage_count || 0 - - if (discount.usage_count >= discount.usage_limit) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Discount has been used maximum allowed times" - ) - } - } - - const today = new Date() - if (discount.starts_at > today) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Discount is not valid yet" - ) - } - - if (discount.ends_at && discount.ends_at < today) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Discount is expired" - ) - } - - let regions = discount.regions - if (discount.parent_discount_id) { - const parent = await this.discountService_.retrieve( - discount.parent_discount_id, - { - relations: ["rule", "regions"], - } - ) - - regions = parent.regions - } - - if (discount.is_disabled) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "The discount code is disabled" - ) - } - - if (!regions.find(({ id }) => id === cart.region_id)) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "The discount is not available in current region" - ) - } - - // if discount is already there, we simply resolve - if (cart.discounts.find(({ id }) => id === discount.id)) { - return Promise.resolve() - } - - const toParse = [...cart.discounts, discount] - - let sawNotShipping = false - const newDiscounts = toParse.map((d) => { - const drule = d.rule - switch (drule.type) { - case "free_shipping": - if (d.rule.type === rule.type) { - return discount - } - return d - default: - if (!sawNotShipping) { - sawNotShipping = true - if (rule.type !== "free_shipping") { + let sawNotShipping = false + const newDiscounts = toParse.map((d) => { + const drule = d.rule + switch (drule.type) { + case "free_shipping": + if (d.rule.type === rule.type) { return discount } return d - } - return null + default: + if (!sawNotShipping) { + sawNotShipping = true + if (rule.type !== "free_shipping") { + return discount + } + return d + } + return null + } + }) + + cart.discounts = newDiscounts.filter(Boolean) as Discount[] + + // ignore if free shipping + if (rule.type !== "free_shipping" && cart?.items) { + await this.refreshAdjustments_(cart) } }) - - cart.discounts = newDiscounts.filter(Boolean) as Discount[] } /** @@ -1888,6 +1855,23 @@ class CartService extends BaseService { }) } + async refreshAdjustments_(cart: Cart): Promise { + return this.atomicPhase_(async (manager: EntityManager) => { + const nonReturnLines = cart.items.filter((item) => !item.is_return) + const nonReturnLineIDs = nonReturnLines.map((i) => i.id) + + // delete all old non return line item adjustments + await this.lineItemAdjustmentService_.withTransaction(manager).delete({ + item_id: nonReturnLineIDs, + }) + + // potentially create/update line item adjustments + await this.lineItemAdjustmentService_ + .withTransaction(manager) + .createAdjustments(cart) + }) + } + /** * Dedicated method to delete metadata for a cart. * @param cartId - the cart to delete metadata from. diff --git a/packages/medusa/src/services/discount.ts b/packages/medusa/src/services/discount.ts index b6547bb802..f2cf604c97 100644 --- a/packages/medusa/src/services/discount.ts +++ b/packages/medusa/src/services/discount.ts @@ -30,6 +30,7 @@ import { UpdateDiscountInput, UpsertDiscountConditionInput, } from "../types/discount" +import { isFuture, isPast } from "../utils/date-helpers" import { formatException, PostgresError } from "../utils/exception-formatter" /** @@ -735,6 +736,99 @@ class DiscountService extends BaseService { return adjustment >= fullItemPrice ? fullItemPrice : adjustment } + async validateDiscountForCartOrThrow( + cart: Cart, + discount: Discount + ): Promise { + if (this.hasReachedLimit(discount)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Discount has been used maximum allowed times" + ) + } + + if (this.hasNotStarted(discount)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Discount is not valid yet" + ) + } + + if (this.hasExpired(discount)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Discount is expired" + ) + } + + if (this.isDisabled(discount)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "The discount code is disabled" + ) + } + + const isValidForRegion = await this.isValidForRegion( + discount, + cart.region_id + ) + if (!isValidForRegion) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "The discount is not available in current region" + ) + } + + if (cart.customer_id) { + const canApplyForCustomer = await this.canApplyForCustomer( + discount.rule.id, + cart.customer_id + ) + + if (!canApplyForCustomer) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Discount is not valid for customer" + ) + } + } + } + + hasReachedLimit(discount: Discount): boolean { + const count = discount.usage_count || 0 + const limit = discount.usage_limit + return !!limit && count >= limit + } + + hasNotStarted(discount: Discount): boolean { + return isFuture(discount.starts_at) + } + + hasExpired(discount: Discount): boolean { + return discount.ends_at && isPast(discount.ends_at) + } + + isDisabled(discount: Discount): boolean { + return discount.is_disabled + } + + async isValidForRegion( + discount: Discount, + region_id: string + ): Promise { + let regions = discount.regions + + if (discount.parent_discount_id) { + const parent = await this.retrieve(discount.parent_discount_id, { + relations: ["rule", "regions"], + }) + + regions = parent.regions + } + + return regions.find(({ id }) => id === region_id) !== undefined + } + async canApplyForCustomer( discountRuleId: string, customerId: string | undefined diff --git a/packages/medusa/src/services/draft-order.js b/packages/medusa/src/services/draft-order.js index 9654021b31..4da9958b58 100644 --- a/packages/medusa/src/services/draft-order.js +++ b/packages/medusa/src/services/draft-order.js @@ -290,6 +290,7 @@ class DraftOrderService extends BaseService { .generate(item.variant_id, data.region_id, item.quantity, { metadata: item?.metadata || {}, unit_price: item.unit_price, + cart: createdCart, }) await this.lineItemService_.withTransaction(manager).create({ diff --git a/packages/medusa/src/services/line-item-adjustment.ts b/packages/medusa/src/services/line-item-adjustment.ts new file mode 100644 index 0000000000..8ce03c7229 --- /dev/null +++ b/packages/medusa/src/services/line-item-adjustment.ts @@ -0,0 +1,303 @@ +import { EntityManager } from "typeorm" +import { BaseService } from "medusa-interfaces" +import { MedusaError } from "medusa-core-utils" +import { LineItemAdjustmentRepository } from "../repositories/line-item-adjustment" +import { FindConfig } from "../types/common" +import { LineItemAdjustment } from "../models/line-item-adjustment" +import { FilterableLineItemAdjustmentProps } from "../types/line-item-adjustment" +import { LineItem } from "../models/line-item" +import { Cart } from "../models/cart" +import DiscountService from "./discount" +import { ProductVariant } from "../models/product-variant" + +type LineItemAdjustmentServiceProps = { + manager: EntityManager + lineItemAdjustmentRepository: typeof LineItemAdjustmentRepository + discountService: DiscountService +} + +type AdjustmentContext = { + variant: ProductVariant +} + +type GeneratedAdjustment = Omit + +/** + * Provides layer to manipulate line item adjustments. + * @extends BaseService + */ +class LineItemAdjustmentService extends BaseService { + private manager_: EntityManager + private lineItemAdjustmentRepo_: typeof LineItemAdjustmentRepository + private discountService: DiscountService + + constructor({ + manager, + lineItemAdjustmentRepository, + discountService, + }: LineItemAdjustmentServiceProps) { + super() + this.manager_ = manager + this.lineItemAdjustmentRepo_ = lineItemAdjustmentRepository + this.discountService = discountService + } + + withTransaction( + transactionManager: EntityManager + ): LineItemAdjustmentService { + if (!transactionManager) { + return this + } + + const cloned = new LineItemAdjustmentService({ + manager: transactionManager, + lineItemAdjustmentRepository: this.lineItemAdjustmentRepo_, + discountService: this.discountService, + }) + + cloned.transactionManager_ = transactionManager + + return cloned + } + + /** + * Retrieves a line item adjustment by id. + * @param id - the id of the line item adjustment to retrieve + * @param config - the config to retrieve the line item adjustment by + * @return the line item adjustment. + */ + async retrieve( + id: string, + config: FindConfig = {} + ): Promise { + const lineItemAdjustmentRepo: LineItemAdjustmentRepository = + this.manager_.getCustomRepository(this.lineItemAdjustmentRepo_) + + const query = this.buildQuery_({ id }, config) + const lineItemAdjustment = await lineItemAdjustmentRepo.findOne(query) + + if (!lineItemAdjustment) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Line item adjustment with id: ${id} was not found` + ) + } + + return lineItemAdjustment + } + + /** + * Creates a line item adjustment + * @param data - the line item adjustment to create + * @return line item adjustment + */ + async create(data: Partial): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const lineItemAdjustmentRepo: LineItemAdjustmentRepository = + manager.getCustomRepository(this.lineItemAdjustmentRepo_) + + const lineItemAdjustment = await lineItemAdjustmentRepo.create(data) + + return await lineItemAdjustmentRepo.save(lineItemAdjustment) + }) + } + + /** + * Creates a line item adjustment + * @param id - the line item adjustment id to update + * @param data - the line item adjustment to create + * @return line item adjustment + */ + async update( + id: string, + data: Partial + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const lineItemAdjustmentRepo: LineItemAdjustmentRepository = + manager.getCustomRepository(this.lineItemAdjustmentRepo_) + + const lineItemAdjustment = await this.retrieve(id) + + const { metadata, ...rest } = data + + if (metadata) { + lineItemAdjustment.metadata = this.setMetadata_( + lineItemAdjustment, + metadata + ) + } + + for (const [key, value] of Object.entries(rest)) { + lineItemAdjustment[key] = value + } + + const result = await lineItemAdjustmentRepo.save(lineItemAdjustment) + return result + }) + } + + /** + * Lists line item adjustments + * @param selector - the query object for find + * @param config - the config to be used for find + * @return the result of the find operation + */ + async list( + selector: FilterableLineItemAdjustmentProps = {}, + config: FindConfig = { skip: 0, take: 20 } + ): Promise { + const lineItemAdjustmentRepo = this.manager_.getCustomRepository( + this.lineItemAdjustmentRepo_ + ) + + const query = this.buildQuery_(selector, config) + return await lineItemAdjustmentRepo.find(query) + } + + /** + * Deletes line item adjustments matching a selector + * @param selectorOrId - the query object for find or the line item adjustment id + * @return the result of the delete operation + */ + async delete( + selectorOrId: string | FilterableLineItemAdjustmentProps + ): Promise { + return this.atomicPhase_(async (manager) => { + const lineItemAdjustmentRepo: LineItemAdjustmentRepository = + manager.getCustomRepository(this.lineItemAdjustmentRepo_) + + if (typeof selectorOrId === "string") { + return await this.delete({ id: selectorOrId }) + } + + const query = this.buildQuery_(selectorOrId) + + const lineItemAdjustments = await lineItemAdjustmentRepo.find(query) + + await lineItemAdjustmentRepo.remove(lineItemAdjustments) + + return Promise.resolve() + }) + } + + /** + * Creates adjustment for a line item + * @param cart - the cart object holding discounts + * @param generatedLineItem - the line item for which a line item adjustment might be created + * @param context - the line item for which a line item adjustment might be created + * @return a line item adjustment or undefined if no adjustment was created + */ + async generateAdjustments( + cart: Cart, + generatedLineItem: LineItem, + context: AdjustmentContext + ): Promise { + return this.atomicPhase_(async (manager) => { + // if lineItem should not be discounted + // or lineItem is a return line item + // or the cart does not have any discounts + // then do nothing + if ( + !generatedLineItem.allow_discounts || + generatedLineItem.is_return || + !cart?.discounts?.length + ) { + return [] + } + + const [discount] = cart.discounts.filter( + (d) => d.rule.type !== "free_shipping" + ) + + // if no discount is applied to the cart then return + if (!discount) { + return [] + } + + const lineItemProduct = context.variant.product_id + + const isValid = await this.discountService + .withTransaction(manager) + .validateDiscountForProduct(discount.rule_id, lineItemProduct) + + // if discount is not valid for line item, then do nothing + if (!isValid) { + return [] + } + + const amount = await this.discountService.calculateDiscountForLineItem( + discount.id, + generatedLineItem, + cart + ) + + // if discounted amount is 0, then do nothing + if (amount === 0) { + return [] + } + + const adjustments = [ + { + amount, + discount_id: discount.id, + description: "discount", + }, + ] + + return adjustments + }) + } + + /** + * Creates adjustment for a line item + * @param cart - the cart object holding discounts + * @param lineItem - the line item for which a line item adjustment might be created + * @return a line item adjustment or undefined if no adjustment was created + */ + async createAdjustmentForLineItem( + cart: Cart, + lineItem: LineItem + ): Promise { + const adjustments = await this.generateAdjustments(cart, lineItem, { + variant: lineItem.variant, + }) + + const createdAdjustments: LineItemAdjustment[] = [] + for (const adjustment of adjustments) { + const created = await this.create({ + item_id: lineItem.id, + ...adjustment, + }) + + createdAdjustments.push(created) + } + + return createdAdjustments + } + + /** + * Creates adjustment for a line item + * @param cart - the cart object holding discounts + * @param lineItem - the line item for which a line item adjustment might be created + * @return if a lineItem was given, returns a line item adjustment or undefined if no adjustment was created + * otherwise returns an array of line item adjustments for each line item in the cart + */ + async createAdjustments( + cart: Cart, + lineItem?: LineItem + ): Promise { + if (lineItem) { + return await this.createAdjustmentForLineItem(cart, lineItem) + } + + if (!cart.items) { + return [] + } + + return await Promise.all( + cart.items.map((li) => this.createAdjustmentForLineItem(cart, li)) + ) + } +} + +export default LineItemAdjustmentService diff --git a/packages/medusa/src/services/line-item.js b/packages/medusa/src/services/line-item.js index 732920bf33..e48f0892f4 100644 --- a/packages/medusa/src/services/line-item.js +++ b/packages/medusa/src/services/line-item.js @@ -14,6 +14,7 @@ class LineItemService extends BaseService { productService, regionService, cartRepository, + lineItemAdjustmentService, }) { super() @@ -37,6 +38,8 @@ class LineItemService extends BaseService { /** @private @const {CartRepository} */ this.cartRepository_ = cartRepository + + this.lineItemAdjustmentService_ = lineItemAdjustmentService } withTransaction(transactionManager) { @@ -52,6 +55,7 @@ class LineItemService extends BaseService { productService: this.productService_, regionService: this.regionService_, cartRepository: this.cartRepository_, + lineItemAdjustmentService: this.lineItemAdjustmentService_, }) cloned.transactionManager_ = transactionManager @@ -131,13 +135,21 @@ class LineItemService extends BaseService { }) }), metadata: i.metadata, + adjustments: i.adjustments.map((adjustment) => { + return { + amount: -1 * adjustment.amount, + description: adjustment.description, + discount_id: adjustment.discount_id, + metadata: adjustment.metadata, + } + }), }) ) return await lineItemRepo.save(toCreate) } - async generate(variantId, regionId, quantity, config = {}) { + async generate(variantId, regionId, quantity, context = {}) { return this.atomicPhase_(async (manager) => { const variant = await this.productVariantService_ .withTransaction(manager) @@ -153,14 +165,14 @@ class LineItemService extends BaseService { let price let shouldMerge = true - if (config.unit_price !== undefined && config.unit_price !== null) { + if (context.unit_price !== undefined && context.unit_price !== null) { // if custom unit_price, we ensure positive values // and we choose to not merge the items shouldMerge = false - if (config.unit_price < 0) { + if (context.unit_price < 0) { price = 0 } else { - price = config.unit_price + price = context.unit_price } } else { price = await this.productVariantService_ @@ -168,7 +180,7 @@ class LineItemService extends BaseService { .getRegionPrice(variant.id, { regionId: region.id, quantity: quantity, - customer_id: config.customer_id, + customer_id: context.customer_id, include_discount_prices: true, }) } @@ -182,10 +194,17 @@ class LineItemService extends BaseService { quantity: quantity || 1, allow_discounts: variant.product.discountable, is_giftcard: variant.product.is_giftcard, - metadata: config?.metadata || {}, + metadata: context?.metadata || {}, should_merge: shouldMerge, } + if (context.cart) { + const adjustments = await this.lineItemAdjustmentService_ + .withTransaction(manager) + .generateAdjustments(context.cart, toCreate, { variant }) + toCreate.adjustments = adjustments + } + return toCreate }) } diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index 157f33b366..bc09f91787 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -298,12 +298,15 @@ class OrderService extends BaseService { const relationSet = new Set(relations) relationSet.add("items") relationSet.add("items.tax_lines") + relationSet.add("items.adjustments") relationSet.add("swaps") relationSet.add("swaps.additional_items") relationSet.add("swaps.additional_items.tax_lines") + relationSet.add("swaps.additional_items.adjustments") relationSet.add("claims") relationSet.add("claims.additional_items") relationSet.add("claims.additional_items.tax_lines") + relationSet.add("claims.additional_items.adjustments") relationSet.add("discounts") relationSet.add("discounts.rule") relationSet.add("gift_cards") diff --git a/packages/medusa/src/services/swap.js b/packages/medusa/src/services/swap.js index 356b1ee038..04760792d0 100644 --- a/packages/medusa/src/services/swap.js +++ b/packages/medusa/src/services/swap.js @@ -33,6 +33,7 @@ class SwapService extends BaseService { orderService, inventoryService, customShippingOptionService, + lineItemAdjustmentService, }) { super() @@ -77,6 +78,9 @@ class SwapService extends BaseService { /** @private @const {CustomShippingOptionService} */ this.customShippingOptionService_ = customShippingOptionService + + /** @private @const {LineItemAdjustmentService} */ + this.lineItemAdjustmentService_ = lineItemAdjustmentService } withTransaction(transactionManager) { @@ -99,6 +103,7 @@ class SwapService extends BaseService { inventoryService: this.inventoryService_, fulfillmentService: this.fulfillmentService_, customShippingOptionService: this.customShippingOptionService_, + lineItemAdjustmentService: this.lineItemAdjustmentService_, }) cloned.transactionManager_ = transactionManager @@ -549,6 +554,7 @@ class SwapService extends BaseService { "order.claims", "order.claims.additional_items", "additional_items", + "additional_items.variant", "return_order", "return_order.items", "return_order.shipping_method", @@ -605,6 +611,10 @@ class SwapService extends BaseService { await this.lineItemService_.withTransaction(manager).update(item.id, { cart_id: cart.id, }) + // we generate adjustments in case the cart has any discounts that should be applied to the additional items + await this.lineItemAdjustmentService_ + .withTransaction(manager) + .createAdjustmentForLineItem(cart, item) } // If the swap has a return shipping method the price has to be added to diff --git a/packages/medusa/src/services/totals.ts b/packages/medusa/src/services/totals.ts index 380162997e..53322f14bc 100644 --- a/packages/medusa/src/services/totals.ts +++ b/packages/medusa/src/services/totals.ts @@ -200,8 +200,6 @@ class TotalsService extends BaseService { calculationContext ) - console.log(orderLines) - taxLines = orderLines.filter((ol) => { if ("shipping_method_id" in ol) { return ol.shipping_method_id === shippingMethod.id @@ -465,6 +463,7 @@ class TotalsService extends BaseService { const discountAmount = (allocationMap[lineItem.id]?.discount?.unit_amount || 0) * lineItem.quantity + const lineSubtotal = lineItem.unit_price * lineItem.quantity - discountAmount @@ -589,11 +588,46 @@ class TotalsService extends BaseService { discount: Discount, cart: Cart | Order ): LineDiscount[] { - const discounts: LineDiscount[] = [] - // TODO: Add line item adjustments + const discounts: LineDiscount[] = cart.items.map((item) => ({ + lineItem: item, + variant: item.variant.id, + amount: this.getLineItemDiscountAdjustment(item, discount), + })) + return discounts } + getLineItemDiscountAdjustment( + lineItem: LineItem, + discount: Discount + ): number { + const matchingDiscount = lineItem.adjustments.find( + (adjustment) => adjustment.discount_id === discount.id + ) + + if (!matchingDiscount) { + return 0 + } + + return matchingDiscount.amount + } + + getLineItemAdjustmentsTotal(cartOrOrder: Cart | Order): number { + if (!cartOrOrder?.items?.length) { + return 0 + } + + return cartOrOrder.items.reduce( + (total, item) => + total + + item.adjustments?.reduce( + (total, adjustment) => total + adjustment.amount, + 0 + ) || 0, + 0 + ) + } + /** * Returns the discount amount allocated to the line items of an order. * @param cartOrOrder - the cart or order to get line discount allocations for @@ -605,10 +639,6 @@ class TotalsService extends BaseService { cartOrOrder: Cart | Order, discount: Discount ): LineDiscountAmount[] { - const subtotal = this.getSubtotal(cartOrOrder, { - excludeNonDiscounts: true, - }) - let merged: LineItem[] = [...cartOrOrder.items] // merge items from order with items from order swaps @@ -624,43 +654,22 @@ class TotalsService extends BaseService { } } - const { type, allocation, value } = discount.rule - if (allocation === "total") { - let percentage = 0 - if (type === "percentage") { - percentage = value / 100 - } else if (type === "fixed") { - // If the fixed discount exceeds the subtotal we should - // calculate a 100% discount - const nominator = Math.min(value, subtotal) - percentage = nominator / subtotal - } - - return merged.map((item) => { - const lineTotal = item.unit_price * item.quantity - - return { - item, - amount: item.allow_discounts ? lineTotal * percentage : 0, - } - }) - } else if (allocation === "item") { - const allocationDiscounts = this.getAllocationItemDiscounts( - discount, - cartOrOrder + return merged.map((item) => { + const adjustments = item?.adjustments || [] + const discountAdjustments = adjustments.filter( + (adjustment) => adjustment.discount_id === discount.id ) - return merged.map((item) => { - const discounted = allocationDiscounts.find( - (a) => a.lineItem.id === item.id - ) - return { - item, - amount: discounted ? discounted.amount : 0, - } - }) - } - return merged.map((i) => ({ item: i, amount: 0 })) + return { + item, + amount: item.allow_discounts + ? discountAdjustments.reduce( + (total, adjustment) => total + adjustment.amount, + 0 + ) + : 0, + } + }) } /** @@ -865,32 +874,13 @@ class TotalsService extends BaseService { return 0 } - const { type, allocation, value } = discount.rule - let toReturn = 0 - - if (type === "percentage" && allocation === "total") { - toReturn = (subtotal / 100) * value - } else if (type === "percentage" && allocation === "item") { - const itemPercentageDiscounts = this.getAllocationItemDiscounts( - discount, - cartOrOrder - ) - toReturn = _.sumBy(itemPercentageDiscounts, (d) => d.amount) - } else if (type === "fixed" && allocation === "total") { - toReturn = value - } else if (type === "fixed" && allocation === "item") { - const itemFixedDiscounts = this.getAllocationItemDiscounts( - discount, - cartOrOrder - ) - toReturn = _.sumBy(itemFixedDiscounts, (d) => d.amount) - } + const discountTotal = this.getLineItemAdjustmentsTotal(cartOrOrder) if (subtotal < 0) { - return this.rounded(Math.max(subtotal, toReturn)) + return this.rounded(Math.max(subtotal, discountTotal)) } - return this.rounded(Math.min(subtotal, toReturn)) + return this.rounded(Math.min(subtotal, discountTotal)) } /** diff --git a/packages/medusa/src/types/line-item-adjustment.ts b/packages/medusa/src/types/line-item-adjustment.ts new file mode 100644 index 0000000000..f386c9faca --- /dev/null +++ b/packages/medusa/src/types/line-item-adjustment.ts @@ -0,0 +1,27 @@ +import { ValidateNested } from "class-validator" +import { IsType } from "../utils/validators/is-type" +import { DateComparisonOperator, StringComparisonOperator } from "./common" + +export class FilterableLineItemAdjustmentProps { + @ValidateNested() + @IsType([String, [String], StringComparisonOperator]) + id?: string | string[] | StringComparisonOperator + + @ValidateNested() + @IsType([String, [String]]) + item_id?: string | string[] + + @ValidateNested() + @IsType([String, [String]]) + description?: string | string[] + + @ValidateNested() + @IsType([String, [String]]) + resource_id?: string | string[] + + @IsType([DateComparisonOperator]) + created_at?: DateComparisonOperator + + @IsType([DateComparisonOperator]) + updated_at?: DateComparisonOperator +} diff --git a/packages/medusa/src/utils/date-helpers.ts b/packages/medusa/src/utils/date-helpers.ts new file mode 100644 index 0000000000..e3bdf015b7 --- /dev/null +++ b/packages/medusa/src/utils/date-helpers.ts @@ -0,0 +1,11 @@ +import { isDate } from "lodash" + +export const isPast = (date: Date | null) => { + const now = new Date() + return isDate(date) && now > date +} + +export const isFuture = (date: Date | null) => { + const now = new Date() + return isDate(date) && date > now +} \ No newline at end of file