feat: In band inventory updates (#311)

Co-authored-by: olivermrbl <oliver@mrbltech.com>
This commit is contained in:
Kasper Fabricius Kristensen
2021-08-05 12:21:15 +02:00
committed by GitHub
co-authored by olivermrbl
parent 44fce520aa
commit f07cc0fa40
32 changed files with 1870 additions and 268 deletions
+10 -2
View File
@@ -12,6 +12,11 @@ export const MedusaErrorTypes = {
NOT_ALLOWED: "not_allowed",
}
export const MedusaErrorCodes = {
INSUFFICIENT_INVENTORY: "insufficient_inventory",
CART_INCOMPATIBLE_STATE: "cart_incompatible_state",
}
/**
* Standardized error to be used across Medusa project.
* @extends Error
@@ -22,19 +27,22 @@ class MedusaError extends Error {
* @param type {MedusaErrorType} - the type of error.
* @param params {Array} - Error params.
*/
constructor(name, message, ...params) {
constructor(type, message, code, ...params) {
super(...params)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, MedusaError)
}
this.name = name
this.type = type
this.name = type
this.code = code
this.message = message
this.date = new Date()
}
}
MedusaError.Types = MedusaErrorTypes
MedusaError.Codes = MedusaErrorCodes
export default MedusaError
@@ -104,8 +104,8 @@ describe("POST /store/carts/:id", () => {
)
})
it("Call CartService retrieve 0 times", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(0)
it("Call CartService retrieve 1 time", () => {
expect(CartServiceMock.retrieve).toHaveBeenCalledTimes(1)
})
it("returns 200", () => {
@@ -71,7 +71,20 @@ export default async (req, res) => {
const { key, error } = await idempotencyKeyService.workStage(
idempotencyKey.idempotency_key,
async manager => {
const cart = await cartService
let cart = await cartService.withTransaction(manager).retrieve(id)
if (cart.completed_at) {
return {
response_code: 409,
response_body: {
code: MedusaError.Codes.CART_INCOMPATIBLE_STATE,
message: "Cart has already been completed",
type: MedusaError.Types.NOT_ALLOWED,
},
}
}
cart = await cartService
.withTransaction(manager)
.authorizePayment(id, {
...req.request_context,
@@ -85,7 +98,11 @@ export default async (req, res) => {
) {
return {
response_code: 200,
response_body: { data: cart },
response_body: {
data: cart,
payment_status: cart.payment_session.status,
type: "cart",
},
}
}
}
@@ -122,17 +139,17 @@ export default async (req, res) => {
switch (cart.type) {
case "swap": {
const swapId = cart.metadata?.swap_id
order = await swapService
let swap = await swapService
.withTransaction(manager)
.registerCartCompletion(swapId)
order = await swapService
swap = await swapService
.withTransaction(manager)
.retrieve(order.id, { relations: ["shipping_address"] })
.retrieve(swap.id, { relations: ["shipping_address"] })
return {
response_code: 200,
response_body: { data: order },
response_body: { data: swap, type: "swap" },
}
}
// case "payment_link":
@@ -168,7 +185,19 @@ export default async (req, res) => {
return {
response_code: 200,
response_body: { data: order },
response_body: { data: order, type: "order" },
}
} else if (
error &&
error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY
) {
return {
response_code: 409,
response_body: {
message: error.message,
type: error.type,
code: error.code,
},
}
} else {
throw error
@@ -192,7 +221,7 @@ export default async (req, res) => {
return {
response_code: 200,
response_body: { data: order },
response_body: { data: order, type: "order" },
}
}
)
@@ -230,7 +259,6 @@ export default async (req, res) => {
res.status(idempotencyKey.response_code).json(idempotencyKey.response_body)
} catch (error) {
console.log(error)
throw error
}
}
@@ -260,6 +260,9 @@ export const CartServiceMock = {
if (cartId === IdMap.getId("cartWithPaySessions")) {
return Promise.resolve(carts.cartWithPaySessions)
}
if (cartId === IdMap.getId("test-cart2")) {
return Promise.resolve(carts.testCart)
}
throw new MedusaError(MedusaError.Types.NOT_FOUND, "cart not found")
}),
addLineItem: jest.fn().mockImplementation((cartId, lineItem) => {
@@ -0,0 +1,20 @@
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`
)
}
}),
}
@@ -111,6 +111,46 @@ const giftCardVar = {
title: "100 USD",
}
const outOfStockBackOrder = {
id: "bo",
title: "variant_popular",
inventory_quantity: 0,
allow_backorder: true,
manage_inventory: true,
}
const outOfStockNoBackOrder = {
id: "no_bo",
title: "variant_popular",
inventory_quantity: 0,
allow_backorder: false,
manage_inventory: true,
}
const outOfStockNoManage = {
id: "no_manage",
title: "variant_popular",
inventory_quantity: 0,
allow_backorder: false,
manage_inventory: false,
}
const StockOf10Manage = {
id: "10_man",
title: "variant_popular",
inventory_quantity: 10,
allow_backorder: false,
manage_inventory: true,
}
const StockOf1Manage = {
id: "1_man",
title: "variant_popular",
inventory_quantity: 1,
allow_backorder: false,
manage_inventory: true,
}
export const variants = {
one: variant1,
two: variant2,
@@ -171,17 +211,21 @@ export const ProductVariantServiceMock = {
if (variantId === IdMap.getId("testVariant")) {
return Promise.resolve(testVariant)
}
}),
canCoverQuantity: jest.fn().mockImplementation((variantId, quantity) => {
if (variantId === IdMap.getId("can-cover")) {
return Promise.resolve(true)
if (variantId === "bo") {
return Promise.resolve(outOfStockBackOrder)
}
if (variantId === IdMap.getId("cannot-cover")) {
return Promise.resolve(false)
if (variantId === "no_bo") {
return Promise.resolve(outOfStockNoBackOrder)
}
if (variantId === "no_manage") {
return Promise.resolve(outOfStockNoManage)
}
if (variantId === "10_man") {
return Promise.resolve(StockOf10Manage)
}
if (variantId === "1_man") {
return Promise.resolve(StockOf1Manage)
}
return Promise.reject(new Error("Not found"))
}),
getRegionPrice: jest.fn().mockImplementation((variantId, regionId) => {
if (variantId === IdMap.getId("eur-10-us-12")) {
+29 -10
View File
@@ -1,6 +1,8 @@
import _ from "lodash"
import { IdMap, MockRepository, MockManager } from "medusa-test-utils"
import CartService from "../cart"
import { InventoryServiceMock } from "../__mocks__/inventory"
import { MedusaError } from "medusa-core-utils"
const eventBusService = {
emit: jest.fn(),
@@ -311,10 +313,18 @@ describe("CartService", () => {
},
}
const productVariantService = {
canCoverQuantity: jest
.fn()
.mockImplementation(id => id !== IdMap.getId("cannot-cover")),
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 cartRepository = MockRepository({
@@ -349,9 +359,9 @@ describe("CartService", () => {
totalsService,
cartRepository,
lineItemService,
productVariantService,
eventBusService,
shippingOptionService,
inventoryService,
})
beforeEach(() => {
@@ -449,7 +459,11 @@ describe("CartService", () => {
await expect(
cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem)
).rejects.toThrow(`Inventory doesn't cover the desired quantity`)
).rejects.toThrow(
`Variant with id: ${IdMap.getId(
"cannot-cover"
)} does not have the required inventory`
)
})
it("throws if inventory isn't covered", async () => {
@@ -463,7 +477,11 @@ describe("CartService", () => {
await expect(
cartService.addLineItem(IdMap.getId("cartWithLine"), lineItem)
).rejects.toThrow(`Inventory doesn't cover the desired quantity`)
).rejects.toThrow(
`Variant with id: ${IdMap.getId(
"cannot-cover"
)} does not have the required inventory`
)
})
})
@@ -635,8 +653,9 @@ describe("CartService", () => {
return this
},
}
const productVariantService = {
canCoverQuantity: jest
const inventoryService = {
...InventoryServiceMock,
confirmInventory: jest
.fn()
.mockImplementation(id => id !== IdMap.getId("cannot-cover")),
}
@@ -669,9 +688,9 @@ describe("CartService", () => {
manager: MockManager,
totalsService,
cartRepository,
productVariantService,
lineItemService,
eventBusService,
inventoryService,
})
beforeEach(() => {
@@ -1,6 +1,7 @@
import _ from "lodash"
import { IdMap, MockRepository, MockManager } from "medusa-test-utils"
import ClaimService from "../claim"
import { InventoryServiceMock } from "../__mocks__/inventory"
const withTransactionMock = jest.fn()
const eventBusService = {
@@ -73,6 +74,14 @@ describe("ClaimService", () => {
},
}
const inventoryService = {
...InventoryServiceMock,
withTransaction: function() {
withTransactionMock("inventory")
return this
},
}
const claimItemService = {
create: jest.fn(),
withTransaction: function() {
@@ -88,6 +97,7 @@ describe("ClaimService", () => {
returnService,
lineItemService,
claimItemService,
inventoryService,
eventBusService,
})
@@ -125,6 +135,18 @@ 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(withTransactionMock).toHaveBeenCalledWith("claimItem")
expect(claimItemService.create).toHaveBeenCalledTimes(1)
expect(claimItemService.create).toHaveBeenCalledWith({
@@ -213,6 +235,24 @@ describe("ClaimService", () => {
).rejects.toThrow(`Claims must have at least one claim item.`)
})
it("fails if additional items are not in stock", async () => {
try {
const res = await claimService.create({
...testClaim,
additional_items: [
{
variant_id: "var_123",
quantity: 25,
},
],
})
console.warn(res)
} catch (e) {
expect(e.message).toEqual(
`Variant with id: var_123 does not have the required inventory`
)
}
})
it.each(
[
[false, false],
@@ -0,0 +1,91 @@
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,
}
)
})
})
})
+106 -4
View File
@@ -1,5 +1,6 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import OrderService from "../order"
import { InventoryServiceMock } from "../__mocks__/inventory"
describe("OrderService", () => {
const totalsService = {
@@ -37,6 +38,10 @@ describe("OrderService", () => {
},
}
const inventoryService = {
...InventoryServiceMock,
}
describe("create", () => {
const orderRepo = MockRepository({ create: f => f })
const orderService = new OrderService({
@@ -95,6 +100,9 @@ describe("OrderService", () => {
return Promise.resolve(payment.status || "authorized")
},
updatePayment: jest.fn(),
cancelPayment: jest.fn().mockImplementation(payment => {
return Promise.resolve({ ...payment, status: "cancelled" })
}),
withTransaction: function() {
return this
},
@@ -153,6 +161,7 @@ describe("OrderService", () => {
regionService,
eventBusService,
cartService,
inventoryService,
})
beforeEach(async () => {
@@ -186,7 +195,10 @@ describe("OrderService", () => {
gift_cards: [],
discounts: [],
shipping_methods: [{ id: "method_1" }],
items: [{ id: "item_1" }, { id: "item_2" }],
items: [
{ id: "item_1", variant_id: "variant-1", quantity: 1 },
{ id: "item_2", variant_id: "variant-2", quantity: 1 },
],
total: 100,
}
@@ -231,6 +243,16 @@ 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",
@@ -272,7 +294,10 @@ describe("OrderService", () => {
],
discounts: [],
shipping_methods: [{ id: "method_1" }],
items: [{ id: "item_1" }, { id: "item_2" }],
items: [
{ id: "item_1", variant_id: "variant-1", quantity: 1 },
{ id: "item_2", variant_id: "variant-2", quantity: 1 },
],
subtotal: 100,
total: 100,
}
@@ -361,7 +386,10 @@ describe("OrderService", () => {
billing_address_id: "1234",
discounts: [],
shipping_methods: [{ id: "method_1" }],
items: [{ id: "item_1" }, { id: "item_2" }],
items: [
{ id: "item_1", variant_id: "variant-1", quantity: 1 },
{ id: "item_2", variant_id: "variant-2", quantity: 1 },
],
total: 0,
}
orderService.cartService_.retrieve = () => Promise.resolve(cart)
@@ -395,6 +423,45 @@ 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_.retrieve = () => 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", () => {
@@ -545,6 +612,10 @@ describe("OrderService", () => {
status: "pending",
fulfillments: [{ id: "fulfillment_test" }],
payments: [{ id: "payment_test" }],
items: [
{ id: "item_1", variant_id: "variant-1", quantity: 12 },
{ id: "item_2", variant_id: "variant-2", quantity: 1 },
],
})
}
},
@@ -571,6 +642,7 @@ describe("OrderService", () => {
paymentProviderService,
fulfillmentService,
eventBusService,
inventoryService,
})
beforeEach(async () => {
@@ -578,7 +650,15 @@ describe("OrderService", () => {
})
it("calls order model functions", async () => {
await orderService.cancel(IdMap.getId("not-fulfilled-order"))
try {
const order = await orderService.retrieve(
IdMap.getId("not-fulfilled-order")
)
console.warn(order)
await orderService.cancel(IdMap.getId("not-fulfilled-order"))
} catch (e) {
console.warn(e)
}
expect(paymentProviderService.cancelPayment).toHaveBeenCalledTimes(1)
expect(paymentProviderService.cancelPayment).toHaveBeenCalledWith({
@@ -590,6 +670,16 @@ describe("OrderService", () => {
id: "fulfillment_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",
@@ -597,6 +687,18 @@ describe("OrderService", () => {
status: "canceled",
fulfillments: [{ id: "fulfillment_test" }],
payments: [{ id: "payment_test" }],
items: [
{
id: "item_1",
quantity: 12,
variant_id: "variant-1",
},
{
id: "item_2",
quantity: 1,
variant_id: "variant-2",
},
],
})
})
@@ -384,6 +384,29 @@ describe("ProductVariantService", () => {
})
})
it("successfully updates variant inventory_quantity", async () => {
await productVariantService.update(IdMap.getId("ironman"), {
title: "new title",
inventory_quantity: 98,
})
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
"product-variant.updated",
{
id: IdMap.getId("ironman"),
fields: ["title", "inventory_quantity"],
}
)
expect(productVariantRepository.save).toHaveBeenCalledTimes(1)
expect(productVariantRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("ironman"),
inventory_quantity: 98,
title: "new title",
})
})
it("successfully updates variant prices", async () => {
await productVariantService.update(IdMap.getId("ironman"), {
title: "new title",
@@ -809,73 +832,4 @@ describe("ProductVariantService", () => {
expect(result).toBe(undefined)
})
})
describe("canCoverQuantity", () => {
const productVariantRepository = MockRepository({
findOne: query => {
if (query.where.id === IdMap.getId("no-manageable-ironman")) {
return Promise.resolve({ manage_inventory: false })
}
if (query.where.id === IdMap.getId("backorder-ironman")) {
return Promise.resolve({ allow_backorder: true })
}
if (query.where.id === IdMap.getId("no-ironman")) {
return Promise.resolve({
inventory_quantity: 5,
manage_inventory: true,
allow_backorder: false,
})
}
return Promise.resolve({
inventory_quantity: 20,
})
},
})
const productVariantService = new ProductVariantService({
manager: MockManager,
eventBusService,
productVariantRepository,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("returns true if there is more inventory than requested", async () => {
const res = await productVariantService.canCoverQuantity(
IdMap.getId("ironman"),
10
)
expect(res).toEqual(true)
})
it("returns true if inventory not managed", async () => {
const res = await productVariantService.canCoverQuantity(
IdMap.getId("no-manageable-ironman"),
10
)
expect(res).toEqual(true)
})
it("returns true if backorders allowed", async () => {
const res = await productVariantService.canCoverQuantity(
IdMap.getId("backorder-ironman"),
10
)
expect(res).toEqual(true)
})
it("returns false if insufficient inventory", async () => {
const res = await productVariantService.canCoverQuantity(
IdMap.getId("no-ironman"),
20
)
expect(res).toEqual(false)
})
})
})
@@ -1,5 +1,7 @@
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"
describe("ReturnService", () => {
// describe("requestReturn", () => {
@@ -196,11 +198,13 @@ describe("ReturnService", () => {
id: IdMap.getId("test-line"),
quantity: 10,
returned_quantity: 0,
variant_id: "test-variant",
},
{
id: IdMap.getId("test-line-2"),
quantity: 10,
returned_quantity: 0,
variant_id: "test-variant-2",
},
],
payments: [{ id: "payment_test" }],
@@ -221,12 +225,29 @@ describe("ReturnService", () => {
},
}
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,
totalsService,
lineItemService,
orderService,
returnRepository,
inventoryService,
})
beforeEach(async () => {
@@ -268,6 +289,12 @@ describe("ReturnService", () => {
returned_quantity: 10,
}
)
expect(inventoryService.adjustInventory).toHaveBeenCalledTimes(1)
expect(inventoryService.adjustInventory).toHaveBeenCalledWith(
"test-variant",
10
)
})
it("successfully receives a return with requires_action status", async () => {
@@ -280,6 +307,16 @@ describe("ReturnService", () => {
1000
)
expect(inventoryService.adjustInventory).toHaveBeenCalledTimes(2)
expect(inventoryService.adjustInventory).toHaveBeenCalledWith(
"test-variant",
10
)
expect(inventoryService.adjustInventory).toHaveBeenCalledWith(
"test-variant-2",
10
)
expect(returnRepository.save).toHaveBeenCalledTimes(1)
expect(returnRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("test-return-2"),
+92 -35
View File
@@ -1,5 +1,6 @@
import { IdMap, MockRepository, MockManager } from "medusa-test-utils"
import SwapService from "../swap"
import { InventoryServiceMock } from "../__mocks__/inventory"
const eventBusService = {
emit: jest.fn(),
@@ -685,44 +686,51 @@ describe("SwapService", () => {
Date.now = jest.fn(() => 1572393600000)
})
const eventBusService = {
emit: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function() {
return this
},
}
const totalsService = {
getTotal: () => {
return Promise.resolve(100)
},
}
const shippingOptionService = {
updateShippingMethod: () => {
return Promise.resolve()
},
withTransaction: function() {
return this
},
}
const paymentProviderService = {
getStatus: jest.fn(() => {
return Promise.resolve("authorized")
}),
updatePayment: jest.fn(() => {
return Promise.resolve()
}),
withTransaction: function() {
return this
},
}
const inventoryService = {
...InventoryServiceMock,
withTransaction: function() {
return this
},
}
describe("success", () => {
const eventBusService = {
emit: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function() {
return this
},
}
const totalsService = {
getTotal: () => {
return Promise.resolve(100)
},
}
const shippingOptionService = {
updateShippingMethod: () => {
return Promise.resolve()
},
withTransaction: function() {
return this
},
}
const paymentProviderService = {
getStatus: jest.fn(() => {
return Promise.resolve("authorized")
}),
updatePayment: jest.fn(() => {
return Promise.resolve()
}),
withTransaction: function() {
return this
},
}
const existing = {
cart: {
items: [{ id: "1" }],
items: [{ id: "1", variant_id: "variant", quantity: 2 }],
shipping_methods: [{ id: "method_1" }],
payment: {
good: "yes",
@@ -744,6 +752,7 @@ describe("SwapService", () => {
paymentProviderService,
eventBusService,
shippingOptionService,
inventoryService,
})
it("creates a shipment", async () => {
@@ -753,6 +762,15 @@ describe("SwapService", () => {
good: "yes",
})
expect(inventoryService.confirmInventory).toHaveBeenCalledWith(
"variant",
2
)
expect(inventoryService.adjustInventory).toHaveBeenCalledWith(
"variant",
-2
)
expect(swapRepo.save).toHaveBeenCalledWith({
...existing,
difference_due: 100,
@@ -761,6 +779,45 @@ describe("SwapService", () => {
})
})
})
describe("failure", () => {
const existing = {
cart: {
items: [{ id: "1", variant_id: "variant", quantity: 25 }],
shipping_methods: [{ id: "method_1" }],
payment: {
good: "yes",
},
shipping_address_id: 1234,
},
other: "data",
}
const swapRepo = MockRepository({
findOneWithRelations: () => Promise.resolve(existing),
})
const swapService = new SwapService({
manager: MockManager,
eventBusService,
swapRepository: swapRepo,
totalsService,
paymentProviderService,
eventBusService,
shippingOptionService,
inventoryService,
})
it("throws an error because inventory is to low", async () => {
try {
await swapService.registerCartCompletion(IdMap.getId("swap"))
} catch (e) {
expect(e.message).toEqual(
`Variant with id: variant does not have the required inventory`
)
}
})
})
})
describe("processDifference", () => {
+16 -50
View File
@@ -31,6 +31,7 @@ class CartService extends BaseService {
totalsService,
addressRepository,
paymentSessionRepository,
inventoryService,
}) {
super()
@@ -84,6 +85,9 @@ class CartService extends BaseService {
/** @private @const {PaymentSessionRepository} */
this.paymentSessionRepository_ = paymentSessionRepository
/** @private @const {InventoryService} */
this.inventoryService_ = inventoryService
}
withTransaction(transactionManager) {
@@ -109,6 +113,7 @@ class CartService extends BaseService {
totalsService: this.totalsService_,
addressRepository: this.addressRepository_,
giftCardService: this.giftCardService_,
inventoryService: this.inventoryService_,
})
cloned.transactionManager_ = transactionManager
@@ -135,27 +140,6 @@ class CartService extends BaseService {
* @typedef {LineItemContent[]} LineItemContentArray
*/
/**
* Confirms if the contents of a line item is covered by the inventory.
* To be covered a variant must either not have its inventory managed or it
* must allow backorders or it must have enough inventory to cover the request.
* If the content is made up of multiple variants it will return true if all
* variants can be covered. If the content consists of a single variant it will
* return true if the variant is covered.
* @param {(LineItemContent | LineItemContentArray)} - the content of the line
* item
* @param {number} - the quantity of the line item
* @return {boolean} true if the inventory covers the line item.
*/
async confirmInventory_(variantId, quantity) {
// If the line item is not stock tracked we don't have double check it
if (!variantId) {
return true
}
return this.productVariantService_.canCoverQuantity(variantId, quantity)
}
transformQueryForTotals_(config) {
let { select, relations } = config
@@ -454,19 +438,10 @@ class CartService extends BaseService {
// simply update the quantity of the existing line item
if (currentItem) {
const newQuantity = currentItem.quantity + lineItem.quantity
// Confirm inventory
const hasInventory = await this.confirmInventory_(
lineItem.variant_id,
newQuantity
)
if (!hasInventory) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Inventory doesn't cover the desired quantity"
)
}
// Confirm inventory or throw error
await this.inventoryService_
.withTransaction(manager)
.confirmInventory(lineItem.variant_id, newQuantity)
await this.lineItemService_
.withTransaction(manager)
@@ -474,18 +449,10 @@ class CartService extends BaseService {
quantity: newQuantity,
})
} else {
// Confirm inventory
const hasInventory = await this.confirmInventory_(
lineItem.variant_id,
lineItem.quantity
)
if (!hasInventory) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Inventory doesn't cover the desired quantity"
)
}
// Confirm inventory or throw error
await this.inventoryService_
.withTransaction(manager)
.confirmInventory(lineItem.variant_id, lineItem.quantity)
await this.lineItemService_.withTransaction(manager).create({
...lineItem,
@@ -541,10 +508,9 @@ class CartService extends BaseService {
}
if (lineItemUpdate.quantity) {
const hasInventory = await this.confirmInventory_(
lineItemExists.variant_id,
lineItemUpdate.quantity
)
const hasInventory = await this.inventoryService_
.withTransaction(manager)
.confirmInventory(lineItemExists.variant_id, lineItemUpdate.quantity)
if (!hasInventory) {
throw new MedusaError(
+16
View File
@@ -26,6 +26,7 @@ class ClaimService extends BaseService {
shippingOptionService,
claimItemService,
regionService,
inventoryService,
eventBusService,
}) {
super()
@@ -63,6 +64,9 @@ class ClaimService extends BaseService {
/** @private @constant {TotalsService} */
this.totalsService_ = totalsService
/** @private @constant {InventoryService} */
this.inventoryService_ = inventoryService
/** @private @constant {EventBus} */
this.eventBus_ = eventBusService
@@ -88,6 +92,7 @@ class ClaimService extends BaseService {
claimItemService: this.claimItemService_,
eventBusService: this.eventBus_,
totalsService: this.totalsService_,
inventoryService: this.inventoryService_,
shippingOptionService: this.shippingOptionService_,
})
@@ -232,6 +237,12 @@ class ClaimService extends BaseService {
toRefund = await this.totalsService_.getRefundTotal(order, lines)
}
for (const item of additional_items) {
await this.inventoryService_
.withTransaction(manager)
.confirmInventory(item.variant_id, item.quantity)
}
const newItems = await Promise.all(
additional_items.map(i =>
this.lineItemService_
@@ -240,6 +251,11 @@ class ClaimService extends BaseService {
)
)
for (const newItem of newItems) {
await this.inventoryService_
.withTransaction(manager)
.adjustInventory(newItem.variant_id, -newItem.quantity)
}
const evaluatedNoNotification =
no_notification !== undefined ? no_notification : order.no_notification
@@ -148,7 +148,7 @@ class IdempotencyKeyService extends BaseService {
*/
async workStage(idempotencyKey, func) {
try {
return this.atomicPhase_(async manager => {
return await this.atomicPhase_(async manager => {
let key
const { recovery_point, response_code, response_body } = await func(
+87
View File
@@ -0,0 +1,87 @@
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
const fs = require("fs")
class InventoryService extends BaseService {
constructor({ manager, productVariantService }) {
super()
/** @private @const {EntityManager} */
this.manager_ = manager
/** @private @const {ProductVariantRepository_} */
this.productVariantService_ = productVariantService
}
withTransaction(transactionManager) {
if (!transactionManager) {
return this
}
const cloned = new InventoryService({
manager: transactionManager,
productVariantService: this.productVariantService_,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Updates the inventory of a variant based on a given adjustment.
* @params {string} variantId - the id of the variant to update
* @params {number} adjustment - the number to adjust the inventory quantity by
* @return {Promise} resolves to the update result.
*/
async adjustInventory(variantId, adjustment) {
//if variantId is undefined ergo. a custom item then do nothing
if (typeof variantId === "undefined") {
return
}
return this.atomicPhase_(async manager => {
const variant = await this.productVariantService_.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,
})
}
})
}
/**
* 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`.
* @params {string} variantId - the id of the variant to check
* @params {number} quantity - the number of units to check availability for
* @return {boolean} true if the inventory covers the quantity
*/
async confirmInventory(variantId, quantity) {
//if variantId is undefined then confirm inventory as it
//is a custom item that is not managed
if (typeof variantId === "undefined") {
return true
}
const variant = await this.productVariantService_.retrieve(variantId)
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
+33 -2
View File
@@ -39,6 +39,7 @@ class OrderService extends BaseService {
addressRepository,
giftCardService,
draftOrderService,
inventoryService,
eventBusService,
}) {
super()
@@ -93,6 +94,9 @@ class OrderService extends BaseService {
/** @private @constant {DraftOrderService} */
this.draftOrderService_ = draftOrderService
/** @private @constant {InventoryService} */
this.inventoryService_ = inventoryService
}
withTransaction(manager) {
@@ -118,6 +122,7 @@ class OrderService extends BaseService {
giftCardService: this.giftCardService_,
addressRepository: this.addressRepository_,
draftOrderService: this.draftOrderService_,
inventoryService: this.inventoryService_,
})
cloned.transactionManager_ = manager
@@ -451,6 +456,21 @@ class OrderService extends BaseService {
)
}
const { payment, region, total } = cart
for (const item of cart.items) {
try {
await this.inventoryService_
.withTransaction(manager)
.confirmInventory(item.variant_id, item.quantity)
} catch (err) {
await this.paymentProviderService_
.withTransaction(manager)
.cancelPayment(payment)
throw err
}
}
const exists = await this.existsByCartId(cart.id)
if (exists) {
throw new MedusaError(
@@ -459,7 +479,6 @@ class OrderService extends BaseService {
)
}
const { payment, region, total } = cart
// Would be the case if a discount code is applied that covers the item
// total
if (total !== 0) {
@@ -551,6 +570,12 @@ class OrderService extends BaseService {
.update(item.id, { order_id: result.id })
}
for (const item of cart.items) {
await this.inventoryService_
.withTransaction(manager)
.adjustInventory(item.variant_id, -item.quantity)
}
await this.eventBus_
.withTransaction(manager)
.emit(OrderService.Events.PLACED, {
@@ -864,7 +889,7 @@ class OrderService extends BaseService {
async cancel(orderId) {
return this.atomicPhase_(async manager => {
const order = await this.retrieve(orderId, {
relations: ["fulfillments", "payments"],
relations: ["fulfillments", "payments", "items"],
})
if (order.payment_status !== "awaiting") {
@@ -882,6 +907,12 @@ class OrderService extends BaseService {
)
)
for (const item of order.items) {
await this.inventoryService_
.withTransaction(manager)
.adjustInventory(item.variant_id, item.quantity)
}
for (const p of order.payments) {
await this.paymentProviderService_
.withTransaction(manager)
@@ -253,6 +253,7 @@ class PaymentProviderService extends BaseService {
amount: total,
currency_code: region.currency_code,
data: paymentData,
cart_id: cart.id,
})
return paymentRepo.save(created)
@@ -260,7 +260,7 @@ class ProductVariantService extends BaseService {
)
}
const { prices, options, metadata, ...rest } = update
const { prices, options, metadata, inventory_quantity, ...rest } = update
if (prices) {
for (const price of prices) {
@@ -291,11 +291,16 @@ class ProductVariantService extends BaseService {
variant.metadata = this.setMetadata_(variant, metadata)
}
if (typeof inventory_quantity === "number") {
variant.inventory_quantity = inventory_quantity
}
for (const [key, value] of Object.entries(rest)) {
variant[key] = value
}
const result = await variantRepo.save(variant)
await this.eventBus_
.withTransaction(manager)
.emit(ProductVariantService.Events.UPDATED, {
@@ -518,23 +523,6 @@ class ProductVariantService extends BaseService {
})
}
/**
* 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`.
* @params {string} variantId - the id of the variant to check
* @params {number} quantity - the number of units to check availability for
* @return {boolean} true if the inventory covers the quantity
*/
async canCoverQuantity(variantId, quantity) {
const variant = await this.retrieve(variantId)
const { inventory_quantity, allow_backorder, manage_inventory } = variant
return (
!manage_inventory || allow_backorder || inventory_quantity >= quantity
)
}
/**
* @param {Object} selector - the query object for find
* @return {Promise} the result of the find operation
+13
View File
@@ -16,6 +16,7 @@ class ReturnService extends BaseService {
shippingOptionService,
returnReasonService,
fulfillmentProviderService,
inventoryService,
orderService,
}) {
super()
@@ -43,6 +44,8 @@ class ReturnService extends BaseService {
this.returnReasonService_ = returnReasonService
this.inventoryService_ = inventoryService
/** @private @const {OrderService} */
this.orderService_ = orderService
}
@@ -61,6 +64,7 @@ class ReturnService extends BaseService {
shippingOptionService: this.shippingOptionService_,
fulfillmentProviderService: this.fulfillmentProviderService_,
returnReasonService: this.returnReasonService_,
inventoryService: this.inventoryService_,
orderService: this.orderService_,
})
@@ -513,6 +517,15 @@ class ReturnService extends BaseService {
})
}
for (const line of newLines) {
const orderItem = order.items.find(i => i.id === line.item_id)
if (orderItem) {
await this.inventoryService_
.withTransaction(manager)
.adjustInventory(orderItem.variant_id, line.received_quantity)
}
}
return result
})
}
+19
View File
@@ -31,6 +31,7 @@ class SwapService extends BaseService {
shippingOptionService,
fulfillmentService,
orderService,
inventoryService,
}) {
super()
@@ -64,6 +65,9 @@ class SwapService extends BaseService {
/** @private @const {ShippingOptionService} */
this.shippingOptionService_ = shippingOptionService
/** @private @const {InventoryService} */
this.inventoryService_ = inventoryService
/** @private @const {EventBusService} */
this.eventBus_ = eventBusService
}
@@ -84,6 +88,7 @@ class SwapService extends BaseService {
paymentProviderService: this.paymentProviderService_,
shippingOptionService: this.shippingOptionService_,
orderService: this.orderService_,
inventoryService: this.inventoryService_,
fulfillmentService: this.fulfillmentService_,
})
@@ -621,6 +626,14 @@ class SwapService extends BaseService {
const cart = swap.cart
const items = swap.cart.items
for (const item of items) {
await this.inventoryService_
.withTransaction(manager)
.confirmInventory(item.variant_id, item.quantity)
}
const total = await this.totalsService_.getTotal(cart)
if (total > 0) {
@@ -651,6 +664,12 @@ class SwapService extends BaseService {
swap_id: swapId,
order_id: swap.order_id,
})
for (const item of items) {
await this.inventoryService_
.withTransaction(manager)
.adjustInventory(item.variant_id, -item.quantity)
}
}
const now = new Date()
+548 -41
View File
File diff suppressed because it is too large Load Diff