From b9680b641f2984eddbc1f49a37c050499fbaff69 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Fri, 30 Dec 2022 11:30:04 +0100 Subject: [PATCH] feat(medusa): Add ProductVariantInventoryService (#2883) * add mw feature flag * add services * add types * add module interfaces * add interface export * add models for mw * to be ammended * remove featureflag * use correct count * update cart completion strategy * swap service conversion * update return service * update order service * update claim service * add exception to claim item * update cart service * add indicies * add changeset * nullable changes in store * store model update * fix unit tests * remove old inventory service * format integration test * update snapshots * remove old inventory service tests * update snapshots * remove old code * option updates * naming * add jsdoc to pv inventory service * rename class variables * pr feedback * rename option to context * if(variant_id) instead of if(typeof varia...) * update tests * add jsdoc * go for custom * update code for readability --- .changeset/wise-mangos-design.md | 8 + .../admin/__snapshots__/store.js.snap | 4 + .../store/__snapshots__/swaps.js.snap | 2 + .../api/__tests__/store/cart/cart.js | 53 +-- .../__snapshots__/index.js.snap | 5 + packages/medusa/src/index.js | 4 + .../medusa/src/interfaces/services/index.ts | 2 + .../src/interfaces/services/inventory.ts | 94 ++++ .../src/interfaces/services/stock-location.ts | 26 ++ .../1671711415179-multi_location.ts | 45 ++ packages/medusa/src/models/index.ts | 2 + packages/medusa/src/models/line-item.ts | 2 +- .../models/product-variant-inventory-item.ts | 23 + packages/medusa/src/models/return.ts | 4 + .../src/models/sales-channel-location.ts | 20 + packages/medusa/src/models/store.ts | 3 + .../src/repositories/product-variant.ts | 2 +- .../src/services/__mocks__/inventory.js | 20 - .../__mocks__/product-variant-inventory.js | 26 ++ .../medusa/src/services/__tests__/cart.js | 123 +++-- .../src/services/__tests__/claim-item.js | 26 +- .../medusa/src/services/__tests__/claim.js | 36 +- .../src/services/__tests__/inventory.js | 91 ---- .../medusa/src/services/__tests__/order.js | 104 ++--- .../medusa/src/services/__tests__/return.js | 220 ++------- .../medusa/src/services/__tests__/swap.ts | 45 +- packages/medusa/src/services/cart.ts | 143 ++++-- packages/medusa/src/services/claim-item.ts | 7 + packages/medusa/src/services/claim.ts | 41 +- packages/medusa/src/services/index.ts | 4 +- packages/medusa/src/services/inventory.ts | 100 ---- packages/medusa/src/services/order.ts | 58 +-- .../src/services/product-variant-inventory.ts | 429 ++++++++++++++++++ packages/medusa/src/services/return.ts | 18 +- .../src/services/sales-channel-inventory.ts | 54 +++ .../src/services/sales-channel-location.ts | 95 ++++ packages/medusa/src/services/swap.ts | 63 ++- .../strategies/__tests__/cart-completion.js | 13 +- .../medusa/src/strategies/cart-completion.ts | 103 ++++- packages/medusa/src/types/inventory.ts | 108 +++++ packages/medusa/src/types/stock-location.ts | 48 ++ packages/medusa/src/utils/index.ts | 1 + 42 files changed, 1514 insertions(+), 761 deletions(-) create mode 100644 .changeset/wise-mangos-design.md create mode 100644 packages/medusa/src/interfaces/services/inventory.ts create mode 100644 packages/medusa/src/interfaces/services/stock-location.ts create mode 100644 packages/medusa/src/migrations/1671711415179-multi_location.ts create mode 100644 packages/medusa/src/models/product-variant-inventory-item.ts create mode 100644 packages/medusa/src/models/sales-channel-location.ts delete mode 100644 packages/medusa/src/services/__mocks__/inventory.js create mode 100644 packages/medusa/src/services/__mocks__/product-variant-inventory.js delete mode 100644 packages/medusa/src/services/__tests__/inventory.js delete mode 100644 packages/medusa/src/services/inventory.ts create mode 100644 packages/medusa/src/services/product-variant-inventory.ts create mode 100644 packages/medusa/src/services/sales-channel-inventory.ts create mode 100644 packages/medusa/src/services/sales-channel-location.ts create mode 100644 packages/medusa/src/types/inventory.ts create mode 100644 packages/medusa/src/types/stock-location.ts diff --git a/.changeset/wise-mangos-design.md b/.changeset/wise-mangos-design.md new file mode 100644 index 0000000000..bdb97571ea --- /dev/null +++ b/.changeset/wise-mangos-design.md @@ -0,0 +1,8 @@ +--- +"@medusajs/medusa": patch +--- + +Add services: +- `sales-channel-inventory` +- `sales-channel-location` +- `product-variant-inventory` diff --git a/integration-tests/api/__tests__/admin/__snapshots__/store.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/store.js.snap index 6510ccfb6f..4e595002ff 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/store.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/store.js.snap @@ -25,6 +25,7 @@ Object { }, ], "default_currency_code": "usd", + "default_location_id": null, "id": Any, "invite_link_template": null, "metadata": null, @@ -59,6 +60,7 @@ Object { "symbol_native": "¥", }, "default_currency_code": "jpy", + "default_location_id": null, "id": Any, "invite_link_template": null, "metadata": null, @@ -93,6 +95,7 @@ Object { "symbol_native": "kr", }, "default_currency_code": "dkk", + "default_location_id": null, "id": Any, "invite_link_template": null, "metadata": null, @@ -121,6 +124,7 @@ Object { "symbol_native": "$", }, "default_currency_code": "usd", + "default_location_id": null, "feature_flags": Any, "fulfillment_providers": Array [ Object { diff --git a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap index f1397e5121..6e256c0499 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap @@ -157,6 +157,7 @@ Object { "return_id": StringMatching /\\^ret_\\*/, }, ], + "location_id": null, "metadata": null, "no_notification": true, "order_id": null, @@ -333,6 +334,7 @@ Object { "return_id": StringMatching /\\^ret_\\*/, }, ], + "location_id": null, "metadata": null, "no_notification": true, "order_id": null, diff --git a/integration-tests/api/__tests__/store/cart/cart.js b/integration-tests/api/__tests__/store/cart/cart.js index a27273dc03..9a6ac4f8c1 100644 --- a/integration-tests/api/__tests__/store/cart/cart.js +++ b/integration-tests/api/__tests__/store/cart/cart.js @@ -1055,9 +1055,13 @@ describe("/store/carts", () => { regions: ["test-region"], } - const cartId = "discount-cart" + const cartId = "discount-cart" - const discount = await simpleDiscountFactory(dbConnection, discountData, 100) + const discount = await simpleDiscountFactory( + dbConnection, + discountData, + 100 + ) const discountCart = await simpleCartFactory( dbConnection, { @@ -1088,14 +1092,10 @@ describe("/store/carts", () => { const api = useApi() - let response = await api - .post( - `/store/carts/${cartId}/line-items`, - { - quantity: 1, - variant_id: "test-variant-quantity", - }, - ) + let response = await api.post(`/store/carts/${cartId}/line-items`, { + quantity: 1, + variant_id: "test-variant-quantity", + }) expect(response.data.cart.items.length).toEqual(1) expect(response.data.cart.items).toEqual( @@ -1111,13 +1111,9 @@ describe("/store/carts", () => { ]) ) - response = await api - .post( - `/store/carts/${cartId}`, - { - discounts: [], - }, - ) + response = await api.post(`/store/carts/${cartId}`, { + discounts: [], + }) expect(response.data.cart.items.length).toEqual(1) expect(response.data.cart.items[0].adjustments).toHaveLength(0) @@ -2201,7 +2197,11 @@ describe("/store/carts", () => { it("removes line item adjustments upon discount deletion", async () => { const cartId = "discount-cart" - const discount = await simpleDiscountFactory(dbConnection, discountData, 100) + const discount = await simpleDiscountFactory( + dbConnection, + discountData, + 100 + ) const discountCart = await simpleCartFactory( dbConnection, { @@ -2232,14 +2232,10 @@ describe("/store/carts", () => { const api = useApi() - let response = await api - .post( - `/store/carts/${cartId}/line-items`, - { - quantity: 1, - variant_id: "test-variant-quantity", - }, - ) + let response = await api.post(`/store/carts/${cartId}/line-items`, { + quantity: 1, + variant_id: "test-variant-quantity", + }) expect(response.data.cart.items.length).toEqual(1) expect(response.data.cart.items).toEqual( @@ -2255,8 +2251,9 @@ describe("/store/carts", () => { ]) ) - response = await api - .delete(`/store/carts/${cartId}/discounts/${discountData.code}`) + response = await api.delete( + `/store/carts/${cartId}/discounts/${discountData.code}` + ) expect(response.data.cart.items.length).toEqual(1) expect(response.data.cart.items[0].adjustments).toHaveLength(0) 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 0f2cb0fe6a..850d4f39ca 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 @@ -566,6 +566,7 @@ Object { "return_id": Any, }, ], + "location_id": null, "metadata": null, "no_notification": null, "order_id": Any, @@ -709,6 +710,7 @@ Object { "return_id": Any, }, ], + "location_id": null, "metadata": null, "no_notification": null, "order_id": Any, @@ -1737,6 +1739,7 @@ Object { "return_id": Any, }, ], + "location_id": null, "metadata": null, "no_notification": null, "order_id": Any, @@ -1880,6 +1883,7 @@ Object { "return_id": Any, }, ], + "location_id": null, "metadata": null, "no_notification": null, "order_id": Any, @@ -2400,6 +2404,7 @@ Object { "return_id": Any, }, ], + "location_id": null, "metadata": null, "no_notification": null, "order_id": null, diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index d9012c50b5..e92a3faca5 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -1,7 +1,11 @@ export * from "./api" export * from "./interfaces" +export * from "./types/inventory" +export * from "./types/stock-location" +export * from "./types/common" export * from "./types/price-list" export * from "./types/batch-job" +export * from "./types/global" export * from "./models" export * from "./services" export * from "./utils" diff --git a/packages/medusa/src/interfaces/services/index.ts b/packages/medusa/src/interfaces/services/index.ts index 202c17d92e..33f7e70a3d 100644 --- a/packages/medusa/src/interfaces/services/index.ts +++ b/packages/medusa/src/interfaces/services/index.ts @@ -1 +1,3 @@ export * from "./cache" +export * from "./stock-location" +export * from "./inventory" diff --git a/packages/medusa/src/interfaces/services/inventory.ts b/packages/medusa/src/interfaces/services/inventory.ts new file mode 100644 index 0000000000..b1be137bf5 --- /dev/null +++ b/packages/medusa/src/interfaces/services/inventory.ts @@ -0,0 +1,94 @@ +import { FindConfig } from "../../types/common" + +import { + InventoryItemDTO, + ReservationItemDTO, + InventoryLevelDTO, + FilterableInventoryItemProps, + CreateInventoryItemInput, + CreateReservationItemInput, + FilterableInventoryLevelProps, + FilterableReservationItemProps, + CreateInventoryLevelInput, + UpdateInventoryLevelInput, +} from "../../types/inventory" + +export interface IInventoryService { + listInventoryItems( + selector: FilterableInventoryItemProps, + config?: FindConfig + ): Promise<[InventoryItemDTO[], number]> + + listReservationItems( + selector: FilterableReservationItemProps, + config?: FindConfig + ): Promise<[ReservationItemDTO[], number]> + + listInventoryLevels( + selector: FilterableInventoryLevelProps, + config?: FindConfig + ): Promise<[InventoryLevelDTO[], number]> + + retrieveInventoryItem( + itemId: string, + config?: FindConfig + ): Promise + + retrieveInventoryLevel( + itemId: string, + locationId: string + ): Promise + + createReservationItem( + input: CreateReservationItemInput + ): Promise + + createInventoryItem( + input: CreateInventoryItemInput + ): Promise + + createInventoryLevel( + data: CreateInventoryLevelInput + ): Promise + + updateInventoryLevel( + itemId: string, + locationId: string, + update: UpdateInventoryLevelInput + ): Promise + + updateInventoryItem( + itemId: string, + input: CreateInventoryItemInput + ): Promise + + deleteReservationItemsByLineItem(lineItemId: string): Promise + + deleteReservationItem(id: string): Promise + + deleteInventoryItem(itemId: string): Promise + + deleteInventoryLevel(itemId: string, locationId: string): Promise + + adjustInventory( + itemId: string, + locationId: string, + adjustment: number + ): Promise + + confirmInventory( + itemId: string, + locationIds: string[], + quantity: number + ): Promise + + retrieveAvailableQuantity( + itemId: string, + locationIds: string[] + ): Promise + + retrieveStockedQuantity( + itemId: string, + locationIds: string[] + ): Promise +} diff --git a/packages/medusa/src/interfaces/services/stock-location.ts b/packages/medusa/src/interfaces/services/stock-location.ts new file mode 100644 index 0000000000..995dd531ef --- /dev/null +++ b/packages/medusa/src/interfaces/services/stock-location.ts @@ -0,0 +1,26 @@ +import { FindConfig } from "../../types/common" + +import { + StockLocationDTO, + FilterableStockLocationProps, + CreateStockLocationInput, + UpdateStockLocationInput, +} from "../../types/stock-location" + +export interface IStockLocationService { + list( + selector: FilterableStockLocationProps, + config?: FindConfig + ): Promise + + listAndCount( + selector: FilterableStockLocationProps, + config?: FindConfig + ): Promise<[StockLocationDTO[], number]> + + retrieve(id: string): Promise + + create(input: CreateStockLocationInput): Promise + + update(id: string, input: UpdateStockLocationInput): Promise +} diff --git a/packages/medusa/src/migrations/1671711415179-multi_location.ts b/packages/medusa/src/migrations/1671711415179-multi_location.ts new file mode 100644 index 0000000000..7de1686884 --- /dev/null +++ b/packages/medusa/src/migrations/1671711415179-multi_location.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class multiLocation1671711415179 implements MigrationInterface { + name = "multiLocation1666251508718" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "sales_channel_location" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "sales_channel_id" text NOT NULL, "location_id" text NOT NULL, CONSTRAINT "PK_afd2c2c52634bc8280a9c9ee533" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_6caaa358f12ed0b846f00e2dcd" ON "sales_channel_location" ("sales_channel_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_c2203162ca946a71aeb98390b0" ON "sales_channel_location" ("location_id") ` + ) + await queryRunner.query( + `CREATE TABLE "product_variant_inventory_item" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "inventory_item_id" text NOT NULL, "variant_id" text NOT NULL, "quantity" integer NOT NULL DEFAULT '1', CONSTRAINT "UQ_c9be7c1b11a1a729eb51d1b6bca" UNIQUE ("variant_id", "inventory_item_id"), CONSTRAINT "PK_9a1188b8d36f4d198303b4f7efa" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_c74e8c2835094a37dead376a3b" ON "product_variant_inventory_item" ("inventory_item_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_bf5386e7f2acc460adbf96d6f3" ON "product_variant_inventory_item" ("variant_id") ` + ) + await queryRunner.query( + `ALTER TABLE "return" ADD "location_id" character varying` + ) + await queryRunner.query( + `ALTER TABLE "store" ADD "default_location_id" character varying` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "store" DROP COLUMN "default_location_id"` + ) + await queryRunner.query(`ALTER TABLE "return" DROP COLUMN "location_id"`) + await queryRunner.query(`DROP INDEX "IDX_bf5386e7f2acc460adbf96d6f3"`) + await queryRunner.query(`DROP INDEX "IDX_c74e8c2835094a37dead376a3b"`) + await queryRunner.query(`DROP TABLE "product_variant_inventory_item"`) + await queryRunner.query(`DROP INDEX "IDX_c2203162ca946a71aeb98390b0"`) + await queryRunner.query(`DROP INDEX "IDX_6caaa358f12ed0b846f00e2dcd"`) + await queryRunner.query(`DROP TABLE "sales_channel_location"`) + } +} diff --git a/packages/medusa/src/models/index.ts b/packages/medusa/src/models/index.ts index 632455f271..454fe565d1 100644 --- a/packages/medusa/src/models/index.ts +++ b/packages/medusa/src/models/index.ts @@ -52,6 +52,7 @@ export * from "./product-tax-rate" export * from "./product-type" export * from "./product-type-tax-rate" export * from "./product-variant" +export * from "./product-variant-inventory-item" export * from "./publishable-api-key" export * from "./publishable-api-key-sales-channel" export * from "./refund" @@ -60,6 +61,7 @@ export * from "./return" export * from "./return-item" export * from "./return-reason" export * from "./sales-channel" +export * from "./sales-channel-location" export * from "./shipping-method" export * from "./shipping-method-tax-line" export * from "./shipping-option" diff --git a/packages/medusa/src/models/line-item.ts b/packages/medusa/src/models/line-item.ts index 9de9997fd5..1632a347a9 100644 --- a/packages/medusa/src/models/line-item.ts +++ b/packages/medusa/src/models/line-item.ts @@ -134,7 +134,7 @@ export class LineItem extends BaseEntity { @Index() @Column({ nullable: true }) - variant_id: string + variant_id: string | null @ManyToOne(() => ProductVariant, { eager: true }) @JoinColumn({ name: "variant_id" }) diff --git a/packages/medusa/src/models/product-variant-inventory-item.ts b/packages/medusa/src/models/product-variant-inventory-item.ts new file mode 100644 index 0000000000..387bb46299 --- /dev/null +++ b/packages/medusa/src/models/product-variant-inventory-item.ts @@ -0,0 +1,23 @@ +import { Index, Unique, BeforeInsert, Column, Entity } from "typeorm" +import { BaseEntity } from "../interfaces/models/base-entity" +import { DbAwareColumn, generateEntityId } from "../utils" + +@Entity() +@Unique(["variant_id", "inventory_item_id"]) +export class ProductVariantInventoryItem extends BaseEntity { + @Index() + @DbAwareColumn({ type: "text" }) + inventory_item_id: string + + @Index() + @DbAwareColumn({ type: "text" }) + variant_id: string + + @Column({ type: "int", default: 1 }) + quantity: number + + @BeforeInsert() + private beforeInsert(): void { + this.id = generateEntityId(this.id, "pvitem") + } +} diff --git a/packages/medusa/src/models/return.ts b/packages/medusa/src/models/return.ts index 4315e5b6ff..e985c85070 100644 --- a/packages/medusa/src/models/return.ts +++ b/packages/medusa/src/models/return.ts @@ -72,6 +72,10 @@ export class Return extends BaseEntity { @DbAwareColumn({ type: "jsonb", nullable: true }) shipping_data: Record + @Index() + @Column({ nullable: true }) + location_id: string + @Column({ type: "int" }) refund_amount: number diff --git a/packages/medusa/src/models/sales-channel-location.ts b/packages/medusa/src/models/sales-channel-location.ts new file mode 100644 index 0000000000..fc42526bc3 --- /dev/null +++ b/packages/medusa/src/models/sales-channel-location.ts @@ -0,0 +1,20 @@ +import { BeforeInsert, Index, Column } from "typeorm" + +import { FeatureFlagEntity } from "../utils/feature-flag-decorators" +import { BaseEntity } from "../interfaces" +import { generateEntityId } from "../utils" + +export class SalesChannelLocation extends BaseEntity { + @Index() + @Column({ type: "text" }) + sales_channel_id: string + + @Index() + @Column({ type: "text" }) + location_id: string + + @BeforeInsert() + private beforeInsert(): void { + this.id = generateEntityId(this.id, "scloc") + } +} diff --git a/packages/medusa/src/models/store.ts b/packages/medusa/src/models/store.ts index 8c338295aa..228ad7e042 100644 --- a/packages/medusa/src/models/store.ts +++ b/packages/medusa/src/models/store.ts @@ -54,6 +54,9 @@ export class Store extends BaseEntity { @Column({ nullable: true }) invite_link_template: string + @Column({ nullable: true }) + default_location_id: string + @DbAwareColumn({ type: "jsonb", nullable: true }) metadata: Record diff --git a/packages/medusa/src/repositories/product-variant.ts b/packages/medusa/src/repositories/product-variant.ts index e865820074..fd452c283d 100644 --- a/packages/medusa/src/repositories/product-variant.ts +++ b/packages/medusa/src/repositories/product-variant.ts @@ -155,7 +155,7 @@ export class ProductVariantRepository extends Repository { entitiesIds, idsOrOptionsWithoutRelations as FindConditions ) - return [toReturn, toReturn.length] + return [toReturn, count] } const groupedRelations = this.getGroupedRelations( diff --git a/packages/medusa/src/services/__mocks__/inventory.js b/packages/medusa/src/services/__mocks__/inventory.js deleted file mode 100644 index db8fcdad8d..0000000000 --- a/packages/medusa/src/services/__mocks__/inventory.js +++ /dev/null @@ -1,20 +0,0 @@ -import { MedusaError } from "medusa-core-utils" - -export const InventoryServiceMock = { - withTransaction: function() { - return this - }, - adjustInventory: jest.fn().mockReturnValue((_variantId, _quantity) => { - return Promise.resolve({}) - }), - confirmInventory: jest.fn().mockImplementation((variantId, quantity) => { - if (quantity < 10) { - return true - } else { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Variant with id: ${variantId} does not have the required inventory` - ) - } - }), -} diff --git a/packages/medusa/src/services/__mocks__/product-variant-inventory.js b/packages/medusa/src/services/__mocks__/product-variant-inventory.js new file mode 100644 index 0000000000..290ab65023 --- /dev/null +++ b/packages/medusa/src/services/__mocks__/product-variant-inventory.js @@ -0,0 +1,26 @@ +import { MedusaError } from "medusa-core-utils" + +export const ProductVariantInventoryServiceMock = { + withTransaction: function () { + return this + }, + adjustInventory: jest.fn().mockReturnValue((_variantId, _quantity) => { + return Promise.resolve({}) + }), + confirmInventory: jest + .fn() + .mockImplementation((variantId, quantity, options) => { + if (quantity < 10) { + return true + } else { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Variant with id: ${variantId} does not have the required inventory` + ) + } + }), + releaseReservationsByLineItem: jest.fn().mockImplementation((lineItem) => {}), + reserveQuantity: jest + .fn() + .mockImplementation((variantId, quantity, options) => {}), +} diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 007e88c7db..ac18bc1bd6 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -3,7 +3,7 @@ import { MedusaError } from "medusa-core-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import { FlagRouter } from "../../utils/flag-router" import CartService from "../cart" -import { InventoryServiceMock } from "../__mocks__/inventory" +import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant-inventory" import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment" import { newTotalsServiceMock } from "../__mocks__/new-totals" import { taxProviderServiceMock } from "../__mocks__/tax-provider" @@ -314,18 +314,20 @@ describe("CartService", () => { }, } - const inventoryService = { - ...InventoryServiceMock, - confirmInventory: jest.fn().mockImplementation((variantId, _quantity) => { - if (variantId !== IdMap.getId("cannot-cover")) { - return true - } else { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Variant with id: ${variantId} does not have the required inventory` - ) - } - }), + const productVariantInventoryService = { + ...ProductVariantInventoryServiceMock, + confirmInventory: jest + .fn() + .mockImplementation((variantId, _quantity, options) => { + if (variantId !== IdMap.getId("cannot-cover")) { + return true + } else { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Variant with id: ${variantId} does not have the required inventory` + ) + } + }), } const cartRepository = MockRepository({ @@ -387,7 +389,7 @@ describe("CartService", () => { newTotalsService: newTotalsServiceMock, eventBusService, shippingOptionService, - inventoryService, + productVariantInventoryService, productVariantService, lineItemAdjustmentService: LineItemAdjustmentServiceMock, taxProviderService: taxProviderServiceMock, @@ -528,24 +530,6 @@ describe("CartService", () => { )} does not have the required inventory` ) }) - - it("throws if inventory isn't covered", async () => { - const lineItem = { - title: "merge line", - description: "This is a new line", - thumbnail: "test-img-yeah.com/thumb", - quantity: 1, - variant_id: IdMap.getId("cannot-cover"), - } - - await expect( - cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem) - ).rejects.toThrow( - `Variant with id: ${IdMap.getId( - "cannot-cover" - )} does not have the required inventory` - ) - }) }) describe("addLineItem w. SalesChannel", () => { @@ -572,8 +556,8 @@ describe("CartService", () => { }, } - const inventoryService = { - ...InventoryServiceMock, + const productVariantInventoryService = { + ...ProductVariantInventoryServiceMock, confirmInventory: jest.fn().mockImplementation((variantId, _quantity) => { if (variantId !== IdMap.getId("cannot-cover")) { return true @@ -625,7 +609,7 @@ describe("CartService", () => { newTotalsService: newTotalsServiceMock, eventBusService, shippingOptionService, - inventoryService, + productVariantInventoryService, productVariantService, lineItemAdjustmentService: LineItemAdjustmentServiceMock, taxProviderService: taxProviderServiceMock, @@ -876,12 +860,26 @@ describe("CartService", () => { quantity: 1, }) ), + retrieve: jest.fn().mockImplementation((lineItemId) => { + if (lineItemId === IdMap.getId("existing")) { + return Promise.resolve({ + id: lineItemId, + cart_id: IdMap.getId("cannot"), + variant_id: IdMap.getId("cannot-cover"), + }) + } + return Promise.resolve({ + id: lineItemId, + cart_id: IdMap.getId("cartWithLine"), + is_return: false, + }) + }), withTransaction: function () { return this }, } - const inventoryService = { - ...InventoryServiceMock, + const productVariantInventoryService = { + ...ProductVariantInventoryServiceMock, confirmInventory: jest .fn() .mockImplementation((id) => id !== IdMap.getId("cannot-cover")), @@ -905,7 +903,7 @@ describe("CartService", () => { total: 100, items: [ { - id: IdMap.getId("existing"), + id: IdMap.getId("existingUpdate"), variant_id: IdMap.getId("good"), subtotal: 100, quantity: 1, @@ -921,7 +919,7 @@ describe("CartService", () => { lineItemService, eventBusService, newTotalsService: newTotalsServiceMock, - inventoryService, + productVariantInventoryService, lineItemAdjustmentService: LineItemAdjustmentServiceMock, taxProviderService: taxProviderServiceMock, featureFlagRouter: new FlagRouter({}), @@ -934,7 +932,7 @@ describe("CartService", () => { it("successfully updates existing line item", async () => { await cartService.updateLineItem( IdMap.getId("cartWithLine"), - IdMap.getId("existing"), + IdMap.getId("existingUpdate"), { quantity: 2 } ) @@ -946,13 +944,13 @@ describe("CartService", () => { expect(lineItemService.update).toHaveBeenCalledTimes(1) expect(lineItemService.update).toHaveBeenCalledWith( - IdMap.getId("existing"), + IdMap.getId("existingUpdate"), { quantity: 2 } ) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({ - item_id: [IdMap.getId("existing")], + item_id: [IdMap.getId("existingUpdate")], }) expect( @@ -1244,6 +1242,7 @@ describe("CartService", () => { items: [ { id: IdMap.getId("testitem"), + variant_id: IdMap.getId("good"), }, { id: IdMap.getId("fail"), @@ -1527,24 +1526,20 @@ describe("CartService", () => { await cartService.setPaymentSessions(IdMap.getId("cartWithLine")) expect(paymentProviderService.createSession).toHaveBeenCalledTimes(2) - expect(paymentProviderService.createSession).toHaveBeenCalledWith( - { - cart: cart1, - customer: cart1.customer, - amount: cart1.total, - currency_code: cart1.region.currency_code, - provider_id: "provider_1", - } - ) - expect(paymentProviderService.createSession).toHaveBeenCalledWith( - { - cart: cart1, - customer: cart1.customer, - amount: cart1.total, - currency_code: cart1.region.currency_code, - provider_id: "provider_2", - } - ) + expect(paymentProviderService.createSession).toHaveBeenCalledWith({ + cart: cart1, + customer: cart1.customer, + amount: cart1.total, + currency_code: cart1.region.currency_code, + provider_id: "provider_1", + }) + expect(paymentProviderService.createSession).toHaveBeenCalledWith({ + cart: cart1, + customer: cart1.customer, + amount: cart1.total, + currency_code: cart1.region.currency_code, + provider_id: "provider_2", + }) }) it("filters sessions not available in the region", async () => { @@ -2293,7 +2288,9 @@ describe("CartService", () => { }) expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) - expect(LineItemAdjustmentServiceMock.createAdjustments).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) expect(eventBusService.emit).toHaveBeenCalledTimes(1) expect(eventBusService.emit).toHaveBeenCalledWith( @@ -2368,7 +2365,9 @@ describe("CartService", () => { await cartService.removeDiscount(IdMap.getId("fr-cart"), "1234") expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1) - expect(LineItemAdjustmentServiceMock.createAdjustments).toHaveBeenCalledTimes(1) + expect( + LineItemAdjustmentServiceMock.createAdjustments + ).toHaveBeenCalledTimes(1) expect(eventBusService.emit).toHaveBeenCalledTimes(1) expect(eventBusService.emit).toHaveBeenCalledWith( diff --git a/packages/medusa/src/services/__tests__/claim-item.js b/packages/medusa/src/services/__tests__/claim-item.js index 7545d47a93..982db3d365 100644 --- a/packages/medusa/src/services/__tests__/claim-item.js +++ b/packages/medusa/src/services/__tests__/claim-item.js @@ -5,7 +5,7 @@ import ClaimItemService from "../claim-item" const withTransactionMock = jest.fn() const eventBusService = { emit: jest.fn(), - withTransaction: function() { + withTransaction: function () { withTransactionMock("eventBus") return this }, @@ -25,20 +25,20 @@ describe("ClaimItemService", () => { const claimTagRepo = MockRepository({ findOne: () => Promise.resolve(), - create: d => d, + create: (d) => d, }) const claimImgRepo = MockRepository({ findOne: () => Promise.resolve(), - create: d => d, + create: (d) => d, }) const claimItemRepo = MockRepository({ - create: d => ({ id: "ci_1234", ...d }), + create: (d) => ({ id: "ci_1234", ...d }), }) const lineItemService = { - withTransaction: function() { + withTransaction: function () { withTransactionMock("lineItem") return this }, @@ -59,7 +59,10 @@ describe("ClaimItemService", () => { it("successfully creates a claim item", async () => { lineItemService.retrieve = jest.fn(() => - Promise.resolve({ fulfilled_quantity: 1 }) + Promise.resolve({ + variant_id: "var_1", + fulfilled_quantity: 1, + }) ) await claimItemService.create(testItem) @@ -80,6 +83,7 @@ describe("ClaimItemService", () => { expect(claimItemRepo.create).toHaveBeenCalledWith({ claim_order_id: "claim_13", item_id: "itm_1", + variant_id: "var_1", reason: "production_failure", note: "Details", quantity: 1, @@ -90,7 +94,10 @@ describe("ClaimItemService", () => { it("normalizes claim tag value", async () => { lineItemService.retrieve = jest.fn(() => - Promise.resolve({ fulfilled_quantity: 1 }) + Promise.resolve({ + variant_id: "var_1", + fulfilled_quantity: 1, + }) ) await claimItemService.create({ ...testItem, @@ -104,7 +111,10 @@ describe("ClaimItemService", () => { it("fails if fulfilled_quantity < quantity", async () => { lineItemService.retrieve = jest.fn(() => - Promise.resolve({ fulfilled_quantity: 0 }) + Promise.resolve({ + variant_id: "var_1", + fulfilled_quantity: 0, + }) ) await expect(claimItemService.create(testItem)).rejects.toThrow( "Cannot claim more of an item than has been fulfilled" diff --git a/packages/medusa/src/services/__tests__/claim.js b/packages/medusa/src/services/__tests__/claim.js index 19fb11bc0e..7a68b2885f 100644 --- a/packages/medusa/src/services/__tests__/claim.js +++ b/packages/medusa/src/services/__tests__/claim.js @@ -1,6 +1,6 @@ import { IdMap, MockRepository, MockManager } from "medusa-test-utils" import ClaimService from "../claim" -import { InventoryServiceMock } from "../__mocks__/inventory" +import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant-inventory" const withTransactionMock = jest.fn() const eventBusService = { @@ -79,7 +79,11 @@ describe("ClaimService", () => { } const lineItemService = { - generate: jest.fn((d, _, q) => ({ variant_id: d, quantity: q })), + generate: jest.fn((d, _, q) => ({ + id: "test_item", + variant_id: d, + quantity: q, + })), retrieve: () => Promise.resolve({}), list: () => Promise.resolve([{}]), withTransaction: function () { @@ -88,8 +92,8 @@ describe("ClaimService", () => { }, } - const inventoryService = { - ...InventoryServiceMock, + const productVariantInventoryService = { + ...ProductVariantInventoryServiceMock, withTransaction: function () { withTransactionMock("inventory") return this @@ -113,7 +117,7 @@ describe("ClaimService", () => { returnService, lineItemService, claimItemService, - inventoryService, + productVariantInventoryService, eventBusService, }) @@ -151,17 +155,15 @@ describe("ClaimService", () => { 1 ) - expect(inventoryService.confirmInventory).toHaveBeenCalledTimes(1) - expect(inventoryService.confirmInventory).toHaveBeenCalledWith( - "var_123", - 1 - ) - expect(withTransactionMock).toHaveBeenCalledWith("inventory") - expect(inventoryService.adjustInventory).toHaveBeenCalledTimes(1) - expect(inventoryService.adjustInventory).toHaveBeenCalledWith( - "var_123", - -1 - ) + expect( + productVariantInventoryService.reserveQuantity + ).toHaveBeenCalledTimes(1) + expect( + productVariantInventoryService.reserveQuantity + ).toHaveBeenCalledWith("var_123", 1, { + lineItemId: "test_item", + salesChannelId: undefined, + }) expect(withTransactionMock).toHaveBeenCalledWith("claimItem") expect(claimItemService.create).toHaveBeenCalledTimes(1) @@ -184,6 +186,7 @@ describe("ClaimService", () => { order_id: "1234", additional_items: [ { + id: "test_item", variant_id: "var_123", quantity: 1, }, @@ -262,7 +265,6 @@ describe("ClaimService", () => { }, ], }) - console.warn(res) } catch (e) { expect(e.message).toEqual( `Variant with id: var_123 does not have the required inventory` diff --git a/packages/medusa/src/services/__tests__/inventory.js b/packages/medusa/src/services/__tests__/inventory.js deleted file mode 100644 index 9b6eb90e0d..0000000000 --- a/packages/medusa/src/services/__tests__/inventory.js +++ /dev/null @@ -1,91 +0,0 @@ -import { MockManager } from "medusa-test-utils" -import InventoryService from "../inventory" -import { ProductVariantServiceMock } from "../__mocks__/product-variant" - -describe("InventoryService", () => { - describe("confirmInventory", () => { - const inventoryService = new InventoryService({ - manager: MockManager, - productVariantService: ProductVariantServiceMock, - }) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("returns false when inventory is managed, and no back orders are allowed and the quantity is larger than inventory", async () => { - await expect( - inventoryService.confirmInventory("no_bo", 10) - ).rejects.toThrow( - `Variant with id: no_bo does not have the required inventory` - ) - }) - it("returns true when variant is out of stock but allows back orders", async () => { - const result = await inventoryService.confirmInventory("bo", 100) - expect(result).toEqual(true) - }) - it("returns true when variant is out of stock but inventory quantity is not managed", async () => { - const result = await inventoryService.confirmInventory("no_manage", 10000) - expect(result).toEqual(true) - }) - it("returns true when managed variant inventory_quantity > requested quantity", async () => { - const result = await inventoryService.confirmInventory("10_man", 5) - expect(result).toEqual(true) - }) - it("returns false when managed variant inventory_quantity < requested quantity", async () => { - await expect( - inventoryService.confirmInventory("10_man", 50) - ).rejects.toThrow( - `Variant with id: 10_man does not have the required inventory` - ) - }) - }) - describe("adjustInventory", () => { - const inventoryService = new InventoryService({ - manager: MockManager, - productVariantService: ProductVariantServiceMock, - }) - - beforeEach(async () => { - jest.clearAllMocks() - }) - - it("should not call update in productVariantService because variant is not managed", async () => { - await inventoryService.adjustInventory("no_manage", 1000) - expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(0) - }) - - it("should call update in productVariantService once", async () => { - await inventoryService.adjustInventory("10_man", 10) - expect(ProductVariantServiceMock.update).toHaveBeenCalledTimes(1) - expect(ProductVariantServiceMock.update).toHaveBeenCalledWith( - { - id: "10_man", - title: "variant_popular", - inventory_quantity: 10, - allow_backorder: false, - manage_inventory: true, - }, - { - inventory_quantity: 20, - } - ) - }) - - it("should update update once for 1man", async () => { - await inventoryService.adjustInventory("1_man", -1) - expect(ProductVariantServiceMock.update).toHaveBeenCalledWith( - { - id: "1_man", - title: "variant_popular", - inventory_quantity: 1, - allow_backorder: false, - manage_inventory: true, - }, - { - inventory_quantity: 0, - } - ) - }) - }) -}) diff --git a/packages/medusa/src/services/__tests__/order.js b/packages/medusa/src/services/__tests__/order.js index 7e349c7daa..a11c14f14a 100644 --- a/packages/medusa/src/services/__tests__/order.js +++ b/packages/medusa/src/services/__tests__/order.js @@ -1,6 +1,6 @@ import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import OrderService from "../order" -import { InventoryServiceMock } from "../__mocks__/inventory" +import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant-inventory" import { LineItemServiceMock } from "../__mocks__/line-item" import { newTotalsServiceMock } from "../__mocks__/new-totals" import { taxProviderServiceMock } from "../__mocks__/tax-provider" @@ -53,8 +53,8 @@ describe("OrderService", () => { }, } - const inventoryService = { - ...InventoryServiceMock, + const productVariantInventoryService = { + ...ProductVariantInventoryServiceMock, } describe("createFromCart", () => { @@ -150,7 +150,7 @@ describe("OrderService", () => { regionService, eventBusService, cartService, - inventoryService, + productVariantInventoryService, }) beforeEach(async () => { @@ -193,7 +193,9 @@ describe("OrderService", () => { discount_total: 0, } - orderService.cartService_.retrieveWithTotals = jest.fn(() => Promise.resolve(cart)) + orderService.cartService_.retrieveWithTotals = jest.fn(() => + Promise.resolve(cart) + ) await orderService.createFromCart("cart_id") const order = { @@ -224,16 +226,6 @@ describe("OrderService", () => { } ) - expect(inventoryService.adjustInventory).toHaveBeenCalledTimes(2) - expect(inventoryService.adjustInventory).toHaveBeenCalledWith( - "variant-2", - -1 - ) - expect(inventoryService.adjustInventory).toHaveBeenCalledWith( - "variant-1", - -1 - ) - expect(lineItemService.update).toHaveBeenCalledTimes(2) expect(lineItemService.update).toHaveBeenCalledWith("item_1", { order_id: "id", @@ -256,21 +248,24 @@ describe("OrderService", () => { const lineItemWithGiftCard = { id: "item_1", variant_id: "variant-1", - quantity: 2, + // quantity: 2, is_giftcard: true, subtotal: giftCardValue * totalGiftCardsPurchased, quantity: totalGiftCardsPurchased, metadata: {}, - tax_lines: [{ - rate: taxLineRateOne - }, { - rate: taxLineRateTwo - }] + tax_lines: [ + { + rate: taxLineRateOne, + }, + { + rate: taxLineRateTwo, + }, + ], } const lineItemWithoutGiftCard = { ...lineItemWithGiftCard, - is_giftcard: false + is_giftcard: false, } const cartWithGiftcard = { @@ -302,23 +297,29 @@ describe("OrderService", () => { } it("creates gift cards when a lineItem contains a gift card variant", async () => { - orderService.cartService_.retrieveWithTotals = jest.fn(() => Promise.resolve(cartWithGiftcard)) + orderService.cartService_.retrieveWithTotals = jest.fn(() => + Promise.resolve(cartWithGiftcard) + ) await orderService.createFromCart("id") - expect(giftCardService.create).toHaveBeenCalledTimes(totalGiftCardsPurchased) + expect(giftCardService.create).toHaveBeenCalledTimes( + totalGiftCardsPurchased + ) expect(giftCardService.create).toHaveBeenCalledWith({ order_id: "id", region_id: "test", value: giftCardValue, balance: giftCardValue, metadata: {}, - tax_rate: expectedGiftCardTaxRate + tax_rate: expectedGiftCardTaxRate, }) }) it("does not create gift cards when a lineItem doesn't contains a gift card variant", async () => { - orderService.cartService_.retrieveWithTotals = jest.fn(() => Promise.resolve(cartWithoutGiftcard)) + orderService.cartService_.retrieveWithTotals = jest.fn(() => + Promise.resolve(cartWithoutGiftcard) + ) await orderService.createFromCart("id") @@ -487,45 +488,6 @@ describe("OrderService", () => { expect(orderRepo.save).toHaveBeenCalledWith(order) }) - - it("fails because an item does not have the required inventory", async () => { - const cart = { - id: "cart_id", - email: "test@test.com", - customer_id: "cus_1234", - payment: { - id: "testpayment", - amount: 100, - status: "authorized", - }, - region_id: "test", - region: { - id: "test", - currency_code: "eur", - name: "test", - tax_rate: 25, - }, - gift_cards: [], - shipping_address_id: "1234", - billing_address_id: "1234", - discounts: [], - shipping_methods: [{ id: "method_1" }], - items: [ - { id: "item_1", variant_id: "variant-1", quantity: 12 }, - { id: "item_2", variant_id: "variant-2", quantity: 1 }, - ], - total: 100, - } - orderService.cartService_.retrieveWithTotals = () => Promise.resolve(cart) - const res = orderService.createFromCart(cart) - await expect(res).rejects.toThrow( - "Variant with id: variant-1 does not have the required inventory" - ) - // check to see if payment is cancelled - expect( - orderService.paymentProviderService_.cancelPayment - ).toHaveBeenCalledTimes(1) - }) }) describe("retrieve", () => { @@ -723,7 +685,7 @@ describe("OrderService", () => { paymentProviderService, fulfillmentService, eventBusService, - inventoryService, + productVariantInventoryService, }) beforeEach(async () => { @@ -743,16 +705,6 @@ describe("OrderService", () => { id: "payment_test", }) - expect(inventoryService.adjustInventory).toHaveBeenCalledTimes(2) - expect(inventoryService.adjustInventory).toHaveBeenCalledWith( - "variant-1", - 12 - ) - expect(inventoryService.adjustInventory).toHaveBeenCalledWith( - "variant-2", - 1 - ) - expect(orderRepo.save).toHaveBeenCalledTimes(1) expect(orderRepo.save).toHaveBeenCalledWith({ fulfillment_status: "canceled", diff --git a/packages/medusa/src/services/__tests__/return.js b/packages/medusa/src/services/__tests__/return.js index c0bd389baa..2414673735 100644 --- a/packages/medusa/src/services/__tests__/return.js +++ b/packages/medusa/src/services/__tests__/return.js @@ -1,150 +1,11 @@ import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import idMap from "medusa-test-utils/dist/id-map" import ReturnService from "../return" -import { InventoryServiceMock } from "../__mocks__/inventory" - +import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant-inventory" describe("ReturnService", () => { - // describe("requestReturn", () => { - // const returnRepository = MockRepository({}) - - // const fulfillmentProviderService = { - // createReturn: jest.fn().mockImplementation(data => { - // return Promise.resolve(data) - // }), - // } - - // const shippingOptionService = { - // retrieve: jest.fn().mockImplementation(data => { - // return Promise.resolve({ - // id: IdMap.getId("default"), - // name: "default_profile", - // provider_id: "default", - // }) - // }), - // } - - // const totalsService = { - // getTotal: jest.fn().mockImplementation(cart => { - // return 1000 - // }), - // getSubtotal: jest.fn().mockImplementation(cart => { - // return 75 - // }), - // getRefundTotal: jest.fn().mockImplementation((order, lineItems) => { - // return 1000 - // }), - // getRefundedTotal: jest.fn().mockImplementation((order, lineItems) => { - // return 0 - // }), - // } - - // const returnService = new ReturnService({ - // manager: MockManager, - // totalsService, - // shippingOptionService, - // fulfillmentProviderService, - // returnRepository, - // }) - - // beforeEach(async () => { - // jest.clearAllMocks() - // }) - - // it("successfully requests a return", async () => { - // await returnService.requestReturn( - // { - // id: IdMap.getId("test-order"), - // items: [{ id: IdMap.getId("existingLine"), quantity: 10 }], - // tax_rate: 0.25, - // payment_status: "captured", - // }, - // [ - // { - // item_id: IdMap.getId("existingLine"), - // quantity: 10, - // }, - // ], - // { id: "some-shipping-method", price: 150 } - // ) - - // expect(returnRepository.create).toHaveBeenCalledTimes(1) - // expect(returnRepository.create).toHaveBeenCalledWith({ - // status: "requested", - // items: [], - // order_id: IdMap.getId("test-order"), - // shipping_method: { - // id: "some-shipping-method", - // price: 150, - // }, - // shipping_data: { - // id: "some-shipping-method", - // price: 150, - // }, - // refund_amount: 1000 - 150 * (1 + 0.25), - // }) - // }) - - // it("successfully requests a return with custom refund amount", async () => { - // await returnService.requestReturn( - // { - // id: IdMap.getId("test-order"), - // items: [{ id: IdMap.getId("existingLine"), quantity: 10 }], - // tax_rate: 0.25, - // payment_status: "captured", - // }, - // [ - // { - // item_id: IdMap.getId("existingLine"), - // quantity: 10, - // }, - // ], - // { id: "some-shipping-method", price: 150 }, - // 500 - // ) - - // expect(returnRepository.create).toHaveBeenCalledTimes(1) - // expect(returnRepository.create).toHaveBeenCalledWith({ - // status: "requested", - // items: [], - // order_id: IdMap.getId("test-order"), - // shipping_method: { - // id: "some-shipping-method", - // price: 150, - // }, - // shipping_data: expect.anything(), - // refund_amount: 500 - 150 * (1 + 0.25), - // }) - // }) - - // it("throws if refund amount is above captured amount", async () => { - // try { - // await returnService.requestReturn( - // { - // id: IdMap.getId("test-order"), - // items: [{ id: IdMap.getId("existingLine"), quantity: 10 }], - // tax_rate: 0.25, - // payment_status: "captured", - // }, - // [ - // { - // item_id: IdMap.getId("existingLine"), - // quantity: 10, - // }, - // ], - // { id: "some-shipping-method", price: 150 }, - // 2000 - // ) - // } catch (error) { - // expect(error.message).toBe( - // "Cannot refund more than the original payment" - // ) - // } - // }) - // }) - describe("receive", () => { const returnRepository = MockRepository({ - findOne: query => { + findOne: (query) => { switch (query.where.id) { case IdMap.getId("test-return-2"): return Promise.resolve({ @@ -188,7 +49,7 @@ describe("ReturnService", () => { }) const totalsService = { - getTotal: jest.fn().mockImplementation(cart => { + getTotal: jest.fn().mockImplementation((cart) => { return 1000 }), getRefundedTotal: jest.fn().mockImplementation((order, lineItems) => { @@ -216,36 +77,36 @@ describe("ReturnService", () => { payments: [{ id: "payment_test" }], }) }), - withTransaction: function() { + withTransaction: function () { return this }, } const lineItemService = { - retrieve: jest.fn().mockImplementation(data => { + retrieve: jest.fn().mockImplementation((data) => { return Promise.resolve({ ...data, returned_quantity: 0 }) }), update: jest.fn(), - withTransaction: function() { + withTransaction: function () { return this }, } - const inventoryService = { - adjustInventory: jest.fn((variantId, quantity) => { - return Promise.resolve({}) - }), - confirmInventory: jest.fn((variantId, quantity) => { - if (quantity < 10) { - return true - } else { - return false - } - }), - withTransaction: function() { - return this - }, - } + // const inventoryService = { + // adjustInventory: jest.fn((variantId, quantity) => { + // return Promise.resolve({}) + // }), + // confirmInventory: jest.fn((variantId, quantity) => { + // if (quantity < 10) { + // return true + // } else { + // return false + // } + // }), + // withTransaction: function () { + // return this + // }, + // } const returnService = new ReturnService({ manager: MockManager, @@ -253,7 +114,7 @@ describe("ReturnService", () => { lineItemService, orderService, returnRepository, - inventoryService, + productVariantInventoryService: ProductVariantInventoryServiceMock, }) beforeEach(async () => { @@ -296,11 +157,12 @@ describe("ReturnService", () => { } ) - expect(inventoryService.adjustInventory).toHaveBeenCalledTimes(1) - expect(inventoryService.adjustInventory).toHaveBeenCalledWith( - "test-variant", - 10 - ) + expect( + ProductVariantInventoryServiceMock.adjustInventory + ).toHaveBeenCalledTimes(1) + expect( + ProductVariantInventoryServiceMock.adjustInventory + ).toHaveBeenCalledWith("test-variant", undefined, 10) }) it("successfully receives a return with requires_action status", async () => { @@ -313,15 +175,15 @@ describe("ReturnService", () => { 1000 ) - expect(inventoryService.adjustInventory).toHaveBeenCalledTimes(2) - expect(inventoryService.adjustInventory).toHaveBeenCalledWith( - "test-variant", - 10 - ) - expect(inventoryService.adjustInventory).toHaveBeenCalledWith( - "test-variant-2", - 10 - ) + expect( + ProductVariantInventoryServiceMock.adjustInventory + ).toHaveBeenCalledTimes(2) + expect( + ProductVariantInventoryServiceMock.adjustInventory + ).toHaveBeenCalledWith("test-variant", undefined, 10) + expect( + ProductVariantInventoryServiceMock.adjustInventory + ).toHaveBeenCalledWith("test-variant-2", undefined, 10) expect(returnRepository.save).toHaveBeenCalledTimes(1) expect(returnRepository.save).toHaveBeenCalledWith({ @@ -368,7 +230,7 @@ describe("ReturnService", () => { describe("canceled", () => { const returnRepository = MockRepository({ - findOne: query => { + findOne: (query) => { switch (query.where.id) { case IdMap.getId("test-return"): return Promise.resolve({ @@ -384,7 +246,7 @@ describe("ReturnService", () => { return Promise.resolve({}) } }, - save: f => f, + save: (f) => f, }) const returnService = new ReturnService({ @@ -415,7 +277,7 @@ describe("ReturnService", () => { describe("fulfilled", () => { const returnRepository = MockRepository({ - findOne: query => { + findOne: (query) => { switch (query.where.id) { case IdMap.getId("test-return"): return Promise.resolve({ @@ -445,7 +307,7 @@ describe("ReturnService", () => { describe("update", () => { const returnRepository = MockRepository({ - findOne: query => { + findOne: (query) => { switch (query.where.id) { case IdMap.getId("test-return"): return Promise.resolve({ diff --git a/packages/medusa/src/services/__tests__/swap.ts b/packages/medusa/src/services/__tests__/swap.ts index eeaabc4d14..3cfab43ad8 100644 --- a/packages/medusa/src/services/__tests__/swap.ts +++ b/packages/medusa/src/services/__tests__/swap.ts @@ -1,16 +1,15 @@ import { IdMap, MockManager, MockRepository } from "medusa-test-utils" - import SwapService from "../swap" -import { InventoryServiceMock } from "../__mocks__/inventory" +import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant-inventory" import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment" import { CustomShippingOptionService, EventBusService, FulfillmentService, - InventoryService, LineItemService, OrderService, PaymentProviderService, + ProductVariantInventoryService, ReturnService, ShippingOptionService, TotalsService, @@ -104,7 +103,8 @@ const paymentProviderService = { const orderService = {} as unknown as OrderService const returnService = {} as unknown as ReturnService -const inventoryService = {} as unknown as InventoryService +const productVariantInventoryService = + {} as unknown as ProductVariantInventoryService const fulfillmentService = {} as unknown as FulfillmentService const lineItemAdjustmentService = {} as unknown as LineItemAdjustmentService @@ -119,7 +119,7 @@ const defaultProps = { totalsService: totalsService, eventBusService: eventBusService, lineItemService: lineItemService, - inventoryService: inventoryService, + productVariantInventoryService: productVariantInventoryService, fulfillmentService: fulfillmentService, shippingOptionService: shippingOptionService, paymentProviderService: paymentProviderService, @@ -319,9 +319,14 @@ describe("SwapService", () => { expect( LineItemAdjustmentServiceMock.createAdjustmentForLineItem ).toHaveBeenCalledWith( - { id: "cart", items: [{ - id: "test-item", - }] }, + { + id: "cart", + items: [ + { + id: "test-item", + }, + ], + }, { id: "test-item", } @@ -852,12 +857,12 @@ describe("SwapService", () => { }, } as unknown as PaymentProviderService - const inventoryService = { - ...InventoryServiceMock, + const productVariantInventoryService = { + ...ProductVariantInventoryServiceMock, withTransaction: function () { return this }, - } as unknown as InventoryService + } as unknown as ProductVariantInventoryService describe("success", () => { const cart = { @@ -889,7 +894,7 @@ describe("SwapService", () => { cartService, paymentProviderService, shippingOptionService, - inventoryService, + productVariantInventoryService, }) it("creates a shipment", async () => { @@ -899,14 +904,12 @@ describe("SwapService", () => { good: "yes", }) - expect(inventoryService.confirmInventory).toHaveBeenCalledWith( - "variant", - 2 - ) - expect(inventoryService.adjustInventory).toHaveBeenCalledWith( - "variant", - -2 - ) + expect( + productVariantInventoryService.reserveQuantity + ).toHaveBeenCalledWith("variant", 2, { + lineItemId: "1", + salesChannelId: undefined, + }) expect(swapRepo.save).toHaveBeenCalledWith({ ...existing, @@ -950,7 +953,7 @@ describe("SwapService", () => { cartService, paymentProviderService, shippingOptionService, - inventoryService, + productVariantInventoryService, }) it("fails to register cart completion when swap is canceled", async () => { diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index a07e692069..2b227888e8 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -42,8 +42,11 @@ import CustomerService from "./customer" import DiscountService from "./discount" import EventBusService from "./event-bus" import GiftCardService from "./gift-card" -import { NewTotalsService, SalesChannelService } from "./index" -import InventoryService from "./inventory" +import { + NewTotalsService, + ProductVariantInventoryService, + SalesChannelService, +} from "./index" import LineItemService from "./line-item" import LineItemAdjustmentService from "./line-item-adjustment" import PaymentProviderService from "./payment-provider" @@ -79,10 +82,10 @@ type InjectedDependencies = { giftCardService: GiftCardService totalsService: TotalsService newTotalsService: NewTotalsService - inventoryService: InventoryService customShippingOptionService: CustomShippingOptionService lineItemAdjustmentService: LineItemAdjustmentService priceSelectionStrategy: IPriceSelectionStrategy + productVariantInventoryService: ProductVariantInventoryService } type TotalsConfig = { @@ -122,11 +125,12 @@ class CartService extends TransactionBaseService { protected readonly taxProviderService_: TaxProviderService protected readonly totalsService_: TotalsService protected readonly newTotalsService_: NewTotalsService - protected readonly inventoryService_: InventoryService protected readonly customShippingOptionService_: CustomShippingOptionService protected readonly priceSelectionStrategy_: IPriceSelectionStrategy protected readonly lineItemAdjustmentService_: LineItemAdjustmentService protected readonly featureFlagRouter_: FlagRouter + // eslint-disable-next-line max-len + protected readonly productVariantInventoryService_: ProductVariantInventoryService constructor({ manager, @@ -148,13 +152,13 @@ class CartService extends TransactionBaseService { newTotalsService, addressRepository, paymentSessionRepository, - inventoryService, customShippingOptionService, lineItemAdjustmentService, priceSelectionStrategy, salesChannelService, featureFlagRouter, storeService, + productVariantInventoryService, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -177,7 +181,6 @@ class CartService extends TransactionBaseService { this.newTotalsService_ = newTotalsService this.addressRepository_ = addressRepository this.paymentSessionRepository_ = paymentSessionRepository - this.inventoryService_ = inventoryService this.customShippingOptionService_ = customShippingOptionService this.taxProviderService_ = taxProviderService this.lineItemAdjustmentService_ = lineItemAdjustmentService @@ -185,6 +188,7 @@ class CartService extends TransactionBaseService { this.salesChannelService_ = salesChannelService this.featureFlagRouter_ = featureFlagRouter this.storeService_ = storeService + this.productVariantInventoryService_ = productVariantInventoryService } /** @@ -584,6 +588,10 @@ class CartService extends TransactionBaseService { return true } + if (!lineItem.variant_id) { + return true + } + const lineItemVariant = lineItem.variant?.product_id ? lineItem.variant : await this.productVariantService_ @@ -627,11 +635,18 @@ class CartService extends TransactionBaseService { if (this.featureFlagRouter_.isFeatureEnabled("sales_channels")) { if (config.validateSalesChannels) { - if (!(await this.validateLineItem(cart, lineItem))) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `The product "${lineItem.title}" must belongs to the sales channel on which the cart has been created.` + if (lineItem.variant_id) { + const lineItemIsValid = await this.validateLineItem( + cart, + lineItem as LineItemValidateData ) + + if (!lineItemIsValid) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The product "${lineItem.title}" must belongs to the sales channel on which the cart has been created.` + ) + } } } } @@ -664,9 +679,22 @@ class CartService extends TransactionBaseService { : lineItem.quantity // Confirm inventory or throw error - await this.inventoryService_ - .withTransaction(transactionManager) - .confirmInventory(lineItem.variant_id, quantity) + if (lineItem.variant_id) { + const isCovered = + await this.productVariantInventoryService_.confirmInventory( + lineItem.variant_id, + quantity, + { salesChannelId: cart.sales_channel_id } + ) + + if (!isCovered) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Variant with id: ${lineItem.variant_id} does not have the required inventory`, + MedusaError.Codes.INSUFFICIENT_INVENTORY + ) + } + } if (currentItem) { await lineItemServiceTx.update(currentItem.id, { @@ -737,7 +765,13 @@ class CartService extends TransactionBaseService { if (config.validateSalesChannels) { const areValid = await Promise.all( items.map(async (item) => { - return await this.validateLineItem(cart, item) + if (item.variant_id) { + return await this.validateLineItem( + cart, + item as LineItemValidateData + ) + } + return true }) ) @@ -762,8 +796,10 @@ class CartService extends TransactionBaseService { const lineItemServiceTx = this.lineItemService_.withTransaction(transactionManager) - const inventoryServiceTx = - this.inventoryService_.withTransaction(transactionManager) + const productVariantInventoryServiceTx = + this.productVariantInventoryService_.withTransaction( + transactionManager + ) const existingItems = await lineItemServiceTx.list( { @@ -797,10 +833,22 @@ class CartService extends TransactionBaseService { ? (currentItem.quantity += item.quantity) : item.quantity - await inventoryServiceTx.confirmInventory( - item.variant_id, - item.quantity - ) + if (item.variant_id) { + const isSufficient = + await productVariantInventoryServiceTx.confirmInventory( + item.variant_id, + item.quantity, + { salesChannelId: cart.sales_channel_id } + ) + + if (!isSufficient) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Variant with id: ${item.variant_id} does not have the required inventory`, + MedusaError.Codes.INSUFFICIENT_INVENTORY + ) + } + } if (currentItem) { lineItemsToUpdate[currentItem.id] = { @@ -873,13 +921,12 @@ class CartService extends TransactionBaseService { ): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { - const cart = await this.retrieve(cartId, { - relations: ["items", "items.adjustments", "payment_sessions"], + const lineItem = await this.lineItemService_.retrieve(lineItemId, { + select: ["id", "quantity", "variant_id", "cart_id"], }) - // Ensure that the line item exists in the cart - const lineItemExists = cart.items.find((i) => i.id === lineItemId) - if (!lineItemExists) { + if (lineItem.cart_id !== cartId) { + // Ensure that the line item exists in the cart throw new MedusaError( MedusaError.Types.INVALID_DATA, "A line item with the provided id doesn't exist in the cart" @@ -887,18 +934,31 @@ class CartService extends TransactionBaseService { } if (lineItemUpdate.quantity) { - const hasInventory = await this.inventoryService_ - .withTransaction(transactionManager) - .confirmInventory( - lineItemExists.variant_id, - lineItemUpdate.quantity - ) + if (lineItem.variant_id) { + const select: (keyof Cart)[] = ["id"] + if ( + this.featureFlagRouter_.isFeatureEnabled( + SalesChannelFeatureFlag.key + ) + ) { + select.push("sales_channel_id") + } - if (!hasInventory) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Inventory doesn't cover the desired quantity" - ) + const cart = await this.retrieve(cartId, { select: select }) + + const hasInventory = + await this.productVariantInventoryService_.confirmInventory( + lineItem.variant_id, + lineItemUpdate.quantity, + { salesChannelId: cart.sales_channel_id } + ) + + if (!hasInventory) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Inventory doesn't cover the desired quantity" + ) + } } } @@ -2009,6 +2069,10 @@ class CartService extends TransactionBaseService { cart.items = ( await Promise.all( cart.items.map(async (item) => { + if (!item.variant_id) { + return item + } + const availablePrice = await this.priceSelectionStrategy_ .withTransaction(transactionManager) .calculateVariantPrice(item.variant_id, { @@ -2024,14 +2088,13 @@ class CartService extends TransactionBaseService { availablePrice !== undefined && availablePrice.calculatedPrice !== null ) { - return lineItemServiceTx.update(item.id, { + return await lineItemServiceTx.update(item.id, { has_shipping: false, unit_price: availablePrice.calculatedPrice, }) - } else { - await lineItemServiceTx.delete(item.id) - return } + + return await lineItemServiceTx.delete(item.id) }) ) ) diff --git a/packages/medusa/src/services/claim-item.ts b/packages/medusa/src/services/claim-item.ts index bdc83b5489..d7b6a49bd0 100644 --- a/packages/medusa/src/services/claim-item.ts +++ b/packages/medusa/src/services/claim-item.ts @@ -70,6 +70,13 @@ class ClaimItemService extends TransactionBaseService { .withTransaction(manager) .retrieve(item_id) + if (!lineItem.variant_id) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot claim a custom line item" + ) + } + if (lineItem.fulfilled_quantity < quantity) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, diff --git a/packages/medusa/src/services/claim.ts b/packages/medusa/src/services/claim.ts index 38b05cf42b..cf82aef951 100644 --- a/packages/medusa/src/services/claim.ts +++ b/packages/medusa/src/services/claim.ts @@ -21,9 +21,9 @@ import ClaimItemService from "./claim-item" import EventBusService from "./event-bus" import FulfillmentService from "./fulfillment" import FulfillmentProviderService from "./fulfillment-provider" -import InventoryService from "./inventory" import LineItemService from "./line-item" import PaymentProviderService from "./payment-provider" +import ProductVariantInventoryService from "./product-variant-inventory" import RegionService from "./region" import ReturnService from "./return" import ShippingOptionService from "./shipping-option" @@ -40,7 +40,7 @@ type InjectedDependencies = { eventBusService: EventBusService fulfillmentProviderService: FulfillmentProviderService fulfillmentService: FulfillmentService - inventoryService: InventoryService + productVariantInventoryService: ProductVariantInventoryService lineItemService: LineItemService paymentProviderService: PaymentProviderService regionService: RegionService @@ -71,7 +71,6 @@ export default class ClaimService extends TransactionBaseService { protected readonly eventBus_: EventBusService protected readonly fulfillmentProviderService_: FulfillmentProviderService protected readonly fulfillmentService_: FulfillmentService - protected readonly inventoryService_: InventoryService protected readonly lineItemService_: LineItemService protected readonly paymentProviderService_: PaymentProviderService protected readonly regionService_: RegionService @@ -79,6 +78,8 @@ export default class ClaimService extends TransactionBaseService { protected readonly shippingOptionService_: ShippingOptionService protected readonly taxProviderService_: TaxProviderService protected readonly totalsService_: TotalsService + // eslint-disable-next-line max-len + protected readonly productVariantInventoryService_: ProductVariantInventoryService constructor({ manager, @@ -90,7 +91,7 @@ export default class ClaimService extends TransactionBaseService { eventBusService, fulfillmentProviderService, fulfillmentService, - inventoryService, + productVariantInventoryService, lineItemService, paymentProviderService, regionService, @@ -112,7 +113,7 @@ export default class ClaimService extends TransactionBaseService { this.eventBus_ = eventBusService this.fulfillmentProviderService_ = fulfillmentProviderService this.fulfillmentService_ = fulfillmentService - this.inventoryService_ = inventoryService + this.productVariantInventoryService_ = productVariantInventoryService this.lineItemService_ = lineItemService this.paymentProviderService_ = paymentProviderService this.regionService_ = regionService @@ -334,16 +335,6 @@ export default class ClaimService extends TransactionBaseService { let newItems: LineItem[] = [] if (isDefined(additional_items)) { - const inventoryServiceTx = - this.inventoryService_.withTransaction(transactionManager) - - for (const item of additional_items) { - await inventoryServiceTx.confirmInventory( - item.variant_id, - item.quantity - ) - } - newItems = await Promise.all( additional_items.map(async (i) => lineItemServiceTx.generate( @@ -354,12 +345,20 @@ export default class ClaimService extends TransactionBaseService { ) ) - for (const newItem of newItems) { - await inventoryServiceTx.adjustInventory( - newItem.variant_id, - -newItem.quantity - ) - } + await Promise.all( + newItems.map(async (newItem) => { + if (newItem.variant_id) { + await this.productVariantInventoryService_.reserveQuantity( + newItem.variant_id, + newItem.quantity, + { + lineItemId: newItem.id, + salesChannelId: order.sales_channel_id, + } + ) + } + }) + ) } const evaluatedNoNotification = diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index 51b97cdc71..e127c495ad 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -17,7 +17,6 @@ export { default as FulfillmentService } from "./fulfillment" export { default as FulfillmentProviderService } from "./fulfillment-provider" export { default as GiftCardService } from "./gift-card" export { default as IdempotencyKeyService } from "./idempotency-key" -export { default as InventoryService } from "./inventory" export { default as LineItemService } from "./line-item" export { default as LineItemAdjustmentService } from "./line-item-adjustment" export { default as MiddlewareService } from "./middleware" @@ -35,11 +34,14 @@ export { default as PricingService } from "./pricing" export { default as ProductService } from "./product" export { default as ProductCollectionService } from "./product-collection" export { default as ProductTypeService } from "./product-type" +export { default as ProductVariantInventoryService } from "./product-variant-inventory" export { default as ProductVariantService } from "./product-variant" export { default as RegionService } from "./region" export { default as ReturnService } from "./return" export { default as ReturnReasonService } from "./return-reason" +export { default as SalesChannelInventoryService } from "./sales-channel-inventory" +export { default as SalesChannelLocationService } from "./sales-channel-location" export { default as SalesChannelService } from "./sales-channel" export { default as SearchService } from "./search" export { default as ShippingOptionService } from "./shipping-option" diff --git a/packages/medusa/src/services/inventory.ts b/packages/medusa/src/services/inventory.ts deleted file mode 100644 index 7d6f26592f..0000000000 --- a/packages/medusa/src/services/inventory.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { isDefined, MedusaError } from "medusa-core-utils" -import { TransactionBaseService } from "../interfaces" -import { EntityManager } from "typeorm" -import ProductVariantService from "./product-variant" -import { ProductVariant } from "../models" - -type InventoryServiceProps = { - manager: EntityManager - productVariantService: ProductVariantService -} - -class InventoryService extends TransactionBaseService { - protected readonly productVariantService_: ProductVariantService - - protected manager_: EntityManager - protected transactionManager_: EntityManager | undefined - - constructor({ manager, productVariantService }: InventoryServiceProps) { - super(arguments[0]) - - this.manager_ = manager - this.productVariantService_ = productVariantService - } - - /** - * Updates the inventory of a variant based on a given adjustment. - * @param variantId - the id of the variant to update - * @param adjustment - the number to adjust the inventory quantity by - * @return resolves to the update result. - */ - async adjustInventory( - variantId: string, - adjustment: number - ): Promise { - if (!variantId) { - return - } - - return await this.atomicPhase_(async (manager) => { - const variant = await this.productVariantService_ - .withTransaction(manager) - .retrieve(variantId) - // if inventory is managed then update - if (variant.manage_inventory) { - return await this.productVariantService_ - .withTransaction(manager) - .update(variant, { - inventory_quantity: variant.inventory_quantity + adjustment, - }) - } else { - return variant - } - }) - } - - /** - * Checks if the inventory of a variant can cover a given quantity. Will - * return true if the variant doesn't have managed inventory or if the variant - * allows backorders or if the inventory quantity is greater than `quantity`. - * @param variantId - the id of the variant to check - * @param quantity - the number of units to check availability for - * @return true if the inventory covers the quantity - */ - async confirmInventory( - variantId: string | null | undefined, - quantity: number - ): Promise { - // if variantId is undefined then confirm inventory as it - // is a custom item that is not managed - if (!isDefined(variantId) || variantId === null) { - return true - } - - const variant = await this.productVariantService_ - .withTransaction(this.manager_) - .retrieve(variantId, { - select: [ - "id", - "inventory_quantity", - "allow_backorder", - "manage_inventory", - ], - }) - - const { inventory_quantity, allow_backorder, manage_inventory } = variant - const isCovered = - !manage_inventory || allow_backorder || inventory_quantity >= quantity - if (!isCovered) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Variant with id: ${variant.id} does not have the required inventory`, - MedusaError.Codes.INSUFFICIENT_INVENTORY - ) - } - - return isCovered - } -} - -export default InventoryService diff --git a/packages/medusa/src/services/order.ts b/packages/medusa/src/services/order.ts index 037934d967..297d96551a 100644 --- a/packages/medusa/src/services/order.ts +++ b/packages/medusa/src/services/order.ts @@ -38,13 +38,13 @@ import EventBusService from "./event-bus" import FulfillmentService from "./fulfillment" import FulfillmentProviderService from "./fulfillment-provider" import GiftCardService from "./gift-card" -import InventoryService from "./inventory" import LineItemService from "./line-item" import PaymentProviderService from "./payment-provider" import RegionService from "./region" import ShippingOptionService from "./shipping-option" import ShippingProfileService from "./shipping-profile" import TotalsService from "./totals" +import ProductVariantInventoryService from "./product-variant-inventory" import { NewTotalsService, TaxProviderService } from "./index" export const ORDER_CART_ALREADY_EXISTS_ERROR = "Order from cart already exists" @@ -68,9 +68,9 @@ type InjectedDependencies = { addressRepository: typeof AddressRepository giftCardService: GiftCardService draftOrderService: DraftOrderService - inventoryService: InventoryService eventBusService: EventBusService featureFlagRouter: FlagRouter + productVariantInventoryService: ProductVariantInventoryService } type TotalsConfig = { @@ -117,9 +117,10 @@ class OrderService extends TransactionBaseService { protected readonly addressRepository_: typeof AddressRepository protected readonly giftCardService_: GiftCardService protected readonly draftOrderService_: DraftOrderService - protected readonly inventoryService_: InventoryService protected readonly eventBus_: EventBusService protected readonly featureFlagRouter_: FlagRouter + // eslint-disable-next-line max-len + protected readonly productVariantInventoryService_: ProductVariantInventoryService constructor({ manager, @@ -140,9 +141,9 @@ class OrderService extends TransactionBaseService { addressRepository, giftCardService, draftOrderService, - inventoryService, eventBusService, featureFlagRouter, + productVariantInventoryService, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -166,8 +167,8 @@ class OrderService extends TransactionBaseService { this.cartService_ = cartService this.addressRepository_ = addressRepository this.draftOrderService_ = draftOrderService - this.inventoryService_ = inventoryService this.featureFlagRouter_ = featureFlagRouter + this.productVariantInventoryService_ = productVariantInventoryService } /** @@ -540,7 +541,6 @@ class OrderService extends TransactionBaseService { async createFromCart(cartOrId: string | Cart): Promise { return await this.atomicPhase_(async (manager) => { const cartServiceTx = this.cartService_.withTransaction(manager) - const inventoryServiceTx = this.inventoryService_.withTransaction(manager) const exists = !!(await this.retrieveByCartId( isString(cartOrId) ? cartOrId : cartOrId?.id, @@ -571,23 +571,6 @@ class OrderService extends TransactionBaseService { const { payment, region, total } = cart - await Promise.all( - cart.items.map(async (lineItem) => { - return await inventoryServiceTx.confirmInventory( - lineItem.variant_id, - lineItem.quantity - ) - }) - ).catch(async (err) => { - if (payment) { - await this.paymentProviderService_ - .withTransaction(manager) - .cancelPayment(payment) - } - await cartServiceTx.update(cart.id, { payment_authorized_at: null }) - throw err - }) - // Would be the case if a discount code is applied that covers the item // total if (total !== 0) { @@ -707,22 +690,18 @@ class OrderService extends TransactionBaseService { await Promise.all( [ - cart.items.map((lineItem) => { - const lineItemPromises: unknown[] = [ + cart.items.map((lineItem): unknown[] => { + const toReturn: unknown[] = [ lineItemServiceTx.update(lineItem.id, { order_id: order.id }), - inventoryServiceTx.adjustInventory( - lineItem.variant_id, - -lineItem.quantity - ), ] if (lineItem.is_giftcard) { - lineItemPromises.push( + toReturn.push( this.createGiftCardsFromLineItem_(order, lineItem, manager) ) } - return lineItemPromises + return toReturn }), cart.shipping_methods.map(async (method) => { // TODO: Due to cascade insert we have to remove the tax_lines that have been added by the cart decorate totals. @@ -1159,10 +1138,19 @@ class OrderService extends TransactionBaseService { throwErrorIf(order.swaps, notCanceled, "swaps") throwErrorIf(order.claims, notCanceled, "claims") - const inventoryServiceTx = this.inventoryService_.withTransaction(manager) - for (const item of order.items) { - await inventoryServiceTx.adjustInventory(item.variant_id, item.quantity) - } + const inventoryServiceTx = + this.productVariantInventoryService_.withTransaction(manager) + await Promise.all( + order.items.map(async (item) => { + if (item.variant_id) { + return await inventoryServiceTx.releaseReservationsByLineItem( + item.id, + item.variant_id, + item.quantity + ) + } + }) + ) const paymentProviderServiceTx = this.paymentProviderService_.withTransaction(manager) diff --git a/packages/medusa/src/services/product-variant-inventory.ts b/packages/medusa/src/services/product-variant-inventory.ts new file mode 100644 index 0000000000..7933176db8 --- /dev/null +++ b/packages/medusa/src/services/product-variant-inventory.ts @@ -0,0 +1,429 @@ +import { isDefined, MedusaError } from "medusa-core-utils" +import { EntityManager, In } from "typeorm" +import { + IStockLocationService, + IInventoryService, + TransactionBaseService, +} from "../interfaces" +import { ProductVariantInventoryItem } from "../models/product-variant-inventory-item" +import { ProductVariantService, SalesChannelLocationService } from "./" +import { InventoryItemDTO, ReserveQuantityContext } from "../types/inventory" +import { ProductVariant } from "../models" + +type InjectedDependencies = { + manager: EntityManager + salesChannelLocationService: SalesChannelLocationService + productVariantService: ProductVariantService + stockLocationService: IStockLocationService + inventoryService: IInventoryService +} + +class ProductVariantInventoryService extends TransactionBaseService { + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + + protected readonly salesChannelLocationService_: SalesChannelLocationService + protected readonly productVariantService_: ProductVariantService + protected readonly stockLocationService_: IStockLocationService + protected readonly inventoryService_: IInventoryService + + constructor({ + manager, + stockLocationService, + salesChannelLocationService, + productVariantService, + inventoryService, + }: InjectedDependencies) { + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) + + this.manager_ = manager + this.salesChannelLocationService_ = salesChannelLocationService + this.stockLocationService_ = stockLocationService + this.productVariantService_ = productVariantService + this.inventoryService_ = inventoryService + } + + /** + * confirms if requested inventory is available + * @param variantId id of the variant to confirm inventory for + * @param quantity quantity of inventory to confirm is available + * @param context optionally include a sales channel if applicable + * @returns boolean indicating if inventory is available + */ + async confirmInventory( + variantId: string, + quantity: number, + context: { salesChannelId?: string | null } = {} + ): Promise { + if (!variantId) { + return true + } + + const manager = this.transactionManager_ || this.manager_ + const productVariant = await this.productVariantService_ + .withTransaction(manager) + .retrieve(variantId, { + select: [ + "id", + "allow_backorder", + "manage_inventory", + "inventory_quantity", + ], + }) + + // If the variant allows backorders or if inventory isn't managed we + // don't need to check inventory + if (productVariant.allow_backorder || !productVariant.manage_inventory) { + return true + } + + if (!this.inventoryService_) { + return productVariant.inventory_quantity >= quantity + } + + const variantInventory = await this.listByVariant(variantId) + + // If there are no inventory items attached to the variant we default + // to true + if (variantInventory.length === 0) { + return true + } + + let locations: string[] = [] + if (context.salesChannelId) { + locations = await this.salesChannelLocationService_.listLocations( + context.salesChannelId + ) + } else { + const stockLocations = await this.stockLocationService_.list( + {}, + { select: ["id"] } + ) + locations = stockLocations.map((l) => l.id) + } + + const hasInventory = await Promise.all( + variantInventory.map(async (inventoryPart) => { + const itemQuantity = inventoryPart.quantity * quantity + return await this.inventoryService_.confirmInventory( + inventoryPart.inventory_item_id, + locations, + itemQuantity + ) + }) + ) + + return hasInventory.every(Boolean) + } + + /** + * list registered inventory items + * @param itemIds list inventory item ids + * @returns list of inventory items + */ + async listByItem(itemIds: string[]): Promise { + const manager = this.transactionManager_ || this.manager_ + + const variantInventoryRepo = manager.getRepository( + ProductVariantInventoryItem + ) + + const variantInventory = await variantInventoryRepo.find({ + where: { inventory_item_id: In(itemIds) }, + }) + + return variantInventory + } + + /** + * List inventory items for a specific variant + * @param variantId variant id + * @returns variant inventory items for the variant id + */ + private async listByVariant( + variantId: string + ): Promise { + const manager = this.transactionManager_ || this.manager_ + + const variantInventoryRepo = manager.getRepository( + ProductVariantInventoryItem + ) + + const variantInventory = await variantInventoryRepo.find({ + where: { variant_id: variantId }, + }) + + return variantInventory + } + + /** + * lists variant by inventory item id + * @param itemId item id + * @returns a list of product variants that are associated with the item id + */ + async listVariantsByItem(itemId: string): Promise { + if (!this.inventoryService_) { + return [] + } + + const variantInventory = await this.listByItem([itemId]) + const items = await this.productVariantService_.list({ + id: variantInventory.map((i) => i.variant_id), + }) + + return items + } + + /** + * lists inventory items for a given variant + * @param variantId variant id + * @returns lidt of inventory items for the variant + */ + async listInventoryItemsByVariant( + variantId: string + ): Promise { + if (!this.inventoryService_) { + return [] + } + + const variantInventory = await this.listByVariant(variantId) + const [items] = await this.inventoryService_.listInventoryItems({ + id: variantInventory.map((i) => i.inventory_item_id), + }) + + return items + } + + /** + * Attach a variant to an inventory item + * @param variantId variant id + * @param inventoryItemId inventory item id + * @param quantity quantity of variant to attach + * @returns the variant inventory item + */ + async attachInventoryItem( + variantId: string, + inventoryItemId: string, + quantity?: number + ): Promise { + const manager = this.transactionManager_ || this.manager_ + + // Verify that variant exists + await this.productVariantService_ + .withTransaction(manager) + .retrieve(variantId, { + select: ["id"], + }) + + // Verify that item exists + await this.inventoryService_.retrieveInventoryItem(inventoryItemId, { + select: ["id"], + }) + + const variantInventoryRepo = manager.getRepository( + ProductVariantInventoryItem + ) + + const existing = await variantInventoryRepo.findOne({ + where: { + variant_id: variantId, + inventory_item_id: inventoryItemId, + }, + }) + + if (existing) { + return existing + } + + let quantityToStore = 1 + if (typeof quantity !== "undefined") { + if (quantity < 1) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Quantity must be greater than 0" + ) + } else { + quantityToStore = quantity + } + } + + const variantInventory = variantInventoryRepo.create({ + variant_id: variantId, + inventory_item_id: inventoryItemId, + quantity: quantityToStore, + }) + + return await variantInventoryRepo.save(variantInventory) + } + + /** + * Remove a variant from an inventory item + * @param variantId variant id + * @param inventoryItemId inventory item id + */ + async detachInventoryItem( + variantId: string, + inventoryItemId: string + ): Promise { + const manager = this.transactionManager_ || this.manager_ + + const variantInventoryRepo = manager.getRepository( + ProductVariantInventoryItem + ) + + const existing = await variantInventoryRepo.findOne({ + where: { + variant_id: variantId, + inventory_item_id: inventoryItemId, + }, + }) + + if (existing) { + await variantInventoryRepo.remove(existing) + } + } + + /** + * Reserves a quantity of a variant + * @param variantId variant id + * @param quantity quantity to reserve + * @param context optional parameters + */ + async reserveQuantity( + variantId: string, + quantity: number, + context: ReserveQuantityContext = {} + ): Promise { + const manager = this.transactionManager_ || this.manager_ + + if (!this.inventoryService_) { + const variantServiceTx = + this.productVariantService_.withTransaction(manager) + const variant = await variantServiceTx.retrieve(variantId, { + select: ["id", "inventory_quantity"], + }) + await variantServiceTx.update(variant.id, { + inventory_quantity: variant.inventory_quantity - quantity, + }) + return + } + + const toReserve = { + type: "order", + line_item_id: context.lineItemId, + } + + const variantInventory = await this.listByVariant(variantId) + + if (variantInventory.length === 0) { + return + } + + let locationId = context.locationId + if (!isDefined(locationId) && context.salesChannelId) { + const locations = await this.salesChannelLocationService_ + .withTransaction(manager) + .listLocations(context.salesChannelId) + + if (!locations.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Must provide location_id or sales_channel_id to a Sales Channel that has associated Stock Locations" + ) + } + + locationId = locations[0] + } + + await Promise.all( + variantInventory.map(async (inventoryPart) => { + const itemQuantity = inventoryPart.quantity * quantity + return await this.inventoryService_.createReservationItem({ + ...toReserve, + location_id: locationId as string, + item_id: inventoryPart.inventory_item_id, + quantity: itemQuantity, + }) + }) + ) + } + + /** + * Remove reservation of variant quantity + * @param lineItemId line item id + * @param variantId variant id + * @param quantity quantity to release + */ + async releaseReservationsByLineItem( + lineItemId: string, + variantId: string, + quantity: number + ): Promise { + if (!this.inventoryService_) { + const variant = await this.productVariantService_.retrieve(variantId, { + select: ["id", "inventory_quantity", "manage_inventory"], + }) + + if (!variant.manage_inventory) { + return + } + + await this.productVariantService_.update(variantId, { + inventory_quantity: variant.inventory_quantity + quantity, + }) + } else { + await this.inventoryService_.deleteReservationItemsByLineItem(lineItemId) + } + } + + /** + * Adjusts inventory of a variant on a location + * @param variantId variant id + * @param locationId location id + * @param quantity quantity to adjust + */ + async adjustInventory( + variantId: string, + locationId: string, + quantity: number + ): Promise { + const manager = this.transactionManager_ || this.manager_ + if (!this.inventoryService_) { + const variant = await this.productVariantService_ + .withTransaction(manager) + .retrieve(variantId, { + select: ["id", "inventory_quantity", "manage_inventory"], + }) + + if (!variant.manage_inventory) { + return + } + + await this.productVariantService_ + .withTransaction(manager) + .update(variantId, { + inventory_quantity: variant.inventory_quantity + quantity, + }) + } else { + const variantInventory = await this.listByVariant(variantId) + + if (variantInventory.length === 0) { + return + } + + await Promise.all( + variantInventory.map(async (inventoryPart) => { + const itemQuantity = inventoryPart.quantity * quantity + return await this.inventoryService_.adjustInventory( + inventoryPart.inventory_item_id, + locationId, + itemQuantity + ) + }) + ) + } + } +} + +export default ProductVariantInventoryService diff --git a/packages/medusa/src/services/return.ts b/packages/medusa/src/services/return.ts index 3f90eb90f2..1920be5815 100644 --- a/packages/medusa/src/services/return.ts +++ b/packages/medusa/src/services/return.ts @@ -1,5 +1,6 @@ import { isDefined, MedusaError } from "medusa-core-utils" import { DeepPartial, EntityManager } from "typeorm" +import { ProductVariantInventoryService } from "." import { TransactionBaseService } from "../interfaces" import { FulfillmentStatus, @@ -17,7 +18,6 @@ import { OrdersReturnItem } from "../types/orders" import { CreateReturnInput, UpdateReturnInput } from "../types/return" import { buildQuery, setMetadata } from "../utils" import FulfillmentProviderService from "./fulfillment-provider" -import InventoryService from "./inventory" import LineItemService from "./line-item" import OrderService from "./order" import ReturnReasonService from "./return-reason" @@ -35,8 +35,8 @@ type InjectedDependencies = { returnReasonService: ReturnReasonService taxProviderService: TaxProviderService fulfillmentProviderService: FulfillmentProviderService - inventoryService: InventoryService orderService: OrderService + productVariantInventoryService: ProductVariantInventoryService } type Transformer = ( @@ -57,8 +57,9 @@ class ReturnService extends TransactionBaseService { protected readonly shippingOptionService_: ShippingOptionService protected readonly fulfillmentProviderService_: FulfillmentProviderService protected readonly returnReasonService_: ReturnReasonService - protected readonly inventoryService_: InventoryService protected readonly orderService_: OrderService + // eslint-disable-next-line + protected readonly productVariantInventoryService_: ProductVariantInventoryService constructor({ manager, @@ -70,9 +71,10 @@ class ReturnService extends TransactionBaseService { returnReasonService, taxProviderService, fulfillmentProviderService, - inventoryService, orderService, + productVariantInventoryService, }: InjectedDependencies) { + // eslint-disable-next-line prefer-rest-params super(arguments[0]) this.manager_ = manager @@ -84,8 +86,8 @@ class ReturnService extends TransactionBaseService { this.shippingOptionService_ = shippingOptionService this.fulfillmentProviderService_ = fulfillmentProviderService this.returnReasonService_ = returnReasonService - this.inventoryService_ = inventoryService this.orderService_ = orderService + this.productVariantInventoryService_ = productVariantInventoryService } /** @@ -669,12 +671,14 @@ class ReturnService extends TransactionBaseService { }) } - const inventoryServiceTx = this.inventoryService_.withTransaction(manager) + const inventoryServiceTx = + this.productVariantInventoryService_.withTransaction(manager) for (const line of newLines) { const orderItem = order.items.find((i) => i.id === line.item_id) - if (orderItem) { + if (orderItem && orderItem?.variant_id) { await inventoryServiceTx.adjustInventory( orderItem.variant_id, + returnObj.location_id, line.received_quantity ) } diff --git a/packages/medusa/src/services/sales-channel-inventory.ts b/packages/medusa/src/services/sales-channel-inventory.ts new file mode 100644 index 0000000000..8351c87195 --- /dev/null +++ b/packages/medusa/src/services/sales-channel-inventory.ts @@ -0,0 +1,54 @@ +import { EntityManager } from "typeorm" + +import { IInventoryService } from "../interfaces/services" + +import { SalesChannelLocationService, EventBusService } from "./" + +type InjectedDependencies = { + inventoryService: IInventoryService + salesChannelLocationService: SalesChannelLocationService + eventBusService: EventBusService + manager: EntityManager +} + +class SalesChannelInventoryService { + protected manager_: EntityManager + + protected readonly salesChannelLocationService_: SalesChannelLocationService + protected readonly eventBusService_: EventBusService + protected readonly inventoryService_: IInventoryService + + constructor({ + salesChannelLocationService, + inventoryService, + eventBusService, + manager, + }: InjectedDependencies) { + this.manager_ = manager + this.salesChannelLocationService_ = salesChannelLocationService + this.eventBusService_ = eventBusService + this.inventoryService_ = inventoryService + } + + /** + * Retrieves the available quantity of an item across all sales channel locations + * @param salesChannelId Sales channel id + * @param itemId Item id + * @returns available quantity of item across all sales channel locations + */ + async retrieveAvailableItemQuantity( + salesChannelId: string, + itemId: string + ): Promise { + const locations = await this.salesChannelLocationService_.listLocations( + salesChannelId + ) + + return await this.inventoryService_.retrieveAvailableQuantity( + itemId, + locations + ) + } +} + +export default SalesChannelInventoryService diff --git a/packages/medusa/src/services/sales-channel-location.ts b/packages/medusa/src/services/sales-channel-location.ts new file mode 100644 index 0000000000..5e7940d3a7 --- /dev/null +++ b/packages/medusa/src/services/sales-channel-location.ts @@ -0,0 +1,95 @@ +import { EntityManager } from "typeorm" +import { IStockLocationService, TransactionBaseService } from "../interfaces" +import { SalesChannelService, EventBusService } from "./" + +import { SalesChannelLocation } from "../models" + +type InjectedDependencies = { + stockLocationService: IStockLocationService + salesChannelService: SalesChannelService + eventBusService: EventBusService + manager: EntityManager +} + +class SalesChannelLocationService extends TransactionBaseService { + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + + protected readonly salesChannelService_: SalesChannelService + protected readonly eventBusService_: EventBusService + protected readonly stockLocationService_: IStockLocationService + + constructor({ + salesChannelService, + stockLocationService, + eventBusService, + manager, + }: InjectedDependencies) { + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) + + this.manager_ = manager + this.salesChannelService_ = salesChannelService + this.eventBusService_ = eventBusService + this.stockLocationService_ = stockLocationService + } + + /** + * Removes location from sales channel + * @param salesChannelId sales channel id + * @param locationId location id + */ + async removeLocation( + salesChannelId: string, + locationId: string + ): Promise { + const manager = this.transactionManager_ || this.manager_ + await manager.delete(SalesChannelLocation, { + sales_channel_id: salesChannelId, + location_id: locationId, + }) + } + + /** + * Links location to sales channel + * @param salesChannelId sales channel id + * @param locationId location id + */ + async associateLocation( + salesChannelId: string, + locationId: string + ): Promise { + const manager = this.transactionManager_ || this.manager_ + const salesChannel = await this.salesChannelService_ + .withTransaction(manager) + .retrieve(salesChannelId) + const stockLocation = await this.stockLocationService_.retrieve(locationId) + + const salesChannelLocation = manager.create(SalesChannelLocation, { + sales_channel_id: salesChannel.id, + location_id: stockLocation.id, + }) + + await manager.save(salesChannelLocation) + } + + /** + * Lists all locations associated with sales channel by id + * @param salesChannelId sales channel id + * @returns list of location ids associated with sales channel + */ + async listLocations(salesChannelId: string): Promise { + const manager = this.transactionManager_ || this.manager_ + const salesChannel = await this.salesChannelService_ + .withTransaction(manager) + .retrieve(salesChannelId) + + const locations = await manager.find(SalesChannelLocation, { + where: { sales_channel_id: salesChannel.id }, + }) + + return locations.map((l) => l.location_id) + } +} + +export default SalesChannelLocationService diff --git a/packages/medusa/src/services/swap.ts b/packages/medusa/src/services/swap.ts index 6caf4eb527..96b11cbca8 100644 --- a/packages/medusa/src/services/swap.ts +++ b/packages/medusa/src/services/swap.ts @@ -12,10 +12,10 @@ import { CustomShippingOptionService, EventBusService, FulfillmentService, - InventoryService, LineItemService, OrderService, PaymentProviderService, + ProductVariantInventoryService, ReturnService, ShippingOptionService, TotalsService, @@ -48,7 +48,7 @@ type InjectedProps = { totalsService: TotalsService eventBusService: EventBusService lineItemService: LineItemService - inventoryService: InventoryService + productVariantInventoryService: ProductVariantInventoryService fulfillmentService: FulfillmentService shippingOptionService: ShippingOptionService paymentProviderService: PaymentProviderService @@ -83,12 +83,13 @@ class SwapService extends TransactionBaseService { protected readonly returnService_: ReturnService protected readonly totalsService_: TotalsService protected readonly lineItemService_: LineItemService - protected readonly inventoryService_: InventoryService protected readonly fulfillmentService_: FulfillmentService protected readonly shippingOptionService_: ShippingOptionService protected readonly paymentProviderService_: PaymentProviderService protected readonly lineItemAdjustmentService_: LineItemAdjustmentService protected readonly customShippingOptionService_: CustomShippingOptionService + // eslint-disable-next-line max-len + protected readonly productVariantInventoryService_: ProductVariantInventoryService constructor({ manager, @@ -102,7 +103,7 @@ class SwapService extends TransactionBaseService { shippingOptionService, fulfillmentService, orderService, - inventoryService, + productVariantInventoryService, customShippingOptionService, lineItemAdjustmentService, }: InjectedProps) { @@ -120,7 +121,7 @@ class SwapService extends TransactionBaseService { this.fulfillmentService_ = fulfillmentService this.orderService_ = orderService this.shippingOptionService_ = shippingOptionService - this.inventoryService_ = inventoryService + this.productVariantInventoryService_ = productVariantInventoryService this.eventBus_ = eventBusService this.customShippingOptionService_ = customShippingOptionService this.lineItemAdjustmentService_ = lineItemAdjustmentService @@ -357,6 +358,12 @@ class SwapService extends TransactionBaseService { if (additionalItems) { newItems = await Promise.all( additionalItems.map(async ({ variant_id, quantity }) => { + if (variant_id === null) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "You must include a variant when creating additional items on a swap" + ) + } return this.lineItemService_ .withTransaction(manager) .generate(variant_id, order.region_id, quantity) @@ -734,29 +741,6 @@ class SwapService extends TransactionBaseService { const items = cart.items - if (!swap.allow_backorder) { - const inventoryServiceTx = - this.inventoryService_.withTransaction(manager) - const paymentProviderServiceTx = - this.paymentProviderService_.withTransaction(manager) - const cartServiceTx = this.cartService_.withTransaction(manager) - - for (const item of items) { - try { - await inventoryServiceTx.confirmInventory( - item.variant_id, - item.quantity - ) - } catch (err) { - if (payment) { - await paymentProviderServiceTx.cancelPayment(payment) - } - await cartServiceTx.update(cart.id, { payment_authorized_at: null }) - throw err - } - } - } - const total = cart.total! if (total > 0) { @@ -790,15 +774,20 @@ class SwapService extends TransactionBaseService { order_id: swap.order_id, }) - const inventoryServiceTx = - this.inventoryService_.withTransaction(manager) - - for (const item of items) { - await inventoryServiceTx.adjustInventory( - item.variant_id, - -item.quantity - ) - } + await Promise.all( + items.map(async (item) => { + if (item.variant_id) { + await this.productVariantInventoryService_.reserveQuantity( + item.variant_id, + item.quantity, + { + lineItemId: item.id, + salesChannelId: cart.sales_channel_id, + } + ) + } + }) + ) } swap.difference_due = total diff --git a/packages/medusa/src/strategies/__tests__/cart-completion.js b/packages/medusa/src/strategies/__tests__/cart-completion.js index 93615b7211..dd83c10989 100644 --- a/packages/medusa/src/strategies/__tests__/cart-completion.js +++ b/packages/medusa/src/strategies/__tests__/cart-completion.js @@ -1,6 +1,7 @@ import { MockManager } from "medusa-test-utils" import CartCompletionStrategy from "../cart-completion" import { newTotalsServiceMock } from "../../services/__mocks__/new-totals" +import { ProductVariantInventoryServiceMock } from "../../services/__mocks__/product-variant-inventory" const IdempotencyKeyServiceMock = { withTransaction: function () { @@ -183,9 +184,11 @@ describe("CartCompletionStrategy", () => { return this }, createTaxLines: jest.fn(() => { - cart.items[0].tax_lines = [{ - id: "tax_lines" - }] + cart.items[0].tax_lines = [ + { + id: "tax_lines", + }, + ] return Promise.resolve(cart) }), deleteTaxLines: jest.fn(() => Promise.resolve(cart)), @@ -207,12 +210,16 @@ describe("CartCompletionStrategy", () => { withTransaction: function () { return this }, + retrieveByCartId: jest.fn((id) => + Promise.resolve({ id, allow_backorder: true }) + ), registerCartCompletion: jest.fn(() => Promise.resolve({})), retrieve: jest.fn(() => Promise.resolve({})), } const idempotencyKeyServiceMock = IdempotencyKeyServiceMock const completionStrat = new CartCompletionStrategy({ + productVariantInventoryService: ProductVariantInventoryServiceMock, cartService: cartServiceMock, idempotencyKeyService: idempotencyKeyServiceMock, orderService: orderServiceMock, diff --git a/packages/medusa/src/strategies/cart-completion.ts b/packages/medusa/src/strategies/cart-completion.ts index 883d172f14..8c3c7ce0ef 100644 --- a/packages/medusa/src/strategies/cart-completion.ts +++ b/packages/medusa/src/strategies/cart-completion.ts @@ -14,8 +14,14 @@ import { AbstractCartCompletionStrategy, CartCompletionResponse, } from "../interfaces" +import { + PaymentProviderService, + ProductVariantInventoryService, +} from "../services" type InjectedDependencies = { + productVariantInventoryService: ProductVariantInventoryService + paymentProviderService: PaymentProviderService idempotencyKeyService: IdempotencyKeyService cartService: CartService orderService: OrderService @@ -26,12 +32,17 @@ type InjectedDependencies = { class CartCompletionStrategy extends AbstractCartCompletionStrategy { protected manager_: EntityManager + // eslint-disable-next-line max-len + protected readonly productVariantInventoryService_: ProductVariantInventoryService + protected readonly paymentProviderService_: PaymentProviderService protected readonly idempotencyKeyService_: IdempotencyKeyService protected readonly cartService_: CartService protected readonly orderService_: OrderService protected readonly swapService_: SwapService constructor({ + productVariantInventoryService, + paymentProviderService, idempotencyKeyService, cartService, orderService, @@ -40,6 +51,8 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { }: InjectedDependencies) { super() + this.paymentProviderService_ = paymentProviderService + this.productVariantInventoryService_ = productVariantInventoryService this.idempotencyKeyService_ = idempotencyKeyService this.cartService_ = cartService this.orderService_ = orderService @@ -245,26 +258,90 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy { } const orderServiceTx = this.orderService_.withTransaction(manager) + const swapServiceTx = this.swapService_.withTransaction(manager) + const cartServiceTx = this.cartService_.withTransaction(manager) - const cart = await this.cartService_ - .withTransaction(manager) - .retrieveWithTotals(id, { - relations: ["region", "payment", "payment_sessions"], - }) + const cart = await cartServiceTx.retrieveWithTotals(id, { + relations: ["region", "payment", "payment_sessions"], + }) + + let allowBackorder = false + let swapId: string + + if (cart.type === "swap") { + const swap = await swapServiceTx.retrieveByCartId(id) + allowBackorder = swap.allow_backorder + swapId = swap.id + } + + if (!allowBackorder) { + const productVariantInventoryServiceTx = + this.productVariantInventoryService_.withTransaction(manager) + + try { + await Promise.all( + cart.items.map(async (item) => { + if (item.variant_id) { + const inventoryConfirmed = + await productVariantInventoryServiceTx.confirmInventory( + item.variant_id, + item.quantity, + { salesChannelId: cart.sales_channel_id } + ) + + if (!inventoryConfirmed) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Variant with id: ${item.variant_id} does not have the required inventory`, + MedusaError.Codes.INSUFFICIENT_INVENTORY + ) + } + + await productVariantInventoryServiceTx.reserveQuantity( + item.variant_id, + item.quantity, + { + lineItemId: item.id, + salesChannelId: cart.sales_channel_id, + } + ) + } + }) + ) + } catch (error) { + if (error && error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY) { + if (cart.payment) { + await this.paymentProviderService_ + .withTransaction(manager) + .cancelPayment(cart.payment) + } + await cartServiceTx.update(cart.id, { + payment_authorized_at: null, + }) + + return { + response_code: 409, + response_body: { + message: error.message, + type: error.type, + code: error.code, + }, + } + } else { + throw error + } + } + } // If cart is part of swap, we register swap as complete if (cart.type === "swap") { try { const swapId = cart.metadata?.swap_id - let swap = await this.swapService_ - .withTransaction(manager) - .registerCartCompletion(swapId as string) + let swap = await swapServiceTx.registerCartCompletion(swapId as string) - swap = await this.swapService_ - .withTransaction(manager) - .retrieve(swap.id, { - relations: ["shipping_address"], - }) + swap = await swapServiceTx.retrieve(swap.id, { + relations: ["shipping_address"], + }) return { response_code: 200, diff --git a/packages/medusa/src/types/inventory.ts b/packages/medusa/src/types/inventory.ts new file mode 100644 index 0000000000..5c35141588 --- /dev/null +++ b/packages/medusa/src/types/inventory.ts @@ -0,0 +1,108 @@ +import { NumericalComparisonOperator, StringComparisonOperator } from "./common" + +export type InventoryItemDTO = { + id: string + sku?: string | null + origin_country?: string | null + hs_code?: string | null + requires_shipping: boolean + mid_code?: string | null + material?: string | null + weight?: number | null + length?: number | null + height?: number | null + width?: number | null + metadata: Record | null + created_at: string | Date + updated_at: string | Date + deleted_at: string | Date | null +} + +export type ReservationItemDTO = { + id: string + location_id: string + item_id: string + metadata: Record | null + created_at: string | Date + updated_at: string | Date + deleted_at: string | Date | null +} + +export type InventoryLevelDTO = { + id: string + item_id: string + location_id: string + stocked_quantity: number + incoming_quantity: number + metadata: Record | null + created_at: string | Date + updated_at: string | Date + deleted_at: string | Date | null +} + +export type FilterableReservationItemProps = { + id?: string | string[] + type?: string | string[] + line_item_id?: string | string[] + item_id?: string | string[] + location_id?: string | string[] + quantity?: number | NumericalComparisonOperator +} + +export type FilterableInventoryItemProps = { + id?: string | string[] + location_id?: string | string[] + q?: string + sku?: string | string[] | StringComparisonOperator + origin_country?: string | string[] + hs_code?: string | string[] | StringComparisonOperator + requires_shipping?: boolean +} + +export type CreateInventoryItemInput = { + sku?: string + origin_country?: string + mid_code?: string + material?: string + weight?: number + length?: number + height?: number + width?: number + metadata?: Record | null + hs_code?: string + requires_shipping?: boolean +} + +export type CreateReservationItemInput = { + type?: string + line_item_id?: string + item_id: string + location_id: string + quantity: number + metadata?: Record | null +} + +export type FilterableInventoryLevelProps = { + item_id?: string | string[] + location_id?: string | string[] + stocked_quantity?: number | NumericalComparisonOperator + incoming_quantity?: number | NumericalComparisonOperator +} + +export type CreateInventoryLevelInput = { + item_id: string + location_id: string + stocked_quantity: number + incoming_quantity: number +} + +export type UpdateInventoryLevelInput = { + stocked_quantity?: number + incoming_quantity?: number +} + +export type ReserveQuantityContext = { + locationId?: string + lineItemId?: string + salesChannelId?: string | null +} diff --git a/packages/medusa/src/types/stock-location.ts b/packages/medusa/src/types/stock-location.ts new file mode 100644 index 0000000000..4f7662b28e --- /dev/null +++ b/packages/medusa/src/types/stock-location.ts @@ -0,0 +1,48 @@ +import { StringComparisonOperator } from "./common" + +export type StockLocationAddressDTO = { + id?: string + address_1: string + address_2?: string + city?: string + country_code?: string + phone?: string + postal_code?: string + province?: string +} + +export type StockLocationDTO = { + id: string + name: string + metadata: Record | null + address_id: string + created_at: string | Date + updated_at: string | Date + deleted_at: string | Date | null +} + +export type FilterableStockLocationProps = { + id?: string | string[] + name?: string | string[] | StringComparisonOperator +} + +export type StockLocationAddressInput = { + address_1: string + address_2?: string + city?: string + country_code?: string + phone?: string + province?: string + postal_code?: string +} + +export type CreateStockLocationInput = { + name: string + address?: string | StockLocationAddressInput +} + +export type UpdateStockLocationInput = { + name?: string + address_id?: string + address?: StockLocationAddressInput +} diff --git a/packages/medusa/src/utils/index.ts b/packages/medusa/src/utils/index.ts index 398554bac6..f4d5653093 100644 --- a/packages/medusa/src/utils/index.ts +++ b/packages/medusa/src/utils/index.ts @@ -7,3 +7,4 @@ export * from "./is-string" export * from "./calculate-price-tax-amount" export * from "./csv-cell-content-formatter" export * from "./exception-formatter" +export * from "./db-aware-column"