feat(medusa): remove created reservations on subsequent failure for cart completion (#3554)
**What** - If cart completion fails after creating reservations, remove those reservations **Why** - To avoid hanging reservations if something fails at a later point
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/medusa": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(medusa): remove reservations if cart completion fails after reservation creation
|
||||||
@@ -2,17 +2,25 @@
|
|||||||
|
|
||||||
exports[`/store/carts POST /store/carts/:id fails to complete cart with items inventory not/partially covered 1`] = `
|
exports[`/store/carts POST /store/carts/:id fails to complete cart with items inventory not/partially covered 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"code": "insufficient_inventory",
|
"errors": Array [
|
||||||
"message": "Variant with id: test-variant-2 does not have the required inventory",
|
Object {
|
||||||
"type": "not_allowed",
|
"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 fails to complete swap cart with items inventory not/partially covered 1`] = `
|
exports[`/store/carts POST /store/carts/:id fails to complete swap cart with items inventory not/partially covered 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"code": "insufficient_inventory",
|
"errors": Array [
|
||||||
"message": "Variant with id: test-variant-2 does not have the required inventory",
|
Object {
|
||||||
"type": "not_allowed",
|
"code": "insufficient_inventory",
|
||||||
|
"message": "Variant with id: test-variant-2 does not have the required inventory",
|
||||||
|
"type": "not_allowed",
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1948,7 +1948,7 @@ describe("/store/carts", () => {
|
|||||||
await api.post(`/store/carts/test-cart-2/complete-cart`)
|
await api.post(`/store/carts/test-cart-2/complete-cart`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e.response.data).toMatchSnapshot({
|
expect(e.response.data).toMatchSnapshot({
|
||||||
code: "insufficient_inventory",
|
errors: [{ code: "insufficient_inventory" }],
|
||||||
})
|
})
|
||||||
expect(e.response.status).toBe(409)
|
expect(e.response.status).toBe(409)
|
||||||
}
|
}
|
||||||
@@ -1984,7 +1984,7 @@ describe("/store/carts", () => {
|
|||||||
await api.post(`/store/carts/swap-cart/complete-cart`)
|
await api.post(`/store/carts/swap-cart/complete-cart`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e.response.data).toMatchSnapshot({
|
expect(e.response.data).toMatchSnapshot({
|
||||||
code: "insufficient_inventory",
|
errors: [{ code: "insufficient_inventory" }],
|
||||||
})
|
})
|
||||||
expect(e.response.status).toBe(409)
|
expect(e.response.status).toBe(409)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,8 +343,8 @@ describe("/store/carts", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(responseFail.status).toEqual(409)
|
expect(responseFail.status).toEqual(409)
|
||||||
expect(responseFail.data.type).toEqual("not_allowed")
|
expect(responseFail.data.errors[0].type).toEqual("not_allowed")
|
||||||
expect(responseFail.data.code).toEqual(
|
expect(responseFail.data.errors[0].code).toEqual(
|
||||||
MedusaError.Codes.INSUFFICIENT_INVENTORY
|
MedusaError.Codes.INSUFFICIENT_INVENTORY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,47 @@ describe("/store/carts", () => {
|
|||||||
expect(stockLevel.stocked_quantity).toEqual(5)
|
expect(stockLevel.stocked_quantity).toEqual(5)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("removes reserved quantity when failing to complete the cart", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const cartRes = await api.post(
|
||||||
|
`/store/carts`,
|
||||||
|
{
|
||||||
|
region_id: "test-region",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
variant_id: variantId,
|
||||||
|
quantity: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ withCredentials: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const cartId = cartRes.data.cart.id
|
||||||
|
|
||||||
|
await api.post(`/store/carts/${cartId}/payment-sessions`)
|
||||||
|
await api.post(`/store/carts/${cartId}/payment-session`, {
|
||||||
|
provider_id: "test-pay",
|
||||||
|
})
|
||||||
|
|
||||||
|
const getRes = await api
|
||||||
|
.post(`/store/carts/${cartId}/complete`)
|
||||||
|
.catch((err) => err)
|
||||||
|
|
||||||
|
expect(getRes.response.status).toEqual(400)
|
||||||
|
expect(getRes.response.data).toEqual({
|
||||||
|
type: "invalid_data",
|
||||||
|
message:
|
||||||
|
"Can't insert null value in field customer_id on insert in table order",
|
||||||
|
})
|
||||||
|
|
||||||
|
const inventoryService = appContainer.resolve("inventoryService")
|
||||||
|
const [, count] = await inventoryService.listReservationItems({
|
||||||
|
line_item_id: cartRes.data.cart.items.map((i) => i.id),
|
||||||
|
})
|
||||||
|
expect(count).toEqual(0)
|
||||||
|
})
|
||||||
it("fails to add a item on the cart if the inventory isn't enough", async () => {
|
it("fails to add a item on the cart if the inventory isn't enough", async () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -249,10 +290,10 @@ describe("/store/carts", () => {
|
|||||||
.catch((e) => e)
|
.catch((e) => e)
|
||||||
|
|
||||||
expect(completeCartRes.response.status).toEqual(409)
|
expect(completeCartRes.response.status).toEqual(409)
|
||||||
expect(completeCartRes.response.data.code).toEqual(
|
expect(completeCartRes.response.data.errors[0].code).toEqual(
|
||||||
"insufficient_inventory"
|
"insufficient_inventory"
|
||||||
)
|
)
|
||||||
expect(completeCartRes.response.data.message).toEqual(
|
expect(completeCartRes.response.data.errors[0].message).toEqual(
|
||||||
`Variant with id: ${variantId} does not have the required inventory`
|
`Variant with id: ${variantId} does not have the required inventory`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { MedusaError } from "medusa-core-utils"
|
|
||||||
import { EntityManager } from "typeorm"
|
|
||||||
|
|
||||||
import { IdempotencyKey, Order } from "../models"
|
|
||||||
import CartService from "../services/cart"
|
|
||||||
import IdempotencyKeyService from "../services/idempotency-key"
|
|
||||||
import OrderService, {
|
|
||||||
ORDER_CART_ALREADY_EXISTS_ERROR,
|
|
||||||
} from "../services/order"
|
|
||||||
import SwapService from "../services/swap"
|
|
||||||
import { RequestContext } from "../types/request"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AbstractCartCompletionStrategy,
|
AbstractCartCompletionStrategy,
|
||||||
CartCompletionResponse,
|
CartCompletionResponse,
|
||||||
} from "../interfaces"
|
} from "../interfaces"
|
||||||
|
import { IInventoryService, ReservationItemDTO } from "@medusajs/types"
|
||||||
|
import { IdempotencyKey, Order } from "../models"
|
||||||
|
import OrderService, {
|
||||||
|
ORDER_CART_ALREADY_EXISTS_ERROR,
|
||||||
|
} from "../services/order"
|
||||||
import {
|
import {
|
||||||
PaymentProviderService,
|
PaymentProviderService,
|
||||||
ProductVariantInventoryService,
|
ProductVariantInventoryService,
|
||||||
} from "../services"
|
} from "../services"
|
||||||
|
|
||||||
|
import CartService from "../services/cart"
|
||||||
|
import { EntityManager } from "typeorm"
|
||||||
|
import IdempotencyKeyService from "../services/idempotency-key"
|
||||||
|
import { MedusaError } from "medusa-core-utils"
|
||||||
|
import { RequestContext } from "../types/request"
|
||||||
|
import SwapService from "../services/swap"
|
||||||
|
|
||||||
type InjectedDependencies = {
|
type InjectedDependencies = {
|
||||||
productVariantInventoryService: ProductVariantInventoryService
|
productVariantInventoryService: ProductVariantInventoryService
|
||||||
paymentProviderService: PaymentProviderService
|
paymentProviderService: PaymentProviderService
|
||||||
@@ -27,6 +27,7 @@ type InjectedDependencies = {
|
|||||||
orderService: OrderService
|
orderService: OrderService
|
||||||
swapService: SwapService
|
swapService: SwapService
|
||||||
manager: EntityManager
|
manager: EntityManager
|
||||||
|
inventoryService: IInventoryService
|
||||||
}
|
}
|
||||||
|
|
||||||
class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
||||||
@@ -37,6 +38,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
|||||||
protected readonly cartService_: CartService
|
protected readonly cartService_: CartService
|
||||||
protected readonly orderService_: OrderService
|
protected readonly orderService_: OrderService
|
||||||
protected readonly swapService_: SwapService
|
protected readonly swapService_: SwapService
|
||||||
|
protected readonly inventoryService_: IInventoryService
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
productVariantInventoryService,
|
productVariantInventoryService,
|
||||||
@@ -45,6 +47,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
|||||||
cartService,
|
cartService,
|
||||||
orderService,
|
orderService,
|
||||||
swapService,
|
swapService,
|
||||||
|
inventoryService,
|
||||||
}: InjectedDependencies) {
|
}: InjectedDependencies) {
|
||||||
// eslint-disable-next-line prefer-rest-params
|
// eslint-disable-next-line prefer-rest-params
|
||||||
super(arguments[0])
|
super(arguments[0])
|
||||||
@@ -55,6 +58,7 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
|||||||
this.cartService_ = cartService
|
this.cartService_ = cartService
|
||||||
this.orderService_ = orderService
|
this.orderService_ = orderService
|
||||||
this.swapService_ = swapService
|
this.swapService_ = swapService
|
||||||
|
this.inventoryService_ = inventoryService
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(
|
async complete(
|
||||||
@@ -247,6 +251,23 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async removeReservations(reservations) {
|
||||||
|
if (this.inventoryService_) {
|
||||||
|
await Promise.all(
|
||||||
|
reservations.map(async ([reservations]) => {
|
||||||
|
if (reservations) {
|
||||||
|
return reservations.map(async (reservation) => {
|
||||||
|
return await this.inventoryService_.deleteReservationItem(
|
||||||
|
reservation.id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected async handlePaymentAuthorized(
|
protected async handlePaymentAuthorized(
|
||||||
id: string,
|
id: string,
|
||||||
{ manager }: { manager: EntityManager }
|
{ manager }: { manager: EntityManager }
|
||||||
@@ -276,14 +297,18 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
|||||||
allowBackorder = swap.allow_backorder
|
allowBackorder = swap.allow_backorder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reservations: [
|
||||||
|
ReservationItemDTO[] | void | undefined,
|
||||||
|
MedusaError | undefined
|
||||||
|
][] = []
|
||||||
if (!allowBackorder) {
|
if (!allowBackorder) {
|
||||||
const productVariantInventoryServiceTx =
|
const productVariantInventoryServiceTx =
|
||||||
this.productVariantInventoryService_.withTransaction(manager)
|
this.productVariantInventoryService_.withTransaction(manager)
|
||||||
|
|
||||||
try {
|
reservations = await Promise.all(
|
||||||
await Promise.all(
|
cart.items.map(async (item) => {
|
||||||
cart.items.map(async (item) => {
|
if (item.variant_id) {
|
||||||
if (item.variant_id) {
|
try {
|
||||||
const inventoryConfirmed =
|
const inventoryConfirmed =
|
||||||
await productVariantInventoryServiceTx.confirmInventory(
|
await productVariantInventoryServiceTx.confirmInventory(
|
||||||
item.variant_id,
|
item.variant_id,
|
||||||
@@ -299,19 +324,42 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await productVariantInventoryServiceTx.reserveQuantity(
|
return [
|
||||||
item.variant_id,
|
await productVariantInventoryServiceTx.reserveQuantity(
|
||||||
item.quantity,
|
item.variant_id,
|
||||||
{
|
item.quantity,
|
||||||
lineItemId: item.id,
|
{
|
||||||
salesChannelId: cart.sales_channel_id,
|
lineItemId: item.id,
|
||||||
}
|
salesChannelId: cart.sales_channel_id,
|
||||||
)
|
}
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
]
|
||||||
|
} catch (error) {
|
||||||
|
return [undefined, error]
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
)
|
return [undefined, undefined]
|
||||||
} catch (error) {
|
})
|
||||||
if (error && error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY) {
|
)
|
||||||
|
|
||||||
|
if (reservations.some(([_, error]) => error)) {
|
||||||
|
await this.removeReservations(reservations)
|
||||||
|
|
||||||
|
const errors = reservations.reduce((acc, [_, error]) => {
|
||||||
|
if (error) {
|
||||||
|
acc.push(error)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [] as MedusaError[])
|
||||||
|
|
||||||
|
const error = errors[0]
|
||||||
|
|
||||||
|
if (
|
||||||
|
errors.some(
|
||||||
|
(error) => error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY
|
||||||
|
)
|
||||||
|
) {
|
||||||
if (cart.payment) {
|
if (cart.payment) {
|
||||||
await this.paymentProviderService_
|
await this.paymentProviderService_
|
||||||
.withTransaction(manager)
|
.withTransaction(manager)
|
||||||
@@ -324,9 +372,13 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
|||||||
return {
|
return {
|
||||||
response_code: 409,
|
response_code: 409,
|
||||||
response_body: {
|
response_body: {
|
||||||
message: error.message,
|
errors: errors.map((error) => {
|
||||||
type: error.type,
|
return {
|
||||||
code: error.code,
|
message: error.message,
|
||||||
|
type: error.type,
|
||||||
|
code: error.code,
|
||||||
|
}
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -350,6 +402,8 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
|||||||
response_body: { data: swap, type: "swap" },
|
response_body: { data: swap, type: "swap" },
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await this.removeReservations(reservations)
|
||||||
|
|
||||||
if (error && error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY) {
|
if (error && error.code === MedusaError.Codes.INSUFFICIENT_INVENTORY) {
|
||||||
return {
|
return {
|
||||||
response_code: 409,
|
response_code: 409,
|
||||||
@@ -376,6 +430,8 @@ class CartCompletionStrategy extends AbstractCartCompletionStrategy {
|
|||||||
try {
|
try {
|
||||||
order = await orderServiceTx.createFromCart(cart)
|
order = await orderServiceTx.createFromCart(cart)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await this.removeReservations(reservations)
|
||||||
|
|
||||||
if (error && error.message === ORDER_CART_ALREADY_EXISTS_ERROR) {
|
if (error && error.message === ORDER_CART_ALREADY_EXISTS_ERROR) {
|
||||||
order = await orderServiceTx.retrieveByCartId(id, {
|
order = await orderServiceTx.retrieveByCartId(id, {
|
||||||
relations: ["shipping_address", "payments"],
|
relations: ["shipping_address", "payments"],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export enum PostgresError {
|
|||||||
DUPLICATE_ERROR = "23505",
|
DUPLICATE_ERROR = "23505",
|
||||||
FOREIGN_KEY_ERROR = "23503",
|
FOREIGN_KEY_ERROR = "23503",
|
||||||
SERIALIZATION_FAILURE = "40001",
|
SERIALIZATION_FAILURE = "40001",
|
||||||
|
NULL_VIOLATION = "23502",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatException = (err): MedusaError => {
|
export const formatException = (err): MedusaError => {
|
||||||
@@ -43,6 +44,12 @@ export const formatException = (err): MedusaError => {
|
|||||||
err?.detail ?? err?.message
|
err?.detail ?? err?.message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
case PostgresError.NULL_VIOLATION: {
|
||||||
|
return new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`Can't insert null value in field ${err?.column} on insert in table ${err?.table}`
|
||||||
|
)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user