From 71fdd281986781a96f5c50205a4a3628ae8e6282 Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Mon, 6 Feb 2023 11:23:39 +0100 Subject: [PATCH 1/8] fix(medusa-payment-stripe): Prevent Stripe events from retrying (#3160) --- .changeset/wild-ravens-press.md | 5 +++++ .../src/api/routes/hooks/stripe.js | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 .changeset/wild-ravens-press.md diff --git a/.changeset/wild-ravens-press.md b/.changeset/wild-ravens-press.md new file mode 100644 index 000000000000..d7cc7cf73d09 --- /dev/null +++ b/.changeset/wild-ravens-press.md @@ -0,0 +1,5 @@ +--- +"medusa-payment-stripe": patch +--- + +fix(medusa-payment-stripe): Prevent Stripe events from retrying diff --git a/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js b/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js index 5ef9a075ec9e..14ff981c645e 100644 --- a/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js +++ b/packages/medusa-payment-stripe/src/api/routes/hooks/stripe.js @@ -27,12 +27,20 @@ export default async (req, res) => { // handle payment intent events switch (event.type) { case "payment_intent.succeeded": - if (order && order.payment_status !== "captured") { - await manager.transaction(async (manager) => { - await orderService.withTransaction(manager).capturePayment(order.id) - }) + if (order) { + // If order is created but not captured, we attempt to do so + if (order.payment_status !== "captured") { + await manager.transaction(async (manager) => { + await orderService + .withTransaction(manager) + .capturePayment(order.id) + }) + } else { + // Otherwise, respond with 200 preventing Stripe from retrying + return res.sendStatus(200) + } } else { - // If we receive the event, before the order is created, we respond with 404 as this will trigger Stripe to resend the event later + // If order is not created, we respond with 404 to trigger Stripe retry mechanism return res.sendStatus(404) } break From 923ccece24895d6e927fe132ef2ef7e0e0d1b41f Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Mon, 6 Feb 2023 09:02:43 -0300 Subject: [PATCH 2/8] feat(medusa,stock-location,inventory): Integration tests (#3149) --- .../plugins/__tests__/inventory/cart/cart.js | 270 +++++++ .../inventory/inventory-items/index.js | 366 +++++++++ .../inventory/products/create-variant.js | 173 +++++ .../plugins/__tests__/inventory/service.js | 717 ++++++++++++++++++ .../__snapshots__/index.js.snap | 16 +- .../__tests__/stock-location/service.js | 271 +++++++ .../plugins/helpers/cart-seeder.js | 7 +- integration-tests/plugins/medusa-config.js | 12 + .../inventory/src/services/inventory-level.ts | 24 +- .../src/services/reservation-item.ts | 25 +- .../inventory-items/utils/join-levels.ts | 7 +- .../transaction/create-product-variant.ts | 11 +- .../src/services/__mocks__/inventory.js | 93 +++ .../src/services/__mocks__/stock-location.js | 46 ++ .../src/services/sales-channel-location.ts | 10 +- packages/medusa/src/types/inventory.ts | 1 - .../src/services/stock-location.ts | 2 +- 17 files changed, 2019 insertions(+), 32 deletions(-) create mode 100644 integration-tests/plugins/__tests__/inventory/cart/cart.js create mode 100644 integration-tests/plugins/__tests__/inventory/inventory-items/index.js create mode 100644 integration-tests/plugins/__tests__/inventory/products/create-variant.js create mode 100644 integration-tests/plugins/__tests__/inventory/service.js create mode 100644 integration-tests/plugins/__tests__/stock-location/service.js create mode 100644 packages/medusa/src/services/__mocks__/inventory.js create mode 100644 packages/medusa/src/services/__mocks__/stock-location.js diff --git a/integration-tests/plugins/__tests__/inventory/cart/cart.js b/integration-tests/plugins/__tests__/inventory/cart/cart.js new file mode 100644 index 000000000000..6898bad7b1c1 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/cart/cart.js @@ -0,0 +1,270 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const adminSeeder = require("../../../helpers/admin-seeder") +const cartSeeder = require("../../../helpers/cart-seeder") +const { simpleProductFactory } = require("../../../../api/factories") +const { simpleSalesChannelFactory } = require("../../../../api/factories") + +jest.setTimeout(30000) + +const adminHeaders = { headers: { Authorization: "Bearer test_token" } } + +describe("/store/carts", () => { + let express + let appContainer + let dbConnection + + let variantId + let inventoryItemId + let locationId + + const doAfterEach = async () => { + const db = useDb() + return await db.teardown() + } + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("POST /store/carts/:id", () => { + beforeEach(async () => { + await simpleSalesChannelFactory(dbConnection, { + id: "test-channel", + is_default: true, + }) + + await adminSeeder(dbConnection) + await cartSeeder(dbConnection, { sales_channel_id: "test-channel" }) + + await simpleProductFactory( + dbConnection, + { + id: "product1", + sales_channels: [{ id: "test-channel" }], + variants: [], + }, + 100 + ) + + const api = useApi() + + // Add payment provider + await api.post( + `/admin/regions/test-region/payment-providers`, + { + provider_id: "test-pay", + }, + adminHeaders + ) + + const prodVarInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + + const response = await api.post( + `/admin/products/product1/variants`, + { + title: "Test Variant w. inventory", + sku: "MY_SKU", + material: "material", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + options: [ + { + option_id: "product1-option", + value: "SS", + }, + ], + manage_inventory: true, + prices: [{ currency_code: "usd", amount: 2300 }], + }, + adminHeaders + ) + + const variant = response.data.product.variants[0] + + variantId = variant.id + + const inventoryItems = + await prodVarInventoryService.listInventoryItemsByVariant(variantId) + + inventoryItemId = inventoryItems[0].id + + // Add Stock location + const stockRes = await api.post( + `/admin/stock-locations`, + { + name: "Fake Warehouse", + }, + adminHeaders + ) + locationId = stockRes.data.stock_location.id + + // Add stock level + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: locationId, + stocked_quantity: 5, + }, + adminHeaders + ) + + // Associate Stock Location with sales channel + await api.post( + `/admin/sales-channels/test-channel/stock-locations`, + { + location_id: locationId, + }, + adminHeaders + ) + }) + + afterEach(async () => { + await doAfterEach() + }) + + it("reserve quantity when completing the cart", async () => { + const api = useApi() + + const cartId = "test-cart" + + // Add standard line item to cart + await api.post( + `/store/carts/${cartId}/line-items`, + { + variant_id: variantId, + quantity: 3, + }, + { withCredentials: true } + ) + + await api.post(`/store/carts/${cartId}/payment-sessions`) + await api.post(`/store/carts/${cartId}/payment-session`, { + provider_id: "test-pay", + }) + + const getRes = await api.post(`/store/carts/${cartId}/complete`) + + expect(getRes.status).toEqual(200) + expect(getRes.data.type).toEqual("order") + + const inventoryService = appContainer.resolve("inventoryService") + const stockLevel = await inventoryService.retrieveInventoryLevel( + inventoryItemId, + locationId + ) + + expect(stockLevel.location_id).toEqual(locationId) + expect(stockLevel.inventory_item_id).toEqual(inventoryItemId) + expect(stockLevel.reserved_quantity).toEqual(3) + expect(stockLevel.stocked_quantity).toEqual(5) + }) + + it("fails to add a item on the cart if the inventory isn't enough", async () => { + const api = useApi() + + const cartId = "test-cart" + + // Add standard line item to cart + const addCart = await api + .post( + `/store/carts/${cartId}/line-items`, + { + variant_id: variantId, + quantity: 6, + }, + { withCredentials: true } + ) + .catch((e) => e) + + expect(addCart.response.status).toEqual(400) + expect(addCart.response.data.code).toEqual("insufficient_inventory") + expect(addCart.response.data.message).toEqual( + `Variant with id: ${variantId} does not have the required inventory` + ) + }) + + it("fails to complete cart with items inventory not covered", async () => { + const api = useApi() + + const cartId = "test-cart" + + // Add standard line item to cart + await api.post( + `/store/carts/${cartId}/line-items`, + { + variant_id: variantId, + quantity: 5, + }, + { withCredentials: true } + ) + + await api.post(`/store/carts/${cartId}/payment-sessions`) + await api.post(`/store/carts/${cartId}/payment-session`, { + provider_id: "test-pay", + }) + + // Another proccess reserves items before the cart is completed + const inventoryService = appContainer.resolve("inventoryService") + inventoryService.createReservationItem({ + line_item_id: "line_item_123", + inventory_item_id: inventoryItemId, + location_id: locationId, + quantity: 2, + }) + + const completeCartRes = await api + .post(`/store/carts/${cartId}/complete`) + .catch((e) => e) + + expect(completeCartRes.response.status).toEqual(409) + expect(completeCartRes.response.data.code).toEqual( + "insufficient_inventory" + ) + expect(completeCartRes.response.data.message).toEqual( + `Variant with id: ${variantId} does not have the required inventory` + ) + + const stockLevel = await inventoryService.retrieveInventoryLevel( + inventoryItemId, + locationId + ) + + expect(stockLevel.location_id).toEqual(locationId) + expect(stockLevel.inventory_item_id).toEqual(inventoryItemId) + expect(stockLevel.reserved_quantity).toEqual(2) + expect(stockLevel.stocked_quantity).toEqual(5) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js new file mode 100644 index 000000000000..d5275a2d063e --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js @@ -0,0 +1,366 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const adminSeeder = require("../../../helpers/admin-seeder") + +jest.setTimeout(30000) + +const { simpleProductFactory } = require("../../../factories") +const adminHeaders = { headers: { Authorization: "Bearer test_token" } } + +describe("Inventory Items endpoints", () => { + let appContainer + let dbConnection + let express + + let variantId + let inventoryItems + let locationId + let location2Id + let location3Id + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + beforeEach(async () => { + // create inventory item + await adminSeeder(dbConnection) + + const api = useApi() + + await simpleProductFactory( + dbConnection, + { + id: "test-product", + variants: [], + }, + 100 + ) + + const prodVarInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + + const response = await api.post( + `/admin/products/test-product/variants`, + { + title: "Test Variant w. inventory", + sku: "MY_SKU", + material: "material", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + manage_inventory: true, + options: [ + { + option_id: "test-product-option", + value: "SS", + }, + ], + prices: [{ currency_code: "usd", amount: 2300 }], + }, + adminHeaders + ) + + const variant = response.data.product.variants[0] + + variantId = variant.id + + inventoryItems = await prodVarInventoryService.listInventoryItemsByVariant( + variantId + ) + + const stockRes = await api.post( + `/admin/stock-locations`, + { + name: "Fake Warehouse", + }, + adminHeaders + ) + locationId = stockRes.data.stock_location.id + + const secondStockRes = await api.post( + `/admin/stock-locations`, + { + name: "Another random Warehouse", + }, + adminHeaders + ) + location2Id = secondStockRes.data.stock_location.id + + const thirdStockRes = await api.post( + `/admin/stock-locations`, + { + name: "Another random Warehouse", + }, + adminHeaders + ) + location3Id = thirdStockRes.data.stock_location.id + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("Inventory Items", () => { + it("Create, update and delete inventory location level", async () => { + const api = useApi() + const inventoryItemId = inventoryItems[0].id + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: locationId, + stocked_quantity: 17, + incoming_quantity: 2, + }, + adminHeaders + ) + + const inventoryService = appContainer.resolve("inventoryService") + const stockLevel = await inventoryService.retrieveInventoryLevel( + inventoryItemId, + locationId + ) + + expect(stockLevel.location_id).toEqual(locationId) + expect(stockLevel.inventory_item_id).toEqual(inventoryItemId) + expect(stockLevel.stocked_quantity).toEqual(17) + expect(stockLevel.incoming_quantity).toEqual(2) + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`, + { + stocked_quantity: 21, + incoming_quantity: 0, + }, + adminHeaders + ) + + const newStockLevel = await inventoryService.retrieveInventoryLevel( + inventoryItemId, + locationId + ) + expect(newStockLevel.stocked_quantity).toEqual(21) + expect(newStockLevel.incoming_quantity).toEqual(0) + + await api.delete( + `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`, + adminHeaders + ) + const invLevel = await inventoryService + .retrieveInventoryLevel(inventoryItemId, locationId) + .catch((e) => e) + + expect(invLevel.message).toEqual( + `Inventory level for item ${inventoryItemId} and location ${locationId} not found` + ) + }) + + it("Update inventory item", async () => { + const api = useApi() + const inventoryItemId = inventoryItems[0].id + + const response = await api.post( + `/admin/inventory-items/${inventoryItemId}`, + { + mid_code: "updated mid_code", + weight: 120, + }, + adminHeaders + ) + + expect(response.data.inventory_item).toEqual( + expect.objectContaining({ + origin_country: "UK", + hs_code: "hs001", + mid_code: "updated mid_code", + weight: 120, + length: 100, + height: 200, + width: 150, + }) + ) + }) + + it("Retrieve an inventory item", async () => { + const api = useApi() + const inventoryItemId = inventoryItems[0].id + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: locationId, + stocked_quantity: 15, + incoming_quantity: 5, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: location2Id, + stocked_quantity: 7, + incoming_quantity: 0, + }, + adminHeaders + ) + + const response = await api.get( + `/admin/inventory-items/${inventoryItemId}`, + adminHeaders + ) + + expect(response.data).toEqual({ + inventory_item: expect.objectContaining({ + height: 200, + hs_code: "hs001", + id: inventoryItemId, + length: 100, + location_levels: [ + expect.objectContaining({ + available_quantity: 15, + deleted_at: null, + id: expect.any(String), + incoming_quantity: 5, + inventory_item_id: inventoryItemId, + location_id: locationId, + metadata: null, + reserved_quantity: 0, + stocked_quantity: 15, + }), + expect.objectContaining({ + available_quantity: 7, + deleted_at: null, + id: expect.any(String), + incoming_quantity: 0, + inventory_item_id: inventoryItemId, + location_id: location2Id, + metadata: null, + reserved_quantity: 0, + stocked_quantity: 7, + }), + ], + material: "material", + metadata: null, + mid_code: "mids", + origin_country: "UK", + requires_shipping: true, + sku: "MY_SKU", + weight: 300, + width: 150, + }), + }) + }) + + it("List inventory items", async () => { + const api = useApi() + const inventoryItemId = inventoryItems[0].id + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: location2Id, + stocked_quantity: 10, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${inventoryItemId}/location-levels`, + { + location_id: location3Id, + stocked_quantity: 5, + }, + adminHeaders + ) + + const response = await api.get(`/admin/inventory-items`, adminHeaders) + + expect(response.data.inventory_items).toEqual([ + expect.objectContaining({ + id: inventoryItemId, + sku: "MY_SKU", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + material: "material", + weight: 300, + length: 100, + height: 200, + width: 150, + requires_shipping: true, + metadata: null, + variants: expect.arrayContaining([ + expect.objectContaining({ + id: variantId, + title: "Test Variant w. inventory", + product_id: "test-product", + sku: "MY_SKU", + manage_inventory: true, + hs_code: "hs001", + origin_country: "UK", + mid_code: "mids", + material: "material", + weight: 300, + length: 100, + height: 200, + width: 150, + metadata: null, + product: expect.objectContaining({ + id: "test-product", + }), + }), + ]), + location_levels: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItemId, + location_id: location2Id, + stocked_quantity: 10, + reserved_quantity: 0, + incoming_quantity: 0, + metadata: null, + available_quantity: 10, + }), + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItemId, + location_id: location3Id, + stocked_quantity: 5, + reserved_quantity: 0, + incoming_quantity: 0, + metadata: null, + available_quantity: 5, + }), + ]), + }), + ]) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/inventory/products/create-variant.js b/integration-tests/plugins/__tests__/inventory/products/create-variant.js new file mode 100644 index 000000000000..4ac6b2c1b4b7 --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/products/create-variant.js @@ -0,0 +1,173 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../../helpers/use-db") +const { setPort, useApi } = require("../../../../helpers/use-api") + +const { + ProductVariantInventoryService, + ProductVariantService, +} = require("@medusajs/medusa") + +const adminSeeder = require("../../../helpers/admin-seeder") + +jest.setTimeout(30000) + +const { simpleProductFactory } = require("../../../factories") + +describe("Create Variant", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + setPort(port) + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + jest.clearAllMocks() + const db = useDb() + return await db.teardown() + }) + + describe("Inventory Items", () => { + it("When creating a new product variant it should create an Inventory Item", async () => { + await adminSeeder(dbConnection) + + const api = useApi() + + await simpleProductFactory( + dbConnection, + { + id: "test-product", + variants: [{ id: "test-variant" }], + }, + 100 + ) + + const response = await api.post( + `/admin/products/test-product/variants`, + { + title: "Test Variant w. inventory", + sku: "MY_SKU", + material: "material", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + manage_inventory: true, + options: [ + { + option_id: "test-product-option", + value: "SS", + }, + ], + prices: [{ currency_code: "usd", amount: 2300 }], + }, + { headers: { Authorization: "Bearer test_token" } } + ) + + expect(response.status).toEqual(200) + + const variantId = response.data.product.variants.find( + (v) => v.id !== "test-variant" + ).id + + const variantInventoryService = appContainer.resolve( + "productVariantInventoryService" + ) + const inventory = + await variantInventoryService.listInventoryItemsByVariant(variantId) + + expect(inventory).toHaveLength(1) + expect(inventory).toEqual([ + expect.objectContaining({ + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + }), + ]) + }) + + it("When creating a new variant fails, it should revert all the transaction", async () => { + await adminSeeder(dbConnection) + + const api = useApi() + + await simpleProductFactory( + dbConnection, + { + id: "test-product", + variants: [{ id: "test-variant" }], + }, + 100 + ) + + jest + .spyOn(ProductVariantInventoryService.prototype, "attachInventoryItem") + .mockImplementation(() => { + throw new Error("Failure while attaching inventory item") + }) + + const prodVariantDeleteMock = jest.spyOn( + ProductVariantService.prototype, + "delete" + ) + + const error = await api + .post( + `/admin/products/test-product/variants`, + { + title: "Test Variant w. inventory", + sku: "MY_SKU", + material: "material", + origin_country: "UK", + hs_code: "hs001", + mid_code: "mids", + weight: 300, + length: 100, + height: 200, + width: 150, + manage_inventory: true, + options: [ + { + option_id: "test-product-option", + value: "SS", + }, + ], + prices: [{ currency_code: "usd", amount: 2300 }], + }, + { headers: { Authorization: "Bearer test_token" } } + ) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.message).toEqual( + "Failure while attaching inventory item" + ) + + expect(prodVariantDeleteMock).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/integration-tests/plugins/__tests__/inventory/service.js b/integration-tests/plugins/__tests__/inventory/service.js new file mode 100644 index 000000000000..287b271b283c --- /dev/null +++ b/integration-tests/plugins/__tests__/inventory/service.js @@ -0,0 +1,717 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../helpers/use-db") + +jest.setTimeout(30000) + +describe("Inventory Module", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + describe("Inventory Module Interface", () => { + it("createInventoryItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + expect(inventoryItem).toEqual( + expect.objectContaining({ + id: expect.any(String), + sku: "sku_1", + origin_country: "CH", + hs_code: "hs_code 123", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + requires_shipping: true, + metadata: { abc: 123 }, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + }) + + it("updateInventoryItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const item = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + const updatedInventoryItem = await inventoryService.updateInventoryItem( + item.id, + { + origin_country: "CZ", + mid_code: "mid code 345", + material: "lycra and polyester", + weight: 500, + metadata: { + dce: 456, + }, + } + ) + + expect(updatedInventoryItem).toEqual( + expect.objectContaining({ + id: item.id, + sku: item.sku, + origin_country: "CZ", + hs_code: item.hs_code, + mid_code: "mid code 345", + material: "lycra and polyester", + weight: 500, + length: item.length, + height: item.height, + width: item.width, + requires_shipping: true, + metadata: { dce: 456 }, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + }) + + it("deleteInventoryItem and retrieveInventoryItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const item = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + await inventoryService.deleteInventoryItem(item.id) + + const deletedItem = inventoryService.retrieveInventoryItem(item.id) + + await expect(deletedItem).rejects.toThrow( + `InventoryItem with id ${item.id} was not found` + ) + }) + + it("createInventoryLevel", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + const inventoryLevel = await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: "location_123", + stocked_quantity: 50, + reserved_quantity: 15, + incoming_quantity: 4, + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: "second_location", + stocked_quantity: 10, + reserved_quantity: 1, + }) + + expect(inventoryLevel).toEqual( + expect.objectContaining({ + id: expect.any(String), + incoming_quantity: 4, + inventory_item_id: inventoryItem.id, + location_id: "location_123", + metadata: null, + reserved_quantity: 15, + stocked_quantity: 50, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + + expect( + await inventoryService.retrieveStockedQuantity(inventoryItem.id, [ + "location_123", + ]) + ).toEqual(50) + + expect( + await inventoryService.retrieveAvailableQuantity(inventoryItem.id, [ + "location_123", + ]) + ).toEqual(35) + + expect( + await inventoryService.retrieveReservedQuantity(inventoryItem.id, [ + "location_123", + ]) + ).toEqual(15) + + expect( + await inventoryService.retrieveStockedQuantity(inventoryItem.id, [ + "location_123", + "second_location", + ]) + ).toEqual(60) + + expect( + await inventoryService.retrieveAvailableQuantity(inventoryItem.id, [ + "location_123", + "second_location", + ]) + ).toEqual(44) + + expect( + await inventoryService.retrieveReservedQuantity(inventoryItem.id, [ + "location_123", + "second_location", + ]) + ).toEqual(16) + }) + + it("updateInventoryLevel", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: "location_123", + stocked_quantity: 50, + reserved_quantity: 0, + incoming_quantity: 0, + }) + + const updatedLevel = await inventoryService.updateInventoryLevel( + inventoryItem.id, + "location_123", + { + stocked_quantity: 25, + reserved_quantity: 4, + incoming_quantity: 10, + } + ) + + expect(updatedLevel).toEqual( + expect.objectContaining({ + id: expect.any(String), + incoming_quantity: 10, + inventory_item_id: inventoryItem.id, + location_id: "location_123", + metadata: null, + reserved_quantity: 4, + stocked_quantity: 25, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + }) + + it("deleteInventoryLevel", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + origin_country: "CH", + mid_code: "mid code", + material: "lycra", + weight: 100, + length: 200, + height: 50, + width: 50, + metadata: { + abc: 123, + }, + hs_code: "hs_code 123", + requires_shipping: true, + }) + + const inventoryLevel = await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: "location_123", + stocked_quantity: 50, + reserved_quantity: 0, + incoming_quantity: 0, + }) + + await inventoryService.deleteInventoryLevel( + inventoryItem.id, + "location_123" + ) + + const deletedLevel = inventoryService.retrieveInventoryLevel( + inventoryItem.id, + "location_123" + ) + + await expect(deletedLevel).rejects.toThrow( + `Inventory level for item ${inventoryItem.id} and location location_123 not found` + ) + }) + + it("createReservationItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + const tryReserve = inventoryService.createReservationItem({ + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 10, + metadata: { + abc: 123, + }, + }) + + await expect(tryReserve).rejects.toThrow( + `Item ${inventoryItem.id} is not stocked at location ${locationId}` + ) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 50, + reserved_quantity: 0, + incoming_quantity: 0, + }) + + const inventoryReservation = await inventoryService.createReservationItem( + { + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 10, + metadata: { + abc: 123, + }, + } + ) + + expect(inventoryReservation).toEqual( + expect.objectContaining({ + id: expect.any(String), + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 10, + metadata: { abc: 123 }, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + + const [available, reserved] = await Promise.all([ + inventoryService.retrieveAvailableQuantity(inventoryItem.id, [ + locationId, + ]), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + ]) + + expect(available).toEqual(40) + expect(reserved).toEqual(10) + }) + + it("updateReservationItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + const newLocationId = "location_new" + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 50, + reserved_quantity: 0, + incoming_quantity: 0, + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: newLocationId, + stocked_quantity: 20, + reserved_quantity: 5, + incoming_quantity: 0, + }) + + const inventoryReservation = await inventoryService.createReservationItem( + { + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 15, + metadata: { + abc: 123, + }, + } + ) + + const [available, reserved] = await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + ]) + + expect(available).toEqual(35) + expect(reserved).toEqual(15) + + const updatedReservation = await inventoryService.updateReservationItem( + inventoryReservation.id, + { + quantity: 5, + } + ) + + expect(updatedReservation).toEqual( + expect.objectContaining({ + id: expect.any(String), + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 5, + metadata: { abc: 123 }, + }) + ) + + const [newAvailable, newReserved] = await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + ]) + + expect(newAvailable).toEqual(45) + expect(newReserved).toEqual(5) + + const updatedReservationLocation = + await inventoryService.updateReservationItem(inventoryReservation.id, { + quantity: 12, + location_id: newLocationId, + }) + + expect(updatedReservationLocation).toEqual( + expect.objectContaining({ + id: expect.any(String), + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: newLocationId, + quantity: 12, + metadata: { abc: 123 }, + }) + ) + + const [ + oldLocationAvailable, + oldLocationReserved, + newLocationAvailable, + newLocationReserved, + ] = await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + newLocationId + ), + inventoryService.retrieveReservedQuantity( + inventoryItem.id, + newLocationId + ), + ]) + + expect(oldLocationAvailable).toEqual(50) + expect(oldLocationReserved).toEqual(0) + expect(newLocationAvailable).toEqual(3) + expect(newLocationReserved).toEqual(17) + }) + + it("deleteReservationItem and deleteReservationItemsByLineItem", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 10, + }) + + const inventoryReservation = await inventoryService.createReservationItem( + { + line_item_id: "line_item_123", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 1, + } + ) + + for (let quant = 1; quant <= 3; quant++) { + await inventoryService.createReservationItem({ + line_item_id: "line_item_444", + inventory_item_id: inventoryItem.id, + location_id: locationId, + quantity: 1, + }) + } + + const [available, reserved] = await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity(inventoryItem.id, locationId), + ]) + + expect(available).toEqual(6) + expect(reserved).toEqual(4) + + await inventoryService.deleteReservationItemsByLineItem("line_item_444") + const [afterDeleteLineitemAvailable, afterDeleteLineitemReserved] = + await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity( + inventoryItem.id, + locationId + ), + ]) + + expect(afterDeleteLineitemAvailable).toEqual(9) + expect(afterDeleteLineitemReserved).toEqual(1) + + await inventoryService.deleteReservationItem(inventoryReservation.id) + const [afterDeleteReservationAvailable, afterDeleteReservationReserved] = + await Promise.all([ + inventoryService.retrieveAvailableQuantity( + inventoryItem.id, + locationId + ), + inventoryService.retrieveReservedQuantity( + inventoryItem.id, + locationId + ), + ]) + + expect(afterDeleteReservationAvailable).toEqual(10) + expect(afterDeleteReservationReserved).toEqual(0) + }) + + it("confirmInventory", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + const secondLocationId = "location_551" + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 10, + reserved_quantity: 5, + }) + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: secondLocationId, + stocked_quantity: 6, + reserved_quantity: 1, + }) + + expect( + await inventoryService.confirmInventory(inventoryItem.id, locationId, 5) + ).toBeTruthy() + + expect( + await inventoryService.confirmInventory( + inventoryItem.id, + [locationId, secondLocationId], + 10 + ) + ).toBeTruthy() + + expect( + await inventoryService.confirmInventory(inventoryItem.id, locationId, 6) + ).toBeFalsy() + + expect( + await inventoryService.confirmInventory( + inventoryItem.id, + [locationId, secondLocationId], + 11 + ) + ).toBeFalsy() + }) + + it("adjustInventory", async () => { + const inventoryService = appContainer.resolve("inventoryService") + + const locationId = "location_123" + const secondLocationId = "location_551" + + const inventoryItem = await inventoryService.createInventoryItem({ + sku: "sku_1", + }) + + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 10, + reserved_quantity: 5, + }) + await inventoryService.createInventoryLevel({ + inventory_item_id: inventoryItem.id, + location_id: secondLocationId, + stocked_quantity: 6, + reserved_quantity: 1, + }) + + expect( + await inventoryService.adjustInventory(inventoryItem.id, locationId, -5) + ).toEqual( + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 5, + reserved_quantity: 5, + incoming_quantity: 0, + metadata: null, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + + expect( + await inventoryService.adjustInventory( + inventoryItem.id, + secondLocationId, + -10 + ) + ).toEqual( + expect.objectContaining({ + id: expect.any(String), + inventory_item_id: inventoryItem.id, + location_id: secondLocationId, + stocked_quantity: -4, + reserved_quantity: 1, + incoming_quantity: 0, + metadata: null, + deleted_at: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + }) + }) +}) 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 446911e4fd29..186346654cda 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 @@ -368,7 +368,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 11, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -486,7 +486,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 11, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -670,7 +670,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 11, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -836,7 +836,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 12, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -2022,7 +2022,7 @@ Object { "height": null, "hs_code": null, "id": "variant-2", - "inventory_quantity": 9, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -2130,7 +2130,7 @@ Object { "height": null, "hs_code": null, "id": "test-variant", - "inventory_quantity": 9, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -2252,7 +2252,7 @@ Object { "height": null, "hs_code": null, "id": "variant-2", - "inventory_quantity": 9, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, @@ -2368,7 +2368,7 @@ Object { "height": null, "hs_code": null, "id": "variant-2", - "inventory_quantity": 9, + "inventory_quantity": 10, "length": null, "manage_inventory": true, "material": null, diff --git a/integration-tests/plugins/__tests__/stock-location/service.js b/integration-tests/plugins/__tests__/stock-location/service.js new file mode 100644 index 000000000000..fc633efc2eac --- /dev/null +++ b/integration-tests/plugins/__tests__/stock-location/service.js @@ -0,0 +1,271 @@ +const path = require("path") + +const { bootstrapApp } = require("../../../helpers/bootstrap-app") +const { initDb, useDb } = require("../../../helpers/use-db") + +jest.setTimeout(30000) + +describe("Inventory Module", () => { + let appContainer + let dbConnection + let express + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd }) + const { container, app, port } = await bootstrapApp({ cwd }) + appContainer = container + + express = app.listen(port, (err) => { + process.send(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + express.close() + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + describe("Stock Location Module Interface", () => { + it("create", async () => { + const stockLocationService = appContainer.resolve("stockLocationService") + + expect( + await stockLocationService.create({ + name: "first location", + }) + ).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "first location", + deleted_at: null, + address_id: null, + metadata: null, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }) + ) + + expect( + await stockLocationService.create({ + name: "second location", + metadata: { + extra: "abc", + }, + address: { + address_1: "addr_1", + address_2: "line 2", + country_code: "DK", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }, + }) + ).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "second location", + metadata: { + extra: "abc", + }, + address_id: expect.any(String), + }) + ) + }) + + it("update", async () => { + const stockLocationService = appContainer.resolve("stockLocationService") + + const loc = await stockLocationService.create({ + name: "location", + address: { + address_1: "addr_1", + address_2: "line 2", + country_code: "DK", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }, + }) + const addressId = loc.address_id + + expect( + await stockLocationService.retrieve(loc.id, { + relations: ["address"], + }) + ).toEqual( + expect.objectContaining({ + id: loc.id, + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + name: "location", + address_id: addressId, + metadata: null, + address: expect.objectContaining({ + id: addressId, + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + address_1: "addr_1", + address_2: "line 2", + company: null, + city: "city", + country_code: "DK", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { abc: 123 }, + }), + }) + ) + + expect( + await stockLocationService.update(loc.id, { + name: "location name", + address_id: addressId, + address: { + address_1: "addr_1 updated", + country_code: "US", + }, + }) + ).toEqual( + expect.objectContaining({ + id: loc.id, + name: "location name", + address_id: addressId, + }) + ) + + expect( + await stockLocationService.retrieve(loc.id, { + relations: ["address"], + }) + ).toEqual( + expect.objectContaining({ + id: loc.id, + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + name: "location name", + address_id: addressId, + metadata: null, + address: expect.objectContaining({ + id: addressId, + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + address_1: "addr_1 updated", + address_2: "line 2", + company: null, + city: "city", + country_code: "US", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { abc: 123 }, + }), + }) + ) + }) + + it("updateAddress", async () => { + const stockLocationService = appContainer.resolve("stockLocationService") + + const loc = await stockLocationService.create({ + name: "location", + address: { + address_1: "addr_1", + address_2: "line 2", + country_code: "DK", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }, + }) + const addressId = loc.address_id + + expect( + await stockLocationService.updateAddress(addressId, { + address_1: "addr_1 updated", + country_code: "US", + }) + ).toEqual( + expect.objectContaining({ + id: addressId, + address_1: "addr_1 updated", + address_2: "line 2", + country_code: "US", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }) + ) + + expect( + await stockLocationService.retrieve(loc.id, { + relations: ["address"], + }) + ).toEqual( + expect.objectContaining({ + id: loc.id, + address_id: addressId, + address: expect.objectContaining({ + id: addressId, + address_1: "addr_1 updated", + }), + }) + ) + }) + + it("delete", async () => { + const stockLocationService = appContainer.resolve("stockLocationService") + + const loc = await stockLocationService.create({ + name: "location", + address: { + address_1: "addr_1", + address_2: "line 2", + country_code: "DK", + city: "city", + phone: "111222333", + province: "province", + postal_code: "555-714", + metadata: { + abc: 123, + }, + }, + }) + + await stockLocationService.delete(loc.id) + + const deletedItem = stockLocationService.retrieve(loc.id) + + await expect(deletedItem).rejects.toThrow( + `StockLocation with id ${loc.id} was not found` + ) + }) + }) +}) diff --git a/integration-tests/plugins/helpers/cart-seeder.js b/integration-tests/plugins/helpers/cart-seeder.js index da7eb55b8361..b24b85f48cab 100644 --- a/integration-tests/plugins/helpers/cart-seeder.js +++ b/integration-tests/plugins/helpers/cart-seeder.js @@ -17,6 +17,8 @@ const { } = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { + const salesChannelId = data?.sales_channel_id + const yesterday = ((today) => new Date(today.setDate(today.getDate() - 1)))( new Date() ) @@ -240,7 +242,7 @@ module.exports = async (connection, data = {}) => { is_disabled: false, starts_at: tenDaysAgo, ends_at: tenDaysFromToday, - valid_duration: "P1M", //one month + valid_duration: "P1M", // one month }) DynamicDiscount.regions = [r] @@ -381,6 +383,7 @@ module.exports = async (connection, data = {}) => { const cart = manager.create(Cart, { id: "test-cart", customer_id: "some-customer", + sales_channel_id: salesChannelId, email: "some-customer@email.com", shipping_address: { id: "test-shipping-address", @@ -397,6 +400,7 @@ module.exports = async (connection, data = {}) => { const cart2 = manager.create(Cart, { id: "test-cart-2", customer_id: "some-customer", + sales_channel_id: salesChannelId, email: "some-customer@email.com", shipping_address: { id: "test-shipping-address", @@ -413,6 +417,7 @@ module.exports = async (connection, data = {}) => { id: "swap-cart", type: "swap", customer_id: "some-customer", + sales_channel_id: salesChannelId, email: "some-customer@email.com", shipping_address: { id: "test-shipping-address", diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index 2b16170fa952..30192d0c200a 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -28,4 +28,16 @@ module.exports = { jwt_secret: "test", cookie_secret: "test", }, + modules: { + stockLocationService: { + scope: "internal", + resources: "shared", + resolve: "@medusajs/stock-location", + }, + inventoryService: { + scope: "internal", + resources: "shared", + resolve: "@medusajs/inventory", + }, + }, } diff --git a/packages/inventory/src/services/inventory-level.ts b/packages/inventory/src/services/inventory-level.ts index 97f1f4dc63b2..210050c90d81 100644 --- a/packages/inventory/src/services/inventory-level.ts +++ b/packages/inventory/src/services/inventory-level.ts @@ -223,8 +223,12 @@ export default class InventoryLevelService extends TransactionBaseService { */ async getStockedQuantity( inventoryItemId: string, - locationIds: string[] + locationIds: string[] | string ): Promise { + if (!Array.isArray(locationIds)) { + locationIds = [locationIds] + } + const manager = this.getManager() const levelRepository = manager.getRepository(InventoryLevel) @@ -235,7 +239,7 @@ export default class InventoryLevelService extends TransactionBaseService { .andWhere("location_id IN (:...locationIds)", { locationIds }) .getRawOne() - return result.quantity + return parseFloat(result.quantity) } /** @@ -246,8 +250,12 @@ export default class InventoryLevelService extends TransactionBaseService { */ async getAvailableQuantity( inventoryItemId: string, - locationIds: string[] + locationIds: string[] | string ): Promise { + if (!Array.isArray(locationIds)) { + locationIds = [locationIds] + } + const manager = this.getManager() const levelRepository = manager.getRepository(InventoryLevel) @@ -258,7 +266,7 @@ export default class InventoryLevelService extends TransactionBaseService { .andWhere("location_id IN (:...locationIds)", { locationIds }) .getRawOne() - return result.quantity + return parseFloat(result.quantity) } /** @@ -269,8 +277,12 @@ export default class InventoryLevelService extends TransactionBaseService { */ async getReservedQuantity( inventoryItemId: string, - locationIds: string[] + locationIds: string[] | string ): Promise { + if (!Array.isArray(locationIds)) { + locationIds = [locationIds] + } + const manager = this.getManager() const levelRepository = manager.getRepository(InventoryLevel) @@ -281,6 +293,6 @@ export default class InventoryLevelService extends TransactionBaseService { .andWhere("location_id IN (:...locationIds)", { locationIds }) .getRawOne() - return result.quantity + return parseFloat(result.quantity) } } diff --git a/packages/inventory/src/services/reservation-item.ts b/packages/inventory/src/services/reservation-item.ts index a105a325402f..314821b8e8ef 100644 --- a/packages/inventory/src/services/reservation-item.ts +++ b/packages/inventory/src/services/reservation-item.ts @@ -173,8 +173,31 @@ export default class ReservationItemService extends TransactionBaseService { const shouldUpdateQuantity = isDefined(data.quantity) && data.quantity !== item.quantity + const shouldUpdateLocation = + isDefined(data.location_id) && + isDefined(data.quantity) && + data.location_id !== item.location_id + const ops: Promise[] = [] - if (shouldUpdateQuantity) { + + if (shouldUpdateLocation) { + ops.push( + this.inventoryLevelService_ + .withTransaction(manager) + .adjustReservedQuantity( + item.inventory_item_id, + item.location_id, + item.quantity * -1 + ), + this.inventoryLevelService_ + .withTransaction(manager) + .adjustReservedQuantity( + item.inventory_item_id, + data.location_id!, + data.quantity! + ) + ) + } else if (shouldUpdateQuantity) { const quantityDiff = data.quantity! - item.quantity ops.push( this.inventoryLevelService_ diff --git a/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts index 383facf85671..55b116651a57 100644 --- a/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts +++ b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts @@ -12,9 +12,9 @@ export const buildLevelsByInventoryItemId = ( inventoryLevels: InventoryLevelDTO[], locationIds: string[] ) => { - const filteredLevels = inventoryLevels.filter((level) => - locationIds?.includes(level.location_id) - ) + const filteredLevels = inventoryLevels.filter((level) => { + return !locationIds.length || locationIds.includes(level.location_id) + }) return filteredLevels.reduce((acc, level) => { acc[level.inventory_item_id] = acc[level.inventory_item_id] ?? [] @@ -58,6 +58,7 @@ export const joinLevels = async ( locationIds, inventoryService ) + return inventoryItems.map((inventoryItem) => ({ ...inventoryItem, location_levels: levelsByItemId[inventoryItem.id] || [], diff --git a/packages/medusa/src/api/routes/admin/products/transaction/create-product-variant.ts b/packages/medusa/src/api/routes/admin/products/transaction/create-product-variant.ts index fbb69c885c25..e0f1e594b107 100644 --- a/packages/medusa/src/api/routes/admin/products/transaction/create-product-variant.ts +++ b/packages/medusa/src/api/routes/admin/products/transaction/create-product-variant.ts @@ -82,12 +82,7 @@ export const createVariantTransaction = async ( const productVariantServiceTx = productVariantService.withTransaction(manager) async function createVariant(variantInput: CreateProductVariantInput) { - const variant = await productVariantServiceTx.create( - productId, - variantInput - ) - - return { variant } + return await productVariantServiceTx.create(productId, variantInput) } async function removeVariant(variant: ProductVariant) { @@ -101,7 +96,7 @@ export const createVariantTransaction = async ( return } - const inventoryItem = await inventoryServiceTx!.createInventoryItem({ + return await inventoryServiceTx!.createInventoryItem({ sku: variant.sku, origin_country: variant.origin_country, hs_code: variant.hs_code, @@ -112,8 +107,6 @@ export const createVariantTransaction = async ( height: variant.height, width: variant.width, }) - - return { inventoryItem } } async function removeInventoryItem(inventoryItem: InventoryItemDTO) { diff --git a/packages/medusa/src/services/__mocks__/inventory.js b/packages/medusa/src/services/__mocks__/inventory.js new file mode 100644 index 000000000000..352df04b7c60 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/inventory.js @@ -0,0 +1,93 @@ +export const InventoryServiceMock = { + withTransaction: function () { + return this + }, + + listInventoryItems: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + listReservationItems: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + listInventoryLevels: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + retrieveInventoryItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + retrieveInventoryLevel: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + createReservationItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + createInventoryItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + createInventoryLevel: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + updateInventoryLevel: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + updateInventoryItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + updateReservationItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + deleteReservationItemsByLineItem: jest + .fn() + .mockImplementation((id, config) => { + return Promise.resolve() + }), + + deleteReservationItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + deleteInventoryItem: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + deleteInventoryLevel: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + adjustInventory: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + confirmInventory: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), + + retrieveAvailableQuantity: jest.fn().mockImplementation((id, config) => { + return Promise.resolve(10) + }), + + retrieveStockedQuantity: jest.fn().mockImplementation((id, config) => { + return Promise.resolve(9) + }), + + retrieveReservedQuantity: jest.fn().mockImplementation((id, config) => { + return Promise.resolve(1) + }), +} + +const mock = jest.fn().mockImplementation(() => { + return InventoryServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/__mocks__/stock-location.js b/packages/medusa/src/services/__mocks__/stock-location.js new file mode 100644 index 000000000000..f0afe3746cbb --- /dev/null +++ b/packages/medusa/src/services/__mocks__/stock-location.js @@ -0,0 +1,46 @@ +import { IdMap } from "medusa-test-utils" + +export const StockLocationServiceMock = { + withTransaction: function () { + return this + }, + + retrieve: jest.fn().mockImplementation((id, config) => { + return Promise.resolve({ + id: id, + name: "stock location 1 name", + }) + }), + update: jest.fn().mockImplementation((id, data) => { + return Promise.resolve({ id, ...data }) + }), + + listAndCount: jest.fn().mockImplementation(() => { + return Promise.resolve([ + [ + { + id: IdMap.getId("stock_location_1"), + name: "stock location 1 name", + }, + ], + 1, + ]) + }), + + create: jest.fn().mockImplementation((data) => { + return Promise.resolve({ + id: id, + ...data, + }) + }), + + delete: jest.fn().mockImplementation((id, config) => { + return Promise.resolve() + }), +} + +const mock = jest.fn().mockImplementation(() => { + return StockLocationServiceMock +}) + +export default mock diff --git a/packages/medusa/src/services/sales-channel-location.ts b/packages/medusa/src/services/sales-channel-location.ts index a53322f2835b..153f48d28c28 100644 --- a/packages/medusa/src/services/sales-channel-location.ts +++ b/packages/medusa/src/services/sales-channel-location.ts @@ -69,11 +69,17 @@ class SalesChannelLocationService extends TransactionBaseService { const salesChannel = await this.salesChannelService_ .withTransaction(manager) .retrieve(salesChannelId) - const stockLocation = await this.stockLocationService.retrieve(locationId) + + const stockLocationId = locationId + + if (this.stockLocationService) { + const stockLocation = await this.stockLocationService.retrieve(locationId) + locationId = stockLocation.id + } const salesChannelLocation = manager.create(SalesChannelLocation, { sales_channel_id: salesChannel.id, - location_id: stockLocation.id, + location_id: stockLocationId, }) await manager.save(salesChannelLocation) diff --git a/packages/medusa/src/types/inventory.ts b/packages/medusa/src/types/inventory.ts index 68c0260b2f81..1e6d631257b4 100644 --- a/packages/medusa/src/types/inventory.ts +++ b/packages/medusa/src/types/inventory.ts @@ -209,7 +209,6 @@ export type CreateInventoryItemInput = { } export type CreateReservationItemInput = { - type?: string line_item_id?: string inventory_item_id: string location_id: string diff --git a/packages/stock-location/src/services/stock-location.ts b/packages/stock-location/src/services/stock-location.ts index 75118b784116..9d57fc100add 100644 --- a/packages/stock-location/src/services/stock-location.ts +++ b/packages/stock-location/src/services/stock-location.ts @@ -199,7 +199,7 @@ export default class StockLocationService extends TransactionBaseService { } } - const { metadata, ...fields } = updateData + const { metadata, ...fields } = data const toSave = locationRepo.merge(item, fields) if (metadata) { From dc156861d413ecfe3fd264bcd5ad736d83d8a08e Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 6 Feb 2023 14:00:32 +0100 Subject: [PATCH 3/8] fix(medusa): ShippingOption type on `listAndCount` (#2040) **What** Fix typing issue Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> --- .changeset/spicy-jokes-protect.md | 5 +++++ packages/medusa/src/services/shipping-option.ts | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changeset/spicy-jokes-protect.md diff --git a/.changeset/spicy-jokes-protect.md b/.changeset/spicy-jokes-protect.md new file mode 100644 index 000000000000..17f85adcfe97 --- /dev/null +++ b/.changeset/spicy-jokes-protect.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): ShippingOption type on `listAndCount` diff --git a/packages/medusa/src/services/shipping-option.ts b/packages/medusa/src/services/shipping-option.ts index c674ac6dd9e1..7decefc9b204 100644 --- a/packages/medusa/src/services/shipping-option.ts +++ b/packages/medusa/src/services/shipping-option.ts @@ -157,12 +157,12 @@ class ShippingOptionService extends TransactionBaseService { } /** - * @param {Object} selector - the query object for find - * @param {object} config - config object - * @return {Promise} the result of the find operation + * @param selector - the query object for find + * @param config - config object + * @return the result of the find operation */ async listAndCount( - selector: Selector, + selector: Selector, config: FindConfig = { skip: 0, take: 50 } ): Promise<[ShippingOption[], number]> { const manager = this.manager_ From 5c1d2a5e83c3654ae468d17c900892c32ef76060 Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Mon, 6 Feb 2023 14:24:32 +0100 Subject: [PATCH 4/8] feat(medusa): Option to override existing cron job (#2989) --- .changeset/old-scissors-develop.md | 5 +++ packages/medusa/src/services/event-bus.ts | 17 ++++++--- packages/medusa/src/services/job-scheduler.ts | 37 ++++++++++++++----- 3 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 .changeset/old-scissors-develop.md diff --git a/.changeset/old-scissors-develop.md b/.changeset/old-scissors-develop.md new file mode 100644 index 000000000000..d54f5d629a1f --- /dev/null +++ b/.changeset/old-scissors-develop.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): Option to override existing cron job diff --git a/packages/medusa/src/services/event-bus.ts b/packages/medusa/src/services/event-bus.ts index 8ee60ce875ba..f7894a2f7070 100644 --- a/packages/medusa/src/services/event-bus.ts +++ b/packages/medusa/src/services/event-bus.ts @@ -7,7 +7,7 @@ import { StagedJob } from "../models" import { StagedJobRepository } from "../repositories/staged-job" import { ConfigModule, Logger } from "../types/global" import { sleep } from "../utils/sleep" -import JobSchedulerService from "./job-scheduler" +import JobSchedulerService, { CreateJobOptions } from "./job-scheduler" type InjectedDependencies = { manager: EntityManager @@ -413,12 +413,19 @@ export default class EventBusService { * @param handler - the handler to call on each cron job * @return void */ - createCronJob( + async createCronJob( eventName: string, data: T, cron: string, - handler: Subscriber - ): void { - this.jobSchedulerService_.create(eventName, data, cron, handler) + handler: Subscriber, + options?: CreateJobOptions + ): Promise { + await this.jobSchedulerService_.create( + eventName, + data, + cron, + handler, + options ?? {} + ) } } diff --git a/packages/medusa/src/services/job-scheduler.ts b/packages/medusa/src/services/job-scheduler.ts index cc38c0fd23a9..90062d250347 100644 --- a/packages/medusa/src/services/job-scheduler.ts +++ b/packages/medusa/src/services/job-scheduler.ts @@ -13,6 +13,10 @@ type ScheduledJobHandler = ( eventName: string ) => Promise +export type CreateJobOptions = { + keepExisting?: boolean +} + export default class JobSchedulerService { protected readonly config_: ConfigModule protected readonly logger_: Logger @@ -102,21 +106,34 @@ export default class JobSchedulerService { * @param handler - the handler to call on the job * @return void */ - create( + async create( eventName: string, data: T, schedule: string, - handler: ScheduledJobHandler - ): void { + handler: ScheduledJobHandler, + options: CreateJobOptions + ): Promise { this.logger_.info(`Registering ${eventName}`) this.registerHandler(eventName, handler) - this.queue_.add( - { - eventName, - data, - }, - { repeat: { cron: schedule } } - ) + const jobToCreate = { + eventName, + data, + } + const repeatOpts = { repeat: { cron: schedule } } + + if (options?.keepExisting) { + return await this.queue_.add(eventName, jobToCreate, repeatOpts) + } + + const existingJobs = (await this.queue_.getRepeatableJobs()) ?? [] + + const existingJob = existingJobs.find((job) => job.name === eventName) + + if (existingJob) { + await this.queue_.removeRepeatableByKey(existingJob.key) + } + + return await this.queue_.add(eventName, jobToCreate, repeatOpts) } } From 82da3605fb50cef182699900552109ad654f0df2 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 6 Feb 2023 14:25:02 +0100 Subject: [PATCH 5/8] feat(medusa-payment-stripe): Avoid unnecessary customer update if the stripe id already exists (#3046) --- .changeset/pretty-parents-smoke.md | 6 ++++++ packages/medusa-payment-stripe/src/helpers/stripe-base.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/pretty-parents-smoke.md diff --git a/.changeset/pretty-parents-smoke.md b/.changeset/pretty-parents-smoke.md new file mode 100644 index 000000000000..0b92c27f0423 --- /dev/null +++ b/.changeset/pretty-parents-smoke.md @@ -0,0 +1,6 @@ +--- +"medusa-payment-stripe": patch +"@medusajs/medusa": patch +--- + +feat(medusa-payment-stripe): Avoid unnecessary customer update if the stripe id already exists diff --git a/packages/medusa-payment-stripe/src/helpers/stripe-base.js b/packages/medusa-payment-stripe/src/helpers/stripe-base.js index ac3194cc0126..7f5ea1a81f6a 100644 --- a/packages/medusa-payment-stripe/src/helpers/stripe-base.js +++ b/packages/medusa-payment-stripe/src/helpers/stripe-base.js @@ -140,7 +140,7 @@ class StripeBase extends AbstractPaymentService { return { session_data, - update_requests: { + update_requests: customer?.metadata?.stripe_id ? undefined : { customer_metadata: { stripe_id: intentRequest.customer } From 4339d47e1f6c9f6c8f100b3ac72c8a394b6dd44d Mon Sep 17 00:00:00 2001 From: Pevey <7490308+pevey@users.noreply.github.com> Date: Mon, 6 Feb 2023 07:51:59 -0600 Subject: [PATCH 6/8] feat(medusa): Include `rolling` in session options config (#3184) --- .changeset/rare-buckets-crash.md | 5 +++++ packages/medusa/src/loaders/express.ts | 1 + packages/medusa/src/types/global.ts | 1 + 3 files changed, 7 insertions(+) create mode 100644 .changeset/rare-buckets-crash.md diff --git a/.changeset/rare-buckets-crash.md b/.changeset/rare-buckets-crash.md new file mode 100644 index 000000000000..43429f56e6f8 --- /dev/null +++ b/.changeset/rare-buckets-crash.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): Include `rolling` in session options config with default of false diff --git a/packages/medusa/src/loaders/express.ts b/packages/medusa/src/loaders/express.ts index 4b5181c67dd8..695b5a4983f7 100644 --- a/packages/medusa/src/loaders/express.ts +++ b/packages/medusa/src/loaders/express.ts @@ -25,6 +25,7 @@ export default async ({ app, configModule }: Options): Promise => { const { cookie_secret, session_options } = configModule.projectConfig const sessionOpts = { resave: session_options?.resave ?? true, + rolling: session_options?.rolling ?? false, saveUninitialized: session_options?.saveUninitialized ?? true, cookieName: "session", proxy: true, diff --git a/packages/medusa/src/types/global.ts b/packages/medusa/src/types/global.ts index eaf923bb90f2..974334146931 100644 --- a/packages/medusa/src/types/global.ts +++ b/packages/medusa/src/types/global.ts @@ -102,6 +102,7 @@ export type ModuleExports = { type SessionOptions = { resave?: boolean + rolling?: boolean saveUninitialized?: boolean secret?: string ttl?: number From e22a383f4738e8bc80394ccaba3ac9a4ae678955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Mon, 6 Feb 2023 16:18:23 +0100 Subject: [PATCH 7/8] fix(medusa): `fields` params usage in the storefront endpoints (#2980) --- .changeset/smooth-trees-talk.md | 5 + .../api/__tests__/store/orders.js | 52 +++++++++ .../api/__tests__/store/products.js | 108 +++++++++++------- .../src/api/middlewares/transform-query.ts | 14 +++ .../routes/store/gift-cards/get-gift-card.ts | 2 +- .../src/api/routes/store/orders/get-order.ts | 10 +- .../src/api/routes/store/orders/index.ts | 50 +++++++- .../api/routes/store/orders/lookup-order.ts | 16 ++- .../routes/store/payment-collections/index.ts | 4 +- .../store/products/__tests__/list-products.js | 6 +- .../api/routes/store/products/get-product.ts | 18 ++- .../src/api/routes/store/products/index.ts | 58 +++++++++- .../routes/store/products/list-products.ts | 7 +- packages/medusa/src/types/global.ts | 1 + .../medusa/src/utils/clean-response-data.ts | 21 ++++ 15 files changed, 308 insertions(+), 64 deletions(-) create mode 100644 .changeset/smooth-trees-talk.md create mode 100644 packages/medusa/src/utils/clean-response-data.ts diff --git a/.changeset/smooth-trees-talk.md b/.changeset/smooth-trees-talk.md new file mode 100644 index 000000000000..32343611415a --- /dev/null +++ b/.changeset/smooth-trees-talk.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): `fields` param in store products/orders endpoints diff --git a/integration-tests/api/__tests__/store/orders.js b/integration-tests/api/__tests__/store/orders.js index ccf9a2bd7aad..2e3a792788e5 100644 --- a/integration-tests/api/__tests__/store/orders.js +++ b/integration-tests/api/__tests__/store/orders.js @@ -155,6 +155,58 @@ describe("/store/carts", () => { ) }) + it("lookup order response contains only fields defined with `fields` param", async () => { + const api = useApi() + + const response = await api + .get( + "/store/orders?display_id=111&email=test@email.com&fields=status,object" + ) + .catch((err) => { + return err.response + }) + + expect(Object.keys(response.data.order)).toEqual([ + // fields + "status", + "object", + // relations + "shipping_address", + "fulfillments", + "items", + "shipping_methods", + "discounts", + "customer", + "payments", + "region", + ]) + }) + + it("get order response contains only fields defined with `fields` param", async () => { + const api = useApi() + + const response = await api + .get("/store/orders/order_test?fields=status,object") + .catch((err) => { + return err.response + }) + + expect(Object.keys(response.data.order)).toEqual([ + // fields + "status", + "object", + // relations + "shipping_address", + "fulfillments", + "items", + "shipping_methods", + "discounts", + "customer", + "payments", + "region", + ]) + }) + it("looks up order", async () => { const api = useApi() diff --git a/integration-tests/api/__tests__/store/products.js b/integration-tests/api/__tests__/store/products.js index 5998001731ad..e6e509b3c810 100644 --- a/integration-tests/api/__tests__/store/products.js +++ b/integration-tests/api/__tests__/store/products.js @@ -5,7 +5,7 @@ const { initDb, useDb } = require("../../../helpers/use-db") const { simpleProductFactory, - simpleProductCategoryFactory + simpleProductCategoryFactory, } = require("../../factories") const productSeeder = require("../../helpers/store-product-seeder") @@ -194,6 +194,26 @@ describe("/store/products", () => { expect(testProduct2Index).toBe(2) // 200 }) + it("products contain only fields defined with `fields` param", async () => { + const api = useApi() + + const response = await api.get("/store/products?fields=handle") + + expect(response.status).toEqual(200) + + expect(Object.keys(response.data.products[0])).toEqual([ + // fields + "handle", + // relations + "variants", + "options", + "images", + "tags", + "collection", + "type", + ]) + }) + it("returns a list of ordered products by id ASC and filtered with free text search", async () => { const api = useApi() @@ -455,7 +475,10 @@ describe("/store/products", () => { }) describe("Product Category filtering", () => { - let categoryWithProduct, categoryWithoutProduct, nestedCategoryWithProduct, nested2CategoryWithProduct + let categoryWithProduct, + categoryWithoutProduct, + nestedCategoryWithProduct, + nested2CategoryWithProduct const nestedCategoryWithProductId = "nested-category-with-product-id" const nested2CategoryWithProductId = "nested2-category-with-product-id" const categoryWithProductId = "category-with-product-id" @@ -463,14 +486,11 @@ describe("/store/products", () => { beforeEach(async () => { const manager = dbConnection.manager - categoryWithProduct = await simpleProductCategoryFactory( - dbConnection, - { - id: categoryWithProductId, - name: "category with Product", - products: [{ id: testProductId }], - } - ) + categoryWithProduct = await simpleProductCategoryFactory(dbConnection, { + id: categoryWithProductId, + name: "category with Product", + products: [{ id: testProductId }], + }) nestedCategoryWithProduct = await simpleProductCategoryFactory( dbConnection, @@ -504,49 +524,36 @@ describe("/store/products", () => { it("returns a list of products in product category without category children", async () => { const api = useApi() const params = `category_id[]=${categoryWithProductId}` - const response = await api - .get( - `/store/products?${params}`, - ) + const response = await api.get(`/store/products?${params}`) expect(response.status).toEqual(200) expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual( - [ - expect.objectContaining({ - id: testProductId, - }), - ] - ) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: testProductId, + }), + ]) }) it("returns a list of products in product category without category children explicitly set to false", async () => { const api = useApi() const params = `category_id[]=${categoryWithProductId}&include_category_children=false` - const response = await api - .get( - `/store/products?${params}`, - ) + const response = await api.get(`/store/products?${params}`) expect(response.status).toEqual(200) expect(response.data.products).toHaveLength(1) - expect(response.data.products).toEqual( - [ - expect.objectContaining({ - id: testProductId, - }), - ] - ) + expect(response.data.products).toEqual([ + expect.objectContaining({ + id: testProductId, + }), + ]) }) it("returns a list of products in product category with category children", async () => { const api = useApi() const params = `category_id[]=${categoryWithProductId}&include_category_children=true` - const response = await api - .get( - `/store/products?${params}`, - ) + const response = await api.get(`/store/products?${params}`) expect(response.status).toEqual(200) expect(response.data.products).toHaveLength(3) @@ -560,7 +567,7 @@ describe("/store/products", () => { }), expect.objectContaining({ id: testProductFilteringId1, - }) + }), ]) ) }) @@ -569,10 +576,7 @@ describe("/store/products", () => { const api = useApi() const params = `category_id[]=${categoryWithoutProductId}&include_category_children=true` - const response = await api - .get( - `/store/products?${params}`, - ) + const response = await api.get(`/store/products?${params}`) expect(response.status).toEqual(200) expect(response.data.products).toHaveLength(0) @@ -1082,5 +1086,27 @@ describe("/store/products", () => { ]) ) }) + + it("response contains only fields defined with `fields` param", async () => { + const api = useApi() + + const response = await api.get( + "/store/products/test-product?fields=handle" + ) + + expect(response.status).toEqual(200) + + expect(Object.keys(response.data.product)).toEqual([ + // fields + "handle", + // relations + "variants", + "options", + "images", + "tags", + "collection", + "type", + ]) + }) }) }) diff --git a/packages/medusa/src/api/middlewares/transform-query.ts b/packages/medusa/src/api/middlewares/transform-query.ts index ce134d847a86..1fe4b967c770 100644 --- a/packages/medusa/src/api/middlewares/transform-query.ts +++ b/packages/medusa/src/api/middlewares/transform-query.ts @@ -39,6 +39,20 @@ export function transformQuery< ]) req.filterableFields = removeUndefinedProperties(req.filterableFields) + if ( + (queryConfig?.defaultFields || validated.fields) && + (queryConfig?.defaultRelations || validated.expand) + ) { + req.allowedProperties = [ + ...(validated.fields + ? validated.fields.split(",") + : queryConfig?.allowedFields || [])!, + ...(validated.expand + ? validated.expand.split(",") + : queryConfig?.allowedRelations || [])!, + ] as unknown as string[] + } + if (queryConfig?.isList) { req.listConfig = prepareListQuery( validated, diff --git a/packages/medusa/src/api/routes/store/gift-cards/get-gift-card.ts b/packages/medusa/src/api/routes/store/gift-cards/get-gift-card.ts index 7a8cf7610757..df56821f34ac 100644 --- a/packages/medusa/src/api/routes/store/gift-cards/get-gift-card.ts +++ b/packages/medusa/src/api/routes/store/gift-cards/get-gift-card.ts @@ -6,7 +6,7 @@ import GiftCardService from "../../../../services/gift-card" * @oas [get] /gift-cards/{code} * operationId: "GetGiftCardsCode" * summary: "Get Gift Card by Code" - * description: "Retrieves a Gift Card by its associated unqiue code." + * description: "Retrieves a Gift Card by its associated unique code." * parameters: * - (path) code=* {string} The unique Gift Card code. * x-codegen: diff --git a/packages/medusa/src/api/routes/store/orders/get-order.ts b/packages/medusa/src/api/routes/store/orders/get-order.ts index 3b62c3ea4ff7..8f0d17ca38a5 100644 --- a/packages/medusa/src/api/routes/store/orders/get-order.ts +++ b/packages/medusa/src/api/routes/store/orders/get-order.ts @@ -1,6 +1,8 @@ import { defaultStoreOrdersFields, defaultStoreOrdersRelations } from "./index" import { OrderService } from "../../../../services" +import { FindParams } from "../../../../types/common" +import { cleanResponseData } from "../../../../utils/clean-response-data" /** * @oas [get] /orders/{id} @@ -9,6 +11,8 @@ import { OrderService } from "../../../../services" * description: "Retrieves an Order" * parameters: * - (path) id=* {string} The id of the Order. + * - (query) fields {string} (Comma separated) Which fields should be included in the result. + * - (query) expand {string} (Comma separated) Which fields should be expanded in the result. * x-codegen: * method: retrieve * x-codeSamples: @@ -54,5 +58,9 @@ export default async (req, res) => { relations: defaultStoreOrdersRelations, }) - res.json({ order }) + res.json({ + order: cleanResponseData(order, req.allowedProperties || []), + }) } + +export class StoreGetOrderParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/store/orders/index.ts b/packages/medusa/src/api/routes/store/orders/index.ts index ee87d174a62a..7ece67b6fa13 100644 --- a/packages/medusa/src/api/routes/store/orders/index.ts +++ b/packages/medusa/src/api/routes/store/orders/index.ts @@ -1,10 +1,15 @@ import { Router } from "express" import "reflect-metadata" import { Order } from "../../../.." -import middlewares, { transformBody } from "../../../middlewares" +import middlewares, { + transformBody, + transformQuery, +} from "../../../middlewares" import requireCustomerAuthentication from "../../../middlewares/require-customer-authentication" import { StorePostCustomersCustomerOrderClaimReq } from "./request-order" import { StorePostCustomersCustomerAcceptClaimReq } from "./confirm-order-request" +import { StoreGetOrderParams } from "./get-order" +import { StoreGetOrdersParams } from "./lookup-order" const route = Router() @@ -14,12 +19,31 @@ export default (app) => { /** * Lookup */ - route.get("/", middlewares.wrap(require("./lookup-order").default)) + route.get( + "/", + transformQuery(StoreGetOrdersParams, { + defaultFields: defaultStoreOrdersFields, + defaultRelations: defaultStoreOrdersRelations, + allowedFields: allowedStoreOrdersFields, + allowedRelations: allowedStoreOrdersRelations, + isList: true, + }), + middlewares.wrap(require("./lookup-order").default) + ) /** * Retrieve Order */ - route.get("/:id", middlewares.wrap(require("./get-order").default)) + route.get( + "/:id", + transformQuery(StoreGetOrderParams, { + defaultFields: defaultStoreOrdersFields, + defaultRelations: defaultStoreOrdersRelations, + allowedFields: allowedStoreOrdersFields, + allowedRelations: allowedStoreOrdersRelations, + }), + middlewares.wrap(require("./get-order").default) + ) /** * Retrieve by Cart Id @@ -60,6 +84,11 @@ export const defaultStoreOrdersRelations = [ "region", ] +export const allowedStoreOrdersRelations = [ + ...defaultStoreOrdersRelations, + "billing_address", +] + export const defaultStoreOrdersFields = [ "id", "status", @@ -83,6 +112,21 @@ export const defaultStoreOrdersFields = [ "total", ] as (keyof Order)[] +export const allowedStoreOrdersFields = [ + ...defaultStoreOrdersFields, + "object", + "shipping_total", + "discount_total", + "tax_total", + "refunded_total", + "total", + "subtotal", + "paid_total", + "refundable_amount", + "gift_card_total", + "gift_card_tax_total", +] + /** * @schema StoreOrdersRes * type: object diff --git a/packages/medusa/src/api/routes/store/orders/lookup-order.ts b/packages/medusa/src/api/routes/store/orders/lookup-order.ts index 77b7bd3ae4cb..7b19151c84c7 100644 --- a/packages/medusa/src/api/routes/store/orders/lookup-order.ts +++ b/packages/medusa/src/api/routes/store/orders/lookup-order.ts @@ -5,11 +5,13 @@ import { IsString, ValidateNested, } from "class-validator" -import { defaultStoreOrdersFields, defaultStoreOrdersRelations } from "." +import { Type } from "class-transformer" import { OrderService } from "../../../../services" -import { Type } from "class-transformer" -import { validator } from "../../../../utils/validator" +import { cleanResponseData } from "../../../../utils/clean-response-data" + +import { defaultStoreOrdersFields, defaultStoreOrdersRelations } from "." +import { FindParams } from "../../../../types/common" /** * @oas [get] /orders @@ -18,6 +20,8 @@ import { validator } from "../../../../utils/validator" * description: "Look up an order using filters." * parameters: * - (query) display_id=* {number} The display id given to the Order. + * - (query) fields {string} (Comma separated) Which fields should be included in the result. + * - (query) expand {string} (Comma separated) Which fields should be expanded in the result. * - in: query * name: email * style: form @@ -79,7 +83,7 @@ import { validator } from "../../../../utils/validator" * $ref: "#/components/responses/500_error" */ export default async (req, res) => { - const validated = await validator(StoreGetOrdersParams, req.query) + const validated = req.validatedQuery as StoreGetOrdersParams const orderService: OrderService = req.scope.resolve("orderService") @@ -101,7 +105,7 @@ export default async (req, res) => { const order = orders[0] - res.json({ order }) + res.json({ order: cleanResponseData(order, req.allowedProperties || []) }) } export class ShippingAddressPayload { @@ -110,7 +114,7 @@ export class ShippingAddressPayload { postal_code?: string } -export class StoreGetOrdersParams { +export class StoreGetOrdersParams extends FindParams { @IsNumber() @Type(() => Number) display_id: number diff --git a/packages/medusa/src/api/routes/store/payment-collections/index.ts b/packages/medusa/src/api/routes/store/payment-collections/index.ts index 8cc97f6a40a4..1e7d521b400c 100644 --- a/packages/medusa/src/api/routes/store/payment-collections/index.ts +++ b/packages/medusa/src/api/routes/store/payment-collections/index.ts @@ -20,7 +20,7 @@ export default (app, container) => { "/:id", transformQuery(GetPaymentCollectionsParams, { defaultFields: defaultPaymentCollectionFields, - defaultRelations: defaulPaymentCollectionRelations, + defaultRelations: defaultPaymentCollectionRelations, isList: false, }), middlewares.wrap(require("./get-payment-collection").default) @@ -69,7 +69,7 @@ export const defaultPaymentCollectionFields = [ "metadata", ] -export const defaulPaymentCollectionRelations = ["region", "payment_sessions"] +export const defaultPaymentCollectionRelations = ["region", "payment_sessions"] /** * @schema StorePaymentCollectionsRes diff --git a/packages/medusa/src/api/routes/store/products/__tests__/list-products.js b/packages/medusa/src/api/routes/store/products/__tests__/list-products.js index dd686d719a0c..88f050719fab 100644 --- a/packages/medusa/src/api/routes/store/products/__tests__/list-products.js +++ b/packages/medusa/src/api/routes/store/products/__tests__/list-products.js @@ -1,5 +1,5 @@ import { IdMap } from "medusa-test-utils" -import { defaultStoreProductsRelations } from ".." +import { defaultStoreProductsFields, defaultStoreProductsRelations } from ".." import { request } from "../../../../../helpers/test-request" import { ProductServiceMock } from "../../../../../services/__mocks__/product" @@ -21,9 +21,9 @@ describe("GET /store/products", () => { { status: ["published"] }, { relations: defaultStoreProductsRelations, + select: defaultStoreProductsFields, skip: 0, take: 100, - select: undefined, order: { created_at: "DESC", }, @@ -52,12 +52,12 @@ describe("GET /store/products", () => { { is_giftcard: true, status: ["published"] }, { relations: defaultStoreProductsRelations, + select: defaultStoreProductsFields, skip: 0, take: 100, order: { created_at: "DESC", }, - select: undefined, } ) }) diff --git a/packages/medusa/src/api/routes/store/products/get-product.ts b/packages/medusa/src/api/routes/store/products/get-product.ts index 290120f4926e..b29dd6873d6f 100644 --- a/packages/medusa/src/api/routes/store/products/get-product.ts +++ b/packages/medusa/src/api/routes/store/products/get-product.ts @@ -10,7 +10,7 @@ import { } from "../../../../services" import { PriceSelectionParams } from "../../../../types/price-selection" import { FlagRouter } from "../../../../utils/flag-router" -import { validator } from "../../../../utils/validator" +import { cleanResponseData } from "../../../../utils/clean-response-data" /** * @oas [get] /products/{id} @@ -22,6 +22,8 @@ import { validator } from "../../../../utils/validator" * - (query) sales_channel_id {string} The sales channel used when fetching the product. * - (query) cart_id {string} The ID of the customer's cart. * - (query) region_id {string} The ID of the region the customer is using. This is helpful to ensure correct prices are retrieved for a region. + * - (query) fields {string} (Comma separated) Which fields should be included in the result. + * - (query) expand {string} (Comma separated) Which fields should be expanded in each product of the result. * - in: query * name: currency_code * style: form @@ -72,7 +74,7 @@ import { validator } from "../../../../utils/validator" export default async (req, res) => { const { id } = req.params - const validated = await validator(StoreGetProductsProductParams, req.query) + const validated = req.validatedQuery as StoreGetProductsProductParams const customer_id = req.user?.customer_id @@ -123,11 +125,21 @@ export default async (req, res) => { sales_channel_id ) - res.json({ product }) + res.json({ + product: cleanResponseData(product, req.allowedProperties || []), + }) } export class StoreGetProductsProductParams extends PriceSelectionParams { @IsString() @IsOptional() sales_channel_id?: string + + @IsString() + @IsOptional() + fields?: string + + @IsString() + @IsOptional() + expand?: string } diff --git a/packages/medusa/src/api/routes/store/products/index.ts b/packages/medusa/src/api/routes/store/products/index.ts index 3f9e570adfb8..784c50d9c63e 100644 --- a/packages/medusa/src/api/routes/store/products/index.ts +++ b/packages/medusa/src/api/routes/store/products/index.ts @@ -10,6 +10,7 @@ import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/pub import { validateProductSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-product-sales-channel-association" import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param" import { StoreGetProductsParams } from "./list-products" +import { StoreGetProductsProductParams } from "./get-product" const route = Router() @@ -29,11 +30,25 @@ export default (app, featureFlagRouter: FlagRouter) => { "/", transformQuery(StoreGetProductsParams, { defaultRelations: defaultStoreProductsRelations, + defaultFields: defaultStoreProductsFields, + allowedFields: allowedStoreProductsFields, + allowedRelations: allowedStoreProductsRelations, isList: true, }), middlewares.wrap(require("./list-products").default) ) - route.get("/:id", middlewares.wrap(require("./get-product").default)) + + route.get( + "/:id", + transformQuery(StoreGetProductsProductParams, { + defaultRelations: defaultStoreProductsRelations, + defaultFields: defaultStoreProductsFields, + allowedFields: allowedStoreProductsFields, + allowedRelations: allowedStoreProductsRelations, + }), + middlewares.wrap(require("./get-product").default) + ) + route.post("/search", middlewares.wrap(require("./search").default)) return app @@ -51,6 +66,47 @@ export const defaultStoreProductsRelations = [ "type", ] +export const defaultStoreProductsFields: (keyof Product)[] = [ + "id", + "title", + "subtitle", + "status", + "external_id", + "description", + "handle", + "is_giftcard", + "discountable", + "thumbnail", + "profile_id", + "collection_id", + "type_id", + "weight", + "length", + "height", + "width", + "hs_code", + "origin_country", + "mid_code", + "material", + "created_at", + "updated_at", + "deleted_at", + "metadata", +] + +export const allowedStoreProductsFields = [ + ...defaultStoreProductsFields, + // TODO: order prop validation + "variants.title", + "variants.prices.amount", +] + +export const allowedStoreProductsRelations = [ + ...defaultStoreProductsRelations, + "variants.title", + "variants.prices.amount", +] + export * from "./list-products" export * from "./search" diff --git a/packages/medusa/src/api/routes/store/products/list-products.ts b/packages/medusa/src/api/routes/store/products/list-products.ts index 4aea03d145ca..164b245bb997 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -22,6 +22,7 @@ import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" import { IsType } from "../../../../utils/validators/is-type" import { FlagRouter } from "../../../../utils/flag-router" import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/publishable-api-keys" +import { cleanResponseData } from "../../../../utils/clean-response-data" /** * @oas [get] /products @@ -137,8 +138,8 @@ import PublishableAPIKeysFeatureFlag from "../../../../loaders/feature-flags/pub * - (query) include_category_children {boolean} Include category children when filtering by category_id. * - (query) offset=0 {integer} How many products to skip in the result. * - (query) limit=100 {integer} Limit the number of products returned. - * - (query) expand {string} (Comma separated) Which fields should be expanded in each order of the result. - * - (query) fields {string} (Comma separated) Which fields should be included in each order of the result. + * - (query) expand {string} (Comma separated) Which fields should be expanded in each product of the result. + * - (query) fields {string} (Comma separated) Which fields should be included in each product of the result. * - (query) order {string} the field used to order the products. * - (query) cart_id {string} The id of the Cart to set prices based on. * - (query) region_id {string} The id of the Region to set prices based on. @@ -241,7 +242,7 @@ export default async (req, res) => { ) res.json({ - products, + products: cleanResponseData(products, req.allowedProperties || []), count, offset: validated.offset, limit: validated.limit, diff --git a/packages/medusa/src/types/global.ts b/packages/medusa/src/types/global.ts index 974334146931..9a81f7acc46f 100644 --- a/packages/medusa/src/types/global.ts +++ b/packages/medusa/src/types/global.ts @@ -16,6 +16,7 @@ declare global { listConfig: FindConfig retrieveConfig: FindConfig filterableFields: Record + allowedProperties: string[] errors: string[] } } diff --git a/packages/medusa/src/utils/clean-response-data.ts b/packages/medusa/src/utils/clean-response-data.ts new file mode 100644 index 000000000000..cca1002f7f69 --- /dev/null +++ b/packages/medusa/src/utils/clean-response-data.ts @@ -0,0 +1,21 @@ +import { pick } from "lodash" + +/** + * Filter response data to contain props specified in the fields array. + * + * @param data - record or an array of records in the response + * @param fields - record props allowed in the response + */ +function cleanResponseData(data: T, fields: string[]) { + if (!fields.length) { + return data + } + + if (Array.isArray(data)) { + return data.map((record) => pick(record, fields)) + } + + return pick(data, fields) +} + +export { cleanResponseData } From 4d6e63d68f4e64c365ecbba133876d95e6528763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Mon, 6 Feb 2023 17:32:26 +0100 Subject: [PATCH 8/8] feat(medusa): Decorate OrderEdit LineItems with totals (#3108) --- .changeset/tame-items-eat.md | 5 + .../order-edit/ff-tax-inclusive-pricing.js | 193 ++++++++++++++++++ .../admin/{ => order-edit}/order-edit.js | 33 ++- .../__tests__/request-confirmation.ts | 3 - .../admin/order-edits/request-confirmation.ts | 2 +- .../update-order-edit-line-item.ts | 27 ++- .../src/services/__mocks__/order-edit.js | 12 -- .../src/services/__tests__/order-edit.ts | 7 +- packages/medusa/src/services/order-edit.ts | 139 +++++-------- packages/medusa/src/services/totals.ts | 2 +- 10 files changed, 291 insertions(+), 132 deletions(-) create mode 100644 .changeset/tame-items-eat.md create mode 100644 integration-tests/api/__tests__/admin/order-edit/ff-tax-inclusive-pricing.js rename integration-tests/api/__tests__/admin/{ => order-edit}/order-edit.js (99%) diff --git a/.changeset/tame-items-eat.md b/.changeset/tame-items-eat.md new file mode 100644 index 000000000000..fefdbe7c5633 --- /dev/null +++ b/.changeset/tame-items-eat.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): decorate order edit line items with totals diff --git a/integration-tests/api/__tests__/admin/order-edit/ff-tax-inclusive-pricing.js b/integration-tests/api/__tests__/admin/order-edit/ff-tax-inclusive-pricing.js new file mode 100644 index 000000000000..2bbb39b924bf --- /dev/null +++ b/integration-tests/api/__tests__/admin/order-edit/ff-tax-inclusive-pricing.js @@ -0,0 +1,193 @@ +const path = require("path") +const { IdMap } = require("medusa-test-utils") + +const startServerWithEnvironment = + require("../../../../helpers/start-server-with-environment").default +const { useApi } = require("../../../../helpers/use-api") +const { useDb } = require("../../../../helpers/use-db") + +const adminSeeder = require("../../../helpers/admin-seeder") + +const { + simpleProductFactory, + simpleRegionFactory, + simpleCartFactory, +} = require("../../../factories") + +jest.setTimeout(30000) + +const adminReqConfig = { + headers: { + Authorization: "Bearer test_token", + }, +} + +describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/order-edits", () => { + let medusaProcess + let dbConnection + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + const [process, connection] = await startServerWithEnvironment({ + cwd, + env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true }, + }) + dbConnection = connection + medusaProcess = process + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + + medusaProcess.kill() + }) + + describe("Items totals", () => { + let product1 + const prodId1 = IdMap.getId("prodId1") + const lineItemId1 = IdMap.getId("line-item-1") + + beforeEach(async () => { + await adminSeeder(dbConnection) + + product1 = await simpleProductFactory(dbConnection, { + id: prodId1, + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("decorates items with (tax-inclusive) totals", async () => { + const taxInclusiveRegion = await simpleRegionFactory(dbConnection, { + tax_rate: 25, + includes_tax: true, + }) + + const taxInclusiveCart = await simpleCartFactory(dbConnection, { + email: "adrien@test.com", + region: taxInclusiveRegion.id, + line_items: [ + { + id: lineItemId1, + variant_id: product1.variants[0].id, + quantity: 2, + unit_price: 10000, + includes_tax: true, + }, + ], + }) + + const api = useApi() + + await api.post(`/store/carts/${taxInclusiveCart.id}/payment-sessions`) + + const completeRes = await api.post( + `/store/carts/${taxInclusiveCart.id}/complete` + ) + + const order = completeRes.data.data + + const response = await api.post( + `/admin/order-edits/`, + { + order_id: order.id, + internal_note: "This is an internal note", + }, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data.order_edit).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + // 2x items | unit_price: 10000 (tax incl.) | 25% tax + original_item_id: lineItemId1, + subtotal: 2 * 8000, + discount_total: 0, + total: 2 * 10000, + unit_price: 10000, + original_total: 2 * 10000, + original_tax_total: 2 * 2000, + tax_total: 2 * 2000, + }), + ]), + discount_total: 0, + gift_card_total: 0, + gift_card_tax_total: 0, + shipping_total: 0, + subtotal: 16000, + tax_total: 4000, + total: 20000, + difference_due: 0, + }) + ) + }) + + it("decorates items with (tax-exclusive) totals", async () => { + const taxInclusiveRegion = await simpleRegionFactory(dbConnection, { + tax_rate: 25, + }) + + const cart = await simpleCartFactory(dbConnection, { + email: "adrien@test.com", + region: taxInclusiveRegion.id, + line_items: [ + { + id: lineItemId1, + variant_id: product1.variants[0].id, + quantity: 2, + unit_price: 10000, + }, + ], + }) + + const api = useApi() + + await api.post(`/store/carts/${cart.id}/payment-sessions`) + + const completeRes = await api.post(`/store/carts/${cart.id}/complete`) + + const order = completeRes.data.data + + const response = await api.post( + `/admin/order-edits/`, + { + order_id: order.id, + internal_note: "This is an internal note", + }, + adminReqConfig + ) + + expect(response.status).toEqual(200) + expect(response.data.order_edit).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + original_item_id: lineItemId1, + subtotal: 2 * 10000, + discount_total: 0, + unit_price: 10000, + total: 2 * 10000 + 2 * 2500, + original_total: 2 * 10000 + 2 * 2500, + original_tax_total: 2 * 2500, + tax_total: 2 * 2500, + }), + ]), + discount_total: 0, + gift_card_total: 0, + gift_card_tax_total: 0, + shipping_total: 0, + subtotal: 20000, + tax_total: 5000, + total: 25000, + difference_due: 0, + }) + ) + }) + }) +}) diff --git a/integration-tests/api/__tests__/admin/order-edit.js b/integration-tests/api/__tests__/admin/order-edit/order-edit.js similarity index 99% rename from integration-tests/api/__tests__/admin/order-edit.js rename to integration-tests/api/__tests__/admin/order-edit/order-edit.js index 2f96138cbaff..9f8a232abae0 100644 --- a/integration-tests/api/__tests__/admin/order-edit.js +++ b/integration-tests/api/__tests__/admin/order-edit/order-edit.js @@ -1,17 +1,16 @@ const path = require("path") +const { OrderEditItemChangeType, OrderEdit } = require("@medusajs/medusa") +const { IdMap } = require("medusa-test-utils") -const startServerWithEnvironment = - require("../../../helpers/start-server-with-environment").default -const { useApi } = require("../../../helpers/use-api") -const { useDb, initDb } = require("../../../helpers/use-db") -const adminSeeder = require("../../helpers/admin-seeder") +const { useApi } = require("../../../../helpers/use-api") +const { useDb, initDb } = require("../../../../helpers/use-db") +const adminSeeder = require("../../../helpers/admin-seeder") const { simpleOrderEditFactory, -} = require("../../factories/simple-order-edit-factory") -const { IdMap } = require("medusa-test-utils") +} = require("../../../factories/simple-order-edit-factory") const { simpleOrderItemChangeFactory, -} = require("../../factories/simple-order-item-change-factory") +} = require("../../../factories/simple-order-item-change-factory") const { simpleLineItemFactory, simpleProductFactory, @@ -19,9 +18,8 @@ const { simpleDiscountFactory, simpleCartFactory, simpleRegionFactory, -} = require("../../factories") -const { OrderEditItemChangeType, OrderEdit } = require("@medusajs/medusa") -const setupServer = require("../../../helpers/setup-server") +} = require("../../../factories") +const setupServer = require("../../../../helpers/setup-server") jest.setTimeout(30000) @@ -37,11 +35,9 @@ describe("/admin/order-edits", () => { const adminUserId = "admin_user" beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")) + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ - cwd, - }) + medusaProcess = await setupServer({ cwd }) }) afterAll(async () => { @@ -1167,6 +1163,7 @@ describe("/admin/order-edits", () => { id: orderId1, fulfillment_status: "fulfilled", payment_status: "captured", + tax_rate: null, region: { id: "test-region", name: "Test region", @@ -2572,13 +2569,15 @@ describe("/admin/order-edits", () => { ]), }), ]), - discount_total: 2000, + // rounding issue since we are allocating 1/3 of the discount to one item and 2/3 to the other item where both cost 10 + // resulting in adjustment amounts like: 1333... + discount_total: 2001, + total: 1099, gift_card_total: 0, gift_card_tax_total: 0, shipping_total: 0, subtotal: 3000, tax_total: 100, - total: 1100, }) ) diff --git a/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts b/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts index f3a1f849438a..a1f3ffca482a 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/__tests__/request-confirmation.ts @@ -2,7 +2,6 @@ import { IdMap } from "medusa-test-utils" import { request } from "../../../../../helpers/test-request" import { orderEditServiceMock } from "../../../../../services/__mocks__/order-edit" - describe("GET /admin/order-edits/:id", () => { describe("successfully requests an order edit confirmation", () => { const orderEditId = IdMap.getId("testRequestOrder") @@ -35,8 +34,6 @@ describe("GET /admin/order-edits/:id", () => { orderEditId, { requestedBy: IdMap.getId("admin_user") } ) - - expect(orderEditServiceMock.update).toHaveBeenCalledTimes(1) }) it("returns updated orderEdit", () => { diff --git a/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts b/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts index 497babbef54c..b33901ec4982 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/request-confirmation.ts @@ -84,7 +84,7 @@ export default async (req, res) => { requestedBy: loggedInUser, }) - const total = await orderEditServiceTx.getTotals(orderEdit.id) + const total = await orderEditServiceTx.decorateTotals(orderEdit) if (total.difference_due > 0) { const order = await orderService diff --git a/packages/medusa/src/api/routes/admin/order-edits/update-order-edit-line-item.ts b/packages/medusa/src/api/routes/admin/order-edits/update-order-edit-line-item.ts index 0aa07fa9dc53..9ba144f3d0df 100644 --- a/packages/medusa/src/api/routes/admin/order-edits/update-order-edit-line-item.ts +++ b/packages/medusa/src/api/routes/admin/order-edits/update-order-edit-line-item.ts @@ -79,20 +79,25 @@ export default async (req: Request, res: Response) => { const manager: EntityManager = req.scope.resolve("manager") - await manager.transaction(async (transactionManager) => { - await orderEditService - .withTransaction(transactionManager) - .updateLineItem(id, item_id, validatedBody) - }) + const decoratedEdit = await manager.transaction( + async (transactionManager) => { + const orderEditTx = orderEditService.withTransaction(transactionManager) - let orderEdit = await orderEditService.retrieve(id, { - select: defaultOrderEditFields, - relations: defaultOrderEditRelations, - }) - orderEdit = await orderEditService.decorateTotals(orderEdit) + await orderEditTx.updateLineItem(id, item_id, validatedBody) + + const orderEdit = await orderEditTx.retrieve(id, { + select: defaultOrderEditFields, + relations: defaultOrderEditRelations, + }) + + await orderEditTx.decorateTotals(orderEdit) + + return orderEdit + } + ) res.status(200).send({ - order_edit: orderEdit, + order_edit: decoratedEdit, }) } diff --git a/packages/medusa/src/services/__mocks__/order-edit.js b/packages/medusa/src/services/__mocks__/order-edit.js index 0aa055bd41bc..fedf0787ce1e 100644 --- a/packages/medusa/src/services/__mocks__/order-edit.js +++ b/packages/medusa/src/services/__mocks__/order-edit.js @@ -117,18 +117,6 @@ export const orderEditServiceMock = { declined_at: new Date(), }) }), - getTotals: jest.fn().mockImplementation((id) => { - return Promise.resolve({ - shipping_total: 10, - gift_card_total: 0, - gift_card_tax_total: 0, - discount_total: 0, - tax_total: 1, - subtotal: 2000, - difference_due: 1000, - total: 1000, - }) - }), delete: jest.fn().mockImplementation((_) => { return Promise.resolve() }), diff --git a/packages/medusa/src/services/__tests__/order-edit.ts b/packages/medusa/src/services/__tests__/order-edit.ts index ea0d0ca6c3d9..8e3fcf0c6466 100644 --- a/packages/medusa/src/services/__tests__/order-edit.ts +++ b/packages/medusa/src/services/__tests__/order-edit.ts @@ -3,11 +3,12 @@ import { OrderEditItemChangeType, OrderEditStatus } from "../../models" import { EventBusService, LineItemService, + NewTotalsService, OrderEditItemChangeService, OrderEditService, OrderService, TaxProviderService, - TotalsService + TotalsService, } from "../index" import LineItemAdjustmentService from "../line-item-adjustment" import { EventBusServiceMock } from "../__mocks__/event-bus" @@ -17,6 +18,7 @@ import { OrderServiceMock } from "../__mocks__/order" import { orderEditItemChangeServiceMock } from "../__mocks__/order-edit-item-change" import { taxProviderServiceMock } from "../__mocks__/tax-provider" import { TotalsServiceMock } from "../__mocks__/totals" +import NewTotalsServiceMock from "../__mocks__/new-totals" const orderEditToUpdate = { id: IdMap.getId("order-edit-to-update"), @@ -188,6 +190,7 @@ describe("OrderEditService", () => { orderService: OrderServiceMock as unknown as OrderService, eventBusService: EventBusServiceMock as unknown as EventBusService, totalsService: TotalsServiceMock as unknown as TotalsService, + newTotalsService: NewTotalsServiceMock as unknown as NewTotalsService, lineItemService: lineItemServiceMock as unknown as LineItemService, orderEditItemChangeService: orderEditItemChangeServiceMock as unknown as OrderEditItemChangeService, @@ -330,7 +333,7 @@ describe("OrderEditService", () => { let result beforeEach(async () => { - jest.spyOn(orderEditService, "getTotals").mockResolvedValue({ + jest.spyOn(orderEditService, "decorateTotals").mockResolvedValue({ difference_due: 1500, } as any) diff --git a/packages/medusa/src/services/order-edit.ts b/packages/medusa/src/services/order-edit.ts index 34da0fc6f6e2..25e60e24d7e5 100644 --- a/packages/medusa/src/services/order-edit.ts +++ b/packages/medusa/src/services/order-edit.ts @@ -7,23 +7,24 @@ import { Order, OrderEdit, OrderEditItemChangeType, - OrderEditStatus + OrderEditStatus, } from "../models" import { OrderEditRepository } from "../repositories/order-edit" import { FindConfig, Selector } from "../types/common" import { AddOrderEditLineItemInput, - CreateOrderEditInput + CreateOrderEditInput, } from "../types/order-edit" import { buildQuery, isString } from "../utils" import { EventBusService, LineItemAdjustmentService, LineItemService, + NewTotalsService, OrderEditItemChangeService, OrderService, TaxProviderService, - TotalsService + TotalsService, } from "./index" type InjectedDependencies = { @@ -32,6 +33,7 @@ type InjectedDependencies = { orderService: OrderService totalsService: TotalsService + newTotalsService: NewTotalsService lineItemService: LineItemService eventBusService: EventBusService taxProviderService: TaxProviderService @@ -56,6 +58,7 @@ export default class OrderEditService extends TransactionBaseService { protected readonly orderService_: OrderService protected readonly totalsService_: TotalsService + protected readonly newTotalsService_: NewTotalsService protected readonly lineItemService_: LineItemService protected readonly eventBusService_: EventBusService protected readonly taxProviderService_: TaxProviderService @@ -69,6 +72,7 @@ export default class OrderEditService extends TransactionBaseService { lineItemService, eventBusService, totalsService, + newTotalsService, orderEditItemChangeService, lineItemAdjustmentService, taxProviderService, @@ -82,6 +86,7 @@ export default class OrderEditService extends TransactionBaseService { this.lineItemService_ = lineItemService this.eventBusService_ = eventBusService this.totalsService_ = totalsService + this.newTotalsService_ = newTotalsService this.orderEditItemChangeService_ = orderEditItemChangeService this.lineItemAdjustmentService_ = lineItemAdjustmentService this.taxProviderService_ = taxProviderService @@ -148,69 +153,6 @@ export default class OrderEditService extends TransactionBaseService { return orderEdits } - /** - * Compute and return the different totals from the order edit id - * @param orderEditId - */ - async getTotals(orderEditId: string): Promise<{ - shipping_total: number - gift_card_total: number - gift_card_tax_total: number - discount_total: number - tax_total: number | null - subtotal: number - difference_due: number - total: number - }> { - const manager = this.transactionManager_ ?? this.manager_ - const { order_id, items } = await this.retrieve(orderEditId, { - select: ["id", "order_id", "items"], - relations: ["items", "items.tax_lines", "items.adjustments"], - }) - - const order = await this.orderService_ - .withTransaction(manager) - .retrieve(order_id, { - relations: [ - "discounts", - "discounts.rule", - "gift_cards", - "region", - "items", - "items.tax_lines", - "items.adjustments", - "region.tax_rates", - "shipping_methods", - "shipping_methods.tax_lines", - ], - }) - const computedOrder = { ...order, items } as Order - - const totalsServiceTx = this.totalsService_.withTransaction(manager) - - const shipping_total = await totalsServiceTx.getShippingTotal(computedOrder) - const { total: gift_card_total, tax_total: gift_card_tax_total } = - await totalsServiceTx.getGiftCardTotal(computedOrder) - const discount_total = await totalsServiceTx.getDiscountTotal(computedOrder) - const tax_total = await totalsServiceTx.getTaxTotal(computedOrder) - const subtotal = await totalsServiceTx.getSubtotal(computedOrder) - const total = await totalsServiceTx.getTotal(computedOrder) - - const orderTotal = await totalsServiceTx.getTotal(order) - const difference_due = total - orderTotal - - return { - shipping_total, - gift_card_total, - gift_card_tax_total, - discount_total, - tax_total, - subtotal, - total, - difference_due, - } - } - async create( data: CreateOrderEditInput, context: { createdBy: string } @@ -390,11 +332,11 @@ export default class OrderEditService extends TransactionBaseService { ) } - const lineItem = await this.lineItemService_ - .withTransaction(manager) - .retrieve(itemId, { - select: ["id", "order_edit_id", "original_item_id"], - }) + const lineItemServiceTx = this.lineItemService_.withTransaction(manager) + + const lineItem = await lineItemServiceTx.retrieve(itemId, { + select: ["id", "order_edit_id", "original_item_id"], + }) if (lineItem.order_edit_id !== orderEditId) { throw new MedusaError( @@ -427,11 +369,9 @@ export default class OrderEditService extends TransactionBaseService { }) } - await this.lineItemService_ - .withTransaction(manager) - .update(change.line_item_id!, { - quantity: data.quantity, - }) + await lineItemServiceTx.update(change.line_item_id!, { + quantity: data.quantity, + }) await this.refreshAdjustments(orderEditId) }) @@ -538,15 +478,44 @@ export default class OrderEditService extends TransactionBaseService { } async decorateTotals(orderEdit: OrderEdit): Promise { - const totals = await this.getTotals(orderEdit.id) - orderEdit.discount_total = totals.discount_total - orderEdit.gift_card_total = totals.gift_card_total - orderEdit.gift_card_tax_total = totals.gift_card_tax_total - orderEdit.shipping_total = totals.shipping_total - orderEdit.subtotal = totals.subtotal - orderEdit.tax_total = totals.tax_total - orderEdit.total = totals.total - orderEdit.difference_due = totals.difference_due + const manager = this.transactionManager_ ?? this.manager_ + const { order_id, items } = await this.retrieve(orderEdit.id, { + select: ["id", "order_id", "items"], + relations: ["items", "items.tax_lines", "items.adjustments"], + }) + + const orderServiceTx = this.orderService_.withTransaction(manager) + + const order = await orderServiceTx.retrieve(order_id, { + relations: [ + "discounts", + "discounts.rule", + "gift_cards", + "region", + "items", + "items.tax_lines", + "items.adjustments", + "region.tax_rates", + "shipping_methods", + "shipping_methods.tax_lines", + ], + }) + + const computedOrder = { ...order, items } as Order + await Promise.all([ + await orderServiceTx.decorateTotals(computedOrder), + await orderServiceTx.decorateTotals(order), + ]) + + orderEdit.items = computedOrder.items + orderEdit.discount_total = computedOrder.discount_total + orderEdit.gift_card_total = computedOrder.gift_card_total + orderEdit.gift_card_tax_total = computedOrder.gift_card_tax_total + orderEdit.shipping_total = computedOrder.shipping_total + orderEdit.subtotal = computedOrder.subtotal + orderEdit.tax_total = computedOrder.tax_total + orderEdit.total = computedOrder.total + orderEdit.difference_due = computedOrder.total - order.total return orderEdit } diff --git a/packages/medusa/src/services/totals.ts b/packages/medusa/src/services/totals.ts index 893c3dd508d2..62e89d6803b5 100644 --- a/packages/medusa/src/services/totals.ts +++ b/packages/medusa/src/services/totals.ts @@ -129,7 +129,7 @@ class TotalsService extends TransactionBaseService { } /** - * Calculates subtotal of a given cart or order. + * Calculates total of a given cart or order. * @param cartOrOrder - object to calculate total for * @param options - options to calculate by * @return the calculated subtotal