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
This commit is contained in:
Philip Korsholm
2022-12-30 11:30:04 +01:00
committed by GitHub
parent eda26f6e81
commit b9680b641f
42 changed files with 1514 additions and 761 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@medusajs/medusa": patch
---
Add services:
- `sales-channel-inventory`
- `sales-channel-location`
- `product-variant-inventory`
@@ -25,6 +25,7 @@ Object {
},
],
"default_currency_code": "usd",
"default_location_id": null,
"id": Any<String>,
"invite_link_template": null,
"metadata": null,
@@ -59,6 +60,7 @@ Object {
"symbol_native": "¥",
},
"default_currency_code": "jpy",
"default_location_id": null,
"id": Any<String>,
"invite_link_template": null,
"metadata": null,
@@ -93,6 +95,7 @@ Object {
"symbol_native": "kr",
},
"default_currency_code": "dkk",
"default_location_id": null,
"id": Any<String>,
"invite_link_template": null,
"metadata": null,
@@ -121,6 +124,7 @@ Object {
"symbol_native": "$",
},
"default_currency_code": "usd",
"default_location_id": null,
"feature_flags": Any<Array>,
"fulfillment_providers": Array [
Object {
@@ -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,
@@ -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)
@@ -566,6 +566,7 @@ Object {
"return_id": Any<String>,
},
],
"location_id": null,
"metadata": null,
"no_notification": null,
"order_id": Any<String>,
@@ -709,6 +710,7 @@ Object {
"return_id": Any<String>,
},
],
"location_id": null,
"metadata": null,
"no_notification": null,
"order_id": Any<String>,
@@ -1737,6 +1739,7 @@ Object {
"return_id": Any<String>,
},
],
"location_id": null,
"metadata": null,
"no_notification": null,
"order_id": Any<String>,
@@ -1880,6 +1883,7 @@ Object {
"return_id": Any<String>,
},
],
"location_id": null,
"metadata": null,
"no_notification": null,
"order_id": Any<String>,
@@ -2400,6 +2404,7 @@ Object {
"return_id": Any<String>,
},
],
"location_id": null,
"metadata": null,
"no_notification": null,
"order_id": null,
+4
View File
@@ -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"
@@ -1 +1,3 @@
export * from "./cache"
export * from "./stock-location"
export * from "./inventory"
@@ -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<InventoryItemDTO>
): Promise<[InventoryItemDTO[], number]>
listReservationItems(
selector: FilterableReservationItemProps,
config?: FindConfig<ReservationItemDTO>
): Promise<[ReservationItemDTO[], number]>
listInventoryLevels(
selector: FilterableInventoryLevelProps,
config?: FindConfig<InventoryLevelDTO>
): Promise<[InventoryLevelDTO[], number]>
retrieveInventoryItem(
itemId: string,
config?: FindConfig<InventoryItemDTO>
): Promise<InventoryItemDTO>
retrieveInventoryLevel(
itemId: string,
locationId: string
): Promise<InventoryLevelDTO>
createReservationItem(
input: CreateReservationItemInput
): Promise<ReservationItemDTO>
createInventoryItem(
input: CreateInventoryItemInput
): Promise<InventoryItemDTO>
createInventoryLevel(
data: CreateInventoryLevelInput
): Promise<InventoryLevelDTO>
updateInventoryLevel(
itemId: string,
locationId: string,
update: UpdateInventoryLevelInput
): Promise<InventoryLevelDTO>
updateInventoryItem(
itemId: string,
input: CreateInventoryItemInput
): Promise<InventoryItemDTO>
deleteReservationItemsByLineItem(lineItemId: string): Promise<void>
deleteReservationItem(id: string): Promise<void>
deleteInventoryItem(itemId: string): Promise<void>
deleteInventoryLevel(itemId: string, locationId: string): Promise<void>
adjustInventory(
itemId: string,
locationId: string,
adjustment: number
): Promise<InventoryLevelDTO>
confirmInventory(
itemId: string,
locationIds: string[],
quantity: number
): Promise<boolean>
retrieveAvailableQuantity(
itemId: string,
locationIds: string[]
): Promise<number>
retrieveStockedQuantity(
itemId: string,
locationIds: string[]
): Promise<number>
}
@@ -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<StockLocationDTO>
): Promise<StockLocationDTO[]>
listAndCount(
selector: FilterableStockLocationProps,
config?: FindConfig<StockLocationDTO>
): Promise<[StockLocationDTO[], number]>
retrieve(id: string): Promise<StockLocationDTO>
create(input: CreateStockLocationInput): Promise<StockLocationDTO>
update(id: string, input: UpdateStockLocationInput): Promise<StockLocationDTO>
}
@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class multiLocation1671711415179 implements MigrationInterface {
name = "multiLocation1666251508718"
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`)
}
}
+2
View File
@@ -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"
+1 -1
View File
@@ -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" })
@@ -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")
}
}
+4
View File
@@ -72,6 +72,10 @@ export class Return extends BaseEntity {
@DbAwareColumn({ type: "jsonb", nullable: true })
shipping_data: Record<string, unknown>
@Index()
@Column({ nullable: true })
location_id: string
@Column({ type: "int" })
refund_amount: number
@@ -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")
}
}
+3
View File
@@ -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<string, unknown>
@@ -155,7 +155,7 @@ export class ProductVariantRepository extends Repository<ProductVariant> {
entitiesIds,
idsOrOptionsWithoutRelations as FindConditions<ProductVariant>
)
return [toReturn, toReturn.length]
return [toReturn, count]
}
const groupedRelations = this.getGroupedRelations(
@@ -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`
)
}
}),
}
@@ -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) => {}),
}
+61 -62
View File
@@ -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(
@@ -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"
+19 -17
View File
@@ -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`
@@ -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,
}
)
})
})
})
+28 -76
View File
@@ -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",
+41 -179
View File
@@ -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({
+24 -21
View File
@@ -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 () => {
+103 -40
View File
@@ -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<Cart> {
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)
})
)
)
@@ -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,
+20 -21
View File
@@ -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 =
+3 -1
View File
@@ -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"
-100
View File
@@ -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<ProductVariant | undefined> {
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<boolean> {
// 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
+23 -35
View File
@@ -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<Order | never> {
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)
@@ -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<Boolean> {
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<ProductVariantInventoryItem[]> {
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<ProductVariantInventoryItem[]> {
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<ProductVariant[]> {
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<InventoryItemDTO[]> {
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<ProductVariantInventoryItem> {
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<void> {
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<void> {
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<void> {
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<void> {
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
+11 -7
View File
@@ -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
)
}
@@ -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<number> {
const locations = await this.salesChannelLocationService_.listLocations(
salesChannelId
)
return await this.inventoryService_.retrieveAvailableQuantity(
itemId,
locations
)
}
}
export default SalesChannelInventoryService
@@ -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<void> {
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<void> {
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<string[]> {
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
+26 -37
View File
@@ -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
@@ -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,
@@ -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,
+108
View File
@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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
}
@@ -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<string, unknown> | 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
}
+1
View File
@@ -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"