Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/seven-windows-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@medusajs/core-flows": patch
"@medusajs/order": patch
"@medusajs/types": patch
"@medusajs/medusa": patch
---

feat(core-flows,order,medusa,types): update item metadata on item_update change action
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,95 @@ medusaIntegrationTestRunner({

expect(reservations.length).toBe(0)
})

it("should update item metadata", async () => {
// Create edit
await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit`,
{},
adminHeaders
)

// Add item with initial metadata
let orderPreview = (
await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit/items`,
{
items: [
{
variant_id: product.variants.find(
(v) => v.title === "L shirt"
).id,
quantity: 1,
metadata: { initial: "value", custom_field: "original" },
},
],
},
adminHeaders
)
).data.draft_order_preview

const itemToUpdate = orderPreview.items.find(
(i) => i.subtitle === "L shirt"
)
expect(itemToUpdate.metadata).toEqual({
initial: "value",
custom_field: "original",
})

await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit/confirm`,
{},
adminHeaders
)

await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit`,
{},
adminHeaders
)

// Update item with new metadata
orderPreview = (
await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit/items/item/${itemToUpdate.id}`,
{
quantity: 2,
metadata: { updated: "metadata", custom_field: "modified" },
},
adminHeaders
)
).data.draft_order_preview

const updatedItem = orderPreview.items.find(
(i) => i.subtitle === "L shirt"
)
expect(updatedItem.quantity).toBe(2)
expect(updatedItem.metadata).toEqual({
updated: "metadata",
custom_field: "modified",
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test expects metadata replacement instead of merge behavior

Medium Severity

The test expects metadata to be { updated: "metadata", custom_field: "modified" } after updating an item that originally had { initial: "value", custom_field: "original" }. However, the implementation uses mergeMetadata (per the PR discussion), which preserves existing keys not present in the update. The expected result should be { initial: "value", custom_field: "modified", updated: "metadata" } to include the preserved initial key.

Additional Locations (1)

Fix in Cursor Fix in Web


await api.post(
`/admin/draft-orders/${testDraftOrder.id}/edit/confirm`,
{},
adminHeaders
)

const order = (
await api.get(
`/admin/draft-orders/${testDraftOrder.id}`,
adminHeaders
)
).data.draft_order

expect(
order.items.find((i) => i.subtitle === "L shirt").metadata
).toEqual({
updated: "metadata",
custom_field: "modified",
})
})
})

describe("DELETE /draft-orders/:id/shipping-options/methods/:method_id", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export const updateDraftOrderItemWorkflow = createWorkflow(
unit_price: item.unit_price,
compare_at_unit_price: item.compare_at_unit_price,
quantity_diff: quantityDiff,
metadata: item.metadata,
},
}
})
Expand Down
4 changes: 4 additions & 0 deletions packages/core/types/src/workflow/order/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ interface ExistingItem {
* A note viewed by admins only related to the item.
*/
internal_note?: string | null
/**
* Custom key-value pairs to store additional information about the item.
*/
metadata?: Record<string, any> | null
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/medusa/src/api/admin/draft-orders/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export const AdminUpdateDraftOrderItem = z.object({
unit_price: z.number().nullish(),
compare_at_unit_price: z.number().nullish(),
internal_note: z.string().optional(),
metadata: z.record(z.unknown()).nullish(),
})

export type AdminUpdateDraftOrderActionItemType = z.infer<
Expand Down
1 change: 1 addition & 0 deletions packages/modules/order/src/types/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type VirtualOrder = {
unit_price: BigNumberInput
compare_at_unit_price: BigNumberInput | null
quantity: BigNumberInput
metadata?: Record<string, unknown>
adjustments?: (LineItemAdjustmentDTO & { version: number })[]
tax_lines?: LineItemTaxLineDTO[]

Expand Down
5 changes: 5 additions & 0 deletions packages/modules/order/src/utils/actions/item-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ OrderChangeProcessing.registerActionType(ChangeActionType.ITEM_UPDATE, {
existing.quantity = currentQuantity
existing.detail.quantity = currentQuantity

if (action.details.metadata !== undefined) {
existing.detail.metadata = action.details.metadata
existing.metadata = action.details.metadata
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing null metadata causes runtime TypeError crash

High Severity

The validator accepts metadata: null via nullish(), but isDefined(null) returns true (it only checks for undefined). When null is passed to mergeMetadata, Object.entries(null) throws a TypeError: Cannot convert undefined or null to object. The internal service uses isPresent which properly handles null, but this code uses isDefined which does not.

Fix in Cursor Fix in Web


if (action.details.adjustments) {
existing.adjustments = action.details.adjustments
}
Expand Down
1 change: 1 addition & 0 deletions packages/modules/order/src/utils/transform-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function formatOrder<T = any>(
raw_compare_at_unit_price:
detail.raw_compare_at_unit_price ??
orderItem.item.raw_compare_at_unit_price,
metadata: detail.metadata ?? orderItem.item.metadata,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nullish coalescing prevents clearing metadata to null

Medium Severity

The use of ?? (nullish coalescing) for metadata causes null values to be replaced with orderItem.item.metadata. When a user explicitly sets metadata to null to clear it, the API response will incorrectly show the original line item's metadata instead. This conflicts with item-update.ts, which correctly distinguishes null from undefined using !== undefined. The validator allows null via .nullish(), but the formatted response won't reflect that the metadata was cleared.

Fix in Cursor Fix in Web

detail,
}
})
Expand Down