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
parent 44fce520aa
commit f07cc0fa40
32 changed files with 1870 additions and 268 deletions
+295 -2
View File
@@ -1,6 +1,11 @@
const { dropDatabase } = require("pg-god");
const path = require("path");
const { ReturnReason } = require("@medusajs/medusa");
const {
ReturnReason,
Order,
LineItem,
ProductVariant,
} = require("@medusajs/medusa");
const setupServer = require("../../../helpers/setup-server");
const { useApi } = require("../../../helpers/use-api");
@@ -83,6 +88,175 @@ describe("/admin/orders", () => {
});
});
describe("GET /admin/orders", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection);
await orderSeeder(dbConnection);
} catch (err) {
console.log(err);
throw err;
}
const manager = dbConnection.manager;
const order2 = manager.create(Order, {
id: "test-order-not-payed",
customer_id: "test-customer",
email: "test@email.com",
fulfillment_status: "not_fulfilled",
payment_status: "awaiting",
billing_address: {
id: "test-billing-address",
first_name: "lebron",
},
shipping_address: {
id: "test-shipping-address",
first_name: "lebron",
country_code: "us",
},
region_id: "test-region",
currency_code: "usd",
tax_rate: 0,
discounts: [
{
id: "test-discount",
code: "TEST134",
is_dynamic: false,
rule: {
id: "test-rule",
description: "Test Discount",
type: "percentage",
value: 10,
allocation: "total",
},
is_disabled: false,
regions: [
{
id: "test-region",
},
],
},
],
payments: [
{
id: "test-payment",
amount: 10000,
currency_code: "usd",
amount_refunded: 0,
provider_id: "test-pay",
data: {},
},
],
items: [],
});
await manager.save(order2);
const li2 = manager.create(LineItem, {
id: "test-item",
fulfilled_quantity: 0,
returned_quantity: 0,
title: "Line Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 1,
variant_id: "test-variant",
order_id: "test-order-not-payed",
});
await manager.save(li2);
});
afterEach(async () => {
const manager = dbConnection.manager;
await manager.query(`DELETE FROM "cart"`);
await manager.query(`DELETE FROM "fulfillment"`);
await manager.query(`DELETE FROM "swap"`);
await manager.query(`DELETE FROM "return"`);
await manager.query(`DELETE FROM "claim_image"`);
await manager.query(`DELETE FROM "claim_tag"`);
await manager.query(`DELETE FROM "claim_item"`);
await manager.query(`DELETE FROM "claim_order"`);
await manager.query(`DELETE FROM "line_item"`);
await manager.query(`DELETE FROM "money_amount"`);
await manager.query(`DELETE FROM "product_variant"`);
await manager.query(`DELETE FROM "product"`);
await manager.query(`DELETE FROM "shipping_method"`);
await manager.query(`DELETE FROM "shipping_option"`);
await manager.query(`DELETE FROM "discount"`);
await manager.query(`DELETE FROM "payment"`);
await manager.query(`DELETE FROM "order"`);
await manager.query(`DELETE FROM "customer"`);
await manager.query(
`UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'`
);
await manager.query(`DELETE FROM "region"`);
await manager.query(`DELETE FROM "user"`);
});
it("cancels an order and increments inventory_quantity", async () => {
const api = useApi();
const manager = dbConnection.manager;
const initialInventoryRes = await api.get("/store/variants/test-variant");
expect(initialInventoryRes.data.variant.inventory_quantity).toEqual(1);
const response = await api
.post(
`/admin/orders/test-order-not-payed/cancel`,
{},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err);
});
expect(response.status).toEqual(200);
const secondInventoryRes = await api.get("/store/variants/test-variant");
expect(secondInventoryRes.data.variant.inventory_quantity).toEqual(2);
});
it("cancels an order but does not increment inventory_quantity of unmanaged variant", async () => {
const api = useApi();
const manager = dbConnection.manager;
await manager.query(
`UPDATE "product_variant" SET manage_inventory=false WHERE id = 'test-variant'`
);
const initialInventoryRes = await api.get("/store/variants/test-variant");
expect(initialInventoryRes.data.variant.inventory_quantity).toEqual(1);
const response = await api
.post(
`/admin/orders/test-order-not-payed/cancel`,
{},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
.catch((err) => {
console.log(err);
});
expect(response.status).toEqual(200);
const secondInventoryRes = await api.get("/store/variants/test-variant");
expect(secondInventoryRes.data.variant.inventory_quantity).toEqual(1);
});
});
describe("POST /admin/orders/:id/claims", () => {
beforeEach(async () => {
try {
@@ -154,6 +328,18 @@ describe("/admin/orders", () => {
);
expect(response.status).toEqual(200);
const variant = await api.get("/admin/products", {
headers: {
authorization: "Bearer test_token",
},
});
// find test variant and verify that its inventory quantity has changed
const toTest = variant.data.products[0].variants.find(
(v) => v.id === "test-variant"
);
expect(toTest.inventory_quantity).toEqual(0);
expect(response.data.order.claims[0].shipping_address_id).toEqual(
"test-shipping-address"
);
@@ -620,6 +806,43 @@ describe("/admin/orders", () => {
}),
]);
});
it("fails to creates a claim due to no stock on additional items", async () => {
const api = useApi();
try {
await api.post(
"/admin/orders/test-order/claims",
{
type: "replace",
claim_items: [
{
item_id: "test-item",
quantity: 1,
reason: "production_failure",
tags: ["fluff"],
images: ["https://test.image.com"],
},
],
additional_items: [
{
variant_id: "test-variant",
quantity: 2,
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
} catch (e) {
expect(e.response.status).toEqual(400);
expect(e.response.data.message).toEqual(
"Variant with id: test-variant does not have the required inventory"
);
}
});
});
describe("POST /admin/orders/:id/return", () => {
@@ -705,6 +928,71 @@ describe("/admin/orders", () => {
}),
]);
});
it("increases inventory_quantity when return is received", async () => {
const api = useApi();
const returned = await api.post(
"/admin/orders/test-order/return",
{
items: [
{
item_id: "test-item",
quantity: 1,
},
],
receive_now: true,
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
//Find variant that should have its inventory_quantity updated
const toTest = returned.data.order.items.find(
(i) => i.id === "test-item"
);
expect(returned.status).toEqual(200);
expect(toTest.variant.inventory_quantity).toEqual(2);
});
it("does not increases inventory_quantity when return is received when inventory is not managed", async () => {
const api = useApi();
const manager = dbConnection.manager;
await manager.query(
`UPDATE "product_variant" SET manage_inventory=false WHERE id = 'test-variant'`
);
const returned = await api.post(
"/admin/orders/test-order/return",
{
items: [
{
item_id: "test-item",
quantity: 1,
},
],
receive_now: true,
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
//Find variant that should have its inventory_quantity updated
const toTest = returned.data.order.items.find(
(i) => i.id === "test-item"
);
expect(returned.status).toEqual(200);
expect(toTest.variant.inventory_quantity).toEqual(1);
});
});
describe("GET /admin/orders", () => {
@@ -967,8 +1255,13 @@ describe("/admin/orders", () => {
}
);
// find item to test returned quantiy for
const toTest = returnedOrderSecond.data.order.items.find(
(i) => i.id === "test-item-many"
);
expect(returnedOrderSecond.status).toEqual(200);
expect(returnedOrderSecond.data.order.items[1].returned_quantity).toBe(3);
expect(toTest.returned_quantity).toBe(3);
});
it("creates a swap and receives the items", async () => {
@@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/store/carts POST /store/carts/:id fails to complete cart with items inventory not/partially covered 1`] = `
Object {
"code": "insufficient_inventory",
"message": "Variant with id: test-variant-2 does not have the required inventory",
"type": "not_allowed",
}
`;
exports[`/store/carts POST /store/carts/:id returns early, if cart is already completed 1`] = `
Object {
"code": "cart_incompatible_state",
"message": "Cart has already been completed",
"type": "not_allowed",
}
`;
+70 -5
View File
@@ -1,6 +1,6 @@
const { dropDatabase } = require("pg-god");
const path = require("path");
const { Region } = require("@medusajs/medusa");
const { Region, LineItem, Payment } = require("@medusajs/medusa");
const setupServer = require("../../../helpers/setup-server");
const { useApi } = require("../../../helpers/use-api");
@@ -15,15 +15,21 @@ describe("/store/carts", () => {
let dbConnection;
const doAfterEach = async (manager) => {
await manager.query(`DELETE FROM "line_item"`);
await manager.query(`DELETE FROM "money_amount"`);
await manager.query(`DELETE FROM "discount"`);
await manager.query(`DELETE FROM "discount_rule"`);
await manager.query(`DELETE FROM "shipping_method"`);
await manager.query(`DELETE FROM "line_item"`);
await manager.query(`DELETE FROM "cart"`);
await manager.query(`DELETE FROM "money_amount"`);
await manager.query(`DELETE FROM "product_variant"`);
await manager.query(`DELETE FROM "product"`);
await manager.query(`DELETE FROM "shipping_method"`);
await manager.query(`DELETE FROM "shipping_option"`);
await manager.query(`DELETE FROM "payment_provider"`);
await manager.query(`DELETE FROM "payment_session"`);
await manager.query(`UPDATE "payment" SET order_id=NULL`);
await manager.query(`DELETE FROM "order"`);
await manager.query(`UPDATE "payment" SET cart_id=NULL`);
await manager.query(`DELETE FROM "cart"`);
await manager.query(`DELETE FROM "payment"`);
await manager.query(`DELETE FROM "address"`);
await manager.query(`DELETE FROM "customer"`);
await manager.query(
@@ -208,6 +214,65 @@ describe("/store/carts", () => {
expect(cart.data.cart.shipping_total).toBe(1000);
expect(cart.status).toEqual(200);
});
it("complete cart with items inventory covered", async () => {
const api = useApi();
const getRes = await api.post(`/store/carts/test-cart-2/complete-cart`);
expect(getRes.status).toEqual(200);
const variantRes = await api.get("/store/variants/test-variant");
expect(variantRes.data.variant.inventory_quantity).toEqual(0);
});
it("returns early, if cart is already completed", async () => {
const manager = dbConnection.manager;
const api = useApi();
await manager.query(
`UPDATE "cart" SET completed_at=current_timestamp WHERE id = 'test-cart-2'`
);
try {
await api.post(`/store/carts/test-cart-2/complete-cart`);
} catch (error) {
expect(error.response.data).toMatchSnapshot({
code: "not_allowed",
message: "Cart has already been completed",
code: "cart_incompatible_state",
});
expect(error.response.status).toEqual(409);
}
});
it("fails to complete cart with items inventory not/partially covered", async () => {
const manager = dbConnection.manager;
const li = manager.create(LineItem, {
id: "test-item",
title: "Line Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 99,
variant_id: "test-variant-2",
cart_id: "test-cart-2",
});
await manager.save(li);
const api = useApi();
try {
await api.post(`/store/carts/test-cart-2/complete-cart`);
} catch (e) {
expect(e.response.data).toMatchSnapshot({
code: "insufficient_inventory",
});
expect(e.response.status).toBe(409);
}
//check to see if payment has been cancelled
const res = await api.get(`/store/carts/test-cart-2`);
expect(res.data.cart.payment.canceled_at).not.toBe(null);
});
});
describe("POST /store/carts/:id/shipping-methods", () => {
@@ -9,12 +9,19 @@ const {
ProductVariant,
MoneyAmount,
LineItem,
Payment,
Cart,
ShippingMethod,
Swap,
} = require("@medusajs/medusa");
const setupServer = require("../../../helpers/setup-server");
const { useApi } = require("../../../helpers/use-api");
const { initDb } = require("../../../helpers/use-db");
const swapSeeder = require("../../helpers/swap-seeder");
const cartSeeder = require("../../helpers/cart-seeder");
jest.setTimeout(30000);
describe("/store/carts", () => {
@@ -34,6 +41,92 @@ describe("/store/carts", () => {
medusaProcess.kill();
});
describe("/store/swaps", () => {
beforeEach(async () => {
try {
await cartSeeder(dbConnection);
await swapSeeder(dbConnection);
const manager = dbConnection.manager;
await manager.query(
`UPDATE "swap" SET cart_id='test-cart-2' WHERE id = 'test-swap'`
);
await manager.query(
`UPDATE "payment" SET swap_id=NULL WHERE id = 'test-payment-swap'`
);
} catch (err) {
console.log(err);
throw err;
}
});
afterEach(async () => {
const manager = dbConnection.manager;
await manager.query(
`UPDATE "swap" SET cart_id=NULL WHERE id = 'test-swap'`
);
await manager.query(`DELETE FROM "payment_session"`);
await manager.query(`DELETE FROM "shipping_method"`);
await manager.query(`DELETE FROM "return_item"`);
await manager.query(`DELETE FROM "line_item"`);
await manager.query(`DELETE FROM "cart"`);
await manager.query(`DELETE FROM "payment"`);
await manager.query(`DELETE FROM "return"`);
await manager.query(`DELETE FROM "swap"`);
await manager.query(`DELETE FROM "fulfillment_item"`);
await manager.query(`DELETE FROM "fulfillment"`);
await manager.query(`DELETE FROM "shipping_method"`);
await manager.query(`DELETE FROM "money_amount"`);
await manager.query(`DELETE FROM "product_variant"`);
await manager.query(`DELETE FROM "product"`);
await manager.query(`DELETE FROM "shipping_option"`);
await manager.query(`DELETE FROM "order"`);
await manager.query(`DELETE FROM "address"`);
await manager.query(`DELETE FROM "customer"`);
await manager.query(
`UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'`
);
await manager.query(`DELETE FROM "region"`);
});
it("creates a swap from a cart id", async () => {
const api = useApi();
const getRes = await api.post("/store/swaps", {
cart_id: "test-cart-2",
});
expect(getRes.status).toEqual(200);
});
it("fails due to partial inventory", async () => {
const api = useApi();
const manager = dbConnection.manager;
const li = manager.create(LineItem, {
id: "test-item-with-no-stock",
title: "No Stock Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 1,
variant_id: "test-variant-2",
cart_id: "test-cart-2",
});
await manager.save(li);
try {
await api.post("/store/swaps", {
cart_id: "test-cart-2",
});
} catch (e) {
expect(e.response.data.message).toEqual(
"Variant with id: test-variant-2 does not have the required inventory"
);
}
});
});
describe("GET /store/orders", () => {
beforeEach(async () => {
const manager = dbConnection.manager;
+79 -2
View File
@@ -8,9 +8,12 @@ const {
ShippingOption,
ShippingMethod,
Address,
ProductVariant,
Product,
ProductVariant,
MoneyAmount,
LineItem,
Payment,
PaymentSession,
} = require("@medusajs/medusa");
module.exports = async (connection, data = {}) => {
@@ -189,19 +192,42 @@ module.exports = async (connection, data = {}) => {
],
});
await manager.insert(ProductVariant, {
id: "test-variant-2",
title: "test variant 2",
product_id: "test-product",
inventory_quantity: 0,
options: [
{
option_id: "test-option",
value: "Size",
},
],
});
const ma = manager.create(MoneyAmount, {
variant_id: "test-variant",
currency_code: "usd",
amount: 1000,
});
await manager.save(ma);
const ma2 = manager.create(MoneyAmount, {
variant_id: "test-variant-2",
currency_code: "usd",
amount: 8000,
});
await manager.save(ma2);
const ma3 = manager.create(MoneyAmount, {
variant_id: "giftcard-denom",
currency_code: "usd",
amount: 1000,
});
await manager.save(ma2);
await manager.save(ma3);
const cart = manager.create(Cart, {
id: "test-cart",
@@ -219,6 +245,45 @@ module.exports = async (connection, data = {}) => {
await manager.save(cart);
const cart2 = manager.create(Cart, {
id: "test-cart-2",
customer_id: "some-customer",
email: "some-customer@email.com",
shipping_address: {
id: "test-shipping-address",
first_name: "lebron",
country_code: "us",
},
region_id: "test-region",
currency_code: "usd",
completed_at: null,
items: [],
});
const pay = manager.create(Payment, {
id: "test-payment",
amount: 10000,
currency_code: "usd",
amount_refunded: 0,
provider_id: "test-pay",
data: {},
});
await manager.save(pay);
cart2.payment = pay;
await manager.save(cart2);
await manager.insert(PaymentSession, {
id: "test-session",
cart_id: "test-cart-2",
provider_id: "test-pay",
is_selected: true,
data: {},
status: "authorized",
});
await manager.insert(ShippingMethod, {
id: "test-method",
shipping_option_id: "test-option",
@@ -226,4 +291,16 @@ module.exports = async (connection, data = {}) => {
price: 1000,
data: {},
});
const li = manager.create(LineItem, {
id: "test-item",
title: "Line Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 1,
variant_id: "test-variant",
cart_id: "test-cart-2",
});
await manager.save(li);
};
+3 -3
View File
@@ -4,11 +4,11 @@
"main": "index.js",
"license": "MIT",
"scripts": {
"test": "jest --runInBand",
"test": "jest --runInBand --silent=false",
"build": "babel src -d dist --extensions \".ts,.js\""
},
"dependencies": {
"@medusajs/medusa": "1.1.33-dev-1627995051381",
"@medusajs/medusa": "1.1.33-dev-1628079986095",
"medusa-interfaces": "^1.1.18",
"typeorm": "^0.2.31"
},
@@ -19,4 +19,4 @@
"babel-preset-medusa-package": "^1.1.11",
"jest": "^26.6.3"
}
}
}
+4 -4
View File
@@ -1265,10 +1265,10 @@
winston "^3.3.3"
yargs "^15.3.1"
"@medusajs/medusa@1.1.33-dev-1627995051381":
version "1.1.33-dev-1627995051381"
resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.1.33-dev-1627995051381.tgz#def214374c31daca1f37c2dd5ee55f119236555f"
integrity sha512-yeH/YscYfWn4jYF+YSPyexMgAaDOfjvKm2xH95C+T9w4Ct08z06uBw0G0klIKPatNTirfD7HVgvg2vWb+ChDWA==
"@medusajs/medusa@1.1.33-dev-1628079986095":
version "1.1.33-dev-1628079986095"
resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.1.33-dev-1628079986095.tgz#0e8aa3bd83174366d1266823f093f21968b0f6e4"
integrity sha512-zqvz8+NL5+3Ba5uRS6Z2SaXpRTF2r6B+o0HGY8TjkAmvf2pMKyfIOGpBLtnhezDpDb9mkLdiHqrV23lNE+5pAw==
dependencies:
"@hapi/joi" "^16.1.8"
"@medusajs/medusa-cli" "^1.1.14"
+1
View File
@@ -36,6 +36,7 @@
"test:fixtures": "jest --config=docs-util/jest.config.js --runInBand"
},
"dependencies": {
"global": "^4.4.0",
"import-from": "^3.0.0",
"oas-normalize": "^2.3.1",
"swagger-inline": "^3.2.2"
+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
+25
View File
@@ -4296,6 +4296,11 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
dom-walk@^0.1.0:
version "0.1.2"
resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84"
integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==
domexception@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304"
@@ -5109,6 +5114,14 @@ glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
once "^1.3.0"
path-is-absolute "^1.0.0"
global@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406"
integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==
dependencies:
min-document "^2.19.0"
process "^0.11.10"
globals@^11.1.0:
version "11.12.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -6833,6 +6846,13 @@ mimic-fn@^2.0.0, mimic-fn@^2.1.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
min-document@^2.19.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685"
integrity sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=
dependencies:
dom-walk "^0.1.0"
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
@@ -7894,6 +7914,11 @@ process-nextick-args@~2.0.0:
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
process@^0.11.10:
version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
promise-inflight@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"