Feat(medusa, medusa-js, medusa-react): order edit item update (#2246)

**what**
Support `updateLineItem` which does the following:
- If no item change exist then create a new one and attaches the clone item with the adjustments and tax lines
- if an item change exists then delete/create adjustments and tax lines and update the cloned item quantity

**Tests**
- Unit tests core + client
- integration tests
  - When no item change already exists
  - When an item change already exists

FIXES CORE-497
This commit is contained in:
Adrien de Peretti
2022-09-28 11:09:33 +02:00
committed by GitHub
parent 1807bff029
commit 474e97252c
24 changed files with 1247 additions and 63 deletions

View File

@@ -16,10 +16,13 @@ const {
simpleLineItemFactory,
simpleProductFactory,
simpleOrderFactory,
simpleDiscountFactory,
simpleRegionFactory,
simpleCartFactory,
} = require("../../factories")
const { OrderEditItemChangeType, OrderEdit } = require("@medusajs/medusa")
jest.setTimeout(30000)
jest.setTimeout(50000)
const adminHeaders = {
headers: {
@@ -1094,4 +1097,725 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/order-edits", () => {
}
})
})
describe("POST /admin/order-edits/:id/items/:item_id", () => {
let product, product2
const orderId = IdMap.getId("order-1")
const prodId1 = IdMap.getId("product-1")
const prodId2 = IdMap.getId("product-2")
const lineItemId1 = IdMap.getId("line-item-1")
const lineItemId2 = IdMap.getId("line-item-2")
beforeEach(async () => {
await adminSeeder(dbConnection)
product = await simpleProductFactory(dbConnection, {
id: prodId1,
})
product2 = await simpleProductFactory(dbConnection, {
id: prodId2,
})
await simpleOrderFactory(dbConnection, {
id: orderId,
email: "test@testson.com",
tax_rate: null,
fulfillment_status: "fulfilled",
payment_status: "captured",
region: {
id: "test-region",
name: "Test region",
tax_rate: 12.5,
},
line_items: [
{
id: lineItemId1,
variant_id: product.variants[0].id,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
unit_price: 1000,
tax_lines: [
{
item_id: lineItemId1,
rate: 12.5,
code: "default",
name: "default",
},
],
},
{
id: lineItemId2,
variant_id: product2.variants[0].id,
quantity: 1,
fulfilled_quantity: 1,
shipped_quantity: 1,
unit_price: 1000,
tax_lines: [
{
item_id: lineItemId2,
rate: 12.5,
code: "default",
name: "default",
},
],
},
],
})
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("creates an order edit item change of type update on line item update", async () => {
const api = useApi()
const {
data: { order_edit },
} = await api.post(
`/admin/order-edits/`,
{
order_id: orderId,
internal_note: "This is an internal note",
},
adminHeaders
)
const orderEditId = order_edit.id
const updateItemId = order_edit.items.find(
(item) => item.original_item_id === lineItemId1
).id
const response = await api.post(
`/admin/order-edits/${orderEditId}/items/${updateItemId}`,
{ quantity: 2 },
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order_edit.changes).toHaveLength(1)
expect(response.data.order_edit).toEqual(
expect.objectContaining({
id: orderEditId,
changes: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
type: "item_update",
order_edit_id: orderEditId,
original_line_item_id: lineItemId1,
line_item_id: expect.any(String),
line_item: expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
original_item_id: lineItemId1,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
description: "",
thumbnail: "",
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 2,
fulfilled_quantity: 1,
returned_quantity: null,
shipped_quantity: 1,
metadata: null,
variant: expect.any(Object),
}),
original_line_item: expect.objectContaining({
id: lineItemId1,
created_at: expect.any(String),
updated_at: expect.any(String),
cart_id: null,
order_id: orderId,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
description: "",
thumbnail: "",
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 1,
fulfilled_quantity: 1,
returned_quantity: null,
shipped_quantity: 1,
metadata: null,
variant: expect.any(Object),
}),
}),
]),
status: "created",
order_id: orderId,
internal_note: "This is an internal note",
created_by: "admin_user",
items: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
original_item_id: lineItemId1,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 2,
fulfilled_quantity: 1,
returned_quantity: null,
shipped_quantity: 1,
metadata: null,
tax_lines: expect.arrayContaining([
expect.objectContaining({
rate: 12.5,
name: "default",
code: "default",
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
original_item_id: lineItemId2,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 1,
fulfilled_quantity: 1,
returned_quantity: null,
shipped_quantity: 1,
metadata: null,
tax_lines: expect.arrayContaining([
expect.objectContaining({
rate: 12.5,
name: "default",
code: "default",
}),
]),
}),
]),
discount_total: 0,
gift_card_total: 0,
gift_card_tax_total: 0,
shipping_total: 0,
subtotal: 3000,
tax_total: 375,
total: 3375,
})
)
})
it("update an exising order edit item change of type update on multiple line item update", async () => {
const api = useApi()
const {
data: { order_edit },
} = await api.post(
`/admin/order-edits/`,
{
order_id: orderId,
internal_note: "This is an internal note",
},
adminHeaders
)
const orderEditId = order_edit.id
const updateItemId = order_edit.items.find(
(item) => item.original_item_id === lineItemId1
).id
await api.post(
`/admin/order-edits/${orderEditId}/items/${updateItemId}`,
{ quantity: 2 },
adminHeaders
)
const response = await api.post(
`/admin/order-edits/${orderEditId}/items/${updateItemId}`,
{ quantity: 3 },
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order_edit.changes).toHaveLength(1)
expect(response.data.order_edit).toEqual(
expect.objectContaining({
id: orderEditId,
changes: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
type: "item_update",
order_edit_id: orderEditId,
original_line_item_id: lineItemId1,
line_item_id: expect.any(String),
line_item: expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
original_item_id: lineItemId1,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
description: "",
thumbnail: "",
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 3,
fulfilled_quantity: 1,
returned_quantity: null,
shipped_quantity: 1,
metadata: null,
variant: expect.any(Object),
}),
original_line_item: expect.objectContaining({
id: lineItemId1,
created_at: expect.any(String),
updated_at: expect.any(String),
cart_id: null,
order_id: orderId,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
description: "",
thumbnail: "",
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 1,
fulfilled_quantity: 1,
returned_quantity: null,
shipped_quantity: 1,
metadata: null,
variant: expect.any(Object),
}),
}),
]),
status: "created",
order_id: orderId,
internal_note: "This is an internal note",
created_by: "admin_user",
items: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
original_item_id: lineItemId1,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 3,
fulfilled_quantity: 1,
returned_quantity: null,
shipped_quantity: 1,
metadata: null,
tax_lines: expect.arrayContaining([
expect.objectContaining({
rate: 12.5,
name: "default",
code: "default",
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
original_item_id: lineItemId2,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 1,
fulfilled_quantity: 1,
returned_quantity: null,
shipped_quantity: 1,
metadata: null,
tax_lines: expect.arrayContaining([
expect.objectContaining({
rate: 12.5,
name: "default",
code: "default",
}),
]),
}),
]),
discount_total: 0,
gift_card_total: 0,
gift_card_tax_total: 0,
shipping_total: 0,
subtotal: 4000,
tax_total: 500,
total: 4500,
})
)
})
it("update an exising order edit item change of type update on multiple line item update with correct totals including discounts", async () => {
const api = useApi()
const region = await simpleRegionFactory(dbConnection, { tax_rate: 10 })
const discountCode = "FIX_DISCOUNT"
const discount = await simpleDiscountFactory(dbConnection, {
code: discountCode,
rule: {
type: "fixed",
allocation: "total",
value: 2000,
},
regions: [region.id],
})
const cart = await simpleCartFactory(dbConnection, {
email: "adrien@test.com",
region: region.id,
line_items: [
{
id: lineItemId1,
variant_id: product.variants[0].id,
quantity: 1,
unit_price: 1000,
},
{
id: lineItemId2,
variant_id: product2.variants[0].id,
quantity: 1,
unit_price: 1000,
},
],
})
await api.post(`/store/carts/${cart.id}`, {
discounts: [{ code: discountCode }],
})
await api.post(`/store/carts/${cart.id}/payment-sessions`)
const completeRes = await api.post(`/store/carts/${cart.id}/complete`)
const order = completeRes.data.data
const {
data: { order_edit },
} = await api.post(
`/admin/order-edits/`,
{
order_id: order.id,
internal_note: "This is an internal note",
},
adminHeaders
)
const orderEditId = order_edit.id
const updateItemId = order_edit.items.find(
(item) => item.original_item_id === lineItemId1
).id
await api.post(
`/admin/order-edits/${orderEditId}/items/${updateItemId}`,
{ quantity: 2 },
adminHeaders
)
let response = await api.get(
`/admin/order-edits/${orderEditId}?expand=changes,items,items.tax_lines,items.adjustments`,
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order_edit.changes).toHaveLength(1)
let item1 = response.data.order_edit.items.find(
(item) => item.original_item_id === lineItemId1
)
expect(item1.adjustments).toHaveLength(1)
let item2 = response.data.order_edit.items.find(
(item) => item.original_item_id === lineItemId2
)
expect(item2.adjustments).toHaveLength(1)
expect(response.data.order_edit).toEqual(
expect.objectContaining({
id: orderEditId,
changes: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
type: "item_update",
order_edit_id: orderEditId,
original_line_item_id: lineItemId1,
line_item_id: expect.any(String),
}),
]),
status: "created",
order_id: order.id,
internal_note: "This is an internal note",
created_by: "admin_user",
items: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
original_item_id: lineItemId1,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 2,
fulfilled_quantity: null,
returned_quantity: null,
shipped_quantity: null,
metadata: null,
tax_lines: expect.arrayContaining([
expect.objectContaining({
rate: 10,
}),
]),
adjustments: expect.arrayContaining([
expect.objectContaining({
discount_id: discount.id,
amount: 1333,
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
original_item_id: lineItemId2,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 1,
fulfilled_quantity: null,
returned_quantity: null,
shipped_quantity: null,
metadata: null,
tax_lines: expect.arrayContaining([
expect.objectContaining({
rate: 10,
}),
]),
adjustments: expect.arrayContaining([
expect.objectContaining({
discount_id: discount.id,
amount: 667,
}),
]),
}),
]),
discount_total: 2000,
gift_card_total: 0,
gift_card_tax_total: 0,
shipping_total: 0,
subtotal: 3000,
tax_total: 100,
total: 1100,
})
)
await api.post(
`/admin/order-edits/${orderEditId}/items/${updateItemId}`,
{ quantity: 3 },
adminHeaders
)
response = await api.get(
`/admin/order-edits/${orderEditId}?expand=changes,items,items.tax_lines,items.adjustments`,
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.order_edit.changes).toHaveLength(1)
item1 = response.data.order_edit.items.find(
(item) => item.original_item_id === lineItemId1
)
expect(item1.adjustments).toHaveLength(1)
item2 = response.data.order_edit.items.find(
(item) => item.original_item_id === lineItemId2
)
expect(item2.adjustments).toHaveLength(1)
expect(response.data.order_edit).toEqual(
expect.objectContaining({
id: orderEditId,
changes: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
deleted_at: null,
type: "item_update",
order_edit_id: orderEditId,
original_line_item_id: lineItemId1,
line_item_id: expect.any(String),
}),
]),
status: "created",
order_id: order.id,
internal_note: "This is an internal note",
created_by: "admin_user",
items: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
original_item_id: lineItemId1,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 3,
fulfilled_quantity: null,
returned_quantity: null,
shipped_quantity: null,
metadata: null,
tax_lines: expect.arrayContaining([
expect.objectContaining({
rate: 10,
}),
]),
adjustments: expect.arrayContaining([
expect.objectContaining({
discount_id: discount.id,
amount: 1500,
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
original_item_id: lineItemId2,
order_edit_id: orderEditId,
cart_id: null,
order_id: null,
swap_id: null,
claim_order_id: null,
title: expect.any(String),
is_return: false,
is_giftcard: false,
should_merge: true,
allow_discounts: true,
has_shipping: null,
unit_price: 1000,
variant_id: expect.any(String),
quantity: 1,
fulfilled_quantity: null,
returned_quantity: null,
shipped_quantity: null,
metadata: null,
tax_lines: expect.arrayContaining([
expect.objectContaining({
rate: 10,
}),
]),
adjustments: expect.arrayContaining([
expect.objectContaining({
discount_id: discount.id,
amount: 500,
}),
]),
}),
]),
discount_total: 2000,
gift_card_total: 0,
gift_card_tax_total: 0,
shipping_total: 0,
subtotal: 4000,
tax_total: 200,
total: 2200,
})
)
})
})
})

View File

@@ -2,6 +2,7 @@ import {
AdminOrderEditDeleteRes,
AdminOrderEditItemChangeDeleteRes,
AdminOrderEditsRes,
AdminPostOrderEditsEditLineItemsLineItemReq,
AdminPostOrderEditsOrderEditReq,
AdminPostOrderEditsReq,
} from "@medusajs/medusa"
@@ -50,7 +51,7 @@ class AdminOrderEditsResource extends BaseResource {
const path = `/admin/order-edits/${orderEditId}/changes/${itemChangeId}`
return this.client.request("DELETE", path, undefined, {}, customHeaders)
}
requestConfirmation(
id: string,
customHeaders: Record<string, any> = {}
@@ -58,7 +59,7 @@ class AdminOrderEditsResource extends BaseResource {
const path = `/admin/order-edits/${id}/request`
return this.client.request("POST", path, undefined, {}, customHeaders)
}
cancel(
id: string,
customHeaders: Record<string, any> = {}
@@ -66,6 +67,16 @@ class AdminOrderEditsResource extends BaseResource {
const path = `/admin/order-edits/${id}/cancel`
return this.client.request("POST", path, undefined, {}, customHeaders)
}
updateLineItem(
orderEditId: string,
itemId: string,
payload: AdminPostOrderEditsEditLineItemsLineItemReq,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminOrderEditsRes> {
const path = `/admin/order-edits/${orderEditId}/items/${itemId}`
return this.client.request("POST", path, payload, {}, customHeaders)
}
}
export default AdminOrderEditsResource

View File

@@ -1712,7 +1712,7 @@ export const adminHandlers = [
order_edit: {
...fixtures.get("order_edit"),
requested_at: new Date(),
status: "requested"
status: "requested",
},
})
)
@@ -1742,6 +1742,22 @@ export const adminHandlers = [
)
}),
rest.post("/admin/order-edits/:id/items/:item_id", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
order_edit: {
...fixtures.get("order_edit"),
changes: [
{
quantity: (req.body as any).quantity,
},
],
},
})
)
}),
rest.get("/admin/auth", (req, res, ctx) => {
return res(
ctx.status(200),

View File

@@ -5,6 +5,7 @@ import {
AdminOrderEditDeleteRes,
AdminOrderEditItemChangeDeleteRes,
AdminOrderEditsRes,
AdminPostOrderEditsEditLineItemsLineItemReq,
AdminPostOrderEditsOrderEditReq,
AdminPostOrderEditsReq,
} from "@medusajs/medusa"
@@ -68,6 +69,29 @@ export const useAdminDeleteOrderEditItemChange = (
)
}
export const useAdminOrderEditUpdateLineItem = (
orderEditId: string,
itemId: string,
options?: UseMutationOptions<
Response<AdminOrderEditsRes>,
Error,
AdminPostOrderEditsEditLineItemsLineItemReq
>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
(payload: AdminPostOrderEditsEditLineItemsLineItemReq) =>
client.admin.orderEdits.updateLineItem(orderEditId, itemId, payload),
buildOptions(
queryClient,
[adminOrderEditsKeys.detail(orderEditId), adminOrderEditsKeys.lists()],
options
)
)
}
export const useAdminUpdateOrderEdit = (
id: string,
options?: UseMutationOptions<

View File

@@ -1,15 +1,44 @@
import { renderHook } from "@testing-library/react-hooks"
import {
useAdminCancelOrderEdit,
useAdminCreateOrderEdit,
useAdminDeleteOrderEdit,
useAdminDeleteOrderEditItemChange,
useAdminUpdateOrderEdit,
useAdminOrderEditUpdateLineItem,
useAdminRequestOrderEditConfirmation,
useAdminCancelOrderEdit,
useAdminUpdateOrderEdit,
} from "../../../../src/"
import { fixtures } from "../../../../mocks/data"
import { createWrapper } from "../../../utils"
describe("useAdminOrderEditUpdateLineItem hook", () => {
test("Update line item of an order edit and create or update an item change", async () => {
const id = "oe_1"
const itemId = "item_1"
const { result, waitFor } = renderHook(
() => useAdminOrderEditUpdateLineItem(id, itemId),
{
wrapper: createWrapper(),
}
)
result.current.mutate({ quantity: 3 })
await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200)
expect(result.current.data.order_edit).toEqual(
expect.objectContaining({
...fixtures.get("order_edit"),
changes: expect.arrayContaining([
expect.objectContaining({
quantity: 3,
}),
]),
})
)
})
})
describe("useAdminDeleteOrderEditItemChange hook", () => {
test("Deletes an order edit item change", async () => {
const id = "oe_1"
@@ -112,9 +141,12 @@ describe("useAdminCreateOrderEdit hook", () => {
describe("useAdminRequestOrderEditConfirmation hook", () => {
test("Requests an order edit", async () => {
const { result, waitFor } = renderHook(() => useAdminRequestOrderEditConfirmation(fixtures.get("order_edit").id), {
wrapper: createWrapper(),
})
const { result, waitFor } = renderHook(
() => useAdminRequestOrderEditConfirmation(fixtures.get("order_edit").id),
{
wrapper: createWrapper(),
}
)
result.current.mutate()
@@ -123,9 +155,9 @@ describe("useAdminRequestOrderEditConfirmation hook", () => {
expect(result.current.data.response.status).toEqual(200)
expect(result.current.data?.order_edit).toEqual(
expect.objectContaining({
...fixtures.get("order_edit"),
requested_at: expect.any(String),
status: 'requested'
...fixtures.get("order_edit"),
requested_at: expect.any(String),
status: "requested",
})
)
})
@@ -133,10 +165,12 @@ describe("useAdminRequestOrderEditConfirmation hook", () => {
describe("useAdminCancelOrderEdit hook", () => {
test("cancel an order edit", async () => {
const { result, waitFor } = renderHook(() => useAdminCancelOrderEdit(fixtures.get("order_edit").id), {
wrapper: createWrapper(),
})
const { result, waitFor } = renderHook(
() => useAdminCancelOrderEdit(fixtures.get("order_edit").id),
{
wrapper: createWrapper(),
}
)
result.current.mutate()
@@ -148,7 +182,7 @@ describe("useAdminCancelOrderEdit hook", () => {
order_edit: {
...fixtures.get("order_edit"),
canceled_at: expect.any(String),
status: 'canceled'
status: "canceled",
},
})
)

View File

@@ -0,0 +1,47 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import OrderEditingFeatureFlag from "../../../../../loaders/feature-flags/order-editing"
import { orderEditServiceMock } from "../../../../../services/__mocks__/order-edit"
describe("POST /admin/order-edits/:id/items/:item_id", () => {
describe("update line item and create an item change of type update", () => {
const orderEditId = IdMap.getId("test-order-edit")
const lineItemId = IdMap.getId("line-item")
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/order-edits/${orderEditId}/items/${lineItemId}`,
{
payload: {
quantity: 3,
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
flags: [OrderEditingFeatureFlag],
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls orderEditService updateLineItem", () => {
expect(orderEditServiceMock.updateLineItem).toHaveBeenCalledTimes(1)
expect(orderEditServiceMock.updateLineItem).toHaveBeenCalledWith(
orderEditId,
lineItemId,
{ quantity: 3 }
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
})
})

View File

@@ -21,9 +21,9 @@ import {
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.orderEdit.create({ order_id, internal_note })
* .then(({ order_edit }) => {
* console.log(order_edit.id);
* });
* .then(({ order_edit }) => {
* console.log(order_edit.id)
* })
* - lang: Shell
* label: cURL
* source: |

View File

@@ -18,9 +18,9 @@ import { OrderEditService } from "../../../../services"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.orderEdits.deleteItemChange(item_change_id, order_edit_id)
* .then(({ id, object, deleted }) => {
* console.log(id);
* });
* .then(({ id, object, deleted }) => {
* console.log(id)
* })
* - lang: Shell
* label: cURL
* source: |

View File

@@ -17,9 +17,9 @@ import { OrderEditService } from "../../../../services"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.orderEdits.delete(edit_id)
* .then(({ id, object, deleted }) => {
* console.log(id);
* });
* .then(({ id, object, deleted }) => {
* console.log(id)
* })
* - lang: Shell
* label: cURL
* source: |

View File

@@ -17,9 +17,9 @@ import { OrderEditService } from "../../../../services"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.orderEdit.retrieve(orderEditId)
* .then(({ order_edit }) => {
* console.log(order_edit.id);
* });
* .then(({ order_edit }) => {
* console.log(order_edit.id)
* })
* - lang: Shell
* label: cURL
* source: |

View File

@@ -4,7 +4,7 @@ import middlewares, {
transformBody,
transformQuery,
} from "../../../middlewares"
import { DeleteResponse, EmptyQueryParams } from "../../../../types/common"
import { DeleteResponse, FindParams } from "../../../../types/common"
import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled"
import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing"
import {
@@ -14,6 +14,7 @@ import {
import { OrderEdit } from "../../../../models"
import { AdminPostOrderEditsOrderEditReq } from "./update-order-edit"
import { AdminPostOrderEditsReq } from "./create-order-edit"
import { AdminPostOrderEditsEditLineItemsLineItemReq } from "./update-order-edit-line-item"
const route = Router()
@@ -32,7 +33,7 @@ export default (app) => {
route.get(
"/:id",
transformQuery(EmptyQueryParams, {
transformQuery(FindParams, {
defaultRelations: defaultOrderEditRelations,
defaultFields: defaultOrderEditFields,
isList: false,
@@ -62,6 +63,13 @@ export default (app) => {
"/:id/request",
middlewares.wrap(require("./request-confirmation").default)
)
route.post(
"/:id/items/:item_id",
transformBody(AdminPostOrderEditsEditLineItemsLineItemReq),
middlewares.wrap(require("./update-order-edit-line-item").default)
)
return app
}
@@ -76,4 +84,5 @@ export type AdminOrderEditItemChangeDeleteRes = {
}
export * from "./update-order-edit"
export * from "./update-order-edit-line-item"
export * from "./create-order-edit"

View File

@@ -0,0 +1,94 @@
import { EntityManager } from "typeorm"
import { OrderEditService } from "../../../../services"
import { Request, Response } from "express"
import { IsNumber } from "class-validator"
import {
defaultOrderEditFields,
defaultOrderEditRelations,
} from "../../../../types/order-edit"
/**
* @oas [post] /order-edits/{id}/items/{item_id}
* operationId: "PostOrderEditsEditLineItemsLineItem"
* summary: "Create or update the order edit change holding the line item changes"
* description: "Create or update the order edit change holding the line item changes"
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the Order Edit to delete.
* - (path) item_id=* {string} The ID of the order edit item to update.
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.orderEdits.updateLineItem(order_edit_id, line_item_id)
* .then(({ order_edit }) => {
* console.log(order_edit.id)
* })
* - lang: Shell
* label: cURL
* source: |
* curl --location --request DELETE 'https://medusa-url.com/admin/order-edits/{id}/items/{item_id}' \
* --header 'Authorization: Bearer {api_token}'
* -d '{ "quantity": 5 }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - OrderEdit
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* order_edit:
* $ref: "#/components/schemas/order_edit"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const { id, item_id } = req.params
const validatedBody =
req.validatedBody as AdminPostOrderEditsEditLineItemsLineItemReq
const orderEditService: OrderEditService =
req.scope.resolve("orderEditService")
const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
await orderEditService
.withTransaction(transactionManager)
.updateLineItem(id, item_id, validatedBody)
})
let orderEdit = await orderEditService.retrieve(id, {
select: defaultOrderEditFields,
relations: defaultOrderEditRelations,
})
orderEdit = await orderEditService.decorateTotals(orderEdit)
res.status(200).send({
order_edit: orderEdit,
})
}
export class AdminPostOrderEditsEditLineItemsLineItemReq {
@IsNumber()
quantity: number
}

View File

@@ -25,9 +25,9 @@ import {
* // must be previously logged in or use api token
* const params = {internal_note: "internal reason XY"}
* medusa.admin.orderEdit.update(orderEditId, params)
* .then(({ order_edit }) => {
* console.log(order_edit.id);
* });
* .then(({ order_edit }) => {
* console.log(order_edit.id)
* })
* - lang: Shell
* label: cURL
* source: |

View File

@@ -3,7 +3,7 @@ import middlewares, {
transformBody,
transformQuery,
} from "../../../middlewares"
import { EmptyQueryParams } from "../../../../types/common"
import { FindParams } from "../../../../types/common"
import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled"
import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing"
import {
@@ -24,7 +24,7 @@ export default (app) => {
route.get(
"/:id",
transformQuery(EmptyQueryParams, {
transformQuery(FindParams, {
defaultRelations: defaultStoreOrderEditRelations,
defaultFields: defaultStoreOrderEditFields,
allowedFields: defaultStoreOrderEditFields,

View File

@@ -8,7 +8,7 @@ export const LineItemServiceMock = {
list: jest.fn().mockImplementation((data) => {
return Promise.resolve([])
}),
retrieve: jest.fn().mockImplementation((data) => {
retrieve: jest.fn().mockImplementation((id) => {
return Promise.resolve({})
}),
create: jest.fn().mockImplementation((data) => {

View File

@@ -13,6 +13,12 @@ export const orderEditItemChangeServiceMock = {
delete: jest.fn().mockImplementation(() => {
return Promise.resolve()
}),
create: jest.fn().mockImplementation(() => {
return Promise.resolve({})
}),
list: jest.fn().mockImplementation(() => {
return Promise.resolve([])
}),
}
const mock = jest.fn().mockImplementation(() => {

View File

@@ -120,6 +120,9 @@ export const orderEditServiceMock = {
cancel: jest.fn().mockImplementation(() => {
return Promise.resolve({})
}),
updateLineItem: jest.fn().mockImplementation((_) => {
return Promise.resolve()
}),
}
const mock = jest.fn().mockImplementation(() => {

View File

@@ -34,6 +34,9 @@ export const TotalsServiceMock = {
getRefundedTotal: jest.fn().mockImplementation((order, lineItems) => {
return Promise.resolve()
}),
getCalculationContext: jest.fn().mockImplementation((order, lineItems) => {
return Promise.resolve({})
}),
}
const mock = jest.fn().mockImplementation(() => {

View File

@@ -192,17 +192,10 @@ describe("LineItemAdjustmentService", () => {
it("calls lineItemAdjustment delete method with the right params", async () => {
await lineItemAdjustmentService.delete("lia-1")
expect(lineItemAdjustmentRepo.find).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.find).toHaveBeenCalledWith({
where: {
id: "lia-1",
},
expect(lineItemAdjustmentRepo.delete).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.delete).toHaveBeenCalledWith({
id: In(["lia-1"]),
})
expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledWith(
lineItemAdjustment
)
})
})

View File

@@ -35,6 +35,16 @@ const orderEditWithChanges = {
},
],
},
items: [
{
original_item_id: IdMap.getId("line-item-1"),
id: IdMap.getId("cloned-line-item-1"),
},
{
original_item_id: IdMap.getId("line-item-2"),
id: IdMap.getId("cloned-line-item-2"),
},
],
changes: [
{
type: OrderEditItemChangeType.ITEM_REMOVE,
@@ -80,9 +90,19 @@ const lineItemServiceMock = {
])
}),
retrieve: jest.fn().mockImplementation((id) => {
return Promise.resolve({
const data = {
id,
})
quantity: 1,
fulfilled_quantity: 1,
}
if (id === IdMap.getId("line-item-1")) {
return Promise.resolve({
...data,
order_edit_id: IdMap.getId("order-edit-update-line-item"),
})
}
return Promise.resolve(data)
}),
cloneTo: () => [],
}
@@ -100,6 +120,12 @@ describe("OrderEditService", () => {
if (query?.where?.id === IdMap.getId("order-edit-with-changes")) {
return orderEditWithChanges
}
if (query?.where?.id === IdMap.getId("order-edit-update-line-item")) {
return {
...orderEditWithChanges,
changes: [],
}
}
if (query?.where?.id === IdMap.getId("confirmed-order-edit")) {
return {
...orderEditWithChanges,
@@ -199,6 +225,22 @@ describe("OrderEditService", () => {
)
})
it("should update a line item and create an item change to an order edit", async () => {
await orderEditService.updateLineItem(
IdMap.getId("order-edit-update-line-item"),
IdMap.getId("line-item-1"),
{
quantity: 3,
}
)
expect(orderEditItemChangeServiceMock.list).toHaveBeenCalledTimes(1)
expect(orderEditItemChangeServiceMock.create).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
})
describe("decline", () => {
it("declines an order edit", async () => {
const result = await orderEditService.decline(
@@ -305,7 +347,7 @@ describe("OrderEditService", () => {
const id = IdMap.getId("order-edit-with-changes")
const userId = IdMap.getId("user-id")
await orderEditService.cancel(id, {loggedInUser: userId})
await orderEditService.cancel(id, { loggedInUser: userId })
expect(orderEditRepository.save).toHaveBeenCalledWith({
...orderEditWithChanges,

View File

@@ -1,6 +1,6 @@
import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm"
import { EntityManager, In } from "typeorm"
import {
Cart,
DiscountRuleType,
@@ -159,27 +159,28 @@ class LineItemAdjustmentService extends BaseService {
/**
* Deletes line item adjustments matching a selector
* @param selectorOrId - the query object for find or the line item adjustment id
* @param selectorOrIds - the query object for find or the line item adjustment id
* @return the result of the delete operation
*/
async delete(
selectorOrId: string | FilterableLineItemAdjustmentProps
selectorOrIds: string | string[] | FilterableLineItemAdjustmentProps
): Promise<void> {
return this.atomicPhase_(async (manager) => {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository =
manager.getCustomRepository(this.lineItemAdjustmentRepo_)
if (typeof selectorOrId === "string") {
return await this.delete({ id: selectorOrId })
if (typeof selectorOrIds === "string" || Array.isArray(selectorOrIds)) {
const ids =
typeof selectorOrIds === "string" ? [selectorOrIds] : selectorOrIds
return await lineItemAdjustmentRepo.delete({ id: In(ids) })
}
const query = this.buildQuery_(selectorOrId)
const query = this.buildQuery_(selectorOrIds)
const lineItemAdjustments = await lineItemAdjustmentRepo.find(query)
await lineItemAdjustmentRepo.remove(lineItemAdjustments)
return Promise.resolve()
return
})
}

View File

@@ -2,11 +2,12 @@ import { TransactionBaseService } from "../interfaces"
import { OrderItemChangeRepository } from "../repositories/order-item-change"
import { EntityManager, In } from "typeorm"
import { EventBusService, LineItemService } from "./index"
import { FindConfig } from "../types/common"
import { FindConfig, Selector } from "../types/common"
import { OrderItemChange } from "../models"
import { buildQuery } from "../utils"
import { MedusaError } from "medusa-core-utils"
import TaxProviderService from "./tax-provider"
import { CreateOrderEditItemChangeInput } from "../types/order-edit"
type InjectedDependencies = {
manager: EntityManager
@@ -18,6 +19,7 @@ type InjectedDependencies = {
export default class OrderEditItemChangeService extends TransactionBaseService {
static readonly Events = {
CREATED: "order-edit-item-change.CREATED",
DELETED: "order-edit-item-change.DELETED",
}
@@ -49,7 +51,7 @@ export default class OrderEditItemChangeService extends TransactionBaseService {
async retrieve(
id: string,
config: FindConfig<OrderItemChange> = {}
): Promise<OrderItemChange> {
): Promise<OrderItemChange | never> {
const manager = this.transactionManager_ ?? this.manager_
const orderItemChangeRepo = manager.getCustomRepository(
this.orderItemChangeRepository_
@@ -68,6 +70,35 @@ export default class OrderEditItemChangeService extends TransactionBaseService {
return itemChange
}
async list(
selector: Selector<OrderItemChange>,
config: FindConfig<OrderItemChange> = {}
): Promise<OrderItemChange[]> {
const manager = this.transactionManager_ ?? this.manager_
const orderItemChangeRepo = manager.getCustomRepository(
this.orderItemChangeRepository_
)
const query = buildQuery(selector, config)
return await orderItemChangeRepo.find(query)
}
async create(data: CreateOrderEditItemChangeInput): Promise<OrderItemChange> {
return await this.atomicPhase_(async (manager) => {
const orderItemChangeRepo = manager.getCustomRepository(
this.orderItemChangeRepository_
)
const changeEntity = orderItemChangeRepo.create(data)
const change = await orderItemChangeRepo.save(changeEntity)
await this.eventBus_
.withTransaction(manager)
.emit(OrderEditItemChangeService.Events.CREATED, { id: change.id })
return change
})
}
async delete(itemChangeIds: string | string[]): Promise<void> {
itemChangeIds = Array.isArray(itemChangeIds)
? itemChangeIds

View File

@@ -3,7 +3,13 @@ import { FindConfig } from "../types/common"
import { buildQuery, isDefined } from "../utils"
import { MedusaError } from "medusa-core-utils"
import { OrderEditRepository } from "../repositories/order-edit"
import { Order, OrderEdit, OrderEditStatus } from "../models"
import {
Cart,
Order,
OrderEdit,
OrderEditItemChangeType,
OrderEditStatus,
} from "../models"
import { TransactionBaseService } from "../interfaces"
import {
EventBusService,
@@ -14,6 +20,7 @@ import {
TotalsService,
} from "./index"
import { CreateOrderEditInput, UpdateOrderEditInput } from "../types/order-edit"
import region from "./region"
import LineItemAdjustmentService from "./line-item-adjustment"
type InjectedDependencies = {
@@ -76,7 +83,7 @@ export default class OrderEditService extends TransactionBaseService {
async retrieve(
orderEditId: string,
config: FindConfig<OrderEdit> = {}
): Promise<OrderEdit | never> {
): Promise<OrderEdit> {
const manager = this.transactionManager_ ?? this.manager_
const orderEditRepository = manager.getCustomRepository(
this.orderEditRepository_
@@ -315,6 +322,130 @@ export default class OrderEditService extends TransactionBaseService {
})
}
/**
* Create or update order edit item change line item and apply the quantity
* - If the item change already exists then update the quantity of the line item as well as the line adjustments
* - If the item change does not exist then create the item change of type update and apply the quantity as well as update the line adjustments
* @param orderEditId
* @param itemId
* @param data
*/
async updateLineItem(
orderEditId: string,
itemId: string,
data: { quantity: number }
): Promise<void> {
return await this.atomicPhase_(async (manager) => {
const orderEdit = await this.retrieve(orderEditId, {
select: [
"id",
"order_id",
"created_at",
"requested_at",
"confirmed_at",
"declined_at",
"canceled_at",
],
})
const isOrderEditActive = OrderEditService.isOrderEditActive(orderEdit)
if (!isOrderEditActive) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Can not update an item on the order edit ${orderEditId} with the status ${orderEdit.status}`
)
}
const lineItem = await this.lineItemService_
.withTransaction(manager)
.retrieve(itemId, {
select: ["id", "order_edit_id", "original_item_id"],
})
if (lineItem.order_edit_id !== orderEditId) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid line item id ${itemId} it does not belong to the same order edit ${orderEdit.order_id}.`
)
}
const orderEditItemChangeServiceTx =
this.orderEditItemChangeService_.withTransaction(manager)
// Can be of type update or add
let change = (
await orderEditItemChangeServiceTx.list(
{ line_item_id: itemId },
{
select: ["line_item_id", "original_line_item_id"],
}
)
).pop()
// if a change does not exist it means that we are updating an existing item and therefore creating an update change.
// otherwise we are updating either a change of type ADD or UPDATE
if (!change) {
change = await orderEditItemChangeServiceTx.create({
type: OrderEditItemChangeType.ITEM_UPDATE,
order_edit_id: orderEditId,
original_line_item_id: lineItem.original_item_id as string,
line_item_id: itemId,
})
}
await this.lineItemService_
.withTransaction(manager)
.update(change.line_item_id!, {
quantity: data.quantity,
})
await this.refreshAdjustments(orderEditId)
})
}
async refreshAdjustments(orderEditId: string) {
const manager = this.transactionManager_ ?? this.manager_
const lineItemAdjustmentServiceTx =
this.lineItemAdjustmentService_.withTransaction(manager)
const orderEdit = await this.retrieve(orderEditId, {
relations: [
"items",
"items.adjustments",
"items.tax_lines",
"order",
"order.customer",
"order.discounts",
"order.discounts.rule",
"order.gift_cards",
"order.region",
"order.shipping_address",
"order.shipping_methods",
],
})
const clonedItemAdjustmentIds: string[] = []
orderEdit.items.forEach((item) => {
if (item.adjustments?.length) {
item.adjustments.forEach((adjustment) => {
clonedItemAdjustmentIds.push(adjustment.id)
})
}
})
await lineItemAdjustmentServiceTx.delete(clonedItemAdjustmentIds)
const localCart = {
...orderEdit.order,
object: "cart",
items: orderEdit.items,
} as unknown as Cart
await lineItemAdjustmentServiceTx.createAdjustments(localCart)
}
async decorateTotals(orderEdit: OrderEdit): Promise<OrderEdit> {
const totals = await this.getTotals(orderEdit.id)
orderEdit.discount_total = totals.discount_total
@@ -475,4 +606,12 @@ export default class OrderEditService extends TransactionBaseService {
return saved
})
}
private static isOrderEditActive(orderEdit: OrderEdit): boolean {
return !(
orderEdit.status === OrderEditStatus.CONFIRMED ||
orderEdit.status === OrderEditStatus.CANCELED ||
orderEdit.status === OrderEditStatus.DECLINED
)
}
}

View File

@@ -1,4 +1,4 @@
import { OrderEdit } from "../models"
import { OrderEdit, OrderEditItemChangeType } from "../models"
export type UpdateOrderEditInput = {
internal_note?: string
@@ -9,6 +9,13 @@ export type CreateOrderEditInput = {
internal_note?: string
}
export type CreateOrderEditItemChangeInput = {
type: OrderEditItemChangeType
order_edit_id: string
original_line_item_id?: string
line_item_id?: string
}
export const defaultOrderEditRelations: string[] = [
"changes",
"changes.line_item",