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:
Philip Korsholm
2023-03-29 17:03:53 +00:00
committed by GitHub
parent a7e3f2d343
commit 5fd74b38ae
7 changed files with 160 additions and 43 deletions
+5
View File
@@ -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
} }